EnCoach Odoo 19 custom modules

Full backend implementation with custom Odoo modules:
- encoach_api: Core API, user management, JWT auth
- encoach_exam: Exam generation (reading, writing, listening, speaking)
- encoach_evaluate: AI-powered evaluation (writing, speaking)
- encoach_training: Training tips and walkthrough
- encoach_storage: File storage management
- encoach_payment: Stripe, PayPal, Paymob integration
- encoach_mail: Email notifications

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-14 16:46:46 +04:00
commit f5b627256f
168 changed files with 13428 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
from . import base
from . import auth
from . import users
from . import exams
from . import classrooms
from . import assignments
from . import sessions
from . import stats
from . import evaluations
from . import training
from . import subscriptions
from . import registration
from . import tickets
from . import storage
from . import grading
from . import generation
from . import media
from . import entities
from . import discounts
from . import approvals

View File

@@ -0,0 +1,89 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class ApprovalsController(EncoachMixin, http.Controller):
@http.route('/api/approval-workflows', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_workflows(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
workflows = request.env['encoach.approval.workflow'].sudo().search([])
return self._json_response([{
'id': w.id, 'examId': w.exam_id.id if w.exam_id else None,
'creatorId': w.creator_id.id if w.creator_id else None,
'status': w.status, 'modules': w.modules, 'steps': w.steps,
} for w in workflows])
except Exception:
_logger.exception("List approval workflows error")
return self._error_response('Internal server error', 500)
@http.route('/api/approval-workflows', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_workflow(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
vals = {
'creator_id': user.id,
'status': body.get('status', 'pending'),
}
if body.get('examId'): vals['exam_id'] = int(body['examId'])
if 'modules' in body: vals['modules'] = body['modules']
if 'steps' in body: vals['steps'] = body['steps']
wf = request.env['encoach.approval.workflow'].sudo().create(vals)
return self._json_response({
'id': wf.id, 'examId': wf.exam_id.id if wf.exam_id else None,
'status': wf.status,
})
except Exception:
_logger.exception("Create approval workflow error")
return self._error_response('Internal server error', 500)
@http.route('/api/approval-workflows/<int:wid>', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*')
def update_workflow(self, wid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
wf = request.env['encoach.approval.workflow'].sudo().browse(wid)
if not wf.exists():
return self._error_response('Workflow not found', 404)
body = self._get_json_body()
vals = {}
if 'status' in body: vals['status'] = body['status']
if 'modules' in body: vals['modules'] = body['modules']
if 'steps' in body: vals['steps'] = body['steps']
if vals:
wf.write(vals)
return self._json_response({
'id': wf.id, 'status': wf.status,
})
except Exception:
_logger.exception("Update approval workflow error")
return self._error_response('Internal server error', 500)
@http.route('/api/approval-workflows/<int:wid>', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*')
def delete_workflow(self, wid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
wf = request.env['encoach.approval.workflow'].sudo().browse(wid)
if not wf.exists():
return self._error_response('Workflow not found', 404)
wf.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete approval workflow error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,235 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class AssignmentsController(EncoachMixin, http.Controller):
# --------------------------------------------------------------- list
@http.route('/api/assignments', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_assignments(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = []
group_id = kwargs.get('groupId')
if group_id:
domain.append(('group_id', '=', int(group_id)))
creator = kwargs.get('createdBy')
if creator:
domain.append(('create_uid', '=', int(creator)))
status = kwargs.get('status')
if status:
domain.append(('status', '=', status))
participant = kwargs.get('participant')
if participant:
domain.append(('group_id.participant_ids', 'in', [int(participant)]))
is_archived = kwargs.get('isArchived')
if is_archived is not None:
domain.append(('is_archived', '=', is_archived in ('true', '1', True)))
offset, limit, page = self._paginate_params(kwargs)
Assignment = request.env['encoach.assignment'].sudo()
total = Assignment.search_count(domain)
records = Assignment.search(domain, offset=offset, limit=limit, order='create_date desc')
return self._json_response({
'assignments': [self._serialize_assignment(a) for a in records],
'total': total,
'page': page,
'limit': limit,
})
except Exception:
_logger.exception("List assignments error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- create
@http.route('/api/assignments', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_assignment(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
if not body.get('examId'):
return self._error_response('examId is required', 400)
if not body.get('groupId'):
return self._error_response('groupId is required', 400)
vals = {
'name': body.get('name', ''),
'exam_id': int(body['examId']),
'group_id': int(body['groupId']),
'status': 'draft',
}
if body.get('startDate'):
vals['start_date'] = body['startDate']
if body.get('endDate'):
vals['end_date'] = body['endDate']
if body.get('duration'):
vals['duration'] = int(body['duration'])
assignment = request.env['encoach.assignment'].sudo().with_user(user).create(vals)
return self._json_response(self._serialize_assignment(assignment), status=201)
except Exception:
_logger.exception("Create assignment error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- update
@http.route(
'/api/assignments/<int:aid>', type='http', auth='public',
methods=['PATCH', 'OPTIONS'], csrf=False, cors='*',
)
def update_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
body = self._get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'startDate' in body:
vals['start_date'] = body['startDate']
if 'endDate' in body:
vals['end_date'] = body['endDate']
if 'duration' in body:
vals['duration'] = int(body['duration'])
if 'examId' in body:
vals['exam_id'] = int(body['examId'])
if 'groupId' in body:
vals['group_id'] = int(body['groupId'])
if 'status' in body:
vals['status'] = body['status']
if vals:
assignment.write(vals)
return self._json_response(self._serialize_assignment(assignment))
except Exception:
_logger.exception("Update assignment error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- delete
@http.route(
'/api/assignments/<int:aid>', type='http', auth='public',
methods=['DELETE', 'OPTIONS'], csrf=False, cors='*',
)
def delete_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
assignment.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete assignment error")
return self._error_response('Internal server error', 500)
# --------------------------------------------------------------- start
@http.route(
'/api/assignments/<int:aid>/start', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def start_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
assignment.action_start()
return self._json_response(self._serialize_assignment(assignment))
except Exception as exc:
_logger.exception("Start assignment error")
return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500)
# ------------------------------------------------------------- release
@http.route(
'/api/assignments/<int:aid>/release', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def release_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
assignment.action_release()
return self._json_response(self._serialize_assignment(assignment))
except Exception as exc:
_logger.exception("Release assignment error")
return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500)
# ------------------------------------------------------------- archive
@http.route(
'/api/assignments/<int:aid>/archive', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def archive_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
assignment.write({'is_archived': True})
return self._json_response(self._serialize_assignment(assignment))
except Exception:
_logger.exception("Archive assignment error")
return self._error_response('Internal server error', 500)
# ----------------------------------------------------------- unarchive
@http.route(
'/api/assignments/<int:aid>/unarchive', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def unarchive_assignment(self, aid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
assignment = request.env['encoach.assignment'].sudo().browse(aid)
if not assignment.exists():
return self._error_response('Assignment not found', 404)
assignment.write({'is_archived': False})
return self._json_response(self._serialize_assignment(assignment))
except Exception:
_logger.exception("Unarchive assignment error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,110 @@
import logging
from datetime import datetime, timedelta
import jwt as pyjwt
from odoo import http
from odoo.exceptions import AccessDenied
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class AuthController(EncoachMixin, http.Controller):
def _create_jwt(self, user, secret, expires_hours=72):
payload = {
'user_id': user.id,
'email': user.login,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(hours=expires_hours),
}
return pyjwt.encode(payload, secret, algorithm='HS256')
@http.route('/api/login', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def login(self, **kwargs):
try:
body = self._get_json_body()
email = (body.get('credential') or body.get('email') or '').strip()
password = body.get('password', '')
if not email or not password:
return self._error_response('Email and password are required', 400)
user = request.env['res.users'].sudo().search([
('login', '=', email),
], limit=1)
if not user:
return self._error_response('Invalid email or password', 401)
try:
credential = {'type': 'password', 'login': email, 'password': password}
request.env['res.users'].sudo().authenticate(
credential, {'interactive': False},
)
except AccessDenied:
return self._error_response('Invalid email or password', 401)
secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret')
if not secret:
return self._error_response('Server configuration error', 500)
token = self._create_jwt(user, secret)
return self._json_response({
'user': self._serialize(user),
'token': token,
})
except Exception:
_logger.exception("Login error")
return self._error_response('Internal server error', 500)
@http.route('/api/logout', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def logout(self, **kwargs):
try:
return self._json_response({'ok': True})
except Exception:
_logger.exception("Logout error")
return self._error_response('Internal server error', 500)
@http.route('/api/reset', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def reset_password(self, **kwargs):
try:
body = self._get_json_body()
email = body.get('email', '').strip()
new_password = body.get('password', '')
token = body.get('token', '')
if not email:
return self._error_response('Email is required', 400)
user = request.env['res.users'].sudo().search([('login', '=', email)], limit=1)
if not user:
return self._json_response({'ok': True})
if token and new_password:
secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret')
try:
payload = pyjwt.decode(token, secret, algorithms=['HS256'])
except pyjwt.InvalidTokenError:
return self._error_response('Invalid or expired reset token', 400)
if payload.get('user_id') != user.id or payload.get('purpose') != 'password_reset':
return self._error_response('Invalid reset token', 400)
user.sudo().write({'password': new_password})
return self._json_response({'ok': True})
return self._json_response({'ok': True})
except Exception:
_logger.exception("Password reset error")
return self._error_response('Internal server error', 500)
@http.route('/api/reset/sendVerification', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def send_verification(self, **kwargs):
try:
body = self._get_json_body()
email = body.get('email', '').strip()
if not email:
return self._error_response('Email is required', 400)
return self._json_response({'ok': True})
except Exception:
_logger.exception("Send verification error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,73 @@
import json
import logging
import jwt as pyjwt
from odoo.http import request
_logger = logging.getLogger(__name__)
class EncoachMixin:
"""Shared authentication and response helpers for all EnCoach API controllers."""
def _authenticate(self):
"""Decode JWT Bearer token and return the corresponding ``res.users`` record.
Returns ``None`` when the token is missing, expired or invalid.
"""
auth_header = request.httprequest.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
secret = (
request.env["ir.config_parameter"]
.sudo()
.get_param("encoach.jwt_secret")
)
if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
return None
try:
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
except pyjwt.ExpiredSignatureError:
return None
except pyjwt.InvalidTokenError:
return None
user_id = payload.get("user_id")
if not user_id:
return None
user = request.env["res.users"].sudo().browse(int(user_id))
if not user.exists():
return None
return user
def _json_response(self, data, status=200):
return request.make_json_response(data, status=status)
def _error_response(self, message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
def _get_json_body(self):
try:
return json.loads(request.httprequest.get_data(as_text=True))
except (ValueError, TypeError):
return {}
def _paginate_params(self, kwargs):
page = max(int(kwargs.get("page", 1)), 1)
limit = min(max(int(kwargs.get("limit", 20)), 1), 100)
offset = (page - 1) * limit
return offset, limit, page
def _serialize(self, record):
"""Delegate to the record's own to_encoach_dict() if available."""
if hasattr(record, "to_encoach_dict"):
return record.to_encoach_dict()
return {"id": record.id}
def _serialize_list(self, records):
return [self._serialize(r) for r in records]

View File

@@ -0,0 +1,122 @@
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class ClassroomsController(EncoachMixin, http.Controller):
# --------------------------------------------------------------- list
@http.route('/api/groups', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_groups(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = []
participant = kwargs.get('participant')
if participant:
domain.append(('participant_ids', 'in', [int(participant)]))
admin = kwargs.get('admin')
if admin:
domain.append(('admin_ids', 'in', [int(admin)]))
entity_id = kwargs.get('entityID')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
groups = request.env['encoach.group'].sudo().search(domain, order='name asc')
return self._json_response([self._serialize_group(g) for g in groups])
except Exception:
_logger.exception("List groups error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- create
@http.route('/api/groups', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_group(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
if not body.get('name'):
return self._error_response('Group name is required', 400)
vals = {
'name': body['name'],
'admin_ids': [(4, user.id)],
}
if body.get('entityID'):
vals['entity_id'] = int(body['entityID'])
participant_ids = body.get('participants', [])
if participant_ids:
vals['participant_ids'] = [(4, int(pid)) for pid in participant_ids]
group = request.env['encoach.group'].sudo().create(vals)
return self._json_response(self._serialize_group(group), status=201)
except Exception:
_logger.exception("Create group error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- update
@http.route(
'/api/groups/<int:group_id>', type='http', auth='public',
methods=['PATCH', 'OPTIONS'], csrf=False, cors='*',
)
def update_group(self, group_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
group = request.env['encoach.group'].sudo().browse(group_id)
if not group.exists():
return self._error_response('Group not found', 404)
body = self._get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'participants' in body:
vals['participant_ids'] = [(6, 0, [int(pid) for pid in body['participants']])]
if 'admins' in body:
vals['admin_ids'] = [(6, 0, [int(aid) for aid in body['admins']])]
if vals:
group.write(vals)
return self._json_response(self._serialize_group(group))
except Exception:
_logger.exception("Update group error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- delete
@http.route(
'/api/groups/<int:group_id>', type='http', auth='public',
methods=['DELETE', 'OPTIONS'], csrf=False, cors='*',
)
def delete_group(self, group_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
group = request.env['encoach.group'].sudo().browse(group_id)
if not group.exists():
return self._error_response('Group not found', 404)
group.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete group error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,88 @@
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class DiscountsController(EncoachMixin, http.Controller):
@http.route('/api/discounts', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_discounts(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
discounts = request.env['encoach.discount'].sudo().search([])
return self._json_response([{
'id': d.id, 'code': d.code, 'percentage': d.percentage,
'expiryDate': d.expiry_date.isoformat() if d.expiry_date else None,
'maxUses': d.max_uses, 'uses': d.uses,
} for d in discounts])
except Exception:
_logger.exception("List discounts error")
return self._error_response('Internal server error', 500)
@http.route('/api/discounts', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_discount(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
code = body.get('code', '').strip()
if not code:
return self._error_response('code is required', 400)
discount = request.env['encoach.discount'].sudo().create({
'code': code,
'percentage': body.get('percentage', 0),
'expiry_date': body.get('expiryDate'),
'max_uses': body.get('maxUses', 0),
})
return self._json_response({
'id': discount.id, 'code': discount.code,
'percentage': discount.percentage,
})
except Exception:
_logger.exception("Create discount error")
return self._error_response('Internal server error', 500)
@http.route('/api/discounts/<int:did>', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*')
def update_discount(self, did, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
discount = request.env['encoach.discount'].sudo().browse(did)
if not discount.exists():
return self._error_response('Discount not found', 404)
body = self._get_json_body()
vals = {}
if 'code' in body: vals['code'] = body['code']
if 'percentage' in body: vals['percentage'] = body['percentage']
if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate']
if 'maxUses' in body: vals['max_uses'] = body['maxUses']
if vals:
discount.write(vals)
return self._json_response({'id': discount.id, 'code': discount.code})
except Exception:
_logger.exception("Update discount error")
return self._error_response('Internal server error', 500)
@http.route('/api/discounts/<int:did>', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*')
def delete_discount(self, did, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
discount = request.env['encoach.discount'].sudo().browse(did)
if not discount.exists():
return self._error_response('Discount not found', 404)
discount.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete discount error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,175 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class EntitiesController(EncoachMixin, http.Controller):
@http.route('/api/entities', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_entities(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
offset, limit, page = self._paginate_params(kwargs)
entities = request.env['encoach.entity'].sudo().search([], limit=limit, offset=offset, order='name asc')
total = request.env['encoach.entity'].sudo().search_count([])
return self._json_response({
'items': [e.to_encoach_dict() for e in entities],
'total': total, 'page': page, 'limit': limit,
})
except Exception:
_logger.exception("List entities error")
return self._error_response('Internal server error', 500)
@http.route('/api/entities', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_entity(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
name = body.get('name', '').strip()
if not name:
return self._error_response('name is required', 400)
entity = request.env['encoach.entity'].sudo().create({
'name': name,
'label': body.get('label', name),
'licenses': body.get('licenses', 0),
'expiry_date': body.get('expiryDate'),
'payment_currency': body.get('paymentCurrency'),
'payment_price': body.get('paymentPrice', 0),
})
return self._json_response(entity.to_encoach_dict())
except Exception:
_logger.exception("Create entity error")
return self._error_response('Internal server error', 500)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*')
def update_entity(self, entity_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return self._error_response('Entity not found', 404)
body = self._get_json_body()
vals = {}
if 'name' in body: vals['name'] = body['name']
if 'label' in body: vals['label'] = body['label']
if 'licenses' in body: vals['licenses'] = body['licenses']
if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate']
if 'paymentCurrency' in body: vals['payment_currency'] = body['paymentCurrency']
if 'paymentPrice' in body: vals['payment_price'] = body['paymentPrice']
if vals:
entity.write(vals)
return self._json_response(entity.to_encoach_dict())
except Exception:
_logger.exception("Update entity error")
return self._error_response('Internal server error', 500)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*')
def delete_entity(self, entity_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return self._error_response('Entity not found', 404)
entity.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete entity error")
return self._error_response('Internal server error', 500)
@http.route('/api/roles', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_roles(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = []
entity_id = kwargs.get('entityID')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
roles = request.env['encoach.role'].sudo().search(domain)
return self._json_response([{
'id': r.id, 'name': r.name,
'entityId': r.entity_id.id if r.entity_id else None,
} for r in roles])
except Exception:
_logger.exception("List roles error")
return self._error_response('Internal server error', 500)
@http.route('/api/roles', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_role(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
name = body.get('name', '').strip()
if not name:
return self._error_response('name is required', 400)
vals = {'name': name}
if body.get('entityId'):
vals['entity_id'] = int(body['entityId'])
role = request.env['encoach.role'].sudo().create(vals)
return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None})
except Exception:
_logger.exception("Create role error")
return self._error_response('Internal server error', 500)
@http.route('/api/roles/<int:role_id>', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*')
def update_role(self, role_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
role = request.env['encoach.role'].sudo().browse(role_id)
if not role.exists():
return self._error_response('Role not found', 404)
body = self._get_json_body()
vals = {}
if 'name' in body: vals['name'] = body['name']
if vals:
role.write(vals)
return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None})
except Exception:
_logger.exception("Update role error")
return self._error_response('Internal server error', 500)
@http.route('/api/roles/<int:role_id>', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*')
def delete_role(self, role_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
role = request.env['encoach.role'].sudo().browse(role_id)
if not role.exists():
return self._error_response('Role not found', 404)
role.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete role error")
return self._error_response('Internal server error', 500)
@http.route('/api/permissions', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_permissions(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
perms = request.env['encoach.permission'].sudo().search([])
return self._json_response([{'id': p.id, 'topic': p.topic, 'type': p.perm_type} for p in perms])
except Exception:
_logger.exception("List permissions error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,293 @@
import json
import logging
import threading
import odoo
from odoo import api, SUPERUSER_ID, http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
def _run_grading_background(dbname, evaluation_id, input_data, grading_type):
"""Execute AI grading in a background thread with its own cursor."""
try:
registry = odoo.registry(dbname)
with registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
service = env['encoach.ai.grading']
if grading_type == 'writing':
service.grade_writing(evaluation_id, input_data)
elif grading_type == 'speaking':
service.grade_speaking(evaluation_id, input_data)
elif grading_type == 'interactive_speaking':
service.grade_interactive_speaking(evaluation_id, input_data)
except Exception:
_logger.exception("Background grading failed for evaluation %s", evaluation_id)
try:
registry = odoo.registry(dbname)
with registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
env['encoach.evaluation'].browse(evaluation_id).write({
'status': 'error',
'error_message': 'Grading failed unexpectedly',
})
except Exception:
_logger.exception("Failed to update evaluation status after error")
class EvaluationsController(EncoachMixin, http.Controller):
# ------------------------------------------------- evaluate writing
@http.route('/api/evaluate/writing', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def evaluate_writing(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
required = ('sessionId', 'exerciseId', 'question', 'answer', 'task')
for field in required:
if not body.get(field):
return self._error_response(f'{field} is required', 400)
Evaluation = request.env['encoach.evaluation'].sudo()
evaluation = Evaluation.create({
'user_id': int(body.get('userId', user.id)),
'session_id': int(body['sessionId']),
'exercise_id': body['exerciseId'],
'eval_type': 'writing',
'status': 'pending',
'input_data': json.dumps({
'question': body['question'],
'answer': body['answer'],
'task': body['task'],
'attachment': body.get('attachment'),
}),
})
dbname = request.env.cr.dbname
input_data = {
'question': body['question'],
'answer': body['answer'],
'task': body['task'],
'attachment': body.get('attachment'),
}
thread = threading.Thread(
target=_run_grading_background,
args=(dbname, evaluation.id, input_data, 'writing'),
daemon=True,
)
thread.start()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Evaluate writing error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------- evaluate speaking
@http.route('/api/evaluate/speaking', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def evaluate_speaking(self, **kwargs):
"""Accept multipart form with audio files and question fields."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
form = request.httprequest.form
files = request.httprequest.files
user_id = form.get('userId', str(user.id))
session_id = form.get('sessionId')
exercise_id = form.get('exerciseId')
task = form.get('task')
if not session_id or not exercise_id or not task:
return self._error_response('sessionId, exerciseId, and task are required', 400)
audio_entries = []
idx = 1
while True:
audio_key = f'audio_{idx}'
question_key = f'question_{idx}'
audio_file = files.get(audio_key)
if not audio_file:
break
audio_entries.append({
'audio_data': audio_file.read(),
'audio_filename': audio_file.filename,
'question': form.get(question_key, ''),
})
idx += 1
if not audio_entries:
return self._error_response('At least one audio file is required', 400)
Attachment = request.env['ir.attachment'].sudo()
saved_audios = []
for entry in audio_entries:
import base64
att = Attachment.create({
'name': entry['audio_filename'],
'datas': base64.b64encode(entry['audio_data']),
'res_model': 'encoach.evaluation',
'type': 'binary',
})
saved_audios.append({
'attachment_id': att.id,
'question': entry['question'],
})
Evaluation = request.env['encoach.evaluation'].sudo()
evaluation = Evaluation.create({
'user_id': int(user_id),
'session_id': int(session_id),
'exercise_id': exercise_id,
'eval_type': 'speaking',
'status': 'pending',
'input_data': json.dumps({
'task': task,
'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios],
}),
})
dbname = request.env.cr.dbname
input_data = {
'task': task,
'audios': saved_audios,
}
thread = threading.Thread(
target=_run_grading_background,
args=(dbname, evaluation.id, input_data, 'speaking'),
daemon=True,
)
thread.start()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Evaluate speaking error")
return self._error_response('Internal server error', 500)
# ---------------------------------------- evaluate interactive speaking
@http.route(
'/api/evaluate/interactiveSpeaking', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def evaluate_interactive_speaking(self, **kwargs):
"""Same as speaking but for interactive Q&A pairs."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
form = request.httprequest.form
files = request.httprequest.files
user_id = form.get('userId', str(user.id))
session_id = form.get('sessionId')
exercise_id = form.get('exerciseId')
task = form.get('task')
if not session_id or not exercise_id or not task:
return self._error_response('sessionId, exerciseId, and task are required', 400)
audio_entries = []
idx = 1
while True:
audio_key = f'audio_{idx}'
question_key = f'question_{idx}'
audio_file = files.get(audio_key)
if not audio_file:
break
audio_entries.append({
'audio_data': audio_file.read(),
'audio_filename': audio_file.filename,
'question': form.get(question_key, ''),
})
idx += 1
if not audio_entries:
return self._error_response('At least one audio file is required', 400)
import base64
Attachment = request.env['ir.attachment'].sudo()
saved_audios = []
for entry in audio_entries:
att = Attachment.create({
'name': entry['audio_filename'],
'datas': base64.b64encode(entry['audio_data']),
'res_model': 'encoach.evaluation',
'type': 'binary',
})
saved_audios.append({
'attachment_id': att.id,
'question': entry['question'],
})
Evaluation = request.env['encoach.evaluation'].sudo()
evaluation = Evaluation.create({
'user_id': int(user_id),
'session_id': int(session_id),
'exercise_id': exercise_id,
'eval_type': 'interactive_speaking',
'status': 'pending',
'input_data': json.dumps({
'task': task,
'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios],
}),
})
dbname = request.env.cr.dbname
input_data = {
'task': task,
'audios': saved_audios,
}
thread = threading.Thread(
target=_run_grading_background,
args=(dbname, evaluation.id, input_data, 'interactive_speaking'),
daemon=True,
)
thread.start()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Evaluate interactive speaking error")
return self._error_response('Internal server error', 500)
# ----------------------------------------------------- poll status
@http.route(['/api/evaluate/<int:session_id>/<string:exercise_id>', '/api/evaluate/status'], type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def evaluation_status(self, session_id=None, exercise_id=None, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if not session_id:
session_id = kwargs.get('sessionId')
if not exercise_id:
exercise_id = kwargs.get('exerciseId')
if not session_id or not exercise_id:
return self._error_response('sessionId and exerciseId are required', 400)
evaluation = request.env['encoach.evaluation'].sudo().search([
('session_id', '=', int(session_id)),
('exercise_id', '=', str(exercise_id)),
], limit=1, order='create_date desc')
if not evaluation:
return self._error_response('Evaluation not found', 404)
result = None
if evaluation.status == 'completed' and evaluation.result:
result = json.loads(evaluation.result)
return self._json_response({
'status': evaluation.status,
'result': result,
})
except Exception:
_logger.exception("Evaluation status error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,220 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
VALID_MODULES = ('reading', 'listening', 'writing', 'speaking', 'level')
class ExamsController(EncoachMixin, http.Controller):
# --------------------------------------------------------------- list
@http.route('/api/exam', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_exams(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = []
module = kwargs.get('module')
if module and module in VALID_MODULES:
domain.append(('module', '=', module))
creator = kwargs.get('createdBy')
if creator:
domain.append(('create_uid', '=', int(creator)))
is_published = kwargs.get('isPublished')
if is_published is not None:
domain.append(('is_published', '=', is_published in ('true', '1', True)))
entity_id = kwargs.get('entityID')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
offset, limit, page = self._paginate_params(kwargs)
Exam = request.env['encoach.exam'].sudo()
total = Exam.search_count(domain)
exams = Exam.search(domain, offset=offset, limit=limit, order='create_date desc')
return self._json_response({
'exams': [self._serialize_exam(e) for e in exams],
'total': total,
'page': page,
'limit': limit,
})
except Exception:
_logger.exception("List exams error")
return self._error_response('Internal server error', 500)
# --------------------------------------------------------------- create
@http.route(
'/api/exam/<string:module>', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def create_exam(self, module, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if module not in VALID_MODULES:
return self._error_response(f'Invalid module: {module}', 400)
body = self._get_json_body()
vals = {
'name': body.get('name', ''),
'module': module,
'create_uid': user.id,
}
if 'parts' in body:
vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts']
if 'difficulty' in body:
vals['difficulty'] = body['difficulty']
if 'tags' in body:
vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags']
if 'isPublished' in body:
vals['is_published'] = body['isPublished']
if 'entityID' in body and body['entityID']:
vals['entity_id'] = int(body['entityID'])
exam = request.env['encoach.exam'].sudo().with_user(user).create(vals)
return self._json_response(self._serialize_exam(exam), status=201)
except Exception:
_logger.exception("Create exam error")
return self._error_response('Internal server error', 500)
# ----------------------------------------------------------- get by id
@http.route(
'/api/exam/<string:module>/<int:exam_id>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def get_exam(self, module, exam_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
exam = request.env['encoach.exam'].sudo().browse(exam_id)
if not exam.exists():
return self._error_response('Exam not found', 404)
return self._json_response(self._serialize_exam(exam))
except Exception:
_logger.exception("Get exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- update
@http.route(
'/api/exam/<string:module>/<int:exam_id>', type='http', auth='public',
methods=['PATCH', 'OPTIONS'], csrf=False, cors='*',
)
def update_exam(self, module, exam_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
exam = request.env['encoach.exam'].sudo().browse(exam_id)
if not exam.exists():
return self._error_response('Exam not found', 404)
body = self._get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'parts' in body:
vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts']
if 'difficulty' in body:
vals['difficulty'] = body['difficulty']
if 'tags' in body:
vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags']
if 'isPublished' in body:
vals['is_published'] = body['isPublished']
if vals:
exam.write(vals)
return self._json_response(self._serialize_exam(exam))
except Exception:
_logger.exception("Update exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- delete
@http.route(
'/api/exam/<string:module>/<int:exam_id>', type='http', auth='public',
methods=['DELETE', 'OPTIONS'], csrf=False, cors='*',
)
def delete_exam(self, module, exam_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
exam = request.env['encoach.exam'].sudo().browse(exam_id)
if not exam.exists():
return self._error_response('Exam not found', 404)
exam.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- import
@http.route(
'/api/exam/<string:module>/import/', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def import_exam(self, module, **kwargs):
"""Import an exam from an uploaded file (Word/Excel)."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if module not in VALID_MODULES:
return self._error_response(f'Invalid module: {module}', 400)
exercises_file = request.httprequest.files.get('exercises')
solutions_file = request.httprequest.files.get('solutions')
if not exercises_file:
return self._error_response('exercises file is required', 400)
Exam = request.env['encoach.exam'].sudo()
result = Exam.import_from_file(
module=module,
exercises_data=exercises_file.read(),
exercises_filename=exercises_file.filename,
solutions_data=solutions_file.read() if solutions_file else None,
solutions_filename=solutions_file.filename if solutions_file else None,
user=user,
)
return self._json_response(result)
except Exception:
_logger.exception("Import exam error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------------ avatars
@http.route('/api/exam/avatars', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_avatars(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
avatars = request.env['encoach.elai.avatar'].sudo().search([])
data = [{
'name': a.name,
'avatar_code': a.avatar_code,
'avatar_url': a.avatar_url or '',
'gender': a.gender or '',
} for a in avatars]
return self._json_response(data)
except Exception:
_logger.exception("List avatars error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,347 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class GenerationController(EncoachMixin, http.Controller):
# ------------------------------------------------- reading passage
@http.route(
'/api/exam/reading/<int:passage>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def generate_reading_passage(self, passage, **kwargs):
"""Generate a reading passage (1, 2, or 3) using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if passage not in (1, 2, 3):
return self._error_response('passage must be 1, 2, or 3', 400)
topic = kwargs.get('topic')
word_count = int(kwargs.get('word_count', 500))
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_reading_passage(
passage=passage,
topic=topic,
word_count=word_count,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate reading passage error")
return self._error_response('Internal server error', 500)
# ------------------------------------------ reading exercises
@http.route('/api/exam/reading/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def generate_reading_exercises(self, **kwargs):
"""Generate reading exercises from a passage."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
text = body.get('text', '')
exercises = body.get('exercises', [])
difficulty = body.get('difficulty', ['B1', 'B2'])
if not text or not exercises:
return self._error_response('text and exercises are required', 400)
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_reading_exercises(
text=text,
exercises=exercises,
difficulty=difficulty,
)
return self._json_response({'exercises': result})
except Exception:
_logger.exception("Generate reading exercises error")
return self._error_response('Internal server error', 500)
# ----------------------------------------------- listening dialog
@http.route(
'/api/exam/listening/<int:section>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def generate_listening_dialog(self, section, **kwargs):
"""Generate a listening dialog/monologue (section 1-4) using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if section not in (1, 2, 3, 4):
return self._error_response('section must be 1, 2, 3, or 4', 400)
topic = kwargs.get('topic')
difficulty = kwargs.get('difficulty')
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_listening_dialog(
section=section,
topic=topic,
difficulty=difficulty,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate listening dialog error")
return self._error_response('Internal server error', 500)
# ---------------------------------------- listening exercises
@http.route('/api/exam/listening/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def generate_listening_exercises(self, **kwargs):
"""Generate listening exercises from a dialog transcript."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
text = body.get('text', '')
exercises = body.get('exercises', [])
difficulty = body.get('difficulty', ['B1'])
if not text or not exercises:
return self._error_response('text and exercises are required', 400)
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_listening_exercises(
text=text,
exercises=exercises,
difficulty=difficulty,
)
return self._json_response({'exercises': result})
except Exception:
_logger.exception("Generate listening exercises error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------- writing task
@http.route(
'/api/exam/writing/<int:task>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def generate_writing_task(self, task, **kwargs):
"""Generate a writing task prompt (task 1 or 2) using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if task not in (1, 2):
return self._error_response('task must be 1 or 2', 400)
topic = kwargs.get('topic')
difficulty = kwargs.get('difficulty')
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_writing_task(
task=task,
topic=topic,
difficulty=difficulty,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate writing task error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ speaking task
@http.route(
'/api/exam/speaking/<int:task>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def generate_speaking_task(self, task, **kwargs):
"""Generate a speaking task prompt (part 1, 2, or 3) using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if task not in (1, 2, 3):
return self._error_response('task must be 1, 2, or 3', 400)
topic = kwargs.get('topic')
first_topic = kwargs.get('first_topic')
second_topic = kwargs.get('second_topic')
difficulty = kwargs.get('difficulty')
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_speaking_task(
task=task,
topic=topic,
first_topic=first_topic,
second_topic=second_topic,
difficulty=difficulty,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate speaking task error")
return self._error_response('Internal server error', 500)
# --------------------------------------------- level exercises
@http.route('/api/exam/level/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def generate_level_exercises(self, **kwargs):
"""Generate level-test exercises using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
exercises = body.get('exercises', [])
difficulty = body.get('difficulty', ['B1'])
if not exercises:
return self._error_response('exercises specification is required', 400)
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_level_exercises(
exercises=exercises,
difficulty=difficulty,
)
return self._json_response({'exercises': result})
except Exception:
_logger.exception("Generate level exercises error")
return self._error_response('Internal server error', 500)
# --------------------------------------------- writing attachment (vision)
@http.route(
'/api/exam/writing/<int:task>/attachment', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def generate_writing_attachment(self, task, **kwargs):
"""Academic writing Task 1 with image upload for GPT-4o vision."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
if task not in (1, 2):
return self._error_response('task must be 1 or 2', 400)
files = request.httprequest.files
form = request.httprequest.form
image_file = files.get('file')
difficulty = form.get('difficulty', 'B2')
if not image_file:
return self._error_response('file (image) is required', 400)
import base64
image_data = base64.b64encode(image_file.read()).decode('utf-8')
mime_type = image_file.content_type or 'image/png'
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_writing_with_image(
task=task,
image_b64=image_data,
mime_type=mime_type,
difficulty=difficulty,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate writing attachment error")
return self._error_response('Internal server error', 500)
# -------------------------------------------- pre-built level exam
@http.route('/api/exam/level/', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def get_level_exam(self, **kwargs):
"""Return a pre-built level exam (no AI generation)."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
exams = request.env['encoach.exam'].sudo().search([
('module', '=', 'level'),
('access', '=', 'public'),
], limit=1, order='create_date desc')
if not exams:
return self._error_response('No level exam found', 404)
return self._json_response(exams[0].to_encoach_dict())
except Exception:
_logger.exception("Get level exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------- UTAS level exam
@http.route('/api/exam/level/utas', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def get_utas_level_exam(self, **kwargs):
"""Return a UTAS-format level exam."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
exams = request.env['encoach.exam'].sudo().search([
('module', '=', 'level'),
('label', 'ilike', 'utas'),
], limit=1, order='create_date desc')
if not exams:
return self._error_response('No UTAS level exam found', 404)
return self._json_response(exams[0].to_encoach_dict())
except Exception:
_logger.exception("Get UTAS level exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------- level exam import
@http.route('/api/exam/level/import/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def import_level_exam(self, **kwargs):
"""Import level exam from uploaded file."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
files = request.httprequest.files
exercises_file = files.get('exercises')
if not exercises_file:
return self._error_response('exercises file is required', 400)
import json as _json
content = exercises_file.read().decode('utf-8')
data = _json.loads(content)
exam = request.env['encoach.exam'].sudo().create({
'module': 'level',
'label': data.get('label', 'Imported Level Exam'),
'parts': data.get('parts', []),
'access': 'private',
})
return self._json_response(exam.to_encoach_dict())
except Exception:
_logger.exception("Import level exam error")
return self._error_response('Internal server error', 500)
# -------------------------------------------- custom level exam
@http.route('/api/exam/level/custom/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def generate_custom_level(self, **kwargs):
"""Generate custom level exam from JSON specification."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
spec = body.get('spec', body)
GenService = request.env['encoach.ai.generation'].sudo()
result = GenService.generate_level_exercises(
exercises=spec.get('exercises', []),
difficulty=spec.get('difficulty', ['B1', 'B2']),
)
return self._json_response({'exercises': result})
except Exception:
_logger.exception("Generate custom level error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,65 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class GradingController(EncoachMixin, http.Controller):
# ------------------------------------------------- grade multiple
@http.route('/api/grading/multiple', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def grade_multiple(self, **kwargs):
"""Grade multiple short-answer exercises using GPT-4o."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
text = body.get('text', '')
questions = body.get('questions', [])
answers = body.get('answers', [])
if not questions or not answers:
return self._error_response('questions and answers are required', 400)
if len(questions) != len(answers):
return self._error_response('questions and answers must have the same length', 400)
GradingService = request.env['encoach.ai.grading'].sudo()
result = GradingService.grade_short_answers(
text=text,
questions=questions,
answers=answers,
)
return self._json_response({'exercises': result})
except Exception:
_logger.exception("Grade multiple error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ grading summary
@http.route('/api/exam/grade/summary', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def grading_summary(self, **kwargs):
"""Generate a grading summary for a full exam session."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
sections = body.get('sections', [])
if not sections:
return self._error_response('sections array is required', 400)
GradingService = request.env['encoach.ai.grading'].sudo()
result = GradingService.generate_grading_summary(sections=sections)
return self._json_response({'sections': result})
except Exception:
_logger.exception("Grading summary error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,213 @@
import base64
import json
import logging
from odoo import http
from odoo.http import request, Response
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class MediaController(EncoachMixin, http.Controller):
# ------------------------------------------- listening MP3
@http.route(
'/api/exam/listening/media', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def generate_listening_mp3(self, **kwargs):
"""Generate listening MP3 audio from a dialog/monologue via AWS Polly."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
conversation = body.get('conversation')
monologue = body.get('monologue')
if not conversation and not monologue:
return self._error_response('conversation or monologue is required', 400)
MediaService = request.env['encoach.ai.media'].sudo()
mp3_data = MediaService.generate_listening_audio(
conversation=conversation,
monologue=monologue,
)
return Response(
mp3_data,
status=200,
content_type='audio/mpeg',
headers={
'Access-Control-Allow-Origin': '*',
'Content-Disposition': 'attachment; filename="listening.mp3"',
},
)
except Exception:
_logger.exception("Generate listening MP3 error")
return self._error_response('Internal server error', 500)
# ---------------------------------------- transcribe audio
@http.route(
'/api/exam/listening/transcribe', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def transcribe_listening(self, **kwargs):
"""Transcribe audio using Whisper and return dialog object."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
audio_file = request.httprequest.files.get('audio')
if not audio_file:
return self._error_response('audio file is required', 400)
audio_data = audio_file.read()
MediaService = request.env['encoach.ai.media'].sudo()
result = MediaService.transcribe_to_dialog(
audio_data=audio_data,
filename=audio_file.filename,
)
return self._json_response(result)
except Exception:
_logger.exception("Transcribe listening error")
return self._error_response('Internal server error', 500)
# --------------------------------- listening instructions MP3
@http.route(
'/api/exam/listening/instructions', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def generate_instructions_mp3(self, **kwargs):
"""Generate MP3 for listening instructions text via AWS Polly."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
text = body.get('text', '')
if not text:
return self._error_response('text is required', 400)
MediaService = request.env['encoach.ai.media'].sudo()
mp3_data = MediaService.generate_instructions_audio(text=text)
return Response(
mp3_data,
status=200,
content_type='audio/mpeg',
headers={
'Access-Control-Allow-Origin': '*',
'Content-Disposition': 'attachment; filename="instructions.mp3"',
},
)
except Exception:
_logger.exception("Generate instructions MP3 error")
return self._error_response('Internal server error', 500)
# ------------------------------------------ speaking video
@http.route(
'/api/exam/speaking/media', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False, cors='*',
)
def generate_speaking_video(self, **kwargs):
"""Start AI avatar video generation via ELAI (async)."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
text = body.get('text', '')
avatar = body.get('avatar', '')
if not text:
return self._error_response('text is required', 400)
MediaService = request.env['encoach.ai.media'].sudo()
result = MediaService.create_speaking_video(
text=text,
avatar_code=avatar,
)
return self._json_response(result)
except Exception:
_logger.exception("Generate speaking video error")
return self._error_response('Internal server error', 500)
# --------------------------------- poll speaking video status
@http.route(
'/api/exam/speaking/media/<string:vid_id>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def poll_speaking_video(self, vid_id, **kwargs):
"""Poll ELAI video generation status."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
MediaService = request.env['encoach.ai.media'].sudo()
result = MediaService.poll_video_status(video_id=vid_id)
return self._json_response(result)
except Exception:
_logger.exception("Poll speaking video error")
return self._error_response('Internal server error', 500)
# ----------------------------------------- speaking avatars
@http.route(
'/api/exam/speaking/avatars', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def list_speaking_avatars(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
avatars = request.env['encoach.elai.avatar'].sudo().search([])
data = [{
'name': a.name,
'avatar_code': a.avatar_code,
'avatar_url': a.avatar_url or '',
'gender': a.gender or '',
'canvas': getattr(a, 'canvas', '') or '',
'voice_id': getattr(a, 'voice_id', '') or '',
'voice_provider': getattr(a, 'voice_provider', '') or '',
} for a in avatars]
return self._json_response(data)
except Exception:
_logger.exception("List speaking avatars error")
return self._error_response('Internal server error', 500)
# ---------------------------------------- general transcribe
@http.route('/api/transcribe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def transcribe(self, **kwargs):
"""Transcribe an audio file using Whisper."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
audio_file = request.httprequest.files.get('audio') or request.httprequest.files.get('file')
if not audio_file:
return self._error_response('audio file is required', 400)
audio_data = audio_file.read()
MediaService = request.env['encoach.ai.media'].sudo()
result = MediaService.transcribe_audio(
audio_data=audio_data,
filename=audio_file.filename,
)
return self._json_response(result)
except Exception:
_logger.exception("Transcribe error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,149 @@
import logging
from datetime import datetime, timedelta
import jwt as pyjwt
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class RegistrationController(EncoachMixin, http.Controller):
@http.route('/api/register', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def register(self, **kwargs):
try:
body = self._get_json_body()
email = body.get('email', '').strip()
password = body.get('password', '')
name = body.get('name', '').strip()
user_type = body.get('type', 'student')
if not email or not password or not name:
return self._error_response('name, email, and password are required', 400)
Users = request.env['res.users'].sudo()
existing = Users.search([('login', '=', email)], limit=1)
if existing:
return self._error_response('A user with this email already exists', 409)
main_company = request.env['res.company'].sudo().search([], limit=1, order='id')
vals = {
'name': name,
'login': email,
'password': password,
'encoach_type': user_type,
'is_verified': False,
'company_id': main_company.id,
'company_ids': [(6, 0, [main_company.id])],
}
code_str = body.get('code')
if code_str:
result = request.env['encoach.code'].sudo().validate_code(code_str)
if result.get('valid'):
code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1)
if code_rec and code_rec.user_type:
vals['encoach_type'] = code_rec.user_type
user = Users.create(vals)
if code_str:
code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1)
if code_rec:
if code_rec.entity_id:
request.env['encoach.user.entity.rel'].sudo().create({
'user_id': user.id,
'entity_id': code_rec.entity_id.id,
})
code_rec.sudo().write({'uses': code_rec.uses + 1})
secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret')
token = pyjwt.encode({
'user_id': user.id,
'email': user.login,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(hours=72),
}, secret, algorithm='HS256')
return self._json_response({
'user': self._serialize(user),
'token': token,
}, status=201)
except Exception:
_logger.exception("Registration error")
return self._error_response('Internal server error', 500)
@http.route(
'/api/code/<string:code>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def validate_code(self, code, **kwargs):
try:
result = request.env['encoach.code'].sudo().validate_code(code)
if not result.get('valid'):
return self._error_response(result.get('error', 'Invalid code'), 404)
return self._json_response(result['code'])
except Exception:
_logger.exception("Validate code error")
return self._error_response('Internal server error', 500)
@http.route('/api/batch_users', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def batch_users(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
users_data = body.get('users', [])
if not users_data:
return self._error_response('users array is required', 400)
mixin = request.env['encoach.registration.mixin'].sudo()
result = mixin.batch_import(users_data, user.id)
return self._json_response(result)
except Exception:
_logger.exception("Batch users error")
return self._error_response('Internal server error', 500)
@http.route('/api/make_user', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def make_user(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
email = body.get('email', '').strip()
name = body.get('name', '').strip()
password = body.get('password', '')
if not email or not name:
return self._error_response('name and email are required', 400)
Users = request.env['res.users'].sudo()
existing = Users.search([('login', '=', email)], limit=1)
if existing:
return self._error_response('A user with this email already exists', 409)
import secrets
if not password:
password = secrets.token_urlsafe(12)
new_user = Users.create({
'name': name,
'login': email,
'password': password,
'encoach_type': body.get('type', 'student'),
'is_verified': body.get('isVerified', True),
})
result = self._serialize(new_user)
result['tempPassword'] = password
return self._json_response(result, status=201)
except Exception:
_logger.exception("Make user error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,73 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class SessionsController(EncoachMixin, http.Controller):
# -------------------------------------------------------------- save
@http.route('/api/sessions', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def save_session(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
session_id = body.get('_id') or body.get('id')
Session = request.env['encoach.session'].sudo()
vals = {}
if body.get('examId'):
vals['exam_id'] = int(body['examId'])
if body.get('module'):
vals['module'] = body['module']
if body.get('answers') is not None:
vals['answers'] = json.dumps(body['answers']) if isinstance(body['answers'], dict) else body['answers']
if body.get('startedAt'):
vals['started_at'] = body['startedAt']
if body.get('completedAt'):
vals['completed_at'] = body['completedAt']
if body.get('assignmentId'):
vals['assignment_id'] = int(body['assignmentId'])
if session_id:
record = Session.browse(int(session_id))
if record.exists():
record.write(vals)
return self._json_response(self._serialize_session(record))
vals['user_id'] = user.id
record = Session.create(vals)
return self._json_response(self._serialize_session(record), status=201)
except Exception:
_logger.exception("Save session error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------------- delete
@http.route(
'/api/sessions/<int:sid>', type='http', auth='public',
methods=['DELETE', 'OPTIONS'], csrf=False, cors='*',
)
def delete_session(self, sid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
session = request.env['encoach.session'].sudo().browse(sid)
if not session.exists():
return self._error_response('Session not found', 404)
session.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete session error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,137 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class StatsController(EncoachMixin, http.Controller):
# -------------------------------------------------------------- save
@http.route('/api/stats', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def save_stats(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
stat_id = body.get('_id') or body.get('id')
Stat = request.env['encoach.stat'].sudo()
vals = {}
if body.get('sessionId'):
vals['session_id'] = int(body['sessionId'])
if body.get('module'):
vals['module'] = body['module']
if 'score' in body:
vals['score'] = body['score']
if body.get('result') is not None:
vals['result'] = json.dumps(body['result']) if isinstance(body['result'], dict) else body['result']
if body.get('examId'):
vals['exam_id'] = int(body['examId'])
if body.get('assignmentId'):
vals['assignment_id'] = int(body['assignmentId'])
if stat_id:
record = Stat.browse(int(stat_id))
if record.exists():
record.write(vals)
return self._json_response(self._serialize_stat(record))
vals['user_id'] = user.id
record = Stat.create(vals)
return self._json_response(self._serialize_stat(record), status=201)
except Exception:
_logger.exception("Save stats error")
return self._error_response('Internal server error', 500)
# --------------------------------------------------------------- get
@http.route(
'/api/stats/<int:stat_id>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def get_stat(self, stat_id, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
stat = request.env['encoach.stat'].sudo().browse(stat_id)
if not stat.exists():
return self._error_response('Stat not found', 404)
return self._json_response(self._serialize_stat(stat))
except Exception:
_logger.exception("Get stat error")
return self._error_response('Internal server error', 500)
# --------------------------------------------------------- statistical
@http.route('/api/statistical', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def statistical_query(self, **kwargs):
"""Run statistical queries against exam stats."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
Stat = request.env['encoach.stat'].sudo()
domain = []
user_id = body.get('userId')
if user_id:
domain.append(('user_id', '=', int(user_id)))
module = body.get('module')
if module:
domain.append(('module', '=', module))
group_id = body.get('groupId')
if group_id:
domain.append(('assignment_id.group_id', '=', int(group_id)))
assignment_id = body.get('assignmentId')
if assignment_id:
domain.append(('assignment_id', '=', int(assignment_id)))
date_from = body.get('dateFrom')
if date_from:
domain.append(('create_date', '>=', date_from))
date_to = body.get('dateTo')
if date_to:
domain.append(('create_date', '<=', date_to))
stats = Stat.search(domain, order='create_date desc')
scores = [s.score for s in stats if s.score]
avg_score = sum(scores) / len(scores) if scores else 0
total = len(stats)
module_breakdown = {}
for s in stats:
mod = getattr(s, 'module', 'unknown')
if mod not in module_breakdown:
module_breakdown[mod] = {'count': 0, 'scores': []}
module_breakdown[mod]['count'] += 1
if s.score:
module_breakdown[mod]['scores'].append(s.score)
for mod, data in module_breakdown.items():
data['average'] = sum(data['scores']) / len(data['scores']) if data['scores'] else 0
del data['scores']
return self._json_response({
'total': total,
'averageScore': round(avg_score, 2),
'moduleBreakdown': module_breakdown,
'stats': [self._serialize_stat(s) for s in stats[:100]],
})
except Exception:
_logger.exception("Statistical query error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,82 @@
import base64
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class StorageController(EncoachMixin, http.Controller):
# ------------------------------------------------------------- upload
@http.route('/api/storage', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def upload_file(self, **kwargs):
"""Upload a file and return its public URL via ``ir.attachment``."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
uploaded = request.httprequest.files.get('file')
if not uploaded:
return self._error_response('No file provided', 400)
data = uploaded.read()
attachment = request.env['ir.attachment'].sudo().create({
'name': uploaded.filename,
'datas': base64.b64encode(data),
'res_model': 'encoach.storage',
'type': 'binary',
'public': True,
})
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
file_url = f"{base_url}/web/content/{attachment.id}/{uploaded.filename}"
return self._json_response({
'ok': True,
'id': attachment.id,
'url': file_url,
'filename': uploaded.filename,
'size': len(data),
})
except Exception:
_logger.exception("Upload file error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------------ delete
@http.route('/api/storage/delete', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def delete_file(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
attachment_id = body.get('id') or body.get('attachmentId')
url = body.get('url', '')
Attachment = request.env['ir.attachment'].sudo()
if attachment_id:
att = Attachment.browse(int(attachment_id))
elif url:
parts = url.rstrip('/').split('/')
try:
att_id = int(parts[-2]) if len(parts) >= 2 else 0
except (ValueError, IndexError):
att_id = 0
att = Attachment.browse(att_id) if att_id else Attachment
else:
return self._error_response('id or url is required', 400)
if att.exists():
att.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete file error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,192 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class SubscriptionsController(EncoachMixin, http.Controller):
# ---------------------------------------------------------- packages
@http.route('/api/packages', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_packages(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = [('is_active', '=', True)]
entity_id = kwargs.get('entityID')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
packages = request.env['encoach.package'].sudo().search(domain, order='price asc')
return self._json_response([p.to_encoach_dict() for p in packages])
except Exception:
_logger.exception("List packages error")
return self._error_response('Internal server error', 500)
# ---------------------------------------------------- stripe checkout
@http.route('/api/stripe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_stripe_checkout(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
package_id = body.get('packageId')
if not package_id:
return self._error_response('packageId is required', 400)
package = request.env['encoach.package'].sudo().browse(int(package_id))
if not package.exists():
return self._error_response('Package not found', 404)
success_url = body.get('successUrl', '')
cancel_url = body.get('cancelUrl', '')
discount_code = body.get('discountCode')
from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService
service = EncoachStripeService(request.env)
result = service.create_checkout_session(
user=user,
package=package,
success_url=success_url,
cancel_url=cancel_url,
discount_code=discount_code,
)
return self._json_response(result)
except Exception:
_logger.exception("Stripe checkout error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ paypal create order
@http.route('/api/paypal', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_paypal_order(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
package_id = body.get('packageId')
if not package_id:
return self._error_response('packageId is required', 400)
package = request.env['encoach.package'].sudo().browse(int(package_id))
if not package.exists():
return self._error_response('Package not found', 404)
discount_code = body.get('discountCode')
from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService
service = EncoachPaypalService(request.env)
result = service.create_order(
user=user,
package=package,
discount_code=discount_code,
)
return self._json_response(result)
except Exception:
_logger.exception("PayPal create order error")
return self._error_response('Internal server error', 500)
# ---------------------------------------------- paypal capture order
@http.route('/api/paypal/approve', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def capture_paypal_order(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
order_id = body.get('orderId')
if not order_id:
return self._error_response('orderId is required', 400)
from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService
service = EncoachPaypalService(request.env)
result = service.capture_order(
user=user,
order_id=order_id,
)
return self._json_response(result)
except Exception:
_logger.exception("PayPal capture error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ paymob intention
@http.route('/api/paymob', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_paymob_intention(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
package_id = body.get('packageId')
if not package_id:
return self._error_response('packageId is required', 400)
package = request.env['encoach.package'].sudo().browse(int(package_id))
if not package.exists():
return self._error_response('Package not found', 404)
discount_code = body.get('discountCode')
from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService
service = EncoachPaymobService(request.env)
result = service.create_intention(
user=user,
package=package,
discount_code=discount_code,
)
return self._json_response(result)
except Exception:
_logger.exception("Paymob intention error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ stripe webhook
@http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
def stripe_webhook(self, **kwargs):
try:
payload = request.httprequest.get_data(as_text=True)
sig_header = request.httprequest.headers.get('Stripe-Signature', '')
from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService
service = EncoachStripeService(request.env)
result = service.handle_webhook(payload, sig_header)
if 'error' in result:
return self._error_response(result['error'], 400)
return self._json_response(result)
except Exception:
_logger.exception("Stripe webhook error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------ paymob webhook
@http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
def paymob_webhook(self, **kwargs):
try:
payload = request.httprequest.get_data(as_text=True)
hmac_header = request.httprequest.args.get('hmac', '')
from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService
service = EncoachPaymobService(request.env)
result = service.verify_transaction(json.loads(payload), hmac_header)
if not result or 'error' in result:
return self._error_response(result.get('error', 'Verification failed') if result else 'Verification failed', 400)
return self._json_response(result)
except Exception:
_logger.exception("Paymob webhook error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,128 @@
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class TicketsController(EncoachMixin, http.Controller):
# --------------------------------------------------------------- list
@http.route('/api/tickets', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_tickets(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
domain = []
user_type = getattr(user, 'encoach_user_type', 'student')
if user_type not in ('admin', 'developer'):
domain.append(('user_id', '=', user.id))
status = kwargs.get('status')
if status:
domain.append(('status', '=', status))
offset, limit, page = self._paginate_params(kwargs)
Ticket = request.env['encoach.ticket'].sudo()
total = Ticket.search_count(domain)
records = Ticket.search(domain, offset=offset, limit=limit, order='create_date desc')
return self._json_response({
'tickets': [self._serialize_ticket(t) for t in records],
'total': total,
'page': page,
'limit': limit,
})
except Exception:
_logger.exception("List tickets error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- create
@http.route('/api/tickets', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def create_ticket(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
subject = body.get('subject', '').strip()
if not subject:
return self._error_response('Subject is required', 400)
vals = {
'subject': subject,
'description': body.get('description', ''),
'user_id': user.id,
'status': 'open',
}
if body.get('priority'):
vals['priority'] = body['priority']
if body.get('category'):
vals['category'] = body['category']
ticket = request.env['encoach.ticket'].sudo().create(vals)
return self._json_response(self._serialize_ticket(ticket), status=201)
except Exception:
_logger.exception("Create ticket error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- update
@http.route(
'/api/tickets/<int:tid>', type='http', auth='public',
methods=['PATCH', 'OPTIONS'], csrf=False, cors='*',
)
def update_ticket(self, tid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
ticket = request.env['encoach.ticket'].sudo().browse(tid)
if not ticket.exists():
return self._error_response('Ticket not found', 404)
body = self._get_json_body()
vals = {}
if 'subject' in body:
vals['subject'] = body['subject']
if 'description' in body:
vals['description'] = body['description']
if 'status' in body:
vals['status'] = body['status']
if 'priority' in body:
vals['priority'] = body['priority']
if vals:
ticket.write(vals)
return self._json_response(self._serialize_ticket(ticket))
except Exception:
_logger.exception("Update ticket error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------------- delete
@http.route(
'/api/tickets/<int:tid>', type='http', auth='public',
methods=['DELETE', 'OPTIONS'], csrf=False, cors='*',
)
def delete_ticket(self, tid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
ticket = request.env['encoach.ticket'].sudo().browse(tid)
if not ticket.exists():
return self._error_response('Ticket not found', 404)
ticket.unlink()
return self._json_response({'ok': True})
except Exception:
_logger.exception("Delete ticket error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,117 @@
import json
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class TrainingController(EncoachMixin, http.Controller):
# ----------------------------------------------------------- generate
@http.route('/api/training', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def generate_training(self, **kwargs):
"""Generate personalised training content using FAISS + GPT."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
target_user_id = body.get('userID', user.id)
stats = body.get('stats', [])
Training = request.env['encoach.training'].sudo()
training = Training.generate_training(
user_id=int(target_user_id),
stats=stats,
)
return self._json_response({
'id': training.id,
'_id': str(training.id),
})
except Exception:
_logger.exception("Generate training error")
return self._error_response('Internal server error', 500)
# -------------------------------------------------------- get record
@http.route(
'/api/training/<int:tid>', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False, cors='*',
)
def get_training(self, tid, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
training = request.env['encoach.training'].sudo().browse(tid)
if not training.exists():
return self._error_response('Training record not found', 404)
return self._json_response({
'_id': str(training.id),
'id': training.id,
'userId': str(training.user_id.id) if training.user_id else None,
'createdAt': training.created_at.isoformat() if getattr(training, 'created_at', None) else (
training.create_date.isoformat() if training.create_date else None
),
'exams': json.loads(training.exams) if getattr(training, 'exams', None) else [],
'tips': json.loads(training.tips) if getattr(training, 'tips', None) else {},
'weakAreas': json.loads(training.weak_areas) if getattr(training, 'weak_areas', None) else [],
})
except Exception:
_logger.exception("Get training error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------------- tips
@http.route('/api/training/tips', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def fetch_tips(self, **kwargs):
"""Retrieve contextual tips using FAISS semantic search + GPT."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
Training = request.env['encoach.training'].sudo()
tips = Training.fetch_tips(
context=body.get('context', ''),
question=body.get('question', ''),
answer=body.get('answer', ''),
correct_answer=body.get('correct_answer', ''),
)
return self._json_response({'tips': tips})
except Exception:
_logger.exception("Fetch tips error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------ walkthrough
@http.route('/api/training/walkthrough', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def get_walkthrough(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
Walkthrough = request.env['encoach.walkthrough'].sudo()
records = Walkthrough.search([], order='sequence asc')
data = [{
'_id': str(w.id),
'id': w.id,
'title': getattr(w, 'title', ''),
'description': getattr(w, 'description', ''),
'steps': json.loads(w.steps) if getattr(w, 'steps', None) else [],
'module': getattr(w, 'module', ''),
} for w in records]
return self._json_response(data)
except Exception:
_logger.exception("Get walkthrough error")
return self._error_response('Internal server error', 500)

View File

@@ -0,0 +1,185 @@
import logging
from odoo import http
from odoo.http import request
from .base import EncoachMixin
_logger = logging.getLogger(__name__)
class UsersController(EncoachMixin, http.Controller):
# ---------------------------------------------------------- current user
@http.route('/api/user', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def get_current_user(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
return self._json_response(self._serialize(user))
except Exception:
_logger.exception("Get current user error")
return self._error_response('Internal server error', 500)
# ----------------------------------------------------------- update user
@http.route(
'/api/users/update', type='http', auth='public',
methods=['POST', 'PATCH', 'OPTIONS'], csrf=False, cors='*',
)
def update_user(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
vals = {}
field_map = {
'name': 'name',
'phone': 'phone',
'nativeLanguage': 'encoach_native_language',
'country': 'encoach_country',
'learningGoal': 'encoach_learning_goal',
'image': 'encoach_image_url',
}
for api_field, odoo_field in field_map.items():
if api_field in body:
vals[odoo_field] = body[api_field]
if 'password' in body and body['password']:
vals['password'] = body['password']
if vals:
user.sudo().write(vals)
updated = request.env['res.users'].sudo().browse(user.id)
return self._json_response(self._serialize(updated))
except Exception:
_logger.exception("Update user error")
return self._error_response('Internal server error', 500)
# ------------------------------------------------------------- list users
@http.route('/api/users/list', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def list_users(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
offset, limit, page = self._paginate_params(kwargs)
domain = []
search = kwargs.get('search', '').strip()
if search:
domain.append('|')
domain.append(('name', 'ilike', search))
domain.append(('login', 'ilike', search))
user_type = kwargs.get('type')
if user_type:
domain.append(('encoach_user_type', '=', user_type))
entity_id = kwargs.get('entityID')
if entity_id:
domain.append(('encoach_entity_id', '=', int(entity_id)))
Users = request.env['res.users'].sudo()
total = Users.search_count(domain)
records = Users.search(domain, offset=offset, limit=limit, order='name asc')
return self._json_response({
'users': [self._serialize(u) for u in records],
'total': total,
'page': page,
'limit': limit,
})
except Exception:
_logger.exception("List users error")
return self._error_response('Internal server error', 500)
# ---------------------------------------------------------- get user by id
@http.route('/api/users/<int:user_id>', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def get_user(self, user_id, **kwargs):
try:
auth_user = self._authenticate()
if not auth_user:
return self._error_response('Unauthorized', 401)
target = request.env['res.users'].sudo().browse(user_id)
if not target.exists():
return self._error_response('User not found', 404)
return self._json_response(self._serialize(target))
except Exception:
_logger.exception("Get user error")
return self._error_response('Internal server error', 500)
# --------------------------------------------------- user controller action
@http.route('/api/users/controller', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*')
def user_controller(self, **kwargs):
"""Handle user controller actions such as transferring users between groups."""
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
body = self._get_json_body()
action = body.get('action')
if not action:
return self._error_response('Action is required', 400)
Group = request.env['encoach.group'].sudo()
if action == 'transfer':
user_ids = body.get('userIds', [])
from_group_id = body.get('fromGroupId')
to_group_id = body.get('toGroupId')
if not user_ids or not to_group_id:
return self._error_response('userIds and toGroupId are required', 400)
if from_group_id:
from_group = Group.browse(int(from_group_id))
if from_group.exists():
from_group.write({
'participant_ids': [(3, int(uid)) for uid in user_ids],
})
to_group = Group.browse(int(to_group_id))
if not to_group.exists():
return self._error_response('Target group not found', 404)
to_group.write({
'participant_ids': [(4, int(uid)) for uid in user_ids],
})
return self._json_response({'ok': True})
if action == 'remove':
user_ids = body.get('userIds', [])
group_id = body.get('groupId')
if not user_ids or not group_id:
return self._error_response('userIds and groupId are required', 400)
group = Group.browse(int(group_id))
if group.exists():
group.write({
'participant_ids': [(3, int(uid)) for uid in user_ids],
})
return self._json_response({'ok': True})
return self._error_response(f'Unknown action: {action}', 400)
except Exception:
_logger.exception("User controller error")
return self._error_response('Internal server error', 500)
# ---------------------------------------------------------- user balance
@http.route('/api/users/balance', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*')
def user_balance(self, **kwargs):
try:
user = self._authenticate()
if not user:
return self._error_response('Unauthorized', 401)
balance = getattr(user, 'encoach_balance', 0)
return self._json_response({'balance': balance})
except Exception:
_logger.exception("User balance error")
return self._error_response('Internal server error', 500)