diff --git a/custom_addons/encoach_ai_course/controllers/course_plan.py b/custom_addons/encoach_ai_course/controllers/course_plan.py index edb5acae..8ed0118f 100644 --- a/custom_addons/encoach_ai_course/controllers/course_plan.py +++ b/custom_addons/encoach_ai_course/controllers/course_plan.py @@ -27,6 +27,9 @@ from odoo.addons.encoach_ai_course.services.deliverables import ( compute_deliverables, ) from odoo.addons.encoach_ai_course.services.media_service import MediaService +from odoo.addons.encoach_ai_course.services.exercise_extractor import ( + ExerciseExtractor, +) _logger = logging.getLogger(__name__) @@ -45,10 +48,19 @@ def _request_language(): def _entity_scope(): - """Return (entity_ids, is_superadmin) for current JWT user.""" + """Return (entity_ids, is_superadmin) for current JWT user. + + Mirrors ``encoach_lms_api.controllers.lms_core._entity_scope``: any user + linked to one or more entities is entity-scoped, even when they carry + Odoo settings groups. + """ user = request.env.user.sudo() - is_super = bool(user.has_group('base.group_system')) entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else [] + if entity_ids: + return entity_ids, False + is_super = bool( + user.id == 1 or user.has_group('base.group_system') + ) return entity_ids, is_super @@ -222,6 +234,124 @@ class CoursePlanController(http.Controller): _logger.exception('course-plan.generate_week_materials failed') return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + # ------------------------------------------------------------------ + # POST /api/ai/course-plan//weeks//materials/v2 + # ------------------------------------------------------------------ + # RAG-grounded richer-schema generator. Requires at least one indexed + # source on the plan; if none exists, the response is a 409 so the UI + # can prompt the user to upload a reference book first. The pipeline + # is otherwise identical to /materials but persists ``grounded_on_json`` + # on each created row and returns a ``rag.sources_used`` summary. + @http.route('/api/ai/course-plan//weeks//materials/v2', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def generate_week_materials_v2(self, plan_id, week_number, **kw): + try: + plan = self._get_plan_scoped(plan_id) + from odoo.addons.encoach_ai_course.services.rag_context import ( + RAGContextBuilder, + ) + builder = RAGContextBuilder(request.env) + if not builder.has_indexed_sources(plan): + return _error_response( + 'No indexed reference sources for this plan. ' + 'Upload at least one PDF/DOCX before using v2.', + 409, + ) + pipeline = CoursePlanPipeline( + request.env, language=_request_language(), + ) + materials = pipeline.generate_week_materials( + plan_id, week_number, use_rag=True, + ) + sources_used = sorted({ + src['source_id'] + for m in materials + for src in (m._loads(m.grounded_on_json, []) or []) + if src.get('source_id') + }) + return _json_response({ + 'items': [m.to_api_dict() for m in materials], + 'count': len(materials), + 'rag': { + 'enabled': True, + 'sources_used': sources_used, + }, + }) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.generate_week_materials_v2 failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # POST /api/ai/course-plan//extract-workbooks + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan//extract-workbooks', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def extract_workbooks(self, plan_id, **kw): + """Mine indexed sources for original exercises and persist them + as ``interactive_workbook`` materials on the plan.""" + try: + plan = self._get_plan_scoped(plan_id) + body = _get_json_body() or {} + try: + max_batches = int(body.get('max_batches') or 0) or 8 + except (TypeError, ValueError): + max_batches = 8 + extractor = ExerciseExtractor( + request.env, language=_request_language(), + ) + stats = extractor.run(plan, max_batches=max_batches) + return _json_response({ + 'data': stats, + 'plan_id': plan.id, + }) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.extract_workbooks failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan/material//grounding + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan/material//grounding', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_material_grounding(self, material_id, **kw): + """Return the source citations + extracted-from provenance for a material.""" + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + grounded = material._loads(material.grounded_on_json, []) + extracted = material._loads(material.extracted_from_json, None) + sources = [] + ids = [ + int(s.get('source_id')) for s in (grounded or []) + if s.get('source_id') + ] + if extracted and extracted.get('source_id'): + ids.append(int(extracted['source_id'])) + if ids: + Source = request.env['encoach.course.plan.source'].sudo() + rows = Source.browse(list(set(ids))) + sources = [r.to_api_dict() for r in rows if r.exists()] + return _json_response({ + 'material_id': material.id, + 'grounded_on': grounded if isinstance(grounded, list) else [], + 'extracted_from': extracted if isinstance(extracted, dict) else None, + 'sources': sources, + }) + except Exception as exc: + _logger.exception('course-plan.get_material_grounding failed') + code = 404 if isinstance(exc, ValueError) else ( + 403 if isinstance(exc, PermissionError) else 500 + ) + return _error_response(str(exc), code) + # ------------------------------------------------------------------ # GET /api/ai/course-plan//weeks//materials # ------------------------------------------------------------------ @@ -552,6 +682,106 @@ class CoursePlanController(http.Controller): code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 return _error_response(str(exc), code) + @http.route( + '/api/ai/course-plan/material//media/upload', + type='http', auth='none', methods=['POST'], csrf=False, + ) + @jwt_required + def upload_media(self, material_id, **kw): + """Attach an admin-supplied audio / image / video file to a material. + + Multipart form fields: + + * ``kind`` — ``audio`` | ``image`` | ``video`` (required) + * ``file`` — the binary blob (required) + * ``title`` — optional human label, defaults to the filename + + The persisted ``encoach.course.plan.media`` row is marked + ``provider='manual'`` so the drawer renders it distinctly from + AI-generated assets and admins know which clips were hand-curated. + Hard 100 MB cap on the upload to defend the filestore against an + accidental 4K-master-from-a-DSLR upload. + """ + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + + kind = (request.httprequest.form.get('kind') or '').strip().lower() + if kind not in ('audio', 'image', 'video'): + return _error_response( + "kind must be one of 'audio', 'image', 'video'", 400, + ) + + uploaded = request.httprequest.files.get('file') + if not uploaded or not uploaded.filename: + return _error_response( + "Multipart field 'file' is required", 400, + ) + data = uploaded.read() + if not data: + return _error_response('Uploaded file is empty', 400) + # 100 MB upper bound — well above the largest realistic + # listening track or lesson video, well below anything that + # could OOM the worker on base64-encode. + if len(data) > 100 * 1024 * 1024: + return _error_response( + 'File too large — 100 MB maximum per upload', 413, + ) + mime_type = (uploaded.mimetype or '').lower() or 'application/octet-stream' + expected_prefix = { + 'audio': 'audio/', + 'image': 'image/', + 'video': 'video/', + }[kind] + # We don't *require* a strict mime match (browsers sometimes + # send octet-stream for .m4a, etc.) but if a caller flags + # ``kind=audio`` and uploads a clearly-image MIME, that's a + # real bug worth surfacing rather than silently swallowing. + if (mime_type + and not mime_type.startswith(expected_prefix) + and mime_type != 'application/octet-stream'): + return _error_response( + f"MIME {mime_type!r} does not match kind={kind!r}", 400, + ) + + title = ( + (request.httprequest.form.get('title') or '').strip() + or uploaded.filename + ) + + Media = request.env['encoach.course.plan.media'].sudo() + media = Media.create({ + 'plan_id': material.plan_id.id, + 'week_id': material.week_id.id if material.week_id else False, + 'material_id': material.id, + 'kind': kind, + 'provider': 'manual', + 'title': title, + 'mime_type': mime_type, + 'size_bytes': len(data), + 'status': 'generating', + }) + attach = request.env['ir.attachment'].sudo().create({ + 'name': uploaded.filename, + 'type': 'binary', + 'datas': base64.b64encode(data), + 'mimetype': mime_type, + 'res_model': 'encoach.course.plan.media', + 'res_id': media.id, + 'public': True, + }) + media.write({ + 'attachment_id': attach.id, + 'status': 'ready', + 'error': False, + }) + return _json_response({'data': media.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.upload_media failed') + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) + @http.route('/api/ai/course-plan/material//media', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required @@ -819,6 +1049,155 @@ class CoursePlanController(http.Controller): _logger.exception('course-plan.student_list_plans failed') return _error_response(str(exc), 500) + # ------------------------------------------------------------------ + # POST /api/student/course-plans//materials//attempts + # ------------------------------------------------------------------ + @http.route( + '/api/student/course-plans//materials//attempts', + type='http', auth='none', methods=['POST'], csrf=False, + ) + @jwt_required + def student_save_attempt(self, plan_id, material_id, **kw): + """Persist a workbook attempt and return the freshly-graded score. + + Body shape:: + + { "answers": { "": "", ... }, + "finalize": false } + + The response is the same shape as ``GET .../attempts/me`` so the + UI can replace its local state in one swap. + """ + try: + user = request.env.user + material = self._resolve_attempt_target(plan_id, material_id, user) + body = _get_json_body() or {} + answers = body.get('answers') or {} + if not isinstance(answers, dict): + return _error_response('answers must be an object', 400) + finalize = bool(body.get('finalize')) + + Attempt = request.env['encoach.course.plan.workbook.attempt'].sudo() + attempt = Attempt.search([ + ('material_id', '=', material.id), + ('student_id', '=', user.id), + ], order='attempt_number desc', limit=1) + if not attempt: + attempt = Attempt.create({ + 'material_id': material.id, + 'student_id': user.id, + 'attempt_number': 1, + }) + elif attempt.is_final: + # Once finalised, a re-submit becomes a new attempt so the + # original locked attempt survives in the audit trail. + attempt = Attempt.create({ + 'material_id': material.id, + 'student_id': user.id, + 'attempt_number': attempt.attempt_number + 1, + }) + attempt.grade(answers, finalize=finalize) + return _json_response({'data': attempt.to_api_dict()}) + except PermissionError as exc: + return _error_response(str(exc), 403) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.student_save_attempt failed') + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # GET /api/student/course-plans//materials//attempts/me + # ------------------------------------------------------------------ + @http.route( + '/api/student/course-plans//materials//attempts/me', + type='http', auth='none', methods=['GET'], csrf=False, + ) + @jwt_required + def student_my_attempt(self, plan_id, material_id, **kw): + try: + user = request.env.user + material = self._resolve_attempt_target(plan_id, material_id, user) + attempt = request.env['encoach.course.plan.workbook.attempt'].sudo().search([ + ('material_id', '=', material.id), + ('student_id', '=', user.id), + ], order='attempt_number desc', limit=1) + if not attempt: + return _json_response({'data': None}) + return _json_response({'data': attempt.to_api_dict()}) + except PermissionError as exc: + return _error_response(str(exc), 403) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.student_my_attempt failed') + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan//materials//attempts + # ------------------------------------------------------------------ + @http.route( + '/api/ai/course-plan//materials//attempts', + type='http', auth='none', methods=['GET'], csrf=False, + ) + @jwt_required + def list_material_attempts(self, plan_id, material_id, **kw): + """Teacher dashboard: every student's latest attempt on a material.""" + try: + self._get_plan_scoped(plan_id) + material = self._resolve_material(material_id) + if not material or material.plan_id.id != int(plan_id): + return _error_response('Material not found', 404) + attempts = request.env['encoach.course.plan.workbook.attempt'].sudo().search([ + ('material_id', '=', material.id), + ], order='student_id, attempt_number desc') + # Latest attempt per student. + latest_by_student: dict[int, object] = {} + for a in attempts: + latest_by_student.setdefault(a.student_id.id, a) + items = [a.to_api_dict() for a in latest_by_student.values()] + return _json_response({ + 'items': items, + 'count': len(items), + }) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.list_material_attempts failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + def _resolve_attempt_target(self, plan_id, material_id, user): + """Verify the material exists, belongs to the plan, and the student + actually has the plan assigned (entity-based access for non-students + is also allowed so admins / teachers can self-test the workbook). + """ + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + raise ValueError('Plan not found') + material = request.env['encoach.course.plan.material'].sudo().browse( + int(material_id), + ) + if not material.exists() or material.plan_id.id != plan.id: + raise ValueError('Material not found') + + # Student assignment path. + for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'): + if a.mode == 'students' and user.id in a.student_user_ids.ids: + return material + if a.mode == 'batch' and user.id in a.expand_user_ids(): + return material + if a.mode == 'entities' and user.id in a.expand_user_ids(): + return material + + # Entity-admin / teacher fallback so they can preview-and-submit. + entity_ids, is_super = _entity_scope() + if is_super: + return material + if entity_ids and plan.entity_id and plan.entity_id.id in entity_ids: + return material + raise PermissionError('Plan not assigned to you') + @http.route('/api/student/course-plans/', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required diff --git a/custom_addons/encoach_ai_course/models/__init__.py b/custom_addons/encoach_ai_course/models/__init__.py index 5bc4cfe9..7ae0ee6f 100644 --- a/custom_addons/encoach_ai_course/models/__init__.py +++ b/custom_addons/encoach_ai_course/models/__init__.py @@ -4,3 +4,4 @@ from . import course_plan from . import course_plan_source from . import course_plan_media from . import course_plan_assignment +from . import workbook_attempt diff --git a/custom_addons/encoach_ai_course/models/course_plan.py b/custom_addons/encoach_ai_course/models/course_plan.py index ed961d36..9afe38de 100644 --- a/custom_addons/encoach_ai_course/models/course_plan.py +++ b/custom_addons/encoach_ai_course/models/course_plan.py @@ -44,6 +44,7 @@ MATERIAL_TYPE_SELECTION = [ ('grammar_lesson', 'Grammar Lesson'), ('vocabulary_list', 'Vocabulary List'), ('practice', 'Practice Exercises'), + ('interactive_workbook', 'Interactive Workbook'), ('other', 'Other'), ] @@ -302,6 +303,17 @@ class CoursePlanMaterial(models.Model): help='Plain-text rendering for easy preview / copy-paste when the ' 'structured body is not needed.', ) + grounded_on_json = fields.Text( + help='JSON list of source citations used to ground this material via ' + 'RAG: [{source_id, title, chunks_used}]. Surfaced in the UI as ' + 'a "Grounded on N references" badge.', + ) + extracted_from_json = fields.Text( + help='JSON {source_id, page_hint, …} when the material was mined ' + 'from an indexed PDF/DOCX by ExerciseExtractor. Distinct from ' + 'grounded_on_json which records *retrieval* rather than ' + '*extraction* provenance.', + ) media_ids = fields.One2many( 'encoach.course.plan.media', 'material_id', string='Generated media', @@ -317,6 +329,8 @@ class CoursePlanMaterial(models.Model): def to_api_dict(self, include_media=True): self.ensure_one() + grounded = self._loads(self.grounded_on_json, []) + extracted = self._loads(self.extracted_from_json, None) out = { 'id': self.id, 'plan_id': self.plan_id.id, @@ -330,6 +344,8 @@ class CoursePlanMaterial(models.Model): 'summary': self.summary or '', 'body': self._loads(self.body_json, {}), 'body_text': self.body_text or '', + 'grounded_on': grounded if isinstance(grounded, list) else [], + 'extracted_from': extracted if isinstance(extracted, dict) else None, } if include_media: out['media'] = [m.to_api_dict() for m in self.media_ids] diff --git a/custom_addons/encoach_ai_course/models/workbook_attempt.py b/custom_addons/encoach_ai_course/models/workbook_attempt.py new file mode 100644 index 00000000..ffa33333 --- /dev/null +++ b/custom_addons/encoach_ai_course/models/workbook_attempt.py @@ -0,0 +1,337 @@ +"""Per-student workbook attempt — server-side scoring + persistence. + +A student opens an ``interactive_workbook`` material in +``InteractiveWorkbook.tsx``, types/picks/drags answers, and either +clicks "Check answers" (debounced save) or "Submit final" (final save). +Each save creates or updates one ``encoach.course.plan.workbook.attempt`` +row keyed by ``(material_id, student_id, attempt_number)``. + +The score is always recomputed server-side from the persisted answers +so a tampered client cannot inflate the score. The grading function +handles each of the six exercise types defined in the plan schema: + +* ``gap_fill`` — case-insensitive equality, ``alt`` list match. +* ``multiple_choice`` — exact match against ``answer`` (string or letter). +* ``match_pairs`` — set-equality of pair tuples. +* ``reorder_words`` — token-by-token equality (whitespace-tolerant). +* ``transformation`` — equality with normalised punctuation, ``alt`` + fallback, capitalisation tolerated. +* ``short_answer`` — glob-style ``accepted`` template (``*`` matches + any phrase) plus exact ``answer`` fallback. + +Entity isolation +---------------- +Every attempt carries the same ``entity_id`` as the parent plan. A SQL +constraint mirrors the LMS isolation pattern: a student in entity A +cannot create an attempt against a material owned by entity B. +""" + +from __future__ import annotations + +import json +import logging +import re + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- +# Scoring helpers — pure functions so the unit tests can hit them +# directly without spinning up an ORM. +# ---------------------------------------------------------------------- + +def _norm_text(s) -> str: + if s is None: + return '' + return re.sub(r'\s+', ' ', str(s)).strip().lower() + + +def _norm_punct(s) -> str: + """Normalise final punctuation/quotes for transformation checks.""" + return re.sub(r'[\s\.\?\!,]+$', '', _norm_text(s)) + + +def _glob_match(pattern: str, value: str) -> bool: + """Tiny ``*``-only glob matcher used by short-answer templates.""" + pat = _norm_text(pattern) + val = _norm_text(value) + if not pat: + return False + if '*' not in pat: + return pat == val + rx = '^' + re.escape(pat).replace(r'\*', r'.*') + '$' + return bool(re.match(rx, val)) + + +def _pairs_equal(answer, given) -> bool: + """Set-equality check for match_pairs answers.""" + def _to_set(p): + out = set() + for it in (p or []): + if isinstance(it, (list, tuple)) and len(it) == 2: + try: + out.add((int(it[0]), int(it[1]))) + except (TypeError, ValueError): + return None + return out + a = _to_set(answer) + g = _to_set(given) + if a is None or g is None: + return False + return a == g + + +def _check_one(ex: dict, given) -> bool: + """Return True iff ``given`` is a correct answer for ``ex``.""" + t = (ex.get('type') or '').lower() + + if t == 'gap_fill': + if given is None: + return False + accepted = [ex.get('answer')] + list(ex.get('alt') or []) + n = _norm_text(given) + return any(_norm_text(a) == n for a in accepted if a is not None) + + if t == 'multiple_choice': + opts = ex.get('options') or [] + ans = ex.get('answer') + # The "answer" in the schema can be the literal option string + # ("Paris") OR a single letter ("A","B",…) — accept either. + if ans is None: + return False + if isinstance(given, str): + n = _norm_text(given) + if n == _norm_text(ans): + return True + # Letter-mode: convert "A"/"B" to index → option string. + if isinstance(ans, str) and len(ans) == 1 and ans.isalpha(): + idx = ord(ans.upper()) - ord('A') + if 0 <= idx < len(opts) and _norm_text(opts[idx]) == n: + return True + if isinstance(given, str) and len(given) == 1 and given.isalpha(): + idx = ord(given.upper()) - ord('A') + if 0 <= idx < len(opts) and _norm_text(opts[idx]) == _norm_text(ans): + return True + return False + + if t == 'match_pairs': + return _pairs_equal(ex.get('answer'), given) + + if t == 'reorder_words': + if given is None: + return False + # Accept either a list of tokens or a joined string. + if isinstance(given, list): + given_str = ' '.join(str(t) for t in given) + else: + given_str = str(given) + return _norm_text(given_str) == _norm_text(ex.get('answer')) + + if t == 'transformation': + if given is None: + return False + accepted = [ex.get('answer')] + list(ex.get('alt') or []) + n = _norm_punct(given) + return any(_norm_punct(a) == n for a in accepted if a is not None) + + if t == 'short_answer': + if given is None or str(given).strip() == '': + return False + accepted = list(ex.get('accepted') or []) + if ex.get('answer'): + accepted.append(str(ex['answer'])) + return any(_glob_match(p, given) for p in accepted) + + # Unknown / un-checkable type — never auto-mark correct. + return False + + +def score_exercises(exercises: list[dict], answers: dict) -> dict: + """Grade ``answers`` against ``exercises``. Pure / safe / deterministic.""" + items: list[dict] = [] + correct = 0 + for ex in exercises or []: + eid = str(ex.get('id') or '') + if not eid: + continue + given = answers.get(eid) if isinstance(answers, dict) else None + ok = _check_one(ex, given) + if ok: + correct += 1 + items.append({ + 'id': eid, + 'correct': bool(ok), + 'expected': ex.get('answer'), + 'given': given, + }) + total = len(items) + return { + 'items': items, + 'correct': correct, + 'total': total, + 'percent': round((correct / total) * 100, 1) if total else 0.0, + } + + +# ---------------------------------------------------------------------- +# Odoo model +# ---------------------------------------------------------------------- + +class CoursePlanWorkbookAttempt(models.Model): + _name = 'encoach.course.plan.workbook.attempt' + _description = 'Course-plan interactive workbook attempt' + _order = 'submitted_at desc, id desc' + _rec_name = 'display_name' + + material_id = fields.Many2one( + 'encoach.course.plan.material', + required=True, ondelete='cascade', index=True, + ) + plan_id = fields.Many2one( + related='material_id.plan_id', store=True, index=True, + ) + student_id = fields.Many2one( + 'res.users', required=True, ondelete='cascade', index=True, + string='Student user', + ) + entity_id = fields.Many2one( + 'encoach.entity', index=True, string='Entity', + help='Mirrors the parent plan\'s entity for LMS isolation.', + ) + answers_json = fields.Text( + default='{}', + help='Per-exercise dict keyed by exercise ID ' + '— shape mirrors the schema output by InteractiveWorkbook.tsx.', + ) + score_json = fields.Text( + default='{}', + help='Server-side grading output: ' + '{items: [{id, correct, expected, given}], correct, total, percent}.', + ) + correct_count = fields.Integer(default=0, string='Correct') + total_count = fields.Integer(default=0, string='Total') + percent = fields.Float(default=0.0, digits=(5, 1)) + submitted_at = fields.Datetime( + help='Set when the student clicks "Submit final". ' + 'Until then the row is editable on every "Check answers".', + ) + last_updated_at = fields.Datetime(default=fields.Datetime.now) + attempt_number = fields.Integer(default=1) + is_final = fields.Boolean(default=False) + display_name = fields.Char(compute='_compute_display_name', store=False) + + _sql_constraints = [ + ( + 'workbook_attempt_unique', + 'unique(material_id, student_id, attempt_number)', + 'Only one workbook attempt per (material, student, attempt#).', + ), + ] + + # ------------------------------------------------------------------ + @api.depends('material_id', 'student_id', 'attempt_number') + def _compute_display_name(self): + for rec in self: + mat = rec.material_id.title or 'Workbook' + who = rec.student_id.name or rec.student_id.login or '?' + rec.display_name = f'{mat} — {who} (attempt {rec.attempt_number})' + + # ------------------------------------------------------------------ + @api.constrains('material_id', 'entity_id') + def _check_entity_isolation(self): + for rec in self: + plan = rec.material_id.plan_id + plan_entity = plan.entity_id.id if (plan and plan.entity_id) else False + if rec.entity_id and plan_entity and rec.entity_id.id != plan_entity: + raise ValidationError( + 'Workbook attempt entity must match its plan\'s entity.' + ) + + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + mat = self.env['encoach.course.plan.material'].sudo().browse( + int(vals.get('material_id') or 0), + ) + if mat and mat.exists() and not vals.get('entity_id'): + vals['entity_id'] = ( + mat.plan_id.entity_id.id if mat.plan_id and mat.plan_id.entity_id + else False + ) + return super().create(vals_list) + + # ------------------------------------------------------------------ + @staticmethod + def _exercises_from_material(material) -> list[dict]: + try: + body = json.loads(material.body_json or '{}') + except (TypeError, ValueError): + return [] + ex = body.get('exercises') + if isinstance(ex, list): + return ex + # Some skill bodies (grammar / vocabulary) embed the workbook + # one level deeper. Search a couple of known keys before giving up. + nested = ( + (body.get('interactive_workbook') or {}).get('exercises') + if isinstance(body.get('interactive_workbook'), dict) else None + ) + if isinstance(nested, list): + return nested + return [] + + # ------------------------------------------------------------------ + def grade(self, answers: dict, *, finalize: bool = False) -> dict: + """Re-grade ``answers`` server-side and persist on this row.""" + self.ensure_one() + exercises = self._exercises_from_material(self.material_id) + score = score_exercises(exercises, answers or {}) + vals = { + 'answers_json': json.dumps(answers or {}, ensure_ascii=False), + 'score_json': json.dumps(score, ensure_ascii=False), + 'correct_count': score['correct'], + 'total_count': score['total'], + 'percent': score['percent'], + 'last_updated_at': fields.Datetime.now(), + } + if finalize: + vals['submitted_at'] = fields.Datetime.now() + vals['is_final'] = True + self.write(vals) + return score + + # ------------------------------------------------------------------ + def to_api_dict(self) -> dict: + self.ensure_one() + try: + answers = json.loads(self.answers_json or '{}') + except (TypeError, ValueError): + answers = {} + try: + score = json.loads(self.score_json or '{}') + except (TypeError, ValueError): + score = {} + return { + 'id': self.id, + 'material_id': self.material_id.id, + 'plan_id': self.plan_id.id if self.plan_id else None, + 'student_id': self.student_id.id, + 'student_name': self.student_id.name or self.student_id.login or '', + 'attempt_number': self.attempt_number, + 'is_final': bool(self.is_final), + 'submitted_at': ( + self.submitted_at.isoformat() if self.submitted_at else None + ), + 'last_updated_at': ( + self.last_updated_at.isoformat() if self.last_updated_at else None + ), + 'correct_count': self.correct_count, + 'total_count': self.total_count, + 'percent': self.percent, + 'answers': answers, + 'score': score, + } diff --git a/custom_addons/encoach_ai_course/security/ir.model.access.csv b/custom_addons/encoach_ai_course/security/ir.model.access.csv index cacc6edd..6b25be41 100644 --- a/custom_addons/encoach_ai_course/security/ir.model.access.csv +++ b/custom_addons/encoach_ai_course/security/ir.model.access.csv @@ -7,3 +7,4 @@ access_encoach_course_plan_material_user,encoach.course.plan.material.user,model access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1 access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1 access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1 +access_encoach_course_plan_workbook_attempt_user,encoach.course.plan.workbook.attempt.user,model_encoach_course_plan_workbook_attempt,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_ai_course/services/__init__.py b/custom_addons/encoach_ai_course/services/__init__.py index 195738fa..aa81a28d 100644 --- a/custom_addons/encoach_ai_course/services/__init__.py +++ b/custom_addons/encoach_ai_course/services/__init__.py @@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline from .ielts_pipeline import IeltsPipeline from .course_plan_pipeline import CoursePlanPipeline from .source_indexer import SourceIndexer +from .rag_context import RAGContextBuilder +from .exercise_extractor import ExerciseExtractor from .media_service import MediaService from .deliverables import compute_deliverables diff --git a/custom_addons/encoach_ai_course/services/course_plan_pipeline.py b/custom_addons/encoach_ai_course/services/course_plan_pipeline.py index a1114c1a..878ba4eb 100644 --- a/custom_addons/encoach_ai_course/services/course_plan_pipeline.py +++ b/custom_addons/encoach_ai_course/services/course_plan_pipeline.py @@ -203,6 +203,165 @@ Only include skills present in the week's items list. """ +# Richer schema used by the RAG-grounded v2 generator. Each skill body now +# carries enough material to fill a real teacher's lesson, plus a +# self-contained ``interactive_workbook`` block whose ``exercises[]`` are +# rendered by ``InteractiveWorkbook.tsx``. The model is instructed to keep +# the structure exact — UI components depend on it. +_WEEK_JSON_HINT_V2 = """ +Return JSON with EXACTLY this shape (no extra prose, no surrounding markdown): +{ + "materials": [ + { + "skill": "reading", + "material_type": "reading_text", + "title": "...", + "summary": "1-2 sentence teacher note (purpose + LOs targeted)", + "body": { + "intro": "lead-in question to activate schema", + "text": "", + "glossary": [{"term": "...", "definition": "..."}], + "questions": [ + {"id":"r1", "type": "multiple_choice", "stem":"...", + "options":["A","B","C","D"], "answer":"A", + "rationale":"Why this is the correct answer."}, + {"id":"r2", "type": "true_false", "stem":"...", "answer": true, + "rationale":"..."}, + {"id":"r3", "type": "open", "stem":"...", "model_answer":"..."}, + {"id":"r4", "type": "inference", "stem":"...", "model_answer":"..."} + ] + } + }, + { + "skill": "listening", + "material_type": "listening_script", + "title": "...", + "summary": "...", + "body": { + "context": "Where the conversation/monologue is set.", + "script": "<300-500 word natural script with speaker labels>", + "transcript": "Same as script but cleaned for student handout.", + "comprehension_questions": [ + {"id":"l1","type":"multiple_choice","stem":"...","options":["A","B","C","D"],"answer":"A"}, + {"id":"l2","type":"open","stem":"...","model_answer":"..."} + ], + "listen_and_fill": { + "instructions": "Listen and fill in the missing words.", + "transcript_with_gaps": "I ___ (1) to the ___ (2) every morning.", + "answers": ["go","gym"] + } + } + }, + { + "skill": "speaking", + "material_type": "speaking_prompt", + "title": "...", + "summary": "...", + "body": { + "warmup": "30-second pair task to break the ice.", + "lead_in": "Discussion question to set the topic.", + "paired_prompt": "Student A asks; Student B answers; then swap.", + "solo_prompt": "1-minute mini-presentation on the topic.", + "useful_language": ["I usually...", "On the other hand...", "..."], + "model_answer": "Sample 60-90 second answer the teacher can read aloud." + } + }, + { + "skill": "writing", + "material_type": "writing_prompt", + "title": "...", + "summary": "...", + "body": { + "brainstorm": ["3-5 prompts to spark ideas before drafting."], + "task": "The actual writing task.", + "word_count": 150, + "model_paragraph": "", + "annotations": [ + {"highlight":"phrase from the model","note":"Why it works."} + ], + "checklist": ["Topic sentence", "Linking words", "Range of tenses"] + } + }, + { + "skill": "grammar", + "material_type": "grammar_lesson", + "title": "...", + "summary": "...", + "body": { + "rule": "1-2 paragraph rule explanation.", + "form_table": [ + {"subject":"I/you/we/they","positive":"work","negative":"don't work","question":"do ... work?"}, + {"subject":"he/she/it","positive":"works","negative":"doesn't work","question":"does ... work?"} + ], + "examples": ["She works in a hospital.","They don't live here."], + "common_errors": [{"wrong":"He don't like fish.","right":"He doesn't like fish."}], + "interactive_workbook": { + "lesson_plan": { + "warmup":"...","presentation":"...","controlled_practice":"...", + "freer_practice":"...","exit_ticket":"..." + }, + "exercises": [ + {"id":"g1","type":"gap_fill","stem":"I ___ (be) a student.","answer":"am","alt":["am"],"hint":"present simple of 'be'"}, + {"id":"g2","type":"multiple_choice","stem":"She ___ coffee every morning.","options":["drink","drinks","drinking","is drink"],"answer":"drinks","rationale":"3rd person singular adds -s."}, + {"id":"g3","type":"transformation","from":"He plays football.","instruction":"Make the sentence negative.","answer":"He doesn't play football.","alt":["He does not play football."]}, + {"id":"g4","type":"reorder_words","tokens":["Where","do","you","live","?"],"answer":"Where do you live ?"} + ] + } + } + }, + { + "skill": "vocabulary", + "material_type": "vocabulary_list", + "title": "...", + "summary": "...", + "body": { + "words": [ + {"term":"routine","pos":"n.","definition":"a regular sequence of activities","collocations":["daily routine","morning routine"],"example":"My morning routine is busy."} + ], + "interactive_workbook": { + "lesson_plan": {"warmup":"...","presentation":"...","controlled_practice":"...","freer_practice":"...","exit_ticket":"..."}, + "exercises": [ + {"id":"v1","type":"match_pairs","left":["dog","cat","cow","sheep"],"right":["barks","meows","moos","baas"],"answer":[[0,0],[1,1],[2,2],[3,3]]}, + {"id":"v2","type":"short_answer","stem":"Give an example of your daily routine.","answer":"I usually …","accepted":["I usually *","I always *","I often *","I sometimes *"]} + ] + } + } + }, + { + "skill": "integrated", + "material_type": "interactive_workbook", + "title": "Week N — Practice Workbook", + "summary": "Mixed-skill consolidation tasks — students solve and submit.", + "body": { + "lesson_plan": { + "warmup":"...","presentation":"...","controlled_practice":"...", + "freer_practice":"...","exit_ticket":"..." + }, + "exercises": [ + {"id":"ex1","type":"gap_fill","stem":"I ___ (live) in London.","answer":"live","alt":["live"]}, + {"id":"ex2","type":"multiple_choice","stem":"Which is correct?","options":["He don't","He doesn't","He didn't","He do not"],"answer":"He doesn't","rationale":"3rd person singular negative."}, + {"id":"ex3","type":"match_pairs","left":["bus","train","car","plane"],"right":["station","stop","park","airport"],"answer":[[0,1],[1,0],[2,2],[3,3]]}, + {"id":"ex4","type":"reorder_words","tokens":["What","time","do","you","get","up","?"],"answer":"What time do you get up ?"}, + {"id":"ex5","type":"transformation","from":"She is from Spain.","instruction":"Make a yes/no question.","answer":"Is she from Spain?","alt":["Is she from Spain ?"]}, + {"id":"ex6","type":"short_answer","stem":"What's your name?","answer":"My name is …","accepted":["My name is *","I'm *","I am *"]} + ] + } + } + ] +} + +Rules +----- +- Include skills exactly matching the week's items list (do not add or skip). +- Plus ONE additional `interactive_workbook` of skill `integrated` for mixed practice. +- Word counts and CEFR-appropriate vocabulary are MANDATORY — do not output A1 vocabulary in a B2 lesson. +- Every interactive exercise MUST be one of: gap_fill, multiple_choice, match_pairs, reorder_words, transformation, short_answer. +- Provide a clean machine-checkable `answer` (and `alt`/`accepted` where applicable). The frontend grades them server-side. +- Use the `Reference passages` block (when present) as the primary source of vocabulary, examples, and topic. Cite ideas faithfully — do not invent contradicting facts. +- Do not include URLs, copyrighted full pages, or large verbatim quotes. Paraphrase or use only short illustrative phrases. +""" + + class CoursePlanPipeline: """Wrap the LLM call, normalise the JSON, persist the result.""" @@ -384,12 +543,19 @@ class CoursePlanPipeline: # ------------------------------------------------------------------ # Week-level material generation # ------------------------------------------------------------------ - def generate_week_materials(self, plan_id, week_number): + def generate_week_materials(self, plan_id, week_number, *, use_rag=False): """Generate teaching materials for one week and persist them. Any existing materials for the same plan_id + week_number are replaced — callers that want to keep old versions should copy them before re-running. + + :param use_rag: when True, retrieve top-k passages from each + indexed source via :class:`RAGContextBuilder`, inject them + into the prompt, switch to ``_WEEK_JSON_HINT_V2`` (richer + bodies + interactive workbook), and persist the source + citations on each created material under ``grounded_on_json``. + Falls back to v1 cleanly if no sources are indexed. """ plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id)) if not plan.exists(): @@ -402,25 +568,72 @@ class CoursePlanPipeline: outcomes = plan._loads(plan.outcomes_json, {}) items = week._loads(week.items_json, []) - system_msg = ( - "You are an expert English language teacher creating ready-" - "to-use classroom materials. Your output MUST be valid JSON " - "matching the schema in the user prompt. Keep reading texts " - "close to the target word count for the CEFR level. Keep " - "listening scripts natural and conversational. All tasks " - "must target the outcome codes supplied." - ) - user_msg = ( - f"Course: {plan.name}\n" - f"CEFR: {(plan.cefr_level or '').upper()}\n" - f"Week {week.week_number} — {week.date_label or ''}\n" - f"Unit: {week.unit or ''}\n" - f"Focus: {week.focus or ''}\n\n" - f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n" - f"Full outcome catalogue (for looking up codes):\n" - f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n" - + _WEEK_JSON_HINT - ) + # Build per-skill RAG context (only when requested AND sources exist). + rag_blocks: dict[str, dict] = {} + rag_enabled = False + if use_rag: + from .rag_context import RAGContextBuilder + builder = RAGContextBuilder(self.env) + if builder.has_indexed_sources(plan): + rag_enabled = True + # The week may contain duplicates / the integrated workbook + # references "integrated"; we always retrieve once per + # listed skill plus once for the integrated bucket so the + # extra workbook material has its own grounding too. + wanted = {(it.get('skill') or '').lower() + for it in items if it.get('skill')} + wanted.add('integrated') + for skill in wanted: + if not skill: + continue + rag_blocks[skill] = builder.for_week(plan, week, skill) + + if rag_enabled: + system_msg = ( + "You are an expert English language teacher creating ready-" + "to-use, classroom-ready materials. You ground your " + "content on the reference passages provided in the prompt " + "(from the teacher's uploaded book). Paraphrase ideas; " + "do not quote whole pages verbatim. Output MUST be valid " + "JSON matching the schema EXACTLY — no preamble, no " + "trailing commentary." + ) + ref_block = self._render_rag_block(rag_blocks) + user_msg = ( + f"Course: {plan.name}\n" + f"CEFR: {(plan.cefr_level or '').upper()}\n" + f"Week {week.week_number} — {week.date_label or ''}\n" + f"Unit: {week.unit or ''}\n" + f"Focus: {week.focus or ''}\n\n" + f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n" + f"Full outcome catalogue (for looking up codes):\n" + f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n" + f"Reference passages from your uploaded book(s):\n" + f"{ref_block}\n\n" + + _WEEK_JSON_HINT_V2 + ) + max_tokens = 8000 + else: + system_msg = ( + "You are an expert English language teacher creating ready-" + "to-use classroom materials. Your output MUST be valid JSON " + "matching the schema in the user prompt. Keep reading texts " + "close to the target word count for the CEFR level. Keep " + "listening scripts natural and conversational. All tasks " + "must target the outcome codes supplied." + ) + user_msg = ( + f"Course: {plan.name}\n" + f"CEFR: {(plan.cefr_level or '').upper()}\n" + f"Week {week.week_number} — {week.date_label or ''}\n" + f"Unit: {week.unit or ''}\n" + f"Focus: {week.focus or ''}\n\n" + f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n" + f"Full outcome catalogue (for looking up codes):\n" + f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n" + + _WEEK_JSON_HINT + ) + max_tokens = 6000 content = self._invoke_agent_or_chat( agent_key="course_week_materials", @@ -430,9 +643,10 @@ class CoursePlanPipeline: "course": plan.name, "cefr_level": (plan.cefr_level or "").lower(), "week_number": week.week_number, + "rag_enabled": rag_enabled, }, temperature=0.6, - max_tokens=6000, + max_tokens=max_tokens, action="course_plan.generate_week", ) used_week_fallback = False @@ -474,21 +688,47 @@ class CoursePlanPipeline: summary = (m.get('summary') or '').strip() if used_week_fallback: summary = (summary + "\n\n" + skeleton_note).strip() - rec = Material.create({ + skill = (m.get('skill') or 'integrated').strip().lower() + vals = { 'plan_id': plan.id, 'week_id': week.id, - 'skill': (m.get('skill') or 'integrated').strip().lower(), + 'skill': skill, 'material_type': (m.get('material_type') or 'other').strip(), 'title': (m.get('title') or '').strip() or 'Untitled', 'summary': summary, 'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False), 'body_text': self._flatten_body(m.get('body') or {}), - }) + } + if rag_enabled: + block = rag_blocks.get(skill) or rag_blocks.get('integrated') + if block and block.get('sources'): + vals['grounded_on_json'] = json.dumps( + block['sources'], ensure_ascii=False, + ) + rec = Material.create(vals) created.append(rec) except Exception as exc: # pragma: no cover - defensive _logger.warning("Skipping bad material row: %s", exc) return created + # ------------------------------------------------------------------ + @staticmethod + def _render_rag_block(rag_blocks: dict) -> str: + """Render the per-skill RAG passages as a single citation block. + + We keep the per-skill grouping so the model knows which passages + relate to which skill — a flat dump tends to mix topics and the + model loses focus when generating, e.g., a grammar lesson against + a reading-passage source. + """ + parts: list[str] = [] + for skill, block in rag_blocks.items(): + text = (block or {}).get('as_prompt_block') or '' + if not text: + continue + parts.append(f"### For {skill}:\n{text}") + return '\n\n'.join(parts) or '(no reference passages available)' + # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ diff --git a/custom_addons/encoach_ai_course/services/dialogue_parser.py b/custom_addons/encoach_ai_course/services/dialogue_parser.py new file mode 100644 index 00000000..6a8560cc --- /dev/null +++ b/custom_addons/encoach_ai_course/services/dialogue_parser.py @@ -0,0 +1,247 @@ +"""Parse a listening-script into per-speaker segments with gender hints. + +The course-plan AI emits listening scripts in a "screenplay" form:: + + Host: Welcome to Travel Tales. Today we have Sarah ... + Sarah: Hi! Yes, I spent two weeks in Italy ... + Host: That sounds amazing. What was your favourite city? + Sarah: Venice was incredible. + +Naively feeding that whole blob to a TTS engine reads the speaker +labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one +monotone voice for both characters. That's painful for listening +practice and breaks the very pedagogical signal a dialogue is supposed +to carry — turn-taking, contrasted voices, gendered roles. + +This module extracts the labels, classifies each speaker by gender, and +returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS +pipeline can: + +1. Strip the labels from the rendered audio (``Host:`` is never spoken). +2. Synthesize each turn with a voice matching the speaker's gender + (Host with a male voice, Sarah with a female voice). +3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real + conversation rather than a single narration. + +For monologues (no ``Name:`` prefix anywhere in the script) the parser +returns one segment with ``speaker=None``, so callers can keep the +single-voice fast-path without special-casing. +""" + +from __future__ import annotations + +import re +from typing import Iterable + +# A "speaker tag" is a 1–3 word capitalized name followed by a colon at +# either start-of-string or right after sentence-final punctuation. We +# anchor with a positive lookbehind so we don't match colons that appear +# inside dialogue ("She said: 'No.'") — only those that actually mark a +# turn boundary. +_SPEAKER_PREFIX = re.compile( + r'(?:^|(?<=[.!?]\s)|(?<=\n))' + r'(?P[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})' + r'\s*:\s*' +) + +# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of +# the time when generating English-language listening dialogues. Anything +# missing falls back to alternation by encounter order, which always +# produces alternating male/female voices for the typical 2-speaker +# classroom dialogue. +_FEMALE_NAMES = frozenset({ + # English first names + 'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane', + 'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella', + 'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella', + 'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica', + 'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy', + 'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina', + 'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve', + # Arabic / regional first names common in the platform's audience + 'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma', + 'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem', + # Generic role / kinship terms that imply female + 'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister', + 'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs', + 'ms', 'queen', 'princess', +}) + +_MALE_NAMES = frozenset({ + # English first names + 'john', 'david', 'michael', 'james', 'robert', 'william', 'richard', + 'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew', + 'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george', + 'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim', + 'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah', + 'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb', + 'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve', + # Arabic / regional first names + 'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled', + 'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami', + 'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal', + # Generic role / kinship terms + 'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son', + 'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince', +}) + +# Role nouns that frequently appear as speaker labels in listening +# scripts but carry no inherent gender — we route these through the +# alternation path instead of forcing a default voice on them. +_NEUTRAL_ROLES = frozenset({ + 'narrator', 'speaker', 'voice', 'person', 'student', 'teacher', + 'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer', + 'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor', + 'patient', 'manager', 'employee', 'colleague', 'friend', +}) + + +def _lookup_gender(name: str) -> str | None: + """Return ``'male'`` / ``'female'`` from the static name lookup, or + ``None`` if the first token isn't recognised. + """ + first = (name or '').strip().lower().split()[0] if name else '' + if first in _FEMALE_NAMES: + return 'female' + if first in _MALE_NAMES: + return 'male' + return None + + +def parse_dialogue(script: str) -> list[dict]: + """Split ``script`` into per-speaker segments. + + Returns a list of ``{'speaker': str | None, 'gender': str | None, + 'text': str}``: + + * Multi-speaker dialogue → one entry per turn, with the speaker's + name (preserved as written) and a heuristic gender label. + * Monologue (no ``Name:`` prefix) → a single segment with + ``speaker=None`` and ``gender=None`` so callers can short-circuit. + * Empty / whitespace-only script → ``[]``. + + The returned ``text`` never includes the speaker label — that's the + whole point: the TTS engine must not say "Host colon ..." aloud. + + Gender assignment is two-pass so that unknown / role-only speakers + (Host, Interviewer, etc.) end up *contrasting* with the named + speakers in the same dialogue. Without this a "Host + Sarah" + interview would assign female to both (Sarah by lookup, Host by + default), defeating the whole point of multi-voice rendering. + """ + if not script or not script.strip(): + return [] + + matches = list(_SPEAKER_PREFIX.finditer(script)) + if not matches: + return [{'speaker': None, 'gender': None, 'text': script.strip()}] + + # Build the list of (name, text) turns first so we can do a global + # gender assignment over the unique speaker set. + turns: list[tuple[str, str]] = [] + head = script[: matches[0].start()].strip() + for i, m in enumerate(matches): + name = m.group('name').strip() + end = matches[i + 1].start() if i + 1 < len(matches) else len(script) + text = script[m.end():end].strip() + if text: + turns.append((name, text)) + if not turns: + # Edge case: matched some "Name:" labels but every body turned + # out empty. Return the whole pre-match head as a single + # narration, falling back to the script if even that's empty. + return [{'speaker': None, 'gender': None, + 'text': head or script.strip()}] + + # Pass 1: collect unique speakers in order of first appearance, and + # apply the static name → gender lookup. ``None`` here means + # "we don't know yet — fill in pass 2". + unique_order: list[str] = [] + speaker_gender: dict[str, str | None] = {} + for name, _ in turns: + key = name.lower() + if key not in speaker_gender: + unique_order.append(key) + speaker_gender[key] = _lookup_gender(name) + + # Pass 2: resolve unknowns. The contract we want is "contrast" — + # any 2-speaker dialogue should produce one male and one female + # voice. So for each unknown speaker we count how many male vs + # female slots are *already* assigned (by lookup or by an earlier + # iteration of this pass) and pick the under-represented gender. + # Pure parity-by-index would re-introduce the Host/Sarah collision. + for key in unique_order: + if speaker_gender[key] is not None: + continue + male_count = sum(1 for v in speaker_gender.values() if v == 'male') + female_count = sum(1 for v in speaker_gender.values() if v == 'female') + if male_count < female_count: + speaker_gender[key] = 'male' + elif female_count < male_count: + speaker_gender[key] = 'female' + else: + # Truly tied (or first unknown in a no-lookup dialogue) — + # alternate based on how many unknowns we've already filled + # so two unknowns in a row get different voices. + assigned_unknowns = sum( + 1 for k in unique_order + if speaker_gender[k] is not None and _lookup_gender(k) is None + ) + speaker_gender[key] = ( + 'male' if assigned_unknowns % 2 == 0 else 'female' + ) + + # Pass 3: emit the segments, including any unattributed pre-match + # narration ("It was a sunny day. Anna: Hi!"). The narration takes + # whichever gender is *under-represented* among the speakers, so it + # contrasts with the dialogue voices instead of duplicating one. + segments: list[dict] = [] + if head: + female = sum(1 for v in speaker_gender.values() if v == 'female') + male = sum(1 for v in speaker_gender.values() if v == 'male') + narrator_gender = 'female' if female <= male else 'male' + segments.append( + {'speaker': None, 'gender': narrator_gender, 'text': head}, + ) + + for name, text in turns: + segments.append({ + 'speaker': name, + 'gender': speaker_gender[name.lower()], + 'text': text, + }) + + return segments + + +def strip_speaker_labels(script: str) -> str: + """Return ``script`` with all ``Name:`` prefixes removed. + + Used when the active TTS provider can't do multi-voice (gTTS only + speaks one voice per call) so we still get a clean recording — + just one narrator reading both turns instead of an awkward + "Host colon ... Sarah colon ...". + """ + if not script: + return '' + return _SPEAKER_PREFIX.sub('', script).strip() + + +def is_dialogue(segments: Iterable[dict]) -> bool: + """True if ``segments`` represents a multi-speaker dialogue. + + The single-speaker fast-path skips concatenation and just calls the + underlying TTS once with the full text, so this gate has to be + accurate. We require *two distinct named speakers* — a single + speaker who happens to talk in multiple turns (rare but possible + after an intro paragraph) is still a monologue from a TTS + perspective. + """ + seen = set() + for seg in segments: + sp = seg.get('speaker') + if sp: + seen.add(sp.lower()) + if len(seen) >= 2: + return True + return False diff --git a/custom_addons/encoach_ai_course/services/exercise_extractor.py b/custom_addons/encoach_ai_course/services/exercise_extractor.py new file mode 100644 index 00000000..ff7dab6d --- /dev/null +++ b/custom_addons/encoach_ai_course/services/exercise_extractor.py @@ -0,0 +1,373 @@ +"""Mine exercises from indexed reference books and persist them as +``interactive_workbook`` materials on the plan. + +Pipeline +-------- +1. Walk all chunks belonging to the plan's indexed sources + (``encoach.embedding`` rows with ``content_type='course_plan_source'`` + and ``entity_id=plan.id``). +2. Group chunks into N-chunk batches and ask the LLM to identify + exercises in the text — gap-fills, multiple-choice items, matching + pairs, word-reorder, transformation, short-answer. +3. Validate / clean each exercise with cheap heuristics. Drop anything + without a usable answer key. De-dupe against previously-extracted + stems (same plan). +4. For each accepted batch, attach the workbook to the most relevant + week — picked by keyword overlap between the exercise stems and + each week's ``unit + focus``. Falls back to the first week. +5. Persist as one ``encoach.course.plan.material`` per batch with + ``material_type='interactive_workbook'`` and an + ``extracted_from`` provenance record so the UI can show "Extracted + from ". +""" + +from __future__ import annotations + +import json +import logging +import re + +_logger = logging.getLogger(__name__) + + +# Per LLM call: how many ~2K-char chunks to feed in one go. 6 keeps the +# prompt under ~12K chars, well within a 16K context budget while still +# letting the model see enough surrounding context to identify rubrics. +CHUNKS_PER_BATCH = 6 +# Hard cap on total batches per run — protects against runaway cost on a +# very large book. Operators can lift this via ``max_batches`` kwarg. +DEFAULT_MAX_BATCHES = 8 + +ALLOWED_TYPES = { + 'gap_fill', 'multiple_choice', 'match_pairs', + 'reorder_words', 'transformation', 'short_answer', +} + +_EXTRACT_HINT = """ +You are reading raw text extracted from a printed English exercise book. +Identify any drills, controlled-practice exercises, or workbook tasks in +the passage and extract them as machine-checkable JSON. + +Return JSON with EXACTLY this shape: +{ + "exercises": [ + {"id":"e1", "type":"gap_fill", "stem":"I ___ (be) a student.", "answer":"am", "alt":["am"], "hint":"present simple of 'be'"}, + {"id":"e2", "type":"multiple_choice", "stem":"...", "options":["A","B","C","D"], "answer":"B", "rationale":"..."}, + {"id":"e3", "type":"match_pairs", "left":["dog","cat"], "right":["barks","meows"], "answer":[[0,0],[1,1]]}, + {"id":"e4", "type":"reorder_words", "tokens":["I","am","a","student"], "answer":"I am a student"}, + {"id":"e5", "type":"transformation", "from":"He plays football.", "instruction":"Make it negative.", "answer":"He doesn't play football.", "alt":["He does not play football."]}, + {"id":"e6", "type":"short_answer", "stem":"What's your name?", "answer":"My name is …", "accepted":["My name is *","I'm *","I am *"]} + ], + "topic_keywords": ["routine","present simple", "..."], + "page_hint": "if the source mentions 'p. 12' or 'Unit 1', echo it here" +} + +Strict rules +------------ +- Skip narrative prose, scope-and-sequence pages, answer keys. +- Skip exercises whose answer cannot be inferred from the source. +- Use ONLY the six types listed above; never invent a new type. +- Stems must be self-contained (no "look at exercise 3"). +- Answers must be a string, a boolean, or a list of [int,int] pairs as + shown — never a sentence like "answer key on p. 99". +- Output MUST be valid JSON. If the passage contains no usable + exercises, return ``{"exercises": [], "topic_keywords": [], "page_hint": ""}``. +""" + + +def _normalize_stem(stem: str) -> str: + """Lowercase + collapse whitespace — used for de-dup keys.""" + return re.sub(r'\s+', ' ', (stem or '').strip().lower()) + + +def _is_clean_exercise(ex: dict) -> bool: + """Cheap structural validation. Drops anything we can't grade.""" + t = (ex.get('type') or '').lower() + if t not in ALLOWED_TYPES: + return False + if not (ex.get('stem') or ex.get('from') or ex.get('left') or ex.get('tokens')): + return False + ans = ex.get('answer') + if ans is None or ans == '' or ans == []: + return False + if t == 'multiple_choice': + opts = ex.get('options') or [] + if not isinstance(opts, list) or len(opts) < 2: + return False + if isinstance(ans, str) and ans not in opts: + # Some authors use "B" as the answer; that's fine. + if not (len(ans) == 1 and ans.isalpha()): + return False + if t == 'match_pairs': + if not (isinstance(ex.get('left'), list) + and isinstance(ex.get('right'), list) + and isinstance(ans, list)): + return False + if not all(isinstance(p, list) and len(p) == 2 for p in ans): + return False + if t == 'reorder_words': + toks = ex.get('tokens') + if not (isinstance(toks, list) and len(toks) >= 2): + return False + return True + + +class ExerciseExtractor: + """Walk the plan's indexed corpus and persist interactive workbooks.""" + + CONTENT_TYPE = 'course_plan_source' + + def __init__(self, env, *, language: str = 'en'): + self.env = env + self.language = language + try: + from odoo.addons.encoach_ai.services.openai_service import ( + OpenAIService, + ) + except ImportError: + OpenAIService = None + self._OpenAIService = OpenAIService + self._ai = None # lazy + + # ------------------------------------------------------------------ + def _service(self): + if self._ai is not None: + return self._ai + if self._OpenAIService is None: + raise RuntimeError('encoach_ai.OpenAIService not available') + self._ai = self._OpenAIService(self.env, language=self.language) + return self._ai + + # ------------------------------------------------------------------ + def _load_corpus(self, plan) -> list[dict]: + """Return all indexed chunks for the plan, oldest-first.""" + Embedding = self.env['encoach.embedding'].sudo() + rows = Embedding.search([ + ('content_type', '=', self.CONTENT_TYPE), + ('entity_id', '=', plan.id), + ], order='content_id asc, chunk_index asc') + chunks = [] + for r in rows: + metadata = {} + try: + metadata = json.loads(r.metadata_json or '{}') + except (TypeError, ValueError): + pass + chunks.append({ + 'source_id': int(metadata.get('source_id') or r.content_id), + 'title': metadata.get('title') or '', + 'chunk_index': r.chunk_index, + 'text': r.content_text or '', + }) + return chunks + + # ------------------------------------------------------------------ + def _existing_stems(self, plan) -> set[str]: + """Already-stored normalized stems → avoid duplicate workbooks.""" + Material = self.env['encoach.course.plan.material'].sudo() + rows = Material.search([ + ('plan_id', '=', plan.id), + ('material_type', '=', 'interactive_workbook'), + ]) + seen: set[str] = set() + for m in rows: + try: + body = json.loads(m.body_json or '{}') + except (TypeError, ValueError): + continue + for ex in body.get('exercises') or []: + key = _normalize_stem(ex.get('stem') or ex.get('from') or '') + if key: + seen.add(key) + return seen + + # ------------------------------------------------------------------ + @staticmethod + def _pick_week(plan, topic_keywords: list[str], stems: list[str]): + """Return the week most relevant to a batch's topic keywords. + + Falls back to week 1 when the plan has no overlap signal — that's + better than dropping the workbook entirely. + """ + weeks = list(plan.week_ids.sorted('week_number')) + if not weeks: + return None + haystack = ' '.join(topic_keywords + stems).lower() + if not haystack.strip(): + return weeks[0] + + def score(week): + text = f'{week.unit or ""} {week.focus or ""}'.lower() + terms = [t for t in re.split(r'\W+', text) if len(t) > 3] + return sum(1 for t in terms if t in haystack) + + ranked = sorted(weeks, key=score, reverse=True) + # If nothing scored at all, prefer week 1 for stability. + return ranked[0] if score(ranked[0]) > 0 else weeks[0] + + # ------------------------------------------------------------------ + def _llm_extract_batch(self, chunks: list[dict]) -> dict: + """Run one extraction LLM call against a batch of chunks.""" + ai = self._service() + joined = '\n\n---\n\n'.join( + f'[chunk {c["chunk_index"]} | source #{c["source_id"]}]\n{c["text"]}' + for c in chunks + ) + messages = [ + { + 'role': 'system', + 'content': ( + 'You extract original printed exercises from English ' + 'workbook scans. You return STRICT JSON. You never ' + 'invent exercises that are not on the page.' + ), + }, + { + 'role': 'user', + 'content': ( + f'Extract the exercises from the passage below.\n\n' + f'{_EXTRACT_HINT}\n\nPassage:\n{joined}' + ), + }, + ] + try: + return ai.chat_json( + messages, + temperature=0.1, + max_tokens=4000, + action='course_plan.extract_exercises', + ) or {} + except Exception as exc: + _logger.warning('Exercise extraction LLM call failed: %s', exc) + return {} + + # ------------------------------------------------------------------ + def run(self, plan, *, max_batches: int = DEFAULT_MAX_BATCHES) -> dict: + """Extract exercises for ``plan`` and persist workbook materials. + + :returns: ``{materials_created, exercises_total, batches_run, skipped}``. + """ + chunks = self._load_corpus(plan) + if not chunks: + return { + 'materials_created': 0, + 'exercises_total': 0, + 'batches_run': 0, + 'skipped': 0, + 'reason': 'no indexed chunks for this plan', + } + + seen_stems = self._existing_stems(plan) + Material = self.env['encoach.course.plan.material'].sudo() + materials_created = 0 + exercises_total = 0 + batches_run = 0 + skipped_dup = 0 + + # Group chunks per source so a single workbook stays cohesive. + by_source: dict[int, list[dict]] = {} + for ch in chunks: + by_source.setdefault(ch['source_id'], []).append(ch) + + for source_id, source_chunks in by_source.items(): + for i in range(0, len(source_chunks), CHUNKS_PER_BATCH): + if batches_run >= max_batches: + break + batch = source_chunks[i:i + CHUNKS_PER_BATCH] + batches_run += 1 + payload = self._llm_extract_batch(batch) + if not payload or 'error' in payload: + continue + raw_exercises = payload.get('exercises') or [] + clean: list[dict] = [] + for ex in raw_exercises: + if not isinstance(ex, dict): + continue + if not _is_clean_exercise(ex): + continue + key = _normalize_stem( + ex.get('stem') or ex.get('from') or '', + ) + if not key or key in seen_stems: + skipped_dup += 1 + continue + seen_stems.add(key) + clean.append(ex) + if not clean: + continue + + topic_keywords = [ + str(t).strip() for t in (payload.get('topic_keywords') or []) + if str(t).strip() + ] + page_hint = (payload.get('page_hint') or '').strip() + stems = [ex.get('stem') or ex.get('from') or '' for ex in clean] + week = self._pick_week(plan, topic_keywords, stems) + if week is None: + continue + + title = self._pick_title(batch, topic_keywords, week) + summary = ( + f'Extracted from {batch[0].get("title") or "uploaded book"}' + + (f' ({page_hint})' if page_hint else '') + + f' — {len(clean)} interactive exercise(s).' + ) + body = { + 'lesson_plan': { + 'warmup': '', + 'presentation': ( + 'Originally printed exercises mined from the ' + 'reference book. Use them as in-class drills ' + 'or homework.' + ), + 'controlled_practice': '', + 'freer_practice': '', + 'exit_ticket': '', + }, + 'exercises': clean, + } + provenance = { + 'source_id': int(source_id), + 'source_title': batch[0].get('title') or '', + 'page_hint': page_hint, + 'topic_keywords': topic_keywords, + 'chunk_indices': [c['chunk_index'] for c in batch], + } + Material.create({ + 'plan_id': plan.id, + 'week_id': week.id, + 'skill': 'integrated', + 'material_type': 'interactive_workbook', + 'title': title, + 'summary': summary, + 'body_json': json.dumps(body, ensure_ascii=False), + 'body_text': '', + 'extracted_from_json': json.dumps( + provenance, ensure_ascii=False, + ), + }) + materials_created += 1 + exercises_total += len(clean) + + if batches_run >= max_batches: + break + + return { + 'materials_created': materials_created, + 'exercises_total': exercises_total, + 'batches_run': batches_run, + 'skipped': skipped_dup, + } + + # ------------------------------------------------------------------ + @staticmethod + def _pick_title(batch, topic_keywords, week) -> str: + """Compose a friendly material title for the extracted workbook.""" + topic = '' + if topic_keywords: + topic = topic_keywords[0] + if not topic and week and (week.unit or week.focus): + topic = (week.unit or week.focus or '').strip() + if not topic: + topic = 'Practice' + return f'Workbook — {topic.title()}' diff --git a/custom_addons/encoach_ai_course/services/media_service.py b/custom_addons/encoach_ai_course/services/media_service.py index 15f894ff..2ee07be3 100644 --- a/custom_addons/encoach_ai_course/services/media_service.py +++ b/custom_addons/encoach_ai_course/services/media_service.py @@ -31,17 +31,42 @@ import logging import os import shutil import subprocess +import sys import tempfile import time from typing import Optional _logger = logging.getLogger(__name__) + +def _resolve_bin(name: str) -> str | None: + """Locate a binary, preferring the running Python's sibling ``bin/``. + + Odoo is launched with the conda env's interpreter, so its sibling + ``bin/`` reliably hosts ``ffmpeg`` even when ``$PATH`` in the + daemon's environment doesn't include the conda prefix. We use this + same pattern in ``encoach_ai_course.services.source_indexer`` for + tesseract / pdftoppm — keeping it duplicated here rather than + importing across services to avoid a circular dep on a single + helper. + """ + sibling = os.path.join(os.path.dirname(sys.executable), name) + if os.path.isfile(sibling) and os.access(sibling, os.X_OK): + return sibling + return shutil.which(name) + from odoo.addons.encoach_ai.services import provider_router from odoo.addons.encoach_ai.services.provider_router import ( classify_provider_error, should_fallback, ) +from . import dialogue_parser + + +# Providers that can do per-segment multi-voice TTS. gTTS / silent only +# expose a single voice per request, so for those we degrade gracefully +# to label-stripped single-voice narration. +_MULTI_VOICE_PROVIDERS = frozenset({'polly', 'elevenlabs'}) # --- Helpers ---------------------------------------------------------------- @@ -93,7 +118,16 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None: def _build_audio_script(material) -> str: - """Choose the right text from a material's body for narration.""" + """Choose the right text from a material's body for narration. + + NEVER include the structural field labels ("Context:", "Script:", + "Transcript:") — only the actual narratable content. The previous + implementation already did this for ``listening_script`` (only the + ``script`` field was returned), but we re-document the contract + here because the dialogue-aware path below depends on it: the + speaker labels we strip out are ONLY the in-text "Name:" turn + markers, never section headers. + """ body = material._loads(material.body_json, {}) or {} if material.material_type == 'listening_script': return (body.get('script') or '').strip() @@ -208,16 +242,41 @@ class MediaService: return media media.write({'source_text': text[:3000]}) + # Parse "Host: ... Sarah: ..." style dialogues into per-speaker + # segments. For monologues this returns a single segment with + # speaker=None and we fall through to the legacy fast-path. For + # real dialogues we'll synthesize each turn with a gender-matched + # voice and concatenate them with ffmpeg. + segments = dialogue_parser.parse_dialogue(text) + is_dialogue = dialogue_parser.is_dialogue(segments) + chain = provider_router.resolve_chain( self.env, 'audio', requested=provider if provider != 'auto' else None, ) errors = [] for prov in chain: try: - result = self._call_audio_provider( - prov, text=text, voice=voice, - language=language, gender=gender, - ) + if is_dialogue and prov in _MULTI_VOICE_PROVIDERS: + # The good path: per-speaker, gender-matched voices, + # stitched into a single MP3 by ffmpeg. The user asked + # for "if conversation between two persons, person if + # man voice must be man and if woman voice is for + # woman" — this is where that contract lives. + result = self._synthesize_dialogue( + prov, segments=segments, language=language, + ) + else: + # Single-voice path. Even here we must NOT speak the + # speaker labels aloud, so for dialogue scripts hitting + # a free fallback we feed the label-stripped form. + spoken = ( + dialogue_parser.strip_speaker_labels(text) + if is_dialogue else text + ) + result = self._call_audio_provider( + prov, text=spoken, voice=voice, + language=language, gender=gender, + ) content_type = result.get('content_type', 'audio/mpeg') ext = 'wav' if content_type == 'audio/wav' else 'mp3' attach = _attach_bytes( @@ -294,6 +353,132 @@ class MediaService: return synthesize_silent(duration_seconds=seconds) raise RuntimeError(f'Unknown audio provider: {provider!r}') + # -- Multi-voice dialogue -------------------------------------------- + def _synthesize_dialogue(self, provider, *, segments, language): + """Render a dialogue script with per-speaker, gendered voices. + + Each turn is synthesized independently — Polly/ElevenLabs both + let us pick a voice per call — and the resulting MP3 / WAV blobs + are concatenated via ffmpeg's concat filter into a single audio + track. We re-encode through libmp3lame so the output is uniform + regardless of which provider was used (bitrate, sample-rate + differences would otherwise corrupt the join). + + If ffmpeg is not available we still return *some* audio — a + single-voice rendering of the label-stripped text — rather than + failing the whole request. Listening practice without a real + dialogue is preferable to no audio at all. + """ + if not segments: + raise RuntimeError('No dialogue segments to synthesize') + + ffmpeg_bin = _resolve_bin('ffmpeg') + if not ffmpeg_bin: + # Graceful degradation: produce a single-voice rendition of + # the stripped script. Surface a soft warning but do not + # raise — the caller's fallback chain would just route us to + # gtts/silent, which is identical behaviour but slower. + _logger.warning( + 'ffmpeg missing — falling back to single-voice dialogue; ' + 'install ffmpeg for true per-speaker rendering.' + ) + joined = ' '.join(seg['text'] for seg in segments) + return self._call_audio_provider( + provider, text=joined, voice=None, + language=language, gender='female', + ) + + clip_paths: list[str] = [] + first_voice: Optional[str] = None + # Polly bills per character; cap each turn at ~3000 chars (5x the + # ~600-char average so a stray giant turn from the LLM doesn't + # blow the budget but a typical 3-minute conversation passes + # untouched). + MAX_CHARS_PER_TURN = 3000 + + with tempfile.TemporaryDirectory(prefix='encoach_dialogue_') as tmp: + for idx, seg in enumerate(segments): + seg_text = (seg.get('text') or '').strip() + if not seg_text: + continue + seg_text = seg_text[:MAX_CHARS_PER_TURN] + seg_gender = seg.get('gender') or 'female' + try: + # Pin the per-segment voice via gender. Letting + # ``voice=None`` flow through means each provider + # picks its default gendered voice (Amy/Brian for + # Polly en-GB, Rachel/Arnold for ElevenLabs). + res = self._call_audio_provider( + provider, text=seg_text, voice=None, + language=language, gender=seg_gender, + ) + except Exception as exc: + raise RuntimeError( + f'segment {idx} ({seg.get("speaker") or "narrator"}) ' + f'failed: {exc}' + ) from exc + if not first_voice: + first_voice = res.get('voice') or seg_gender + ext = 'wav' if res.get( + 'content_type') == 'audio/wav' else 'mp3' + clip_path = os.path.join(tmp, f'seg_{idx:03d}.{ext}') + with open(clip_path, 'wb') as fh: + fh.write(res['audio']) + clip_paths.append(clip_path) + + if not clip_paths: + raise RuntimeError('All dialogue segments were empty') + if len(clip_paths) == 1: + # Edge case: 2 speakers but only one had non-empty text. + # Skip the concat dance and return the single clip raw. + with open(clip_paths[0], 'rb') as fh: + return { + 'audio': fh.read(), + 'content_type': 'audio/mpeg' if clip_paths[0].endswith('.mp3') else 'audio/wav', + 'voice': first_voice or 'dialogue', + 'characters': sum(len(s.get('text') or '') for s in segments), + } + + # Build the ffmpeg concat-filter call. We re-encode to a + # uniform 22050 Hz mono MP3 so bitrate/sample-rate mismatches + # between providers (or even between Polly's neural and + # standard engines) don't corrupt the join. + out_path = os.path.join(tmp, 'dialogue.mp3') + cmd = [ffmpeg_bin, '-y'] + for p in clip_paths: + cmd += ['-i', p] + n = len(clip_paths) + filter_inputs = ''.join(f'[{i}:a]' for i in range(n)) + cmd += [ + '-filter_complex', + f'{filter_inputs}concat=n={n}:v=0:a=1[outa]', + '-map', '[outa]', + '-ac', '1', + '-ar', '22050', + '-c:a', 'libmp3lame', + '-b:a', '96k', + out_path, + ] + t0 = time.time() + proc = subprocess.run( + cmd, capture_output=True, check=False, timeout=180, + ) + elapsed = time.time() - t0 + if proc.returncode != 0: + err = (proc.stderr or b'').decode( + 'utf-8', errors='replace')[-500:] + raise RuntimeError( + f'ffmpeg concat failed in {elapsed:.1f}s: {err}', + ) + with open(out_path, 'rb') as fh: + audio_bytes = fh.read() + return { + 'audio': audio_bytes, + 'content_type': 'audio/mpeg', + 'voice': f'dialogue-{n}-turns', + 'characters': sum(len(s.get('text') or '') for s in segments), + } + # -- Image ----------------------------------------------------------- def generate_image(self, material, *, custom_prompt: Optional[str] = None, @@ -497,8 +682,9 @@ class MediaService: # ── Concrete video providers ──────────────────────────────────────── def _compose_video_ffmpeg(self, media, material, audio_media, image_media): - """Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH.""" - if shutil.which('ffmpeg') is None: + """Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available.""" + ffmpeg_bin = _resolve_bin('ffmpeg') + if ffmpeg_bin is None: raise RuntimeError('ffmpeg not found on PATH') plan = material.plan_id @@ -537,7 +723,7 @@ class MediaService: with open(i_path, 'wb') as f: f.write(image_bytes) cmd = [ - 'ffmpeg', '-y', + ffmpeg_bin, '-y', '-loop', '1', '-i', i_path, '-i', a_path, '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', diff --git a/custom_addons/encoach_ai_course/services/rag_context.py b/custom_addons/encoach_ai_course/services/rag_context.py new file mode 100644 index 00000000..3dfa1be1 --- /dev/null +++ b/custom_addons/encoach_ai_course/services/rag_context.py @@ -0,0 +1,281 @@ +"""Build RAG context for course-plan generation. + +Wraps :class:`encoach_vector.services.embedding_service.EmbeddingService` to +return the top-k chunks from a plan's indexed reference sources, scoped to +the canonical ``content_type='course_plan_source'`` and trimmed to a +character budget so the LLM prompt stays within context limits. + +Indexer convention +------------------ +:class:`SourceIndexer` stores each ``encoach.course.plan.source`` chunk +with metadata ``{plan_id, source_id, kind, title, entity_id=plan_id}`` +(see :mod:`source_indexer`). This builder uses that convention to scope +retrieval to a single plan: it filters embeddings on ``entity_id=plan.id`` +and ``content_type='course_plan_source'``. + +Usage +----- +.. code-block:: python + + builder = RAGContextBuilder(env) + ctx = builder.for_week(plan, week, skill='reading', k=6) + if ctx['passages']: + prompt += "\\n\\nReference passages from your uploaded book(s):\\n" + prompt += ctx['as_prompt_block'] +""" + +from __future__ import annotations + +import logging + +_logger = logging.getLogger(__name__) + + +# Soft char budget for the assembled passage block. ~3500 chars is roughly +# 800-1000 tokens, leaving room for the rest of the prompt (~2-3 K tokens) +# under a 16 K context window. +DEFAULT_CHAR_BUDGET = 3500 +DEFAULT_K = 6 + + +class RAGContextBuilder: + """Produce RAG passages for a (plan, week, skill) tuple.""" + + CONTENT_TYPE = 'course_plan_source' + + def __init__(self, env, *, char_budget: int = DEFAULT_CHAR_BUDGET): + self.env = env + self.char_budget = int(char_budget) + self._svc = None # lazily created on first call + + # ------------------------------------------------------------------ + def _service(self): + """Lazy-load the embedding service so import never fails fatally.""" + if self._svc is not None: + return self._svc + try: + from odoo.addons.encoach_vector.services.embedding_service import ( + EmbeddingService, + ) + except ImportError: + _logger.warning( + 'encoach_vector not installed; RAG context disabled.' + ) + return None + self._svc = EmbeddingService(self.env) + return self._svc + + # ------------------------------------------------------------------ + def has_indexed_sources(self, plan) -> bool: + """Return True iff the plan has at least one indexed (status='indexed') source.""" + try: + count = self.env['encoach.course.plan.source'].sudo().search_count([ + ('plan_id', '=', plan.id), + ('status', '=', 'indexed'), + ]) + except Exception: + return False + return bool(count) + + # ------------------------------------------------------------------ + def _build_query(self, plan, week, skill: str) -> str: + """Synthesize a focused retrieval query for (week, skill). + + The query is intentionally dense: skill, week unit + focus, and the + descriptions of the outcomes targeted by this week's items. We feed + the descriptions (not just codes) so semantic search can match book + passages that talk about, say, "personal introductions" even when + no outcome code appears in the source text. + """ + outcomes = plan._loads(plan.outcomes_json, {}) + items = week._loads(week.items_json, []) if week else [] + codes_for_skill: list[str] = [] + for it in items: + if (it.get('skill') or '').lower() == (skill or '').lower(): + codes_for_skill.extend(it.get('outcome_codes') or []) + + outcome_descs: list[str] = [] + skill_outcomes = outcomes.get((skill or '').lower()) or [] + for o in skill_outcomes: + if o.get('code') in set(codes_for_skill): + outcome_descs.append((o.get('description') or '').strip()) + + parts = [ + (skill or '').strip(), + (week.unit or '').strip() if week else '', + (week.focus or '').strip() if week else '', + ' '.join(d for d in outcome_descs if d), + ] + query = ' — '.join(p for p in parts if p) + return query or (plan.name or 'general english') + + # ------------------------------------------------------------------ + def _trim_to_budget(self, results: list[dict]) -> tuple[list[dict], int]: + """Cut chunks until total chars <= self.char_budget. + + Returns the truncated list and the actual char count used. + """ + out: list[dict] = [] + used = 0 + for r in results: + text = (r.get('text') or '').strip() + if not text: + continue + # Per-chunk soft cap so one giant page can't eat the whole budget. + if len(text) > 1200: + text = text[:1200].rsplit(' ', 1)[0] + '…' + if used + len(text) > self.char_budget: + # If we have nothing yet, take a hard slice so the prompt is + # never empty when the budget is tiny. + if not out: + text = text[: max(self.char_budget // 2, 200)] + else: + break + out.append({**r, 'text': text}) + used += len(text) + return out, used + + # ------------------------------------------------------------------ + def for_week(self, plan, week, skill: str, *, k: int = DEFAULT_K) -> dict: + """Return RAG context for a (plan, week, skill). + + :returns: ``{passages, sources, char_budget_used, query, as_prompt_block}``. + Empty dict-shape when the plan has no indexed sources. + """ + empty = { + 'passages': [], + 'sources': [], + 'char_budget_used': 0, + 'query': '', + 'as_prompt_block': '', + } + svc = self._service() + if svc is None: + return empty + if not self.has_indexed_sources(plan): + return empty + + query = self._build_query(plan, week, skill) + try: + raw_results = svc.search( + query, + content_type=self.CONTENT_TYPE, + limit=int(k), + entity_id=plan.id, # SourceIndexer stores plan_id under entity_id + ) + except Exception: + _logger.exception('RAG search failed for plan=%s skill=%s', plan.id, skill) + return empty + + # Decorate with source titles for citation. + Source = self.env['encoach.course.plan.source'].sudo() + passages: list[dict] = [] + for r in raw_results or []: + md = r.get('metadata') or {} + source_id = int(md.get('source_id') or 0) or None + title = md.get('title') or '' + if source_id and not title: + rec = Source.browse(source_id) + if rec.exists(): + title = rec.name or rec.file_name or rec.url or f'Source #{source_id}' + passages.append({ + 'text': r.get('text') or '', + 'similarity': float(r.get('score') or r.get('similarity') or 0.0), + 'source_id': source_id, + 'source_title': title, + 'chunk_index': md.get('chunk_index'), + }) + + passages, used = self._trim_to_budget(passages) + sources = self._collect_sources(passages) + block = self._render_prompt_block(passages) + return { + 'passages': passages, + 'sources': sources, + 'char_budget_used': used, + 'query': query, + 'as_prompt_block': block, + } + + # ------------------------------------------------------------------ + def for_plan(self, plan, *, k: int = 12, query: str | None = None) -> dict: + """Plan-wide retrieval — used by ExerciseExtractor and the workbook + cross-week pass. Falls back to ``plan.name`` when no query is given. + """ + empty = { + 'passages': [], + 'sources': [], + 'char_budget_used': 0, + 'query': '', + 'as_prompt_block': '', + } + svc = self._service() + if svc is None or not self.has_indexed_sources(plan): + return empty + q = (query or plan.name or 'exercises').strip() + try: + raw_results = svc.search( + q, + content_type=self.CONTENT_TYPE, + limit=int(k), + entity_id=plan.id, + ) + except Exception: + _logger.exception('RAG plan-wide search failed for plan=%s', plan.id) + return empty + Source = self.env['encoach.course.plan.source'].sudo() + passages: list[dict] = [] + for r in raw_results or []: + md = r.get('metadata') or {} + source_id = int(md.get('source_id') or 0) or None + title = md.get('title') or '' + if source_id and not title: + rec = Source.browse(source_id) + if rec.exists(): + title = rec.name or rec.file_name or rec.url or f'Source #{source_id}' + passages.append({ + 'text': r.get('text') or '', + 'similarity': float(r.get('score') or r.get('similarity') or 0.0), + 'source_id': source_id, + 'source_title': title, + 'chunk_index': md.get('chunk_index'), + }) + passages, used = self._trim_to_budget(passages) + sources = self._collect_sources(passages) + block = self._render_prompt_block(passages) + return { + 'passages': passages, + 'sources': sources, + 'char_budget_used': used, + 'query': q, + 'as_prompt_block': block, + } + + # ------------------------------------------------------------------ + @staticmethod + def _collect_sources(passages: list[dict]) -> list[dict]: + """Group passages by source_id → ``[{source_id, title, chunks_used}]``.""" + agg: dict[int, dict] = {} + for p in passages: + sid = p.get('source_id') or 0 + entry = agg.setdefault(sid, { + 'source_id': sid, + 'title': p.get('source_title') or '', + 'chunks_used': 0, + }) + entry['chunks_used'] += 1 + if not entry['title']: + entry['title'] = p.get('source_title') or '' + return [v for v in agg.values() if v['source_id']] + + # ------------------------------------------------------------------ + @staticmethod + def _render_prompt_block(passages: list[dict]) -> str: + """Format passages as a citation-ready prompt block.""" + if not passages: + return '' + lines: list[str] = [] + for i, p in enumerate(passages, start=1): + title = p.get('source_title') or 'Reference' + lines.append(f'[{i}] ({title})\n{p.get("text", "")}\n') + return '\n'.join(lines).strip() diff --git a/custom_addons/encoach_ai_course/services/source_indexer.py b/custom_addons/encoach_ai_course/services/source_indexer.py index 6aa693d9..ccaeeea8 100644 --- a/custom_addons/encoach_ai_course/services/source_indexer.py +++ b/custom_addons/encoach_ai_course/services/source_indexer.py @@ -20,17 +20,140 @@ from __future__ import annotations import base64 import io import logging +import os +import sys from datetime import datetime _logger = logging.getLogger(__name__) +# A doc with fewer than this many extractable characters is treated as +# image-only and routed to OCR. Most legitimate PDFs cross 200 chars in +# their first few pages; below that we're almost certainly looking at a +# scan whose pypdf/pdfminer output is junk metadata only. +_OCR_TRIGGER_CHARS = 200 + +# Cap how much we OCR in a single indexing pass so a 500-page scanned +# tome doesn't tie up the request thread for 20 minutes. Editable via +# env var so an operator can dial it up for big workbooks. +_OCR_MAX_PAGES = int(os.environ.get('ENCOACH_OCR_MAX_PAGES', '300')) +_OCR_DPI = int(os.environ.get('ENCOACH_OCR_DPI', '200')) +_OCR_LANGS = os.environ.get('ENCOACH_OCR_LANGS', 'eng') + + +def _resolve_bin(name: str) -> str | None: + """Locate a binary by checking the running Python's bin/ first. + + Odoo is launched with the conda env's interpreter, so its sibling + ``bin/`` reliably hosts ``tesseract`` / ``pdftoppm`` even when + ``$PATH`` in the daemon's environment is sparse. Fall back to + ``shutil.which`` for systems where they're installed globally. + """ + sibling = os.path.join(os.path.dirname(sys.executable), name) + if os.path.isfile(sibling) and os.access(sibling, os.X_OK): + return sibling + import shutil + return shutil.which(name) + + +def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str: + """OCR a scanned/image-only PDF using tesseract + poppler. + + Streams page-by-page so peak memory stays bounded even on a 100+ + page workbook: rasterizing all pages eagerly into PIL.Image objects + blows past Odoo's ~2 GB ``limit_memory_soft`` and forces a worker + reload mid-request. We instead call ``pdftoppm`` for one page at a + time, OCR the image, drop it, and move on. + + Raises a ``RuntimeError`` with an actionable message when the OCR + stack isn't available, so the UI can surface "install OCR" rather + than the cryptic "Extracted no text from source". + """ + try: + from pdf2image import convert_from_bytes, pdfinfo_from_bytes # type: ignore + import pytesseract # type: ignore + except ImportError as exc: + raise RuntimeError( + 'This PDF appears to be scanned/image-only and the OCR stack ' + 'is not installed. Install the deps: ' + '`micromamba install -n odoo19 -c conda-forge tesseract poppler` ' + 'and `pip install pytesseract pdf2image`. ' + f'(missing: {exc.name})', + ) from exc + + tesseract_bin = _resolve_bin('tesseract') + poppler_bin = _resolve_bin('pdftoppm') + if not tesseract_bin or not poppler_bin: + raise RuntimeError( + 'OCR is required for this PDF (no embedded text layer) but ' + f'tesseract={tesseract_bin or "missing"} / ' + f'pdftoppm={poppler_bin or "missing"} could not be located. ' + 'Install with `micromamba install -n odoo19 -c conda-forge ' + 'tesseract poppler`.', + ) + pytesseract.pytesseract.tesseract_cmd = tesseract_bin + poppler_path = os.path.dirname(poppler_bin) + + try: + info = pdfinfo_from_bytes(payload, poppler_path=poppler_path) + total_pages = int(info.get('Pages') or 0) + except Exception as exc: + raise RuntimeError(f'Could not read PDF page count: {exc}') from exc + + page_count = min(total_pages, max_pages) + if page_count <= 0: + return '' + + _logger.info( + 'OCR streaming %s page(s) at %sdpi (lang=%s)', + page_count, _OCR_DPI, _OCR_LANGS, + ) + + pages: list[str] = [] + for page_num in range(1, page_count + 1): + try: + images = convert_from_bytes( + payload, + dpi=_OCR_DPI, + first_page=page_num, + last_page=page_num, + poppler_path=poppler_path, + fmt='png', + thread_count=1, + ) + except Exception as exc: + _logger.warning('OCR rasterize page %s failed: %s', page_num, exc) + continue + if not images: + continue + img = images[0] + try: + txt = pytesseract.image_to_string(img, lang=_OCR_LANGS) or '' + except Exception as exc: + _logger.warning('OCR page %s failed: %s', page_num, exc) + txt = '' + finally: + try: + img.close() + except Exception: + pass + del img, images + if txt.strip(): + pages.append(txt) + + return '\n\n'.join(pages).strip() + def _extract_pdf(payload: bytes) -> str: - """Best-effort PDF text extraction. + """Best-effort PDF text extraction with OCR fallback. - Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older, - still common). Both raise on encrypted PDFs we can't decrypt — we - swallow that and let the caller record the error. + 1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the + embedded text layer — fast, deterministic, lossless. + 2. If that yields virtually nothing (< ``_OCR_TRIGGER_CHARS``), the + PDF is almost certainly a scan; rasterize the first + ``_OCR_MAX_PAGES`` pages and run them through tesseract. + + Both raise on encrypted PDFs we can't decrypt — we swallow that and + let the caller record the error. """ try: from pypdf import PdfReader # type: ignore @@ -48,7 +171,22 @@ def _extract_pdf(payload: bytes) -> str: pages.append(page.extract_text() or '') except Exception: pages.append('') - return '\n\n'.join(p for p in pages if p).strip() + text = '\n\n'.join(p for p in pages if p).strip() + + if len(text) >= _OCR_TRIGGER_CHARS: + return text + + # Looks image-only — try OCR. If OCR is unavailable, propagate a + # clear error rather than silently returning the empty/near-empty + # text-layer output. + _logger.info( + 'PDF text layer empty (%s chars over %s pages); attempting OCR', + len(text), len(reader.pages), + ) + ocr_text = _ocr_pdf(payload) + if ocr_text: + return ocr_text + return text def _extract_docx(payload: bytes) -> str: @@ -238,13 +376,26 @@ class SourceIndexer: return {'status': 'failed', 'error': str(exc)} if not text or not text.strip(): + kind = (source.kind or '').lower() + if kind in ('file', 'resource') and ( + (source.file_name or '').lower().endswith('.pdf') + or (source.mime_type or '').lower() == 'application/pdf' + ): + err = ( + 'No text could be extracted from this PDF. It looks ' + 'image-only (a scan) and OCR returned nothing. Try a ' + 'higher-quality scan, a publisher PDF with an ' + 'embedded text layer, or raise ENCOACH_OCR_DPI.' + ) + else: + err = 'Extracted no text from source' source.write({ 'status': 'failed', - 'error': 'Extracted no text from source', + 'error': err, 'chunks_count': 0, 'extracted_chars': 0, }) - return {'status': 'failed', 'error': 'empty'} + return {'status': 'failed', 'error': err} try: svc = EmbeddingService(self.env) diff --git a/custom_addons/encoach_lms_api/controllers/__init__.py b/custom_addons/encoach_lms_api/controllers/__init__.py index 37d22d5d..7270f61f 100644 --- a/custom_addons/encoach_lms_api/controllers/__init__.py +++ b/custom_addons/encoach_lms_api/controllers/__init__.py @@ -26,3 +26,4 @@ from . import paymob from . import platform_settings from . import training from . import reports +from . import branches diff --git a/custom_addons/encoach_lms_api/controllers/branches.py b/custom_addons/encoach_lms_api/controllers/branches.py new file mode 100644 index 00000000..88d3d086 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/branches.py @@ -0,0 +1,174 @@ +import logging + +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _entity_scope(): + """Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``.""" + user = request.env.user.sudo() + ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else [] + if ids: + return ids, False + is_super = bool( + user.id == 1 or user.has_group('base.group_system') + ) + return ids, is_super + + +def _ensure_entity_access(entity_id): + if not entity_id: + raise PermissionError('entity_id is required') + ids, is_super = _entity_scope() + if is_super: + return int(entity_id) + if not ids: + raise PermissionError('User is not linked to any entity') + if int(entity_id) not in ids: + raise PermissionError('Entity access denied') + return int(entity_id) + + +def _default_entity_id_from_scope(): + ids, is_super = _entity_scope() + if ids: + return ids[0] + if is_super: + return False + raise PermissionError('User is not linked to any entity') + + +def _scoped_entity_domain(base_domain=None): + domain = list(base_domain or []) + ids, is_super = _entity_scope() + if is_super: + return domain + if not ids: + return domain + [('id', '=', 0)] + return domain + [('entity_id', 'in', ids)] + + +def _ser_branch(rec): + return { + 'id': rec.id, + 'name': rec.name or '', + 'code': rec.code or '', + 'active': bool(rec.active), + 'notes': rec.notes or '', + 'entity_id': rec.entity_id.id if rec.entity_id else None, + 'entity_name': rec.entity_id.name if rec.entity_id else '', + 'course_count': rec.course_count or 0, + 'batch_count': rec.batch_count or 0, + 'student_count': rec.student_count or 0, + 'teacher_count': rec.teacher_count or 0, + } + + +class LmsBranchController(http.Controller): + @http.route('/api/branches', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_branches(self, **kw): + try: + Branch = request.env['encoach.lms.branch'].sudo() + domain = _scoped_entity_domain([]) + if kw.get('search'): + q = kw['search'] + domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)] + if kw.get('active') in ('true', 'false', '1', '0'): + domain.append(('active', '=', kw.get('active') in ('true', '1'))) + if kw.get('entity_id'): + domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + offset, limit, page = _paginate(kw) + total = Branch.search_count(domain) + recs = Branch.search(domain, offset=offset, limit=limit, order='name asc, id asc') + items = [_ser_branch(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as exc: + _logger.exception('list_branches failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/branches/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_branch(self, branch_id, **kw): + try: + rec = request.env['encoach.lms.branch'].sudo().browse(branch_id) + if not rec.exists(): + return _error_response('Not found', 404) + _ensure_entity_access(rec.entity_id.id) + return _json_response({'data': _ser_branch(rec)}) + except Exception as exc: + _logger.exception('get_branch failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/branches', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_branch(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('name is required', 400) + requested_entity = body.get('entity_id') + if requested_entity: + entity_id = _ensure_entity_access(int(requested_entity)) + else: + entity_id = _default_entity_id_from_scope() + vals = { + 'name': name, + 'entity_id': entity_id, + 'code': (body.get('code') or '').strip() or False, + 'notes': (body.get('notes') or '').strip(), + } + if 'active' in body: + vals['active'] = bool(body.get('active')) + rec = request.env['encoach.lms.branch'].sudo().create(vals) + return _json_response({'data': _ser_branch(rec)}) + except Exception as exc: + _logger.exception('create_branch failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/branches/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_branch(self, branch_id, **kw): + try: + rec = request.env['encoach.lms.branch'].sudo().browse(branch_id) + if not rec.exists(): + return _error_response('Not found', 404) + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = (body.get('name') or '').strip() + if 'code' in body: + vals['code'] = (body.get('code') or '').strip() or False + if 'notes' in body: + vals['notes'] = (body.get('notes') or '').strip() + if 'active' in body: + vals['active'] = bool(body.get('active')) + if 'entity_id' in body and body.get('entity_id'): + vals['entity_id'] = _ensure_entity_access(int(body.get('entity_id'))) + if vals: + rec.write(vals) + return _json_response({'data': _ser_branch(rec)}) + except Exception as exc: + _logger.exception('update_branch failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/branches/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_branch(self, branch_id, **kw): + try: + rec = request.env['encoach.lms.branch'].sudo().browse(branch_id) + if rec.exists(): + _ensure_entity_access(rec.entity_id.id) + rec.unlink() + return _json_response({'success': True}) + except Exception as exc: + _logger.exception('delete_branch failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + diff --git a/custom_addons/encoach_lms_api/controllers/classrooms.py b/custom_addons/encoach_lms_api/controllers/classrooms.py index 20cb1073..77a9527e 100644 --- a/custom_addons/encoach_lms_api/controllers/classrooms.py +++ b/custom_addons/encoach_lms_api/controllers/classrooms.py @@ -1,4 +1,30 @@ +"""HTTP controller for classrooms (homerooms). + +Routes: +- GET /api/classrooms list (entity-scoped) +- GET /api/classrooms/ fetch with full nested data +- POST /api/classrooms create +- PATCH /api/classrooms/ partial update +- DELETE /api/classrooms/ delete + +- GET /api/classrooms//students list roster +- POST /api/classrooms//students set/add students (body: {student_ids:[..], mode:'set'|'add'}) +- DELETE /api/classrooms//students remove students (body: {student_ids:[..]}) + +- GET /api/classrooms//teachers list homeroom teachers +- POST /api/classrooms//teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'}) + +- GET /api/classrooms//courses list courses +- POST /api/classrooms//assign-course cascade course → batch + enroll students +- DELETE /api/classrooms//courses/ detach course (does NOT delete batches/enrollments) + +- GET /api/classrooms//batches list classroom batches + +The legacy ``/api/groups`` aliases on op.classroom remain for backward +compatibility but new clients should target ``/api/classrooms``. +""" import logging + from odoo import http from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( @@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import ( _logger = logging.getLogger(__name__) -def _ser_classroom(r): +# ---------------------------------------------------------------------- +# Entity scoping helpers (same shape used in branches.py / lms_core.py) +# ---------------------------------------------------------------------- +def _entity_scope(): + """Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``.""" + user = request.env.user.sudo() + ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else [] + if ids: + return ids, False + is_super = bool( + user.id == 1 or user.has_group('base.group_system') + ) + return ids, is_super + + +def _ensure_entity_access(entity_id): + if not entity_id: + raise PermissionError('entity_id is required') + ids, is_super = _entity_scope() + if is_super: + return int(entity_id) + if not ids: + raise PermissionError('User is not linked to any entity') + if int(entity_id) not in ids: + raise PermissionError('Entity access denied') + return int(entity_id) + + +def _default_entity_id_from_scope(): + ids, is_super = _entity_scope() + if ids: + return ids[0] + if is_super: + return False + raise PermissionError('User is not linked to any entity') + + +def _scoped_entity_domain(base_domain=None): + domain = list(base_domain or []) + ids, is_super = _entity_scope() + if is_super: + return domain + if not ids: + return domain + [('id', '=', 0)] + return domain + [('entity_id', 'in', ids)] + + +def _ensure_branch_access(branch_id, *, entity_id=None): + """Validate that the branch exists, belongs to ``entity_id`` (when set) + and is accessible by the current user.""" + if not branch_id: + return None + Branch = request.env['encoach.lms.branch'].sudo() + branch = Branch.browse(int(branch_id)) + if not branch.exists(): + raise ValueError('Branch not found') + _ensure_entity_access(branch.entity_id.id) + if entity_id and branch.entity_id.id != int(entity_id): + raise PermissionError('Branch does not belong to the requested entity.') + return branch.id + + +def _filter_ids_in_entity(model, ids, entity_id, *, label='record'): + """Return ids that exist *and* belong to ``entity_id``. + + When ``entity_id`` is falsy (e.g. legacy classroom without an entity), we + accept any record that has no ``entity_id`` set yet. This keeps the API + safe by default while still allowing administrators to operate on + pre-LMS-isolation data without surprises. + """ + if not ids: + return [] + Model = request.env[model].sudo() + recs = Model.browse([int(x) for x in ids]) + out = [] + for rec in recs: + if not rec.exists(): + continue + rec_entity = rec.entity_id.id if rec.entity_id else None + if entity_id and rec_entity and rec_entity != int(entity_id): + raise PermissionError( + f"{label} {rec.id} belongs to a different entity" + ) + out.append(rec.id) + return out + + +# ---------------------------------------------------------------------- +# Serializers +# ---------------------------------------------------------------------- +def _ser_student_short(s): return { - 'id': r.id, - 'name': r.name or '', - 'code': getattr(r, 'code', '') or '', - 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, - 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', - 'capacity': getattr(r, 'capacity', 0) or 0, + 'id': s.id, + 'name': s.name or '', + 'email': s.email or '', + 'gr_no': getattr(s, 'gr_no', '') or '', } +def _ser_teacher_short(f): + return { + 'id': f.id, + 'name': f.name or '', + 'email': getattr(f, 'email', '') or '', + } + + +def _ser_course_short(c): + return { + 'id': c.id, + 'name': c.name or '', + 'code': c.code or '', + } + + +def _ser_section_short(s): + return { + 'id': s.id, + 'name': s.name or '', + 'code': s.code or '', + 'sequence': s.sequence or 0, + 'course_id': s.course_id.id if s.course_id else None, + 'course_name': s.course_id.name if s.course_id else '', + } + + +def _ser_batch_short(b): + return { + 'id': b.id, + 'name': b.name or '', + 'code': b.code or '', + 'course_id': b.course_id.id if b.course_id else None, + 'course_name': b.course_id.name if b.course_id else '', + 'course_section_id': ( + b.course_section_id.id + if hasattr(b, 'course_section_id') and b.course_section_id + else None + ), + 'course_section_name': ( + b.course_section_id.name + if hasattr(b, 'course_section_id') and b.course_section_id + else '' + ), + 'course_section_code': ( + b.course_section_id.code + if hasattr(b, 'course_section_id') and b.course_section_id + else '' + ), + 'term_key': getattr(b, 'term_key', '') or '', + 'start_date': b.start_date and str(b.start_date) or '', + 'end_date': b.end_date and str(b.end_date) or '', + 'student_count': getattr(b, 'student_count', 0) or 0, + 'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [], + } + + +def _ser_classroom(r, *, full=False): + data = { + 'id': r.id, + 'name': r.name or '', + 'code': r.code or '', + 'capacity': r.capacity or 0, + 'active': bool(r.active), + 'notes': getattr(r, 'notes', '') or '', + 'entity_id': r.entity_id.id if r.entity_id else None, + 'entity_name': r.entity_id.name if r.entity_id else '', + 'branch_id': r.branch_id.id if r.branch_id else None, + 'branch_name': r.branch_id.name if r.branch_id else '', + 'student_count': r.student_count or 0, + 'teacher_count': r.teacher_count or 0, + 'course_count': r.course_count or 0, + 'batch_count': r.batch_count or 0, + 'student_ids': r.student_ids.ids, + 'teacher_ids': r.teacher_ids.ids, + 'course_ids': r.course_ids.ids, + } + if full: + data['students'] = [_ser_student_short(s) for s in r.student_ids] + data['teachers'] = [_ser_teacher_short(t) for t in r.teacher_ids] + data['courses'] = [_ser_course_short(c) for c in r.course_ids] + data['sections'] = [ + _ser_section_short(s) + for s in request.env['encoach.course.section'].sudo().search([ + ('course_id', 'in', r.course_ids.ids) + ], order='course_id, sequence, id') + ] + data['batches'] = [_ser_batch_short(b) for b in r.batch_ids] + return data + + +def _classroom_or_404(cid): + rec = request.env['op.classroom'].sudo().browse(int(cid)) + if not rec.exists(): + return None + return rec + + +def _scoped_classroom_domain(base_domain=None): + """Same as ``_scoped_entity_domain`` but tolerates legacy classrooms + without ``entity_id`` for backward-compatibility (so existing rooms + keep showing up to admins).""" + domain = list(base_domain or []) + ids, is_super = _entity_scope() + if is_super: + return domain + if not ids: + return domain + [('id', '=', 0)] + return domain + ['|', ('entity_id', 'in', ids), ('entity_id', '=', False)] + + +# ---------------------------------------------------------------------- +# Controller +# ---------------------------------------------------------------------- class ClassroomsController(http.Controller): + # ---- CRUD -------------------------------------------------------- + @http.route('/api/classrooms', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classrooms(self, **kw): + try: + Classroom = request.env['op.classroom'].sudo() + domain = _scoped_classroom_domain([]) + if kw.get('search'): + q = kw['search'] + domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)] + if kw.get('active') in ('true', 'false', '1', '0'): + domain.append(('active', '=', kw.get('active') in ('true', '1'))) + if kw.get('branch_id'): + domain.append(('branch_id', '=', int(kw['branch_id']))) + if kw.get('entity_id'): + domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + offset, limit, page = _paginate(kw) + total = Classroom.search_count(domain) + recs = Classroom.search(domain, offset=offset, limit=limit, order='name asc, id asc') + items = [_ser_classroom(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as exc: + _logger.exception('list_classrooms failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_classroom(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('get_classroom failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_classroom(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('name is required', 400) + entity_id = ( + _ensure_entity_access(int(body['entity_id'])) + if body.get('entity_id') else _default_entity_id_from_scope() + ) + branch_id = _ensure_branch_access(body.get('branch_id'), entity_id=entity_id) if body.get('branch_id') else None + + code = (body.get('code') or '').strip() or name.upper().replace(' ', '_')[:16] + vals = { + 'name': name, + 'code': code, + 'capacity': int(body.get('capacity') or 30), + 'notes': (body.get('notes') or '').strip(), + 'entity_id': entity_id, + 'branch_id': branch_id or False, + } + if 'active' in body: + vals['active'] = bool(body.get('active')) + if body.get('student_ids'): + vals['student_ids'] = [(6, 0, [int(x) for x in body['student_ids']])] + if body.get('teacher_ids'): + vals['teacher_ids'] = [(6, 0, [int(x) for x in body['teacher_ids']])] + rec = request.env['op.classroom'].sudo().create(vals) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('create_classroom failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_classroom(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = (body.get('name') or '').strip() + if 'code' in body: + vals['code'] = (body.get('code') or '').strip() or rec.code + if 'capacity' in body: + vals['capacity'] = int(body.get('capacity') or rec.capacity) + if 'notes' in body: + vals['notes'] = (body.get('notes') or '').strip() + if 'active' in body: + vals['active'] = bool(body.get('active')) + target_entity = rec.entity_id.id + if 'entity_id' in body and body.get('entity_id'): + target_entity = _ensure_entity_access(int(body['entity_id'])) + vals['entity_id'] = target_entity + if 'branch_id' in body: + vals['branch_id'] = ( + _ensure_branch_access(int(body['branch_id']), entity_id=target_entity) + if body.get('branch_id') else False + ) + if vals: + rec.write(vals) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('update_classroom failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_classroom(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if rec: + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + rec.unlink() + return _json_response({'success': True}) + except Exception as exc: + _logger.exception('delete_classroom failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ---- Roster (students) ------------------------------------------ + @http.route('/api/classrooms//students', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classroom_students(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + items = [_ser_student_short(s) for s in rec.student_ids] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as exc: + _logger.exception('list_classroom_students failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//students', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def set_classroom_students(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + raw_ids = [int(x) for x in (body.get('student_ids') or [])] + ids = _filter_ids_in_entity( + 'op.student', raw_ids, + rec.entity_id.id if rec.entity_id else None, + label='Student', + ) + mode = (body.get('mode') or 'set').lower() + if mode == 'set': + rec.write({'student_ids': [(6, 0, ids)]}) + else: + rec.write({'student_ids': [(4, x) for x in ids]}) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('set_classroom_students failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//students', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def remove_classroom_students(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + ids = [int(x) for x in (body.get('student_ids') or [])] + rec.write({'student_ids': [(3, x) for x in ids]}) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('remove_classroom_students failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ---- Homeroom teachers ------------------------------------------ + @http.route('/api/classrooms//teachers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classroom_teachers(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + items = [_ser_teacher_short(t) for t in rec.teacher_ids] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as exc: + _logger.exception('list_classroom_teachers failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//teachers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def set_classroom_teachers(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + raw_ids = [int(x) for x in (body.get('teacher_ids') or [])] + ids = _filter_ids_in_entity( + 'op.faculty', raw_ids, + rec.entity_id.id if rec.entity_id else None, + label='Teacher', + ) + mode = (body.get('mode') or 'set').lower() + if mode == 'set': + rec.write({'teacher_ids': [(6, 0, ids)]}) + else: + rec.write({'teacher_ids': [(4, x) for x in ids]}) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('set_classroom_teachers failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ---- Courses + cascade ------------------------------------------ + @http.route('/api/classrooms//courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classroom_courses(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + items = [_ser_course_short(c) for c in rec.course_ids] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as exc: + _logger.exception('list_classroom_courses failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//sections', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classroom_sections(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + domain = [('course_id', 'in', rec.course_ids.ids)] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('active') in ('true', 'false', '1', '0'): + domain.append(('active', '=', kw.get('active') in ('true', '1'))) + items = [ + _ser_section_short(s) + for s in request.env['encoach.course.section'].sudo().search( + domain, order='course_id, sequence, id' + ) + ] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as exc: + _logger.exception('list_classroom_sections failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//assign-course', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def assign_classroom_course(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + body = _get_json_body() + course_id = int(body.get('course_id') or 0) + if not course_id: + return _error_response('course_id is required', 400) + classroom_entity_id = rec.entity_id.id if rec.entity_id else None + _filter_ids_in_entity('op.course', [course_id], classroom_entity_id, label='Course') + + section_id = int(body.get('section_id') or 0) or None + if section_id: + _filter_ids_in_entity( + 'encoach.course.section', [section_id], + classroom_entity_id, label='Course section', + ) + + teacher_ids = body.get('teacher_ids') + if teacher_ids is not None: + teacher_ids = _filter_ids_in_entity( + 'op.faculty', [int(x) for x in teacher_ids], + classroom_entity_id, label='Teacher', + ) + term_name = body.get('term_name') or None + term_key = (body.get('term_key') or '').strip() or None + start_date = body.get('start_date') or None + end_date = body.get('end_date') or None + batch = rec.assign_course( + course_id, + term_name=term_name, + start_date=start_date, + end_date=end_date, + teacher_ids=teacher_ids, + section_id=section_id, + term_key=term_key, + ) + return _json_response({ + 'data': _ser_classroom(rec, full=True), + 'batch': _ser_batch_short(batch), + }) + except Exception as exc: + _logger.exception('assign_classroom_course failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + @http.route('/api/classrooms//assign-section', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def assign_classroom_section(self, cid, **kw): + """Alias to make the classroom×section workflow explicit for clients.""" + return self.assign_classroom_course(cid, **kw) + + @http.route('/api/classrooms//courses/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def detach_classroom_course(self, cid, course_id, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + rec.write({'course_ids': [(3, int(course_id))]}) + return _json_response({'data': _ser_classroom(rec, full=True)}) + except Exception as exc: + _logger.exception('detach_classroom_course failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ---- Batches ----------------------------------------------------- + @http.route('/api/classrooms//batches', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_classroom_batches(self, cid, **kw): + try: + rec = _classroom_or_404(cid) + if not rec: + return _error_response('Not found', 404) + if rec.entity_id: + _ensure_entity_access(rec.entity_id.id) + items = [_ser_batch_short(b) for b in rec.batch_ids] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as exc: + _logger.exception('list_classroom_batches failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ---- Legacy /api/groups aliases (back-compat) ------------------- @http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def list_groups(self, **kw): - try: - M = request.env['op.classroom'].sudo() - offset, limit, page = _paginate(kw) - total = M.search_count([]) - recs = M.search([], offset=offset, limit=limit, order='id desc') - items = [_ser_classroom(r) for r in recs] - return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) - except Exception as e: - return _error_response(str(e), 500) + return self.list_classrooms(**kw) @http.route('/api/groups/', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def get_group(self, gid, **kw): - try: - rec = request.env['op.classroom'].sudo().browse(gid) - if not rec.exists(): - return _error_response('Not found', 404) - return _json_response(_ser_classroom(rec)) - except Exception as e: - return _error_response(str(e), 500) + return self.get_classroom(gid, **kw) @http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False) @jwt_required def create_group(self, **kw): - try: - body = _get_json_body() - vals = {'name': body.get('name', '')} - if body.get('code'): - vals['code'] = body['code'] - if body.get('capacity'): - vals['capacity'] = int(body['capacity']) - rec = request.env['op.classroom'].sudo().create(vals) - return _json_response(_ser_classroom(rec)) - except Exception as e: - return _error_response(str(e), 500) + return self.create_classroom(**kw) @http.route('/api/groups/', type='http', auth='public', methods=['DELETE'], csrf=False) @jwt_required def delete_group(self, gid, **kw): - try: - rec = request.env['op.classroom'].sudo().browse(gid) - if rec.exists(): - rec.unlink() - return _json_response({'success': True}) - except Exception as e: - return _error_response(str(e), 500) - - @http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False) - @jwt_required - def transfer(self, **kw): - try: - return _json_response({'success': True}) - except Exception as e: - return _error_response(str(e), 500) - - @http.route('/api/groups//members', type='http', auth='public', methods=['POST'], csrf=False) - @jwt_required - def add_members(self, gid, **kw): - try: - return _json_response({'success': True}) - except Exception as e: - return _error_response(str(e), 500) - - @http.route('/api/groups//members/remove', type='http', auth='public', methods=['POST'], csrf=False) - @jwt_required - def remove_members(self, gid, **kw): - try: - return _json_response({'success': True}) - except Exception as e: - return _error_response(str(e), 500) + return self.delete_classroom(gid, **kw) diff --git a/custom_addons/encoach_lms_api/controllers/lms_core.py b/custom_addons/encoach_lms_api/controllers/lms_core.py index 2d8d79dd..6debc043 100644 --- a/custom_addons/encoach_lms_api/controllers/lms_core.py +++ b/custom_addons/encoach_lms_api/controllers/lms_core.py @@ -12,10 +12,21 @@ _logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- def _entity_scope(): - """Return (entity_ids, is_superadmin) for the current user.""" + """Return (entity_ids, is_superadmin) for the current user. + + A user is treated as a *platform* superadmin only when they have no + entities assigned (``entity_ids`` is empty) AND either are the bootstrap + Odoo user (uid=1) or carry ``base.group_system`` directly. Any user + linked to one or more entities is always entity-scoped — even Odoo + settings admins — so multi-entity LMS isolation is enforced. + """ user = request.env.user.sudo() - is_super = bool(user.has_group('base.group_system')) ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else [] + if ids: + return ids, False + is_super = bool( + user.id == 1 or user.has_group('base.group_system') + ) return ids, is_super @@ -53,6 +64,49 @@ def _scoped_entity_domain(base_domain=None, field='entity_id'): return domain + [('id', '=', 0)] return domain + [(field, 'in', ids)] + +def _ensure_branch_access(branch_id, *, entity_id=None): + """Validate branch ownership and user access, return branch id.""" + if not branch_id: + raise PermissionError('branch_id is required') + branch = request.env['encoach.lms.branch'].sudo().browse(int(branch_id)) + if not branch.exists(): + raise ValueError('Branch not found') + _ensure_entity_access(branch.entity_id.id) + if entity_id and branch.entity_id.id != int(entity_id): + raise PermissionError('Branch entity mismatch') + return branch.id + + +def _assert_record_in_entity(model, rec_id, entity_id, *, label='record'): + """Raise PermissionError if a record (M2O) doesn't belong to ``entity_id``. + + A record without ``entity_id`` is considered legacy/unscoped and is + allowed; this keeps pre-isolation data usable while still blocking + explicit cross-entity references. + """ + if not rec_id or not entity_id: + return int(rec_id) if rec_id else False + rec = request.env[model].sudo().browse(int(rec_id)) + if not rec.exists(): + raise ValueError(f'{label} {rec_id} not found') + rec_entity = rec.entity_id.id if rec.entity_id else None + if rec_entity and rec_entity != int(entity_id): + raise PermissionError( + f'{label} {rec_id} belongs to a different entity' + ) + return rec.id + + +def _filter_ids_in_entity(model, ids, entity_id, *, label='record'): + """Same as ``_assert_record_in_entity`` but for a list of ids.""" + if not ids: + return [] + out = [] + for rid in ids: + out.append(_assert_record_in_entity(model, rid, entity_id, label=label)) + return out + def _serialize_course(c): subj = getattr(c, 'encoach_subject_id', False) tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) @@ -85,8 +139,39 @@ def _serialize_course(c): 'chapter_count': getattr(c, 'chapter_count', 0) or 0, 'resource_count': getattr(c, 'resource_count', 0) or 0, 'objective_count': getattr(c, 'objective_count', 0) or 0, + 'section_count': getattr(c, 'section_count', 0) or 0, + 'sections': [ + { + 'id': s.id, + 'name': s.name or '', + 'code': s.code or '', + 'sequence': s.sequence or 0, + 'active': bool(s.active), + } + for s in (c.section_ids if hasattr(c, 'section_ids') else c.env['encoach.course.section']) + ], 'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None, 'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '', + 'branch_id': c.branch_id.id if hasattr(c, 'branch_id') and c.branch_id else None, + 'branch_name': c.branch_id.name if hasattr(c, 'branch_id') and c.branch_id else '', + } + + +def _serialize_course_section(s): + return { + 'id': s.id, + 'name': s.name or '', + 'code': s.code or '', + 'sequence': s.sequence or 0, + 'active': bool(s.active), + 'notes': s.notes or '', + 'course_id': s.course_id.id if s.course_id else None, + 'course_name': s.course_id.name if s.course_id else '', + 'entity_id': s.entity_id.id if s.entity_id else None, + 'entity_name': s.entity_id.name if s.entity_id else '', + 'branch_id': s.branch_id.id if s.branch_id else None, + 'branch_name': s.branch_id.name if s.branch_id else '', + 'batch_count': s.batch_count or 0, } @@ -121,6 +206,8 @@ def _serialize_student(s): 'user_id': s.user_id.id if s.user_id else None, 'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None, 'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '', + 'branch_id': s.branch_id.id if hasattr(s, 'branch_id') and s.branch_id else None, + 'branch_name': s.branch_id.name if hasattr(s, 'branch_id') and s.branch_id else '', } @@ -145,6 +232,8 @@ def _serialize_teacher(f): 'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [], 'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None, 'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '', + 'branch_id': f.branch_id.id if hasattr(f, 'branch_id') and f.branch_id else None, + 'branch_name': f.branch_id.name if hasattr(f, 'branch_id') and f.branch_id else '', } @@ -177,6 +266,28 @@ def _serialize_batch(b): 'students': students, 'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None, 'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '', + 'branch_id': b.branch_id.id if hasattr(b, 'branch_id') and b.branch_id else None, + 'branch_name': b.branch_id.name if hasattr(b, 'branch_id') and b.branch_id else '', + 'classroom_id': b.classroom_id.id if hasattr(b, 'classroom_id') and b.classroom_id else None, + 'classroom_name': b.classroom_id.name if hasattr(b, 'classroom_id') and b.classroom_id else '', + 'course_section_id': ( + b.course_section_id.id + if hasattr(b, 'course_section_id') and b.course_section_id + else None + ), + 'course_section_name': ( + b.course_section_id.name + if hasattr(b, 'course_section_id') and b.course_section_id + else '' + ), + 'course_section_code': ( + b.course_section_id.code + if hasattr(b, 'course_section_id') and b.course_section_id + else '' + ), + 'term_key': getattr(b, 'term_key', '') or '', + 'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [], + 'teacher_names': [t.name or '' for t in b.teacher_ids] if hasattr(b, 'teacher_ids') else [], } @@ -194,6 +305,8 @@ class LmsCoreController(http.Controller): pass # op.course has no status field by default if kw.get('entity_id'): domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + if kw.get('branch_id'): + domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id'])))) offset, limit, page = _paginate(kw) total = Course.search_count(domain) records = Course.search(domain, offset=offset, limit=limit, order='id desc') @@ -239,6 +352,8 @@ class LmsCoreController(http.Controller): } if entity_id: vals['entity_id'] = entity_id + if body.get('branch_id'): + vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id) if body.get('description'): vals['description'] = body['description'] if body.get('max_capacity'): @@ -292,6 +407,12 @@ class LmsCoreController(http.Controller): vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] if 'entity_id' in body: vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False + effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False) + if 'branch_id' in body: + vals['branch_id'] = ( + _ensure_branch_access(int(body['branch_id']), entity_id=effective_entity) + if body['branch_id'] else False + ) if vals: rec.write(vals) return _json_response({'data': _serialize_course(rec)}) @@ -327,6 +448,172 @@ class LmsCoreController(http.Controller): _logger.exception('delete_course failed') return _error_response(str(e), 500) + @http.route('/api/courses//sections', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_course_sections(self, course_id, **kw): + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + ids, is_super = _entity_scope() + if not is_super and (not course.entity_id or course.entity_id.id not in ids): + return _error_response('Forbidden', 403) + Section = request.env['encoach.course.section'].sudo() + domain = [('course_id', '=', course_id)] + if kw.get('active') in ('true', 'false', '1', '0'): + domain.append(('active', '=', kw.get('active') in ('true', '1'))) + recs = Section.search(domain, order='sequence, id') + items = [_serialize_course_section(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list_course_sections failed') + return _error_response(str(e), 500) + + @http.route('/api/courses//sections', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_course_section(self, course_id, **kw): + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + ids, is_super = _entity_scope() + if not is_super and (not course.entity_id or course.entity_id.id not in ids): + return _error_response('Forbidden', 403) + body = _get_json_body() + name = (body.get('name') or '').strip() + code = (body.get('code') or '').strip() + if not name or not code: + return _error_response('name and code are required', 400) + vals = { + 'name': name, + 'code': code, + 'course_id': course_id, + 'sequence': int(body.get('sequence') or 10), + 'notes': (body.get('notes') or '').strip(), + 'active': bool(body.get('active', True)), + } + if body.get('branch_id'): + vals['branch_id'] = _ensure_branch_access( + int(body['branch_id']), + entity_id=course.entity_id.id if course.entity_id else None, + ) + rec = request.env['encoach.course.section'].sudo().create(vals) + return _json_response({'data': _serialize_course_section(rec)}) + except Exception as e: + _logger.exception('create_course_section failed') + return _error_response(str(e), 500) + + @http.route('/api/courses//sections/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_course_section(self, course_id, section_id, **kw): + try: + section = request.env['encoach.course.section'].sudo().browse(section_id) + if not section.exists() or section.course_id.id != course_id: + return _error_response('Section not found', 404) + ids, is_super = _entity_scope() + if not is_super and (not section.entity_id or section.entity_id.id not in ids): + return _error_response('Forbidden', 403) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = (body.get('name') or '').strip() + if 'code' in body: + vals['code'] = (body.get('code') or '').strip() + if 'sequence' in body: + vals['sequence'] = int(body.get('sequence') or 10) + if 'notes' in body: + vals['notes'] = (body.get('notes') or '').strip() + if 'active' in body: + vals['active'] = bool(body.get('active')) + if 'branch_id' in body: + vals['branch_id'] = ( + _ensure_branch_access( + int(body['branch_id']), + entity_id=section.entity_id.id if section.entity_id else None, + ) + if body.get('branch_id') else False + ) + if vals: + section.write(vals) + return _json_response({'data': _serialize_course_section(section)}) + except Exception as e: + _logger.exception('update_course_section failed') + return _error_response(str(e), 500) + + @http.route('/api/courses//sections/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_course_section(self, course_id, section_id, **kw): + try: + section = request.env['encoach.course.section'].sudo().browse(section_id) + if section.exists() and section.course_id.id == course_id: + ids, is_super = _entity_scope() + if not is_super and (not section.entity_id or section.entity_id.id not in ids): + return _error_response('Forbidden', 403) + section.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_course_section failed') + return _error_response(str(e), 500) + + @http.route('/api/courses//sections/generate-defaults', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def generate_default_course_sections(self, course_id, **kw): + """Idempotently create A/B/C section templates for a course. + + Body (optional): + - codes: ["A","B","C", ...] (defaults to ["A","B","C"]) + - branch_id: int (optional default branch for new sections) + + Existing sections (matched by code per course) are kept untouched. + Returns the full ordered list of sections after generation. + """ + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + ids, is_super = _entity_scope() + if not is_super and (not course.entity_id or course.entity_id.id not in ids): + return _error_response('Forbidden', 403) + body = _get_json_body() or {} + raw_codes = body.get('codes') + if not isinstance(raw_codes, list) or not raw_codes: + raw_codes = ['A', 'B', 'C'] + codes = [str(c).strip().upper() for c in raw_codes if str(c).strip()] + branch_id = body.get('branch_id') + if branch_id: + branch_id = _ensure_branch_access( + int(branch_id), + entity_id=course.entity_id.id if course.entity_id else None, + ) + Section = request.env['encoach.course.section'].sudo() + existing = Section.search([('course_id', '=', course_id)]) + existing_codes = {s.code for s in existing} + created = [] + for idx, code in enumerate(codes, start=1): + if code in existing_codes: + continue + vals = { + 'name': f'Section {code}', + 'code': code, + 'course_id': course_id, + 'sequence': 10 * idx, + 'active': True, + } + if branch_id: + vals['branch_id'] = branch_id + created.append(Section.create(vals)) + recs = Section.search([('course_id', '=', course_id)], order='sequence, id') + return _json_response({ + 'created_count': len(created), + 'created_ids': [r.id for r in created], + 'items': [_serialize_course_section(r) for r in recs], + 'data': [_serialize_course_section(r) for r in recs], + 'total': len(recs), + }) + except Exception as e: + _logger.exception('generate_default_course_sections failed') + return _error_response(str(e), 500) + # ── Student: My Courses (enrollment-filtered) ────────────────── @http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False) @@ -486,6 +773,8 @@ class LmsCoreController(http.Controller): domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id']))) if kw.get('entity_id'): domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + if kw.get('branch_id'): + domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id'])))) offset, limit, page = _paginate(kw) total = Student.search_count(domain) records = Student.search(domain, offset=offset, limit=limit, order='id desc') @@ -540,6 +829,8 @@ class LmsCoreController(http.Controller): } if entity_id: student_vals['entity_id'] = entity_id + if body.get('branch_id'): + student_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id) if body.get('birth_date'): student_vals['birth_date'] = body['birth_date'] student = request.env['op.student'].sudo().create(student_vals) @@ -594,6 +885,12 @@ class LmsCoreController(http.Controller): student_vals['gender'] = body['gender'] if 'entity_id' in body: student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False + effective_entity = student_vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False) + if 'branch_id' in body: + student_vals['branch_id'] = ( + _ensure_branch_access(int(body['branch_id']), entity_id=effective_entity) + if body['branch_id'] else False + ) if student_vals: rec.write(student_vals) return _json_response({'data': _serialize_student(rec)}) @@ -628,6 +925,8 @@ class LmsCoreController(http.Controller): domain.append(('partner_id.name', 'ilike', kw['search'])) if kw.get('entity_id'): domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + if kw.get('branch_id'): + domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id'])))) offset, limit, page = _paginate(kw) total = Faculty.search_count(domain) records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') @@ -652,9 +951,18 @@ class LmsCoreController(http.Controller): entity_id = _ensure_entity_access(int(requested_entity)) else: entity_id = _default_entity_id_from_scope() - first = body.get('first_name', '') - last = body.get('last_name', '') - name = f"{first} {last}".strip() + first = (body.get('first_name') or '').strip() + last = (body.get('last_name') or '').strip() + name = f"{first} {last}".strip() or (body.get('name') or '').strip() + if not name: + return _error_response('first_name/last_name (or name) is required', 400) + if not first and ' ' in name: + first, last = name.split(' ', 1) + elif not first: + first = name + last = name + if not last: + last = first partner = request.env['res.partner'].sudo().create({ 'name': name, 'email': body.get('email', ''), @@ -662,14 +970,18 @@ class LmsCoreController(http.Controller): }) fac_vals = { 'partner_id': partner.id, - 'gender': body.get('gender', ''), + 'first_name': first, + 'last_name': last, + 'name': name, + 'gender': body.get('gender') or 'male', + 'birth_date': body.get('birth_date') or '1990-01-01', } if entity_id: fac_vals['entity_id'] = entity_id + if body.get('branch_id'): + fac_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id) if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'): fac_vals['department_id'] = int(body['department_id']) - if body.get('birth_date'): - fac_vals['birth_date'] = body['birth_date'] faculty = request.env['op.faculty'].sudo().create(fac_vals) return _json_response({'data': _serialize_teacher(faculty)}) except Exception as e: @@ -716,6 +1028,12 @@ class LmsCoreController(http.Controller): vals['gender'] = body['gender'] if 'entity_id' in body: vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False + effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False) + if 'branch_id' in body: + vals['branch_id'] = ( + _ensure_branch_access(int(body['branch_id']), entity_id=effective_entity) + if body['branch_id'] else False + ) if vals: rec.write(vals) return _json_response({'data': _serialize_teacher(rec)}) @@ -748,6 +1066,18 @@ class LmsCoreController(http.Controller): domain = _scoped_entity_domain([]) if kw.get('entity_id'): domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id'])))) + if kw.get('branch_id'): + domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id'])))) + if kw.get('classroom_id'): + domain.append(('classroom_id', '=', int(kw['classroom_id']))) + if kw.get('teacher_id'): + domain.append(('teacher_ids', 'in', int(kw['teacher_id']))) + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('course_section_id'): + domain.append(('course_section_id', '=', int(kw['course_section_id']))) + if kw.get('term_key'): + domain.append(('term_key', '=', str(kw['term_key']))) offset, limit, page = _paginate(kw) total = Batch.search_count(domain) records = Batch.search(domain, offset=offset, limit=limit, order='id desc') @@ -791,13 +1121,65 @@ class LmsCoreController(http.Controller): if body.get('code'): vals['code'] = body['code'] if body.get('course_id'): - vals['course_id'] = int(body['course_id']) + vals['course_id'] = _assert_record_in_entity( + 'op.course', int(body['course_id']), entity_id, label='Course', + ) + section_id = body.get('course_section_id') + section = None + if section_id: + section = request.env['encoach.course.section'].sudo().browse(int(section_id)) + if not section.exists(): + return _error_response('course_section_id not found', 400) + if vals.get('course_id') and section.course_id.id != vals['course_id']: + return _error_response('course_section_id does not belong to course_id', 400) + _assert_record_in_entity( + 'encoach.course.section', section.id, entity_id, label='Course section', + ) + vals['course_section_id'] = section.id if entity_id: vals['entity_id'] = entity_id + if body.get('branch_id'): + vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id) + if body.get('classroom_id'): + vals['classroom_id'] = _assert_record_in_entity( + 'op.classroom', int(body['classroom_id']), entity_id, label='Classroom', + ) + if body.get('teacher_ids'): + tids = _filter_ids_in_entity( + 'op.faculty', [int(x) for x in body['teacher_ids']], + entity_id, label='Teacher', + ) + vals['teacher_ids'] = [(6, 0, tids)] + if 'term_key' in body: + vals['term_key'] = (body.get('term_key') or '').strip() or False if body.get('start_date'): vals['start_date'] = body['start_date'] if body.get('end_date'): vals['end_date'] = body['end_date'] + # Auto-generate a unique batch code if the client didn't supply one. + # `op.batch.code` is NOT NULL + UNIQUE, and AdminBatches UI doesn't ship a code field. + if not vals.get('code'): + Batch = request.env['op.batch'].sudo() + course = request.env['op.course'].sudo().browse(vals['course_id']) if vals.get('course_id') else None + course_part = ((course.code or course.name or 'C') if course else 'C') + course_part = ''.join(ch for ch in course_part.upper() if ch.isalnum())[:6] or 'C' + section_part = '' + if section: + section_part = '-' + ''.join(ch for ch in (section.code or '').upper() if ch.isalnum())[:4] + term_part = '' + term_key_val = vals.get('term_key') + if term_key_val: + term_part = '-' + ''.join(ch for ch in term_key_val.upper() if ch.isalnum())[:5] + base = f"B{course_part}{section_part}{term_part}"[:14] + candidate = base + idx = 1 + while Batch.search_count([('code', '=', candidate)]): + suffix = f"-{idx}" + candidate = (base[: 16 - len(suffix)]) + suffix + idx += 1 + if idx > 999: + break + vals['code'] = candidate[:16] rec = request.env['op.batch'].sudo().create(vals) return _json_response({'data': _serialize_batch(rec)}) except Exception as e: @@ -819,10 +1201,46 @@ class LmsCoreController(http.Controller): for k in ('name', 'code', 'start_date', 'end_date'): if k in body: vals[k] = body[k] - if 'course_id' in body: - vals['course_id'] = int(body['course_id']) if 'entity_id' in body: vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False + effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False) + if 'course_id' in body: + vals['course_id'] = _assert_record_in_entity( + 'op.course', int(body['course_id']), effective_entity, label='Course', + ) + if 'course_section_id' in body: + if body['course_section_id']: + section = request.env['encoach.course.section'].sudo().browse(int(body['course_section_id'])) + if not section.exists(): + return _error_response('course_section_id not found', 400) + target_course = vals.get('course_id') or (rec.course_id.id if rec.course_id else None) + if target_course and section.course_id.id != target_course: + return _error_response('course_section_id does not belong to course_id', 400) + _assert_record_in_entity( + 'encoach.course.section', section.id, effective_entity, label='Course section', + ) + vals['course_section_id'] = section.id + else: + vals['course_section_id'] = False + if 'branch_id' in body: + vals['branch_id'] = ( + _ensure_branch_access(int(body['branch_id']), entity_id=effective_entity) + if body['branch_id'] else False + ) + if 'classroom_id' in body: + vals['classroom_id'] = ( + _assert_record_in_entity( + 'op.classroom', int(body['classroom_id']), effective_entity, label='Classroom', + ) if body['classroom_id'] else False + ) + if 'teacher_ids' in body: + tids = _filter_ids_in_entity( + 'op.faculty', [int(x) for x in (body['teacher_ids'] or [])], + effective_entity, label='Teacher', + ) + vals['teacher_ids'] = [(6, 0, tids)] + if 'term_key' in body: + vals['term_key'] = (body.get('term_key') or '').strip() or False if vals: rec.write(vals) return _json_response({'data': _serialize_batch(rec)}) @@ -956,6 +1374,94 @@ class LmsCoreController(http.Controller): except Exception as e: return _error_response(str(e), 500) + # ── Per-batch teacher assignment ──────────────────────────────── + @http.route('/api/batches//teachers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_batch_teachers(self, batch_id, **kw): + try: + batch = request.env['op.batch'].sudo().browse(batch_id) + if not batch.exists(): + return _error_response('Batch not found', 404) + scope_ids, is_super = _entity_scope() + if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids): + return _error_response('Forbidden', 403) + teachers = [] + for t in batch.teacher_ids: + p = t.partner_id + teachers.append({ + 'id': t.id, + 'name': p.name if p else '', + 'email': (p.email or '') if p else '', + }) + return _json_response({ + 'items': teachers, + 'data': teachers, + 'total': len(teachers), + }) + except Exception as e: + _logger.exception('list_batch_teachers failed') + return _error_response(str(e), 500) + + @http.route('/api/batches//teachers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def set_batch_teachers(self, batch_id, **kw): + """Assign teachers to a single batch. + + Body: + - teacher_ids: int[] required + - mode: 'set' | 'add' default 'set' + + Cross-entity teachers are rejected. ``mode='set'`` replaces the whole + list; ``mode='add'`` unions with the existing teachers. + """ + try: + batch = request.env['op.batch'].sudo().browse(batch_id) + if not batch.exists(): + return _error_response('Batch not found', 404) + scope_ids, is_super = _entity_scope() + if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids): + return _error_response('Forbidden', 403) + body = _get_json_body() or {} + mode = (body.get('mode') or 'set').lower() + raw_ids = [int(x) for x in (body.get('teacher_ids') or [])] + target_entity = batch.entity_id.id if batch.entity_id else None + ids = _filter_ids_in_entity('op.faculty', raw_ids, target_entity, label='Teacher') + if mode == 'set': + batch.write({'teacher_ids': [(6, 0, ids)]}) + else: + batch.write({'teacher_ids': [(4, x) for x in ids]}) + return _json_response({ + 'success': True, + 'data': _serialize_batch(batch), + 'teacher_ids': batch.teacher_ids.ids, + }) + except Exception as e: + _logger.exception('set_batch_teachers failed') + return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500) + + @http.route('/api/batches//teachers/remove', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def remove_batch_teachers(self, batch_id, **kw): + try: + batch = request.env['op.batch'].sudo().browse(batch_id) + if not batch.exists(): + return _error_response('Batch not found', 404) + scope_ids, is_super = _entity_scope() + if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids): + return _error_response('Forbidden', 403) + body = _get_json_body() or {} + ids = [int(x) for x in (body.get('teacher_ids') or [])] + if ids: + batch.write({'teacher_ids': [(3, x) for x in ids]}) + return _json_response({ + 'success': True, + 'data': _serialize_batch(batch), + 'teacher_ids': batch.teacher_ids.ids, + }) + except Exception as e: + _logger.exception('remove_batch_teachers failed') + return _error_response(str(e), 500) + # ── Taxonomy Relationship Endpoints ───────────────────────────── @http.route('/api/courses//taxonomy', type='http', auth='public', methods=['GET'], csrf=False) diff --git a/custom_addons/encoach_lms_api/models/__init__.py b/custom_addons/encoach_lms_api/models/__init__.py index b9ed3cb3..e32bd4db 100644 --- a/custom_addons/encoach_lms_api/models/__init__.py +++ b/custom_addons/encoach_lms_api/models/__init__.py @@ -6,7 +6,9 @@ from . import courseware from . import notification from . import faq from . import course_ext +from . import classroom_ext from . import asset from . import ticket from . import training from . import paymob_order +from . import branch diff --git a/custom_addons/encoach_lms_api/models/branch.py b/custom_addons/encoach_lms_api/models/branch.py new file mode 100644 index 00000000..f9897cec --- /dev/null +++ b/custom_addons/encoach_lms_api/models/branch.py @@ -0,0 +1,49 @@ +from odoo import api, fields, models + + +class EncoachLmsBranch(models.Model): + _name = "encoach.lms.branch" + _description = "EnCoach LMS Branch" + _order = "name asc, id asc" + + name = fields.Char(required=True) + code = fields.Char(index=True) + active = fields.Boolean(default=True) + entity_id = fields.Many2one( + "encoach.entity", + required=True, + ondelete="cascade", + index=True, + string="Entity", + ) + notes = fields.Text() + + course_ids = fields.One2many("op.course", "branch_id") + batch_ids = fields.One2many("op.batch", "branch_id") + student_ids = fields.One2many("op.student", "branch_id") + teacher_ids = fields.One2many("op.faculty", "branch_id") + + course_count = fields.Integer(compute="_compute_counts") + batch_count = fields.Integer(compute="_compute_counts") + student_count = fields.Integer(compute="_compute_counts") + teacher_count = fields.Integer(compute="_compute_counts") + + _sql_constraints = [ + ("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."), + ] + + @api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids") + def _compute_counts(self): + for rec in self: + rec.course_count = len(rec.course_ids) + rec.batch_count = len(rec.batch_ids) + rec.student_count = len(rec.student_ids) + rec.teacher_count = len(rec.teacher_ids) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get("code") and vals.get("name"): + vals["code"] = vals["name"].upper().replace(" ", "_")[:24] + return super().create(vals_list) + diff --git a/custom_addons/encoach_lms_api/models/classroom_ext.py b/custom_addons/encoach_lms_api/models/classroom_ext.py new file mode 100644 index 00000000..43a6b601 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/classroom_ext.py @@ -0,0 +1,270 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class OpClassroomExt(models.Model): + """Extend the OpenEduCat classroom into a homeroom (class group). + + Original ``op.classroom`` represents a physical room (with capacity and + facilities). We keep that semantic and add organisational fields so the + same record also acts as the home of a group of students: + * ``student_ids`` — homeroom roster (M2M). + * ``teacher_ids`` — homeroom teachers (M2M). + * ``course_ids`` — courses taught to this classroom (M2M). + * ``batch_ids`` — auto-created batches (one per classroom × course). + + ``assign_course`` is idempotent: re-assigning the same course reuses the + existing batch and only enrolls students that aren't already enrolled. + """ + + _inherit = "op.classroom" + + entity_id = fields.Many2one( + "encoach.entity", + string="Entity", + ondelete="set null", + index=True, + help="Owning entity/organization for LMS isolation.", + ) + branch_id = fields.Many2one( + "encoach.lms.branch", + string="Branch", + ondelete="set null", + index=True, + help="Optional branch/campus inside the owning entity.", + ) + + student_ids = fields.Many2many( + "op.student", + "op_classroom_student_rel", + "classroom_id", + "student_id", + string="Students", + ) + teacher_ids = fields.Many2many( + "op.faculty", + "op_classroom_faculty_rel", + "classroom_id", + "faculty_id", + string="Homeroom Teachers", + ) + course_ids = fields.Many2many( + "op.course", + "op_classroom_course_rel", + "classroom_id", + "course_id", + string="Courses", + ) + + batch_ids = fields.One2many( + "op.batch", + "classroom_id", + string="Batches", + ) + + student_count = fields.Integer(compute="_compute_counts") + teacher_count = fields.Integer(compute="_compute_counts") + course_count = fields.Integer(compute="_compute_counts") + batch_count = fields.Integer(compute="_compute_counts") + + notes = fields.Text(string="Notes") + + @api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids") + def _compute_counts(self): + for rec in self: + rec.student_count = len(rec.student_ids) + rec.teacher_count = len(rec.teacher_ids) + rec.course_count = len(rec.course_ids) + rec.batch_count = len(rec.batch_ids) + + @api.constrains("branch_id", "entity_id") + def _check_branch_entity(self): + for rec in self: + if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id: + raise ValidationError( + "Classroom branch must belong to the same entity as the classroom." + ) + + # ------------------------------------------------------------------ + # Cascade: assign a course to this classroom + # ------------------------------------------------------------------ + def _ensure_unique_batch_code(self, base_code): + """Return a unique batch code derived from ``base_code``. + + ``op.batch`` has a global ``unique(code)`` constraint, so we suffix + with a counter when needed. + """ + Batch = self.env["op.batch"].sudo() + candidate = base_code + idx = 1 + while Batch.search_count([("code", "=", candidate)]): + idx += 1 + candidate = f"{base_code}-{idx}" + return candidate + + def assign_course( + self, + course, + term_name=None, + start_date=None, + end_date=None, + teacher_ids=None, + section_id=None, + term_key=None, + ): + """Assign ``course`` to this classroom and cascade to a batch. + + - Creates (or reuses) the canonical batch for + ``(classroom, course, section, term_key)``. + - Enrolls every classroom student into the batch via ``op.student.course``. + - Optionally sets ``teacher_ids`` (M2M) on the batch; if none is + provided we default to the classroom's homeroom teachers. + + Returns the ``op.batch`` record. + """ + self.ensure_one() + Course = self.env["op.course"].sudo() + Section = self.env["encoach.course.section"].sudo() + Batch = self.env["op.batch"].sudo() + Enroll = self.env["op.student.course"].sudo() + + if isinstance(course, int): + course = Course.browse(course) + if not course or not course.exists(): + raise UserError("Course not found.") + + if ( + self.entity_id + and course.entity_id + and self.entity_id.id != course.entity_id.id + ): + raise ValidationError( + "Course belongs to a different entity than the classroom." + ) + + section = None + if section_id: + section = Section.browse(int(section_id)) + if not section.exists(): + raise UserError("Course section not found.") + if section.course_id.id != course.id: + raise ValidationError("Selected section does not belong to the selected course.") + if ( + self.entity_id + and section.entity_id + and self.entity_id.id != section.entity_id.id + ): + raise ValidationError( + "Course section belongs to a different entity than the classroom." + ) + + if teacher_ids is not None and self.entity_id: + teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids)) + for t in teachers: + if ( + t.exists() + and t.entity_id + and t.entity_id.id != self.entity_id.id + ): + raise ValidationError( + f"Teacher {t.partner_id.name or t.id} belongs to a " + "different entity than the classroom." + ) + + # Add to classroom course list (idempotent) + if course.id not in self.course_ids.ids: + self.write({"course_ids": [(4, course.id)]}) + + # Find or create the canonical batch (classroom × course × section × term) + domain = [ + ("classroom_id", "=", self.id), + ("course_id", "=", course.id), + ("course_section_id", "=", section.id if section else False), + ("term_key", "=", term_key or False), + ] + batch = Batch.search(domain, limit=1) + if not batch: + section_label = f" — Section {section.code}" if section else "" + term_label = f" ({term_name})" if term_name else "" + base_name = f"{self.name} — {course.name or course.code or 'Course'}{section_label}{term_label}" + base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12] + section_code = (section.code if section else "").upper().replace(" ", "") + term_code = (term_key or "").upper().replace(" ", "") + base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}" + if section_code: + base_code = f"{base_code}-{section_code[:5]}" + if term_code: + base_code = f"{base_code}-{term_code[:4]}" + today = fields.Date.context_today(self) + vals = { + "name": base_name, + "code": self._ensure_unique_batch_code(base_code[:16]), + "course_id": course.id, + "classroom_id": self.id, + "course_section_id": section.id if section else False, + "term_key": term_key or False, + "start_date": start_date or today, + "end_date": end_date or today.replace(month=12, day=31), + "entity_id": self.entity_id.id if self.entity_id else False, + "branch_id": self.branch_id.id if self.branch_id else False, + } + batch = Batch.create(vals) + + # Teacher assignment (M2M) — explicit overrides homeroom defaults + target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids + if target_teachers: + batch.write({"teacher_ids": [(6, 0, list(target_teachers))]}) + + # Enroll classroom roster into the batch (idempotent) + for student in self.student_ids: + existing = Enroll.search([ + ("student_id", "=", student.id), + ("course_id", "=", course.id), + ("batch_id", "=", batch.id), + ], limit=1) + if not existing: + Enroll.create({ + "student_id": student.id, + "course_id": course.id, + "batch_id": batch.id, + "state": "running", + }) + + return batch + + @api.model + def _add_students_to_open_batches(self, classroom_id, student_ids): + """When new students join the classroom, enroll them in every existing + course batch under that classroom. + """ + Batch = self.env["op.batch"].sudo() + Enroll = self.env["op.student.course"].sudo() + classroom = self.browse(classroom_id) + if not classroom.exists(): + return + batches = Batch.search([("classroom_id", "=", classroom.id)]) + for batch in batches: + for sid in student_ids: + exists = Enroll.search([ + ("student_id", "=", sid), + ("course_id", "=", batch.course_id.id), + ("batch_id", "=", batch.id), + ], limit=1) + if not exists: + Enroll.create({ + "student_id": sid, + "course_id": batch.course_id.id, + "batch_id": batch.id, + "state": "running", + }) + + def write(self, vals): + """Cascade roster changes into existing classroom batches.""" + old_students = {rec.id: set(rec.student_ids.ids) for rec in self} + res = super().write(vals) + if "student_ids" in vals: + for rec in self: + added = set(rec.student_ids.ids) - old_students.get(rec.id, set()) + if added: + self._add_students_to_open_batches(rec.id, list(added)) + return res diff --git a/custom_addons/encoach_lms_api/models/course_ext.py b/custom_addons/encoach_lms_api/models/course_ext.py index 3ede54e3..8d10560d 100644 --- a/custom_addons/encoach_lms_api/models/course_ext.py +++ b/custom_addons/encoach_lms_api/models/course_ext.py @@ -1,4 +1,61 @@ from odoo import models, fields, api +from odoo.exceptions import ValidationError + + +class EncoachCourseSection(models.Model): + _name = "encoach.course.section" + _description = "Course Section Template" + _order = "course_id, sequence, id" + + name = fields.Char(required=True) + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + notes = fields.Text() + + course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True) + entity_id = fields.Many2one( + "encoach.entity", + string="Entity", + related="course_id.entity_id", + store=True, + index=True, + ) + branch_id = fields.Many2one( + "encoach.lms.branch", + string="Branch", + ondelete="set null", + index=True, + help="Optional default branch for this section template.", + ) + + batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches") + batch_count = fields.Integer(compute="_compute_batch_count") + + _sql_constraints = [ + ( + "uniq_course_section_code", + "unique(course_id, code)", + "Section code must be unique per course.", + ), + ] + + def _compute_batch_count(self): + for rec in self: + rec.batch_count = len(rec.batch_ids) + + @api.constrains("branch_id", "course_id") + def _check_section_branch_entity(self): + for rec in self: + if ( + rec.branch_id + and rec.course_id + and rec.course_id.entity_id + and rec.branch_id.entity_id != rec.course_id.entity_id + ): + raise ValidationError( + "Section branch must belong to the same entity as the course." + ) class OpCourseExt(models.Model): @@ -13,6 +70,13 @@ class OpCourseExt(models.Model): index=True, help='Owning entity/organization for LMS isolation.', ) + branch_id = fields.Many2one( + 'encoach.lms.branch', + string='Branch', + ondelete='set null', + index=True, + help='Optional branch/campus inside the owning entity.', + ) encoach_subject_id = fields.Many2one( 'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, @@ -40,7 +104,11 @@ class OpCourseExt(models.Model): ]) chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters') + section_ids = fields.One2many( + 'encoach.course.section', 'course_id', string='Sections' + ) chapter_count = fields.Integer(compute='_compute_chapter_count', store=True) + section_count = fields.Integer(compute='_compute_section_count', store=True) objective_count = fields.Integer(compute='_compute_objective_count') resource_count = fields.Integer(compute='_compute_resource_count') @@ -49,6 +117,11 @@ class OpCourseExt(models.Model): for rec in self: rec.chapter_count = len(rec.chapter_ids) + @api.depends('section_ids') + def _compute_section_count(self): + for rec in self: + rec.section_count = len(rec.section_ids) + def _compute_objective_count(self): for rec in self: rec.objective_count = len(rec.learning_objective_ids) @@ -73,6 +146,76 @@ class OpBatchExt(models.Model): index=True, help='Owning entity/organization for LMS isolation.', ) + branch_id = fields.Many2one( + 'encoach.lms.branch', + string='Branch', + ondelete='set null', + index=True, + help='Optional branch/campus inside the owning entity.', + ) + classroom_id = fields.Many2one( + 'op.classroom', + string='Classroom', + ondelete='set null', + index=True, + help='Classroom (homeroom) hosting this batch. When set, the batch ' + 'inherits the classroom roster on course assignment.', + ) + teacher_ids = fields.Many2many( + 'op.faculty', + 'op_batch_faculty_rel', + 'batch_id', + 'faculty_id', + string='Teachers', + help='Teachers assigned to this batch. A teacher may be assigned ' + 'to multiple batches.', + ) + student_count = fields.Integer( + string='Student Count', compute='_compute_batch_student_count' + ) + course_section_id = fields.Many2one( + 'encoach.course.section', + string='Course Section', + ondelete='set null', + index=True, + help='Specific section template of the selected course for this batch.', + ) + term_key = fields.Char( + string='Term Key', + help='Optional term/semester key used for one-batch-per-term uniqueness.', + ) + + def _compute_batch_student_count(self): + Enroll = self.env['op.student.course'].sudo() + for rec in self: + rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)]) + + @api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id') + def _check_batch_entity_consistency(self): + for rec in self: + ent = rec.entity_id + if not ent: + continue + if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent: + raise ValidationError( + "Batch course must belong to the same entity as the batch." + ) + if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent: + raise ValidationError( + "Batch classroom must belong to the same entity as the batch." + ) + if ( + rec.course_section_id + and rec.course_section_id.entity_id + and rec.course_section_id.entity_id != ent + ): + raise ValidationError( + "Batch course section must belong to the same entity as the batch." + ) + if rec.branch_id and rec.branch_id.entity_id != ent: + raise ValidationError( + "Batch branch must belong to the same entity as the batch." + ) class OpStudentExt(models.Model): @@ -85,6 +228,25 @@ class OpStudentExt(models.Model): index=True, help='Owning entity/organization for LMS isolation.', ) + branch_id = fields.Many2one( + 'encoach.lms.branch', + string='Branch', + ondelete='set null', + index=True, + help='Optional branch/campus inside the owning entity.', + ) + + @api.constrains('branch_id', 'entity_id') + def _check_student_branch_entity(self): + for rec in self: + if ( + rec.branch_id + and rec.entity_id + and rec.branch_id.entity_id != rec.entity_id + ): + raise ValidationError( + "Student branch must belong to the same entity as the student." + ) class OpFacultyExt(models.Model): @@ -97,3 +259,22 @@ class OpFacultyExt(models.Model): index=True, help='Owning entity/organization for LMS isolation.', ) + branch_id = fields.Many2one( + 'encoach.lms.branch', + string='Branch', + ondelete='set null', + index=True, + help='Optional branch/campus inside the owning entity.', + ) + + @api.constrains('branch_id', 'entity_id') + def _check_faculty_branch_entity(self): + for rec in self: + if ( + rec.branch_id + and rec.entity_id + and rec.branch_id.entity_id != rec.entity_id + ): + raise ValidationError( + "Teacher branch must belong to the same entity as the teacher." + ) diff --git a/custom_addons/encoach_lms_api/security/ir.model.access.csv b/custom_addons/encoach_lms_api/security/ir.model.access.csv index f6e23d74..6825cb58 100644 --- a/custom_addons/encoach_lms_api/security/ir.model.access.csv +++ b/custom_addons/encoach_lms_api/security/ir.model.access.csv @@ -25,3 +25,5 @@ access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_r access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1 access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0 access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1 +access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1 +access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1