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:
20
encoach_api/controllers/__init__.py
Normal file
20
encoach_api/controllers/__init__.py
Normal 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
|
||||
89
encoach_api/controllers/approvals.py
Normal file
89
encoach_api/controllers/approvals.py
Normal 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)
|
||||
235
encoach_api/controllers/assignments.py
Normal file
235
encoach_api/controllers/assignments.py
Normal 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)
|
||||
110
encoach_api/controllers/auth.py
Normal file
110
encoach_api/controllers/auth.py
Normal 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)
|
||||
73
encoach_api/controllers/base.py
Normal file
73
encoach_api/controllers/base.py
Normal 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]
|
||||
122
encoach_api/controllers/classrooms.py
Normal file
122
encoach_api/controllers/classrooms.py
Normal 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)
|
||||
88
encoach_api/controllers/discounts.py
Normal file
88
encoach_api/controllers/discounts.py
Normal 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)
|
||||
175
encoach_api/controllers/entities.py
Normal file
175
encoach_api/controllers/entities.py
Normal 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)
|
||||
293
encoach_api/controllers/evaluations.py
Normal file
293
encoach_api/controllers/evaluations.py
Normal 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)
|
||||
220
encoach_api/controllers/exams.py
Normal file
220
encoach_api/controllers/exams.py
Normal 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)
|
||||
347
encoach_api/controllers/generation.py
Normal file
347
encoach_api/controllers/generation.py
Normal 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)
|
||||
65
encoach_api/controllers/grading.py
Normal file
65
encoach_api/controllers/grading.py
Normal 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)
|
||||
213
encoach_api/controllers/media.py
Normal file
213
encoach_api/controllers/media.py
Normal 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)
|
||||
149
encoach_api/controllers/registration.py
Normal file
149
encoach_api/controllers/registration.py
Normal 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)
|
||||
73
encoach_api/controllers/sessions.py
Normal file
73
encoach_api/controllers/sessions.py
Normal 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)
|
||||
137
encoach_api/controllers/stats.py
Normal file
137
encoach_api/controllers/stats.py
Normal 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)
|
||||
82
encoach_api/controllers/storage.py
Normal file
82
encoach_api/controllers/storage.py
Normal 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)
|
||||
192
encoach_api/controllers/subscriptions.py
Normal file
192
encoach_api/controllers/subscriptions.py
Normal 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)
|
||||
128
encoach_api/controllers/tickets.py
Normal file
128
encoach_api/controllers/tickets.py
Normal 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)
|
||||
117
encoach_api/controllers/training.py
Normal file
117
encoach_api/controllers/training.py
Normal 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)
|
||||
185
encoach_api/controllers/users.py
Normal file
185
encoach_api/controllers/users.py
Normal 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)
|
||||
Reference in New Issue
Block a user