Compare commits
21 Commits
fc384efe85
...
0d7139cbc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7139cbc8 | ||
|
|
565ddd5ff7 | ||
|
|
cd47d01f53 | ||
|
|
afd1662a60 | ||
|
|
cfdf2be527 | ||
|
|
1dd1168fee | ||
|
|
882179870c | ||
|
|
170d7c8d2e | ||
|
|
d35ccc255f | ||
|
|
4253f0174a | ||
|
|
93def02e94 | ||
|
|
3972023a30 | ||
|
|
1a0349c381 | ||
|
|
96f419d653 | ||
|
|
6ec68160c8 | ||
|
|
74d83af57f | ||
|
|
01cce7662d | ||
|
|
ca91544acd | ||
|
|
6a62a43d61 | ||
|
|
0c8443256d | ||
|
|
907a5c0e92 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
337
custom_addons/encoach_ai_course/models/workbook_attempt.py
Normal file
337
custom_addons/encoach_ai_course/models/workbook_attempt.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
247
custom_addons/encoach_ai_course/services/dialogue_parser.py
Normal file
247
custom_addons/encoach_ai_course/services/dialogue_parser.py
Normal 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 1–3 word capitalized name followed by a colon at
|
||||||
|
# either start-of-string or right after sentence-final punctuation. We
|
||||||
|
# anchor with a positive lookbehind so we don't match colons that appear
|
||||||
|
# inside dialogue ("She said: 'No.'") — only those that actually mark a
|
||||||
|
# turn boundary.
|
||||||
|
_SPEAKER_PREFIX = re.compile(
|
||||||
|
r'(?:^|(?<=[.!?]\s)|(?<=\n))'
|
||||||
|
r'(?P<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
|
||||||
373
custom_addons/encoach_ai_course/services/exercise_extractor.py
Normal file
373
custom_addons/encoach_ai_course/services/exercise_extractor.py
Normal 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()}'
|
||||||
@@ -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',
|
||||||
|
|||||||
281
custom_addons/encoach_ai_course/services/rag_context.py
Normal file
281
custom_addons/encoach_ai_course/services/rag_context.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
174
custom_addons/encoach_lms_api/controllers/branches.py
Normal file
174
custom_addons/encoach_lms_api/controllers/branches.py
Normal 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)
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
49
custom_addons/encoach_lms_api/models/branch.py
Normal file
49
custom_addons/encoach_lms_api/models/branch.py
Normal 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)
|
||||||
|
|
||||||
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal 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
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
6
openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
6
openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Normalise line endings:
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Prevent certain files from being exported:
|
||||||
|
.gitattributes export-ignore
|
||||||
|
.gitignore export-ignore
|
||||||
36
openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal file
36
openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
*.py[cod]
|
||||||
|
*.settings
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
eggs
|
||||||
|
parts
|
||||||
|
bin
|
||||||
|
var
|
||||||
|
sdist
|
||||||
|
develop-eggs
|
||||||
|
.installed.cfg
|
||||||
|
lib
|
||||||
|
lib64
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
.coverage
|
||||||
|
.tox
|
||||||
|
nosetests.xml
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Mr Developer
|
||||||
|
.mr.developer.cfg
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.idea
|
||||||
Reference in New Issue
Block a user