feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches

Ported from monorepo v4 commit 3b62075d (backend/* portion).

encoach_ai_course:
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

encoach_lms_api:
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-28 00:20:35 +04:00
parent cd47d01f53
commit 565ddd5ff7
21 changed files with 4045 additions and 117 deletions

View File

@@ -27,6 +27,9 @@ from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables, compute_deliverables,
) )
from odoo.addons.encoach_ai_course.services.media_service import MediaService 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__) _logger = logging.getLogger(__name__)
@@ -45,10 +48,19 @@ def _request_language():
def _entity_scope(): 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() 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 [] 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 return entity_ids, is_super
@@ -222,6 +234,124 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.generate_week_materials failed') _logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/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/<int:plan_id>/weeks/<int:week_number>/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/<id>/extract-workbooks
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/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/<id>/grounding
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/material/<int:material_id>/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/<id>/weeks/<n>/materials # GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -552,6 +682,106 @@ class CoursePlanController(http.Controller):
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code) return _error_response(str(exc), code)
@http.route(
'/api/ai/course-plan/material/<int:material_id>/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/<int:material_id>/media', @http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False) type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -819,6 +1049,155 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.student_list_plans failed') _logger.exception('course-plan.student_list_plans failed')
return _error_response(str(exc), 500) return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# POST /api/student/course-plans/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/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": { "<exercise_id>": "<student answer>", ... },
"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/<plan_id>/materials/<material_id>/attempts/me
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/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/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/ai/course-plan/<int:plan_id>/materials/<int:material_id>/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/<int:plan_id>', @http.route('/api/student/course-plans/<int:plan_id>',
type='http', auth='none', methods=['GET'], csrf=False) type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required @jwt_required

View File

@@ -4,3 +4,4 @@ from . import course_plan
from . import course_plan_source from . import course_plan_source
from . import course_plan_media from . import course_plan_media
from . import course_plan_assignment from . import course_plan_assignment
from . import workbook_attempt

View File

@@ -44,6 +44,7 @@ MATERIAL_TYPE_SELECTION = [
('grammar_lesson', 'Grammar Lesson'), ('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'), ('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'), ('practice', 'Practice Exercises'),
('interactive_workbook', 'Interactive Workbook'),
('other', 'Other'), ('other', 'Other'),
] ]
@@ -302,6 +303,17 @@ class CoursePlanMaterial(models.Model):
help='Plain-text rendering for easy preview / copy-paste when the ' help='Plain-text rendering for easy preview / copy-paste when the '
'structured body is not needed.', '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( media_ids = fields.One2many(
'encoach.course.plan.media', 'material_id', string='Generated media', '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): def to_api_dict(self, include_media=True):
self.ensure_one() self.ensure_one()
grounded = self._loads(self.grounded_on_json, [])
extracted = self._loads(self.extracted_from_json, None)
out = { out = {
'id': self.id, 'id': self.id,
'plan_id': self.plan_id.id, 'plan_id': self.plan_id.id,
@@ -330,6 +344,8 @@ class CoursePlanMaterial(models.Model):
'summary': self.summary or '', 'summary': self.summary or '',
'body': self._loads(self.body_json, {}), 'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '', '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: if include_media:
out['media'] = [m.to_api_dict() for m in self.media_ids] out['media'] = [m.to_api_dict() for m in self.media_ids]

View File

@@ -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,
}

View File

@@ -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_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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1
10 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

View File

@@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline from .course_plan_pipeline import CoursePlanPipeline
from .source_indexer import SourceIndexer from .source_indexer import SourceIndexer
from .rag_context import RAGContextBuilder
from .exercise_extractor import ExerciseExtractor
from .media_service import MediaService from .media_service import MediaService
from .deliverables import compute_deliverables from .deliverables import compute_deliverables

View File

@@ -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": "<reading passage 600-900 words at the target CEFR level>",
"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": "<a fully written 120-180 word model>",
"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: class CoursePlanPipeline:
"""Wrap the LLM call, normalise the JSON, persist the result.""" """Wrap the LLM call, normalise the JSON, persist the result."""
@@ -384,12 +543,19 @@ class CoursePlanPipeline:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Week-level material generation # 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. """Generate teaching materials for one week and persist them.
Any existing materials for the same plan_id + week_number are Any existing materials for the same plan_id + week_number are
replaced — callers that want to keep old versions should copy replaced — callers that want to keep old versions should copy
them before re-running. 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)) plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists(): if not plan.exists():
@@ -402,6 +568,52 @@ class CoursePlanPipeline:
outcomes = plan._loads(plan.outcomes_json, {}) outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, []) items = week._loads(week.items_json, [])
# 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 = ( system_msg = (
"You are an expert English language teacher creating ready-" "You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON " "to-use classroom materials. Your output MUST be valid JSON "
@@ -421,6 +633,7 @@ class CoursePlanPipeline:
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n" f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT + _WEEK_JSON_HINT
) )
max_tokens = 6000
content = self._invoke_agent_or_chat( content = self._invoke_agent_or_chat(
agent_key="course_week_materials", agent_key="course_week_materials",
@@ -430,9 +643,10 @@ class CoursePlanPipeline:
"course": plan.name, "course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(), "cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number, "week_number": week.week_number,
"rag_enabled": rag_enabled,
}, },
temperature=0.6, temperature=0.6,
max_tokens=6000, max_tokens=max_tokens,
action="course_plan.generate_week", action="course_plan.generate_week",
) )
used_week_fallback = False used_week_fallback = False
@@ -474,21 +688,47 @@ class CoursePlanPipeline:
summary = (m.get('summary') or '').strip() summary = (m.get('summary') or '').strip()
if used_week_fallback: if used_week_fallback:
summary = (summary + "\n\n" + skeleton_note).strip() summary = (summary + "\n\n" + skeleton_note).strip()
rec = Material.create({ skill = (m.get('skill') or 'integrated').strip().lower()
vals = {
'plan_id': plan.id, 'plan_id': plan.id,
'week_id': week.id, 'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(), 'skill': skill,
'material_type': (m.get('material_type') or 'other').strip(), 'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled', 'title': (m.get('title') or '').strip() or 'Untitled',
'summary': summary, 'summary': summary,
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False), 'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}), '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) created.append(rec)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad material row: %s", exc) _logger.warning("Skipping bad material row: %s", exc)
return created 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 # Internals
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -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 13 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<name>[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

View File

@@ -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 <book>".
"""
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()}'

View File

@@ -31,17 +31,42 @@ import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
import tempfile import tempfile
import time import time
from typing import Optional from typing import Optional
_logger = logging.getLogger(__name__) _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 import provider_router
from odoo.addons.encoach_ai.services.provider_router import ( from odoo.addons.encoach_ai.services.provider_router import (
classify_provider_error, classify_provider_error,
should_fallback, 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 ---------------------------------------------------------------- # --- Helpers ----------------------------------------------------------------
@@ -93,7 +118,16 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
def _build_audio_script(material) -> str: 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 {} body = material._loads(material.body_json, {}) or {}
if material.material_type == 'listening_script': if material.material_type == 'listening_script':
return (body.get('script') or '').strip() return (body.get('script') or '').strip()
@@ -208,14 +242,39 @@ class MediaService:
return media return media
media.write({'source_text': text[:3000]}) 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( chain = provider_router.resolve_chain(
self.env, 'audio', requested=provider if provider != 'auto' else None, self.env, 'audio', requested=provider if provider != 'auto' else None,
) )
errors = [] errors = []
for prov in chain: for prov in chain:
try: try:
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( result = self._call_audio_provider(
prov, text=text, voice=voice, prov, text=spoken, voice=voice,
language=language, gender=gender, language=language, gender=gender,
) )
content_type = result.get('content_type', 'audio/mpeg') content_type = result.get('content_type', 'audio/mpeg')
@@ -294,6 +353,132 @@ class MediaService:
return synthesize_silent(duration_seconds=seconds) return synthesize_silent(duration_seconds=seconds)
raise RuntimeError(f'Unknown audio provider: {provider!r}') 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 ----------------------------------------------------------- # -- Image -----------------------------------------------------------
def generate_image(self, material, *, def generate_image(self, material, *,
custom_prompt: Optional[str] = None, custom_prompt: Optional[str] = None,
@@ -497,8 +682,9 @@ class MediaService:
# ── Concrete video providers ──────────────────────────────────────── # ── Concrete video providers ────────────────────────────────────────
def _compose_video_ffmpeg(self, media, material, audio_media, image_media): def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH.""" """Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available."""
if shutil.which('ffmpeg') is None: ffmpeg_bin = _resolve_bin('ffmpeg')
if ffmpeg_bin is None:
raise RuntimeError('ffmpeg not found on PATH') raise RuntimeError('ffmpeg not found on PATH')
plan = material.plan_id plan = material.plan_id
@@ -537,7 +723,7 @@ class MediaService:
with open(i_path, 'wb') as f: with open(i_path, 'wb') as f:
f.write(image_bytes) f.write(image_bytes)
cmd = [ cmd = [
'ffmpeg', '-y', ffmpeg_bin, '-y',
'-loop', '1', '-i', i_path, '-loop', '1', '-i', i_path,
'-i', a_path, '-i', a_path,
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',

View File

@@ -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()

View File

@@ -20,17 +20,140 @@ from __future__ import annotations
import base64 import base64
import io import io
import logging import logging
import os
import sys
from datetime import datetime from datetime import datetime
_logger = logging.getLogger(__name__) _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: 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, 1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the
still common). Both raise on encrypted PDFs we can't decrypt — we embedded text layer — fast, deterministic, lossless.
swallow that and let the caller record the error. 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: try:
from pypdf import PdfReader # type: ignore from pypdf import PdfReader # type: ignore
@@ -48,7 +171,22 @@ def _extract_pdf(payload: bytes) -> str:
pages.append(page.extract_text() or '') pages.append(page.extract_text() or '')
except Exception: except Exception:
pages.append('') 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: def _extract_docx(payload: bytes) -> str:
@@ -238,13 +376,26 @@ class SourceIndexer:
return {'status': 'failed', 'error': str(exc)} return {'status': 'failed', 'error': str(exc)}
if not text or not text.strip(): 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({ source.write({
'status': 'failed', 'status': 'failed',
'error': 'Extracted no text from source', 'error': err,
'chunks_count': 0, 'chunks_count': 0,
'extracted_chars': 0, 'extracted_chars': 0,
}) })
return {'status': 'failed', 'error': 'empty'} return {'status': 'failed', 'error': err}
try: try:
svc = EmbeddingService(self.env) svc = EmbeddingService(self.env)

View File

@@ -26,3 +26,4 @@ from . import paymob
from . import platform_settings from . import platform_settings
from . import training from . import training
from . import reports from . import reports
from . import branches

View File

@@ -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/<int:branch_id>', 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/<int:branch_id>', 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/<int:branch_id>', 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)

View File

@@ -1,4 +1,30 @@
"""HTTP controller for classrooms (homerooms).
Routes:
- GET /api/classrooms list (entity-scoped)
- GET /api/classrooms/<id> fetch with full nested data
- POST /api/classrooms create
- PATCH /api/classrooms/<id> partial update
- DELETE /api/classrooms/<id> delete
- GET /api/classrooms/<id>/students list roster
- POST /api/classrooms/<id>/students set/add students (body: {student_ids:[..], mode:'set'|'add'})
- DELETE /api/classrooms/<id>/students remove students (body: {student_ids:[..]})
- GET /api/classrooms/<id>/teachers list homeroom teachers
- POST /api/classrooms/<id>/teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'})
- GET /api/classrooms/<id>/courses list courses
- POST /api/classrooms/<id>/assign-course cascade course → batch + enroll students
- DELETE /api/classrooms/<id>/courses/<cid> detach course (does NOT delete batches/enrollments)
- GET /api/classrooms/<id>/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 import logging
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
@@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import (
_logger = logging.getLogger(__name__) _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 { return {
'id': r.id, 'id': s.id,
'name': r.name or '', 'name': s.name or '',
'code': getattr(r, 'code', '') or '', 'email': s.email or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, 'gr_no': getattr(s, 'gr_no', '') or '',
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'capacity': getattr(r, 'capacity', 0) or 0,
} }
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): 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/<int:cid>', 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/<int:cid>', 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/<int:cid>', 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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/courses/<int:course_id>', 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/<int:cid>/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) @http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_groups(self, **kw): def list_groups(self, **kw):
try: return self.list_classrooms(**kw)
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)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
def get_group(self, gid, **kw): def get_group(self, gid, **kw):
try: return self.get_classroom(gid, **kw)
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)
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False) @http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def create_group(self, **kw): def create_group(self, **kw):
try: return self.create_classroom(**kw)
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)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_group(self, gid, **kw): def delete_group(self, gid, **kw):
try: return self.delete_classroom(gid, **kw)
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/<int:gid>/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/<int:gid>/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)

View File

@@ -12,10 +12,21 @@ _logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _entity_scope(): 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() 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 [] 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 return ids, is_super
@@ -53,6 +64,49 @@ def _scoped_entity_domain(base_domain=None, field='entity_id'):
return domain + [('id', '=', 0)] return domain + [('id', '=', 0)]
return domain + [(field, 'in', ids)] 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): def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False) subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) 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, 'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0, 'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_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_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 '', '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, '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_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 '', '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 [], '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_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 '', '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, 'students': students,
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None, '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 '', '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 pass # op.course has no status field by default
if kw.get('entity_id'): if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['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) offset, limit, page = _paginate(kw)
total = Course.search_count(domain) total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc') records = Course.search(domain, offset=offset, limit=limit, order='id desc')
@@ -239,6 +352,8 @@ class LmsCoreController(http.Controller):
} }
if entity_id: if entity_id:
vals['entity_id'] = 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'): if body.get('description'):
vals['description'] = body['description'] vals['description'] = body['description']
if body.get('max_capacity'): 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']])] vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
if 'entity_id' in body: if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False 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: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
@@ -327,6 +448,172 @@ class LmsCoreController(http.Controller):
_logger.exception('delete_course failed') _logger.exception('delete_course failed')
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/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/<int:course_id>/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/<int:course_id>/sections/<int:section_id>', 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/<int:course_id>/sections/<int:section_id>', 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/<int:course_id>/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) ────────────────── # ── Student: My Courses (enrollment-filtered) ──────────────────
@http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False) @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']))) domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
if kw.get('entity_id'): if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['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) offset, limit, page = _paginate(kw)
total = Student.search_count(domain) total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc') records = Student.search(domain, offset=offset, limit=limit, order='id desc')
@@ -540,6 +829,8 @@ class LmsCoreController(http.Controller):
} }
if entity_id: if entity_id:
student_vals['entity_id'] = 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'): if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date'] student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals) student = request.env['op.student'].sudo().create(student_vals)
@@ -594,6 +885,12 @@ class LmsCoreController(http.Controller):
student_vals['gender'] = body['gender'] student_vals['gender'] = body['gender']
if 'entity_id' in body: if 'entity_id' in body:
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False 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: if student_vals:
rec.write(student_vals) rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)}) return _json_response({'data': _serialize_student(rec)})
@@ -628,6 +925,8 @@ class LmsCoreController(http.Controller):
domain.append(('partner_id.name', 'ilike', kw['search'])) domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('entity_id'): if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['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) offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain) total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') 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)) entity_id = _ensure_entity_access(int(requested_entity))
else: else:
entity_id = _default_entity_id_from_scope() entity_id = _default_entity_id_from_scope()
first = body.get('first_name', '') first = (body.get('first_name') or '').strip()
last = body.get('last_name', '') last = (body.get('last_name') or '').strip()
name = f"{first} {last}".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({ partner = request.env['res.partner'].sudo().create({
'name': name, 'name': name,
'email': body.get('email', ''), 'email': body.get('email', ''),
@@ -662,14 +970,18 @@ class LmsCoreController(http.Controller):
}) })
fac_vals = { fac_vals = {
'partner_id': partner.id, '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: if entity_id:
fac_vals['entity_id'] = 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'): if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['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) faculty = request.env['op.faculty'].sudo().create(fac_vals)
return _json_response({'data': _serialize_teacher(faculty)}) return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e: except Exception as e:
@@ -716,6 +1028,12 @@ class LmsCoreController(http.Controller):
vals['gender'] = body['gender'] vals['gender'] = body['gender']
if 'entity_id' in body: if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False 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: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_teacher(rec)}) return _json_response({'data': _serialize_teacher(rec)})
@@ -748,6 +1066,18 @@ class LmsCoreController(http.Controller):
domain = _scoped_entity_domain([]) domain = _scoped_entity_domain([])
if kw.get('entity_id'): if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['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) offset, limit, page = _paginate(kw)
total = Batch.search_count(domain) total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc') records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
@@ -791,13 +1121,65 @@ class LmsCoreController(http.Controller):
if body.get('code'): if body.get('code'):
vals['code'] = body['code'] vals['code'] = body['code']
if body.get('course_id'): 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: if entity_id:
vals['entity_id'] = 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'): if body.get('start_date'):
vals['start_date'] = body['start_date'] vals['start_date'] = body['start_date']
if body.get('end_date'): if body.get('end_date'):
vals['end_date'] = body['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) rec = request.env['op.batch'].sudo().create(vals)
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
@@ -819,10 +1201,46 @@ class LmsCoreController(http.Controller):
for k in ('name', 'code', 'start_date', 'end_date'): for k in ('name', 'code', 'start_date', 'end_date'):
if k in body: if k in body:
vals[k] = body[k] vals[k] = body[k]
if 'course_id' in body:
vals['course_id'] = int(body['course_id'])
if 'entity_id' in body: if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False 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: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
@@ -956,6 +1374,94 @@ class LmsCoreController(http.Controller):
except Exception as e: except Exception as e:
return _error_response(str(e), 500) return _error_response(str(e), 500)
# ── Per-batch teacher assignment ────────────────────────────────
@http.route('/api/batches/<int:batch_id>/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/<int:batch_id>/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/<int:batch_id>/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 ───────────────────────────── # ── Taxonomy Relationship Endpoints ─────────────────────────────
@http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False)

View File

@@ -6,7 +6,9 @@ from . import courseware
from . import notification from . import notification
from . import faq from . import faq
from . import course_ext from . import course_ext
from . import classroom_ext
from . import asset from . import asset
from . import ticket from . import ticket
from . import training from . import training
from . import paymob_order from . import paymob_order
from . import branch

View File

@@ -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)

View File

@@ -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

View File

@@ -1,4 +1,61 @@
from odoo import models, fields, api 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): class OpCourseExt(models.Model):
@@ -13,6 +70,13 @@ class OpCourseExt(models.Model):
index=True, index=True,
help='Owning entity/organization for LMS isolation.', 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_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, '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') 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) 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') objective_count = fields.Integer(compute='_compute_objective_count')
resource_count = fields.Integer(compute='_compute_resource_count') resource_count = fields.Integer(compute='_compute_resource_count')
@@ -49,6 +117,11 @@ class OpCourseExt(models.Model):
for rec in self: for rec in self:
rec.chapter_count = len(rec.chapter_ids) 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): def _compute_objective_count(self):
for rec in self: for rec in self:
rec.objective_count = len(rec.learning_objective_ids) rec.objective_count = len(rec.learning_objective_ids)
@@ -73,6 +146,76 @@ class OpBatchExt(models.Model):
index=True, index=True,
help='Owning entity/organization for LMS isolation.', 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): class OpStudentExt(models.Model):
@@ -85,6 +228,25 @@ class OpStudentExt(models.Model):
index=True, index=True,
help='Owning entity/organization for LMS isolation.', 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): class OpFacultyExt(models.Model):
@@ -97,3 +259,22 @@ class OpFacultyExt(models.Model):
index=True, index=True,
help='Owning entity/organization for LMS isolation.', 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."
)

View File

@@ -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_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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_encoach_grammar_progress_all encoach.grammar.progress.all model_encoach_grammar_progress base.group_user 1 1 1 1
26 access_encoach_paymob_order_user encoach.paymob.order.user model_encoach_paymob_order base.group_user 1 0 1 0
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1
28 access_encoach_lms_branch_user encoach.lms.branch.user model_encoach_lms_branch base.group_user 1 1 1 1
29 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1