feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
sources and persists grounded_on_json metadata
- security: access rules for new models
Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints
Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)
Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
course-plan RAG pipeline
Made-with: Cursor
This commit is contained in:
47
.cursorindexingignore
Normal file
47
.cursorindexingignore
Normal file
@@ -0,0 +1,47 @@
|
||||
# =====================================================================
|
||||
# .cursorindexingignore — files Cursor should NOT index for semantic
|
||||
# search, but that you can still open or @-mention on demand.
|
||||
#
|
||||
# Use this for vendored / third-party / generated code that is
|
||||
# occasionally useful to read but should not pollute search results
|
||||
# or be sent to the model on every agent turn.
|
||||
#
|
||||
# (Note: .gitignore is already honored by Cursor's indexer, so we
|
||||
# only list things NOT already in .gitignore here.)
|
||||
# =====================================================================
|
||||
|
||||
# ---------- Vendored OpenEduCat ERP (LGPL-3, tracked in repo) ----------
|
||||
# 48 MB of third-party Odoo modules. Required at runtime but rarely
|
||||
# needs to be in semantic search context.
|
||||
backend/openeducat_erp-19.0/
|
||||
|
||||
# ---------- Other vendored snapshots inside new_project/ --------------
|
||||
# Some of these are gitignored already; listed defensively.
|
||||
new_project/enterprise-19/
|
||||
new_project/openeducat_erp-19.0/
|
||||
new_project/encoach_frontend_new_v1/
|
||||
|
||||
# ---------- Translation catalogs (huge, low semantic value) ------------
|
||||
# .po / .pot files are massive and rarely useful to the agent.
|
||||
**/i18n/*.po
|
||||
**/i18n/*.pot
|
||||
**/i18n_extra/*.po
|
||||
**/i18n_extra/*.pot
|
||||
|
||||
# ---------- Vendored static assets inside Odoo modules -----------------
|
||||
# These are minified third-party JS/CSS, fonts, icons, screenshots —
|
||||
# never useful for code understanding.
|
||||
**/static/lib/**
|
||||
**/static/fonts/**
|
||||
**/static/description/**
|
||||
**/static/src/img/**
|
||||
**/static/src/scss/lib/**
|
||||
|
||||
# ---------- Generated docs ---------------------------------------------
|
||||
**/doc/_build/
|
||||
**/docs/_build/
|
||||
|
||||
# ---------- Database / Odoo backup dumps ------------------------------
|
||||
*.dump
|
||||
*.sql.gz
|
||||
*.sql.xz
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ pgdata_bak_*/
|
||||
frontend/.vite/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
@@ -27,6 +27,9 @@ from odoo.addons.encoach_ai_course.services.deliverables import (
|
||||
compute_deliverables,
|
||||
)
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
from odoo.addons.encoach_ai_course.services.exercise_extractor import (
|
||||
ExerciseExtractor,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,10 +48,19 @@ def _request_language():
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user."""
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user.
|
||||
|
||||
Mirrors ``encoach_lms_api.controllers.lms_core._entity_scope``: any user
|
||||
linked to one or more entities is entity-scoped, even when they carry
|
||||
Odoo settings groups.
|
||||
"""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
if entity_ids:
|
||||
return entity_ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return entity_ids, is_super
|
||||
|
||||
|
||||
@@ -222,6 +234,124 @@ class CoursePlanController(http.Controller):
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -552,6 +682,106 @@ class CoursePlanController(http.Controller):
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route(
|
||||
'/api/ai/course-plan/material/<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',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -819,6 +1049,155 @@ class CoursePlanController(http.Controller):
|
||||
_logger.exception('course-plan.student_list_plans failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/student/course-plans/<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>',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
|
||||
@@ -4,3 +4,4 @@ from . import course_plan
|
||||
from . import course_plan_source
|
||||
from . import course_plan_media
|
||||
from . import course_plan_assignment
|
||||
from . import workbook_attempt
|
||||
|
||||
@@ -44,6 +44,7 @@ MATERIAL_TYPE_SELECTION = [
|
||||
('grammar_lesson', 'Grammar Lesson'),
|
||||
('vocabulary_list', 'Vocabulary List'),
|
||||
('practice', 'Practice Exercises'),
|
||||
('interactive_workbook', 'Interactive Workbook'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
@@ -302,6 +303,17 @@ class CoursePlanMaterial(models.Model):
|
||||
help='Plain-text rendering for easy preview / copy-paste when the '
|
||||
'structured body is not needed.',
|
||||
)
|
||||
grounded_on_json = fields.Text(
|
||||
help='JSON list of source citations used to ground this material via '
|
||||
'RAG: [{source_id, title, chunks_used}]. Surfaced in the UI as '
|
||||
'a "Grounded on N references" badge.',
|
||||
)
|
||||
extracted_from_json = fields.Text(
|
||||
help='JSON {source_id, page_hint, …} when the material was mined '
|
||||
'from an indexed PDF/DOCX by ExerciseExtractor. Distinct from '
|
||||
'grounded_on_json which records *retrieval* rather than '
|
||||
'*extraction* provenance.',
|
||||
)
|
||||
|
||||
media_ids = fields.One2many(
|
||||
'encoach.course.plan.media', 'material_id', string='Generated media',
|
||||
@@ -317,6 +329,8 @@ class CoursePlanMaterial(models.Model):
|
||||
|
||||
def to_api_dict(self, include_media=True):
|
||||
self.ensure_one()
|
||||
grounded = self._loads(self.grounded_on_json, [])
|
||||
extracted = self._loads(self.extracted_from_json, None)
|
||||
out = {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
@@ -330,6 +344,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
'grounded_on': grounded if isinstance(grounded, list) else [],
|
||||
'extracted_from': extracted if isinstance(extracted, dict) else None,
|
||||
}
|
||||
if include_media:
|
||||
out['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||
|
||||
@@ -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_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_workbook_attempt_user,encoach.course.plan.workbook.attempt.user,model_encoach_course_plan_workbook_attempt,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
from .course_plan_pipeline import CoursePlanPipeline
|
||||
from .source_indexer import SourceIndexer
|
||||
from .rag_context import RAGContextBuilder
|
||||
from .exercise_extractor import ExerciseExtractor
|
||||
from .media_service import MediaService
|
||||
from .deliverables import compute_deliverables
|
||||
|
||||
@@ -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:
|
||||
"""Wrap the LLM call, normalise the JSON, persist the result."""
|
||||
|
||||
@@ -384,12 +543,19 @@ class CoursePlanPipeline:
|
||||
# ------------------------------------------------------------------
|
||||
# Week-level material generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_week_materials(self, plan_id, week_number):
|
||||
def generate_week_materials(self, plan_id, week_number, *, use_rag=False):
|
||||
"""Generate teaching materials for one week and persist them.
|
||||
|
||||
Any existing materials for the same plan_id + week_number are
|
||||
replaced — callers that want to keep old versions should copy
|
||||
them before re-running.
|
||||
|
||||
:param use_rag: when True, retrieve top-k passages from each
|
||||
indexed source via :class:`RAGContextBuilder`, inject them
|
||||
into the prompt, switch to ``_WEEK_JSON_HINT_V2`` (richer
|
||||
bodies + interactive workbook), and persist the source
|
||||
citations on each created material under ``grounded_on_json``.
|
||||
Falls back to v1 cleanly if no sources are indexed.
|
||||
"""
|
||||
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
@@ -402,25 +568,72 @@ class CoursePlanPipeline:
|
||||
outcomes = plan._loads(plan.outcomes_json, {})
|
||||
items = week._loads(week.items_json, [])
|
||||
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use classroom materials. Your output MUST be valid JSON "
|
||||
"matching the schema in the user prompt. Keep reading texts "
|
||||
"close to the target word count for the CEFR level. Keep "
|
||||
"listening scripts natural and conversational. All tasks "
|
||||
"must target the outcome codes supplied."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
# Build per-skill RAG context (only when requested AND sources exist).
|
||||
rag_blocks: dict[str, dict] = {}
|
||||
rag_enabled = False
|
||||
if use_rag:
|
||||
from .rag_context import RAGContextBuilder
|
||||
builder = RAGContextBuilder(self.env)
|
||||
if builder.has_indexed_sources(plan):
|
||||
rag_enabled = True
|
||||
# The week may contain duplicates / the integrated workbook
|
||||
# references "integrated"; we always retrieve once per
|
||||
# listed skill plus once for the integrated bucket so the
|
||||
# extra workbook material has its own grounding too.
|
||||
wanted = {(it.get('skill') or '').lower()
|
||||
for it in items if it.get('skill')}
|
||||
wanted.add('integrated')
|
||||
for skill in wanted:
|
||||
if not skill:
|
||||
continue
|
||||
rag_blocks[skill] = builder.for_week(plan, week, skill)
|
||||
|
||||
if rag_enabled:
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use, classroom-ready materials. You ground your "
|
||||
"content on the reference passages provided in the prompt "
|
||||
"(from the teacher's uploaded book). Paraphrase ideas; "
|
||||
"do not quote whole pages verbatim. Output MUST be valid "
|
||||
"JSON matching the schema EXACTLY — no preamble, no "
|
||||
"trailing commentary."
|
||||
)
|
||||
ref_block = self._render_rag_block(rag_blocks)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Reference passages from your uploaded book(s):\n"
|
||||
f"{ref_block}\n\n"
|
||||
+ _WEEK_JSON_HINT_V2
|
||||
)
|
||||
max_tokens = 8000
|
||||
else:
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use classroom materials. Your output MUST be valid JSON "
|
||||
"matching the schema in the user prompt. Keep reading texts "
|
||||
"close to the target word count for the CEFR level. Keep "
|
||||
"listening scripts natural and conversational. All tasks "
|
||||
"must target the outcome codes supplied."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
max_tokens = 6000
|
||||
|
||||
content = self._invoke_agent_or_chat(
|
||||
agent_key="course_week_materials",
|
||||
@@ -430,9 +643,10 @@ class CoursePlanPipeline:
|
||||
"course": plan.name,
|
||||
"cefr_level": (plan.cefr_level or "").lower(),
|
||||
"week_number": week.week_number,
|
||||
"rag_enabled": rag_enabled,
|
||||
},
|
||||
temperature=0.6,
|
||||
max_tokens=6000,
|
||||
max_tokens=max_tokens,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
used_week_fallback = False
|
||||
@@ -474,21 +688,47 @@ class CoursePlanPipeline:
|
||||
summary = (m.get('summary') or '').strip()
|
||||
if used_week_fallback:
|
||||
summary = (summary + "\n\n" + skeleton_note).strip()
|
||||
rec = Material.create({
|
||||
skill = (m.get('skill') or 'integrated').strip().lower()
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'skill': skill,
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
}
|
||||
if rag_enabled:
|
||||
block = rag_blocks.get(skill) or rag_blocks.get('integrated')
|
||||
if block and block.get('sources'):
|
||||
vals['grounded_on_json'] = json.dumps(
|
||||
block['sources'], ensure_ascii=False,
|
||||
)
|
||||
rec = Material.create(vals)
|
||||
created.append(rec)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_logger.warning("Skipping bad material row: %s", exc)
|
||||
return created
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _render_rag_block(rag_blocks: dict) -> str:
|
||||
"""Render the per-skill RAG passages as a single citation block.
|
||||
|
||||
We keep the per-skill grouping so the model knows which passages
|
||||
relate to which skill — a flat dump tends to mix topics and the
|
||||
model loses focus when generating, e.g., a grammar lesson against
|
||||
a reading-passage source.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for skill, block in rag_blocks.items():
|
||||
text = (block or {}).get('as_prompt_block') or ''
|
||||
if not text:
|
||||
continue
|
||||
parts.append(f"### For {skill}:\n{text}")
|
||||
return '\n\n'.join(parts) or '(no reference passages available)'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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 shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_bin(name: str) -> str | None:
|
||||
"""Locate a binary, preferring the running Python's sibling ``bin/``.
|
||||
|
||||
Odoo is launched with the conda env's interpreter, so its sibling
|
||||
``bin/`` reliably hosts ``ffmpeg`` even when ``$PATH`` in the
|
||||
daemon's environment doesn't include the conda prefix. We use this
|
||||
same pattern in ``encoach_ai_course.services.source_indexer`` for
|
||||
tesseract / pdftoppm — keeping it duplicated here rather than
|
||||
importing across services to avoid a circular dep on a single
|
||||
helper.
|
||||
"""
|
||||
sibling = os.path.join(os.path.dirname(sys.executable), name)
|
||||
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
|
||||
return sibling
|
||||
return shutil.which(name)
|
||||
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
should_fallback,
|
||||
)
|
||||
from . import dialogue_parser
|
||||
|
||||
|
||||
# Providers that can do per-segment multi-voice TTS. gTTS / silent only
|
||||
# expose a single voice per request, so for those we degrade gracefully
|
||||
# to label-stripped single-voice narration.
|
||||
_MULTI_VOICE_PROVIDERS = frozenset({'polly', 'elevenlabs'})
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
@@ -93,7 +118,16 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
|
||||
|
||||
def _build_audio_script(material) -> str:
|
||||
"""Choose the right text from a material's body for narration."""
|
||||
"""Choose the right text from a material's body for narration.
|
||||
|
||||
NEVER include the structural field labels ("Context:", "Script:",
|
||||
"Transcript:") — only the actual narratable content. The previous
|
||||
implementation already did this for ``listening_script`` (only the
|
||||
``script`` field was returned), but we re-document the contract
|
||||
here because the dialogue-aware path below depends on it: the
|
||||
speaker labels we strip out are ONLY the in-text "Name:" turn
|
||||
markers, never section headers.
|
||||
"""
|
||||
body = material._loads(material.body_json, {}) or {}
|
||||
if material.material_type == 'listening_script':
|
||||
return (body.get('script') or '').strip()
|
||||
@@ -208,16 +242,41 @@ class MediaService:
|
||||
return media
|
||||
media.write({'source_text': text[:3000]})
|
||||
|
||||
# Parse "Host: ... Sarah: ..." style dialogues into per-speaker
|
||||
# segments. For monologues this returns a single segment with
|
||||
# speaker=None and we fall through to the legacy fast-path. For
|
||||
# real dialogues we'll synthesize each turn with a gender-matched
|
||||
# voice and concatenate them with ffmpeg.
|
||||
segments = dialogue_parser.parse_dialogue(text)
|
||||
is_dialogue = dialogue_parser.is_dialogue(segments)
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'audio', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
result = self._call_audio_provider(
|
||||
prov, text=text, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
if is_dialogue and prov in _MULTI_VOICE_PROVIDERS:
|
||||
# The good path: per-speaker, gender-matched voices,
|
||||
# stitched into a single MP3 by ffmpeg. The user asked
|
||||
# for "if conversation between two persons, person if
|
||||
# man voice must be man and if woman voice is for
|
||||
# woman" — this is where that contract lives.
|
||||
result = self._synthesize_dialogue(
|
||||
prov, segments=segments, language=language,
|
||||
)
|
||||
else:
|
||||
# Single-voice path. Even here we must NOT speak the
|
||||
# speaker labels aloud, so for dialogue scripts hitting
|
||||
# a free fallback we feed the label-stripped form.
|
||||
spoken = (
|
||||
dialogue_parser.strip_speaker_labels(text)
|
||||
if is_dialogue else text
|
||||
)
|
||||
result = self._call_audio_provider(
|
||||
prov, text=spoken, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
content_type = result.get('content_type', 'audio/mpeg')
|
||||
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
|
||||
attach = _attach_bytes(
|
||||
@@ -294,6 +353,132 @@ class MediaService:
|
||||
return synthesize_silent(duration_seconds=seconds)
|
||||
raise RuntimeError(f'Unknown audio provider: {provider!r}')
|
||||
|
||||
# -- Multi-voice dialogue --------------------------------------------
|
||||
def _synthesize_dialogue(self, provider, *, segments, language):
|
||||
"""Render a dialogue script with per-speaker, gendered voices.
|
||||
|
||||
Each turn is synthesized independently — Polly/ElevenLabs both
|
||||
let us pick a voice per call — and the resulting MP3 / WAV blobs
|
||||
are concatenated via ffmpeg's concat filter into a single audio
|
||||
track. We re-encode through libmp3lame so the output is uniform
|
||||
regardless of which provider was used (bitrate, sample-rate
|
||||
differences would otherwise corrupt the join).
|
||||
|
||||
If ffmpeg is not available we still return *some* audio — a
|
||||
single-voice rendering of the label-stripped text — rather than
|
||||
failing the whole request. Listening practice without a real
|
||||
dialogue is preferable to no audio at all.
|
||||
"""
|
||||
if not segments:
|
||||
raise RuntimeError('No dialogue segments to synthesize')
|
||||
|
||||
ffmpeg_bin = _resolve_bin('ffmpeg')
|
||||
if not ffmpeg_bin:
|
||||
# Graceful degradation: produce a single-voice rendition of
|
||||
# the stripped script. Surface a soft warning but do not
|
||||
# raise — the caller's fallback chain would just route us to
|
||||
# gtts/silent, which is identical behaviour but slower.
|
||||
_logger.warning(
|
||||
'ffmpeg missing — falling back to single-voice dialogue; '
|
||||
'install ffmpeg for true per-speaker rendering.'
|
||||
)
|
||||
joined = ' '.join(seg['text'] for seg in segments)
|
||||
return self._call_audio_provider(
|
||||
provider, text=joined, voice=None,
|
||||
language=language, gender='female',
|
||||
)
|
||||
|
||||
clip_paths: list[str] = []
|
||||
first_voice: Optional[str] = None
|
||||
# Polly bills per character; cap each turn at ~3000 chars (5x the
|
||||
# ~600-char average so a stray giant turn from the LLM doesn't
|
||||
# blow the budget but a typical 3-minute conversation passes
|
||||
# untouched).
|
||||
MAX_CHARS_PER_TURN = 3000
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='encoach_dialogue_') as tmp:
|
||||
for idx, seg in enumerate(segments):
|
||||
seg_text = (seg.get('text') or '').strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
seg_text = seg_text[:MAX_CHARS_PER_TURN]
|
||||
seg_gender = seg.get('gender') or 'female'
|
||||
try:
|
||||
# Pin the per-segment voice via gender. Letting
|
||||
# ``voice=None`` flow through means each provider
|
||||
# picks its default gendered voice (Amy/Brian for
|
||||
# Polly en-GB, Rachel/Arnold for ElevenLabs).
|
||||
res = self._call_audio_provider(
|
||||
provider, text=seg_text, voice=None,
|
||||
language=language, gender=seg_gender,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'segment {idx} ({seg.get("speaker") or "narrator"}) '
|
||||
f'failed: {exc}'
|
||||
) from exc
|
||||
if not first_voice:
|
||||
first_voice = res.get('voice') or seg_gender
|
||||
ext = 'wav' if res.get(
|
||||
'content_type') == 'audio/wav' else 'mp3'
|
||||
clip_path = os.path.join(tmp, f'seg_{idx:03d}.{ext}')
|
||||
with open(clip_path, 'wb') as fh:
|
||||
fh.write(res['audio'])
|
||||
clip_paths.append(clip_path)
|
||||
|
||||
if not clip_paths:
|
||||
raise RuntimeError('All dialogue segments were empty')
|
||||
if len(clip_paths) == 1:
|
||||
# Edge case: 2 speakers but only one had non-empty text.
|
||||
# Skip the concat dance and return the single clip raw.
|
||||
with open(clip_paths[0], 'rb') as fh:
|
||||
return {
|
||||
'audio': fh.read(),
|
||||
'content_type': 'audio/mpeg' if clip_paths[0].endswith('.mp3') else 'audio/wav',
|
||||
'voice': first_voice or 'dialogue',
|
||||
'characters': sum(len(s.get('text') or '') for s in segments),
|
||||
}
|
||||
|
||||
# Build the ffmpeg concat-filter call. We re-encode to a
|
||||
# uniform 22050 Hz mono MP3 so bitrate/sample-rate mismatches
|
||||
# between providers (or even between Polly's neural and
|
||||
# standard engines) don't corrupt the join.
|
||||
out_path = os.path.join(tmp, 'dialogue.mp3')
|
||||
cmd = [ffmpeg_bin, '-y']
|
||||
for p in clip_paths:
|
||||
cmd += ['-i', p]
|
||||
n = len(clip_paths)
|
||||
filter_inputs = ''.join(f'[{i}:a]' for i in range(n))
|
||||
cmd += [
|
||||
'-filter_complex',
|
||||
f'{filter_inputs}concat=n={n}:v=0:a=1[outa]',
|
||||
'-map', '[outa]',
|
||||
'-ac', '1',
|
||||
'-ar', '22050',
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '96k',
|
||||
out_path,
|
||||
]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, check=False, timeout=180,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b'').decode(
|
||||
'utf-8', errors='replace')[-500:]
|
||||
raise RuntimeError(
|
||||
f'ffmpeg concat failed in {elapsed:.1f}s: {err}',
|
||||
)
|
||||
with open(out_path, 'rb') as fh:
|
||||
audio_bytes = fh.read()
|
||||
return {
|
||||
'audio': audio_bytes,
|
||||
'content_type': 'audio/mpeg',
|
||||
'voice': f'dialogue-{n}-turns',
|
||||
'characters': sum(len(s.get('text') or '') for s in segments),
|
||||
}
|
||||
|
||||
# -- Image -----------------------------------------------------------
|
||||
def generate_image(self, material, *,
|
||||
custom_prompt: Optional[str] = None,
|
||||
@@ -497,8 +682,9 @@ class MediaService:
|
||||
# ── Concrete video providers ────────────────────────────────────────
|
||||
|
||||
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
|
||||
if shutil.which('ffmpeg') is None:
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available."""
|
||||
ffmpeg_bin = _resolve_bin('ffmpeg')
|
||||
if ffmpeg_bin is None:
|
||||
raise RuntimeError('ffmpeg not found on PATH')
|
||||
|
||||
plan = material.plan_id
|
||||
@@ -537,7 +723,7 @@ class MediaService:
|
||||
with open(i_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
ffmpeg_bin, '-y',
|
||||
'-loop', '1', '-i', i_path,
|
||||
'-i', a_path,
|
||||
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
||||
|
||||
281
backend/custom_addons/encoach_ai_course/services/rag_context.py
Normal file
281
backend/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 io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# A doc with fewer than this many extractable characters is treated as
|
||||
# image-only and routed to OCR. Most legitimate PDFs cross 200 chars in
|
||||
# their first few pages; below that we're almost certainly looking at a
|
||||
# scan whose pypdf/pdfminer output is junk metadata only.
|
||||
_OCR_TRIGGER_CHARS = 200
|
||||
|
||||
# Cap how much we OCR in a single indexing pass so a 500-page scanned
|
||||
# tome doesn't tie up the request thread for 20 minutes. Editable via
|
||||
# env var so an operator can dial it up for big workbooks.
|
||||
_OCR_MAX_PAGES = int(os.environ.get('ENCOACH_OCR_MAX_PAGES', '300'))
|
||||
_OCR_DPI = int(os.environ.get('ENCOACH_OCR_DPI', '200'))
|
||||
_OCR_LANGS = os.environ.get('ENCOACH_OCR_LANGS', 'eng')
|
||||
|
||||
|
||||
def _resolve_bin(name: str) -> str | None:
|
||||
"""Locate a binary by checking the running Python's bin/ first.
|
||||
|
||||
Odoo is launched with the conda env's interpreter, so its sibling
|
||||
``bin/`` reliably hosts ``tesseract`` / ``pdftoppm`` even when
|
||||
``$PATH`` in the daemon's environment is sparse. Fall back to
|
||||
``shutil.which`` for systems where they're installed globally.
|
||||
"""
|
||||
sibling = os.path.join(os.path.dirname(sys.executable), name)
|
||||
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
|
||||
return sibling
|
||||
import shutil
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
|
||||
"""OCR a scanned/image-only PDF using tesseract + poppler.
|
||||
|
||||
Streams page-by-page so peak memory stays bounded even on a 100+
|
||||
page workbook: rasterizing all pages eagerly into PIL.Image objects
|
||||
blows past Odoo's ~2 GB ``limit_memory_soft`` and forces a worker
|
||||
reload mid-request. We instead call ``pdftoppm`` for one page at a
|
||||
time, OCR the image, drop it, and move on.
|
||||
|
||||
Raises a ``RuntimeError`` with an actionable message when the OCR
|
||||
stack isn't available, so the UI can surface "install OCR" rather
|
||||
than the cryptic "Extracted no text from source".
|
||||
"""
|
||||
try:
|
||||
from pdf2image import convert_from_bytes, pdfinfo_from_bytes # type: ignore
|
||||
import pytesseract # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'This PDF appears to be scanned/image-only and the OCR stack '
|
||||
'is not installed. Install the deps: '
|
||||
'`micromamba install -n odoo19 -c conda-forge tesseract poppler` '
|
||||
'and `pip install pytesseract pdf2image`. '
|
||||
f'(missing: {exc.name})',
|
||||
) from exc
|
||||
|
||||
tesseract_bin = _resolve_bin('tesseract')
|
||||
poppler_bin = _resolve_bin('pdftoppm')
|
||||
if not tesseract_bin or not poppler_bin:
|
||||
raise RuntimeError(
|
||||
'OCR is required for this PDF (no embedded text layer) but '
|
||||
f'tesseract={tesseract_bin or "missing"} / '
|
||||
f'pdftoppm={poppler_bin or "missing"} could not be located. '
|
||||
'Install with `micromamba install -n odoo19 -c conda-forge '
|
||||
'tesseract poppler`.',
|
||||
)
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_bin
|
||||
poppler_path = os.path.dirname(poppler_bin)
|
||||
|
||||
try:
|
||||
info = pdfinfo_from_bytes(payload, poppler_path=poppler_path)
|
||||
total_pages = int(info.get('Pages') or 0)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Could not read PDF page count: {exc}') from exc
|
||||
|
||||
page_count = min(total_pages, max_pages)
|
||||
if page_count <= 0:
|
||||
return ''
|
||||
|
||||
_logger.info(
|
||||
'OCR streaming %s page(s) at %sdpi (lang=%s)',
|
||||
page_count, _OCR_DPI, _OCR_LANGS,
|
||||
)
|
||||
|
||||
pages: list[str] = []
|
||||
for page_num in range(1, page_count + 1):
|
||||
try:
|
||||
images = convert_from_bytes(
|
||||
payload,
|
||||
dpi=_OCR_DPI,
|
||||
first_page=page_num,
|
||||
last_page=page_num,
|
||||
poppler_path=poppler_path,
|
||||
fmt='png',
|
||||
thread_count=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning('OCR rasterize page %s failed: %s', page_num, exc)
|
||||
continue
|
||||
if not images:
|
||||
continue
|
||||
img = images[0]
|
||||
try:
|
||||
txt = pytesseract.image_to_string(img, lang=_OCR_LANGS) or ''
|
||||
except Exception as exc:
|
||||
_logger.warning('OCR page %s failed: %s', page_num, exc)
|
||||
txt = ''
|
||||
finally:
|
||||
try:
|
||||
img.close()
|
||||
except Exception:
|
||||
pass
|
||||
del img, images
|
||||
if txt.strip():
|
||||
pages.append(txt)
|
||||
|
||||
return '\n\n'.join(pages).strip()
|
||||
|
||||
|
||||
def _extract_pdf(payload: bytes) -> str:
|
||||
"""Best-effort PDF text extraction.
|
||||
"""Best-effort PDF text extraction with OCR fallback.
|
||||
|
||||
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
|
||||
still common). Both raise on encrypted PDFs we can't decrypt — we
|
||||
swallow that and let the caller record the error.
|
||||
1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the
|
||||
embedded text layer — fast, deterministic, lossless.
|
||||
2. If that yields virtually nothing (< ``_OCR_TRIGGER_CHARS``), the
|
||||
PDF is almost certainly a scan; rasterize the first
|
||||
``_OCR_MAX_PAGES`` pages and run them through tesseract.
|
||||
|
||||
Both raise on encrypted PDFs we can't decrypt — we swallow that and
|
||||
let the caller record the error.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
@@ -48,7 +171,22 @@ def _extract_pdf(payload: bytes) -> str:
|
||||
pages.append(page.extract_text() or '')
|
||||
except Exception:
|
||||
pages.append('')
|
||||
return '\n\n'.join(p for p in pages if p).strip()
|
||||
text = '\n\n'.join(p for p in pages if p).strip()
|
||||
|
||||
if len(text) >= _OCR_TRIGGER_CHARS:
|
||||
return text
|
||||
|
||||
# Looks image-only — try OCR. If OCR is unavailable, propagate a
|
||||
# clear error rather than silently returning the empty/near-empty
|
||||
# text-layer output.
|
||||
_logger.info(
|
||||
'PDF text layer empty (%s chars over %s pages); attempting OCR',
|
||||
len(text), len(reader.pages),
|
||||
)
|
||||
ocr_text = _ocr_pdf(payload)
|
||||
if ocr_text:
|
||||
return ocr_text
|
||||
return text
|
||||
|
||||
|
||||
def _extract_docx(payload: bytes) -> str:
|
||||
@@ -238,13 +376,26 @@ class SourceIndexer:
|
||||
return {'status': 'failed', 'error': str(exc)}
|
||||
|
||||
if not text or not text.strip():
|
||||
kind = (source.kind or '').lower()
|
||||
if kind in ('file', 'resource') and (
|
||||
(source.file_name or '').lower().endswith('.pdf')
|
||||
or (source.mime_type or '').lower() == 'application/pdf'
|
||||
):
|
||||
err = (
|
||||
'No text could be extracted from this PDF. It looks '
|
||||
'image-only (a scan) and OCR returned nothing. Try a '
|
||||
'higher-quality scan, a publisher PDF with an '
|
||||
'embedded text layer, or raise ENCOACH_OCR_DPI.'
|
||||
)
|
||||
else:
|
||||
err = 'Extracted no text from source'
|
||||
source.write({
|
||||
'status': 'failed',
|
||||
'error': 'Extracted no text from source',
|
||||
'error': err,
|
||||
'chunks_count': 0,
|
||||
'extracted_chars': 0,
|
||||
})
|
||||
return {'status': 'failed', 'error': 'empty'}
|
||||
return {'status': 'failed', 'error': err}
|
||||
|
||||
try:
|
||||
svc = EmbeddingService(self.env)
|
||||
|
||||
@@ -26,3 +26,4 @@ from . import paymob
|
||||
from . import platform_settings
|
||||
from . import training
|
||||
from . import reports
|
||||
from . import branches
|
||||
|
||||
174
backend/custom_addons/encoach_lms_api/controllers/branches.py
Normal file
174
backend/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
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
@@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import (
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_classroom(r):
|
||||
# ----------------------------------------------------------------------
|
||||
# Entity scoping helpers (same shape used in branches.py / lms_core.py)
|
||||
# ----------------------------------------------------------------------
|
||||
def _entity_scope():
|
||||
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
|
||||
user = request.env.user.sudo()
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
if ids:
|
||||
return ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return ids, is_super
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
ids, is_super = _entity_scope()
|
||||
if ids:
|
||||
return ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _scoped_entity_domain(base_domain=None):
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', ids)]
|
||||
|
||||
|
||||
def _ensure_branch_access(branch_id, *, entity_id=None):
|
||||
"""Validate that the branch exists, belongs to ``entity_id`` (when set)
|
||||
and is accessible by the current user."""
|
||||
if not branch_id:
|
||||
return None
|
||||
Branch = request.env['encoach.lms.branch'].sudo()
|
||||
branch = Branch.browse(int(branch_id))
|
||||
if not branch.exists():
|
||||
raise ValueError('Branch not found')
|
||||
_ensure_entity_access(branch.entity_id.id)
|
||||
if entity_id and branch.entity_id.id != int(entity_id):
|
||||
raise PermissionError('Branch does not belong to the requested entity.')
|
||||
return branch.id
|
||||
|
||||
|
||||
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
|
||||
"""Return ids that exist *and* belong to ``entity_id``.
|
||||
|
||||
When ``entity_id`` is falsy (e.g. legacy classroom without an entity), we
|
||||
accept any record that has no ``entity_id`` set yet. This keeps the API
|
||||
safe by default while still allowing administrators to operate on
|
||||
pre-LMS-isolation data without surprises.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
Model = request.env[model].sudo()
|
||||
recs = Model.browse([int(x) for x in ids])
|
||||
out = []
|
||||
for rec in recs:
|
||||
if not rec.exists():
|
||||
continue
|
||||
rec_entity = rec.entity_id.id if rec.entity_id else None
|
||||
if entity_id and rec_entity and rec_entity != int(entity_id):
|
||||
raise PermissionError(
|
||||
f"{label} {rec.id} belongs to a different entity"
|
||||
)
|
||||
out.append(rec.id)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Serializers
|
||||
# ----------------------------------------------------------------------
|
||||
def _ser_student_short(s):
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
'code': getattr(r, 'code', '') or '',
|
||||
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
|
||||
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
|
||||
'capacity': getattr(r, 'capacity', 0) or 0,
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'email': s.email or '',
|
||||
'gr_no': getattr(s, 'gr_no', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_teacher_short(f):
|
||||
return {
|
||||
'id': f.id,
|
||||
'name': f.name or '',
|
||||
'email': getattr(f, 'email', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_course_short(c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'name': c.name or '',
|
||||
'code': c.code or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_section_short(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_batch_short(b):
|
||||
return {
|
||||
'id': b.id,
|
||||
'name': b.name or '',
|
||||
'code': b.code or '',
|
||||
'course_id': b.course_id.id if b.course_id else None,
|
||||
'course_name': b.course_id.name if b.course_id else '',
|
||||
'course_section_id': (
|
||||
b.course_section_id.id
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else None
|
||||
),
|
||||
'course_section_name': (
|
||||
b.course_section_id.name
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'course_section_code': (
|
||||
b.course_section_id.code
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'term_key': getattr(b, 'term_key', '') or '',
|
||||
'start_date': b.start_date and str(b.start_date) or '',
|
||||
'end_date': b.end_date and str(b.end_date) or '',
|
||||
'student_count': getattr(b, 'student_count', 0) or 0,
|
||||
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
|
||||
}
|
||||
|
||||
|
||||
def _ser_classroom(r, *, full=False):
|
||||
data = {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
'code': r.code or '',
|
||||
'capacity': r.capacity or 0,
|
||||
'active': bool(r.active),
|
||||
'notes': getattr(r, 'notes', '') or '',
|
||||
'entity_id': r.entity_id.id if r.entity_id else None,
|
||||
'entity_name': r.entity_id.name if r.entity_id else '',
|
||||
'branch_id': r.branch_id.id if r.branch_id else None,
|
||||
'branch_name': r.branch_id.name if r.branch_id else '',
|
||||
'student_count': r.student_count or 0,
|
||||
'teacher_count': r.teacher_count or 0,
|
||||
'course_count': r.course_count or 0,
|
||||
'batch_count': r.batch_count or 0,
|
||||
'student_ids': r.student_ids.ids,
|
||||
'teacher_ids': r.teacher_ids.ids,
|
||||
'course_ids': r.course_ids.ids,
|
||||
}
|
||||
if full:
|
||||
data['students'] = [_ser_student_short(s) for s in r.student_ids]
|
||||
data['teachers'] = [_ser_teacher_short(t) for t in r.teacher_ids]
|
||||
data['courses'] = [_ser_course_short(c) for c in r.course_ids]
|
||||
data['sections'] = [
|
||||
_ser_section_short(s)
|
||||
for s in request.env['encoach.course.section'].sudo().search([
|
||||
('course_id', 'in', r.course_ids.ids)
|
||||
], order='course_id, sequence, id')
|
||||
]
|
||||
data['batches'] = [_ser_batch_short(b) for b in r.batch_ids]
|
||||
return data
|
||||
|
||||
|
||||
def _classroom_or_404(cid):
|
||||
rec = request.env['op.classroom'].sudo().browse(int(cid))
|
||||
if not rec.exists():
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def _scoped_classroom_domain(base_domain=None):
|
||||
"""Same as ``_scoped_entity_domain`` but tolerates legacy classrooms
|
||||
without ``entity_id`` for backward-compatibility (so existing rooms
|
||||
keep showing up to admins)."""
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + ['|', ('entity_id', 'in', ids), ('entity_id', '=', False)]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Controller
|
||||
# ----------------------------------------------------------------------
|
||||
class ClassroomsController(http.Controller):
|
||||
|
||||
# ---- CRUD --------------------------------------------------------
|
||||
@http.route('/api/classrooms', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classrooms(self, **kw):
|
||||
try:
|
||||
Classroom = request.env['op.classroom'].sudo()
|
||||
domain = _scoped_classroom_domain([])
|
||||
if kw.get('search'):
|
||||
q = kw['search']
|
||||
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
|
||||
if kw.get('active') in ('true', 'false', '1', '0'):
|
||||
domain.append(('active', '=', kw.get('active') in ('true', '1')))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', int(kw['branch_id'])))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Classroom.search_count(domain)
|
||||
recs = Classroom.search(domain, offset=offset, limit=limit, order='name asc, id asc')
|
||||
items = [_ser_classroom(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classrooms failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<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)
|
||||
@jwt_required
|
||||
def list_groups(self, **kw):
|
||||
try:
|
||||
M = request.env['op.classroom'].sudo()
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = M.search_count([])
|
||||
recs = M.search([], offset=offset, limit=limit, order='id desc')
|
||||
items = [_ser_classroom(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.list_classrooms(**kw)
|
||||
|
||||
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_group(self, gid, **kw):
|
||||
try:
|
||||
rec = request.env['op.classroom'].sudo().browse(gid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
return _json_response(_ser_classroom(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.get_classroom(gid, **kw)
|
||||
|
||||
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_group(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {'name': body.get('name', '')}
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('capacity'):
|
||||
vals['capacity'] = int(body['capacity'])
|
||||
rec = request.env['op.classroom'].sudo().create(vals)
|
||||
return _json_response(_ser_classroom(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.create_classroom(**kw)
|
||||
|
||||
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_group(self, gid, **kw):
|
||||
try:
|
||||
rec = request.env['op.classroom'].sudo().browse(gid)
|
||||
if rec.exists():
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def transfer(self, **kw):
|
||||
try:
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/groups/<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)
|
||||
return self.delete_classroom(gid, **kw)
|
||||
|
||||
@@ -12,10 +12,21 @@ _logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for the current user."""
|
||||
"""Return (entity_ids, is_superadmin) for the current user.
|
||||
|
||||
A user is treated as a *platform* superadmin only when they have no
|
||||
entities assigned (``entity_ids`` is empty) AND either are the bootstrap
|
||||
Odoo user (uid=1) or carry ``base.group_system`` directly. Any user
|
||||
linked to one or more entities is always entity-scoped — even Odoo
|
||||
settings admins — so multi-entity LMS isolation is enforced.
|
||||
"""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
if ids:
|
||||
return ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return ids, is_super
|
||||
|
||||
|
||||
@@ -53,6 +64,49 @@ def _scoped_entity_domain(base_domain=None, field='entity_id'):
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [(field, 'in', ids)]
|
||||
|
||||
|
||||
def _ensure_branch_access(branch_id, *, entity_id=None):
|
||||
"""Validate branch ownership and user access, return branch id."""
|
||||
if not branch_id:
|
||||
raise PermissionError('branch_id is required')
|
||||
branch = request.env['encoach.lms.branch'].sudo().browse(int(branch_id))
|
||||
if not branch.exists():
|
||||
raise ValueError('Branch not found')
|
||||
_ensure_entity_access(branch.entity_id.id)
|
||||
if entity_id and branch.entity_id.id != int(entity_id):
|
||||
raise PermissionError('Branch entity mismatch')
|
||||
return branch.id
|
||||
|
||||
|
||||
def _assert_record_in_entity(model, rec_id, entity_id, *, label='record'):
|
||||
"""Raise PermissionError if a record (M2O) doesn't belong to ``entity_id``.
|
||||
|
||||
A record without ``entity_id`` is considered legacy/unscoped and is
|
||||
allowed; this keeps pre-isolation data usable while still blocking
|
||||
explicit cross-entity references.
|
||||
"""
|
||||
if not rec_id or not entity_id:
|
||||
return int(rec_id) if rec_id else False
|
||||
rec = request.env[model].sudo().browse(int(rec_id))
|
||||
if not rec.exists():
|
||||
raise ValueError(f'{label} {rec_id} not found')
|
||||
rec_entity = rec.entity_id.id if rec.entity_id else None
|
||||
if rec_entity and rec_entity != int(entity_id):
|
||||
raise PermissionError(
|
||||
f'{label} {rec_id} belongs to a different entity'
|
||||
)
|
||||
return rec.id
|
||||
|
||||
|
||||
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
|
||||
"""Same as ``_assert_record_in_entity`` but for a list of ids."""
|
||||
if not ids:
|
||||
return []
|
||||
out = []
|
||||
for rid in ids:
|
||||
out.append(_assert_record_in_entity(model, rid, entity_id, label=label))
|
||||
return out
|
||||
|
||||
def _serialize_course(c):
|
||||
subj = getattr(c, 'encoach_subject_id', False)
|
||||
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
|
||||
@@ -85,8 +139,39 @@ def _serialize_course(c):
|
||||
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
|
||||
'resource_count': getattr(c, 'resource_count', 0) or 0,
|
||||
'objective_count': getattr(c, 'objective_count', 0) or 0,
|
||||
'section_count': getattr(c, 'section_count', 0) or 0,
|
||||
'sections': [
|
||||
{
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'active': bool(s.active),
|
||||
}
|
||||
for s in (c.section_ids if hasattr(c, 'section_ids') else c.env['encoach.course.section'])
|
||||
],
|
||||
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
|
||||
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
|
||||
'branch_id': c.branch_id.id if hasattr(c, 'branch_id') and c.branch_id else None,
|
||||
'branch_name': c.branch_id.name if hasattr(c, 'branch_id') and c.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
def _serialize_course_section(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'active': bool(s.active),
|
||||
'notes': s.notes or '',
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
'entity_id': s.entity_id.id if s.entity_id else None,
|
||||
'entity_name': s.entity_id.name if s.entity_id else '',
|
||||
'branch_id': s.branch_id.id if s.branch_id else None,
|
||||
'branch_name': s.branch_id.name if s.branch_id else '',
|
||||
'batch_count': s.batch_count or 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +206,8 @@ def _serialize_student(s):
|
||||
'user_id': s.user_id.id if s.user_id else None,
|
||||
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
|
||||
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
|
||||
'branch_id': s.branch_id.id if hasattr(s, 'branch_id') and s.branch_id else None,
|
||||
'branch_name': s.branch_id.name if hasattr(s, 'branch_id') and s.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +232,8 @@ def _serialize_teacher(f):
|
||||
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
|
||||
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
|
||||
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
|
||||
'branch_id': f.branch_id.id if hasattr(f, 'branch_id') and f.branch_id else None,
|
||||
'branch_name': f.branch_id.name if hasattr(f, 'branch_id') and f.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +266,28 @@ def _serialize_batch(b):
|
||||
'students': students,
|
||||
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
|
||||
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
|
||||
'branch_id': b.branch_id.id if hasattr(b, 'branch_id') and b.branch_id else None,
|
||||
'branch_name': b.branch_id.name if hasattr(b, 'branch_id') and b.branch_id else '',
|
||||
'classroom_id': b.classroom_id.id if hasattr(b, 'classroom_id') and b.classroom_id else None,
|
||||
'classroom_name': b.classroom_id.name if hasattr(b, 'classroom_id') and b.classroom_id else '',
|
||||
'course_section_id': (
|
||||
b.course_section_id.id
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else None
|
||||
),
|
||||
'course_section_name': (
|
||||
b.course_section_id.name
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'course_section_code': (
|
||||
b.course_section_id.code
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'term_key': getattr(b, 'term_key', '') or '',
|
||||
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
|
||||
'teacher_names': [t.name or '' for t in b.teacher_ids] if hasattr(b, 'teacher_ids') else [],
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +305,8 @@ class LmsCoreController(http.Controller):
|
||||
pass # op.course has no status field by default
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Course.search_count(domain)
|
||||
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -239,6 +352,8 @@ class LmsCoreController(http.Controller):
|
||||
}
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('description'):
|
||||
vals['description'] = body['description']
|
||||
if body.get('max_capacity'):
|
||||
@@ -292,6 +407,12 @@ class LmsCoreController(http.Controller):
|
||||
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
@@ -327,6 +448,172 @@ class LmsCoreController(http.Controller):
|
||||
_logger.exception('delete_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<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) ──────────────────
|
||||
|
||||
@http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@@ -486,6 +773,8 @@ class LmsCoreController(http.Controller):
|
||||
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Student.search_count(domain)
|
||||
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -540,6 +829,8 @@ class LmsCoreController(http.Controller):
|
||||
}
|
||||
if entity_id:
|
||||
student_vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
student_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('birth_date'):
|
||||
student_vals['birth_date'] = body['birth_date']
|
||||
student = request.env['op.student'].sudo().create(student_vals)
|
||||
@@ -594,6 +885,12 @@ class LmsCoreController(http.Controller):
|
||||
student_vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
effective_entity = student_vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
student_vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if student_vals:
|
||||
rec.write(student_vals)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
@@ -628,6 +925,8 @@ class LmsCoreController(http.Controller):
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Faculty.search_count(domain)
|
||||
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -652,9 +951,18 @@ class LmsCoreController(http.Controller):
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
first = body.get('first_name', '')
|
||||
last = body.get('last_name', '')
|
||||
name = f"{first} {last}".strip()
|
||||
first = (body.get('first_name') or '').strip()
|
||||
last = (body.get('last_name') or '').strip()
|
||||
name = f"{first} {last}".strip() or (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('first_name/last_name (or name) is required', 400)
|
||||
if not first and ' ' in name:
|
||||
first, last = name.split(' ', 1)
|
||||
elif not first:
|
||||
first = name
|
||||
last = name
|
||||
if not last:
|
||||
last = first
|
||||
partner = request.env['res.partner'].sudo().create({
|
||||
'name': name,
|
||||
'email': body.get('email', ''),
|
||||
@@ -662,14 +970,18 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
fac_vals = {
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
'first_name': first,
|
||||
'last_name': last,
|
||||
'name': name,
|
||||
'gender': body.get('gender') or 'male',
|
||||
'birth_date': body.get('birth_date') or '1990-01-01',
|
||||
}
|
||||
if entity_id:
|
||||
fac_vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
fac_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
|
||||
fac_vals['department_id'] = int(body['department_id'])
|
||||
if body.get('birth_date'):
|
||||
fac_vals['birth_date'] = body['birth_date']
|
||||
faculty = request.env['op.faculty'].sudo().create(fac_vals)
|
||||
return _json_response({'data': _serialize_teacher(faculty)})
|
||||
except Exception as e:
|
||||
@@ -716,6 +1028,12 @@ class LmsCoreController(http.Controller):
|
||||
vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
@@ -748,6 +1066,18 @@ class LmsCoreController(http.Controller):
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
if kw.get('classroom_id'):
|
||||
domain.append(('classroom_id', '=', int(kw['classroom_id'])))
|
||||
if kw.get('teacher_id'):
|
||||
domain.append(('teacher_ids', 'in', int(kw['teacher_id'])))
|
||||
if kw.get('course_id'):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('course_section_id'):
|
||||
domain.append(('course_section_id', '=', int(kw['course_section_id'])))
|
||||
if kw.get('term_key'):
|
||||
domain.append(('term_key', '=', str(kw['term_key'])))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Batch.search_count(domain)
|
||||
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -791,13 +1121,65 @@ class LmsCoreController(http.Controller):
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
vals['course_id'] = _assert_record_in_entity(
|
||||
'op.course', int(body['course_id']), entity_id, label='Course',
|
||||
)
|
||||
section_id = body.get('course_section_id')
|
||||
section = None
|
||||
if section_id:
|
||||
section = request.env['encoach.course.section'].sudo().browse(int(section_id))
|
||||
if not section.exists():
|
||||
return _error_response('course_section_id not found', 400)
|
||||
if vals.get('course_id') and section.course_id.id != vals['course_id']:
|
||||
return _error_response('course_section_id does not belong to course_id', 400)
|
||||
_assert_record_in_entity(
|
||||
'encoach.course.section', section.id, entity_id, label='Course section',
|
||||
)
|
||||
vals['course_section_id'] = section.id
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('classroom_id'):
|
||||
vals['classroom_id'] = _assert_record_in_entity(
|
||||
'op.classroom', int(body['classroom_id']), entity_id, label='Classroom',
|
||||
)
|
||||
if body.get('teacher_ids'):
|
||||
tids = _filter_ids_in_entity(
|
||||
'op.faculty', [int(x) for x in body['teacher_ids']],
|
||||
entity_id, label='Teacher',
|
||||
)
|
||||
vals['teacher_ids'] = [(6, 0, tids)]
|
||||
if 'term_key' in body:
|
||||
vals['term_key'] = (body.get('term_key') or '').strip() or False
|
||||
if body.get('start_date'):
|
||||
vals['start_date'] = body['start_date']
|
||||
if body.get('end_date'):
|
||||
vals['end_date'] = body['end_date']
|
||||
# Auto-generate a unique batch code if the client didn't supply one.
|
||||
# `op.batch.code` is NOT NULL + UNIQUE, and AdminBatches UI doesn't ship a code field.
|
||||
if not vals.get('code'):
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
course = request.env['op.course'].sudo().browse(vals['course_id']) if vals.get('course_id') else None
|
||||
course_part = ((course.code or course.name or 'C') if course else 'C')
|
||||
course_part = ''.join(ch for ch in course_part.upper() if ch.isalnum())[:6] or 'C'
|
||||
section_part = ''
|
||||
if section:
|
||||
section_part = '-' + ''.join(ch for ch in (section.code or '').upper() if ch.isalnum())[:4]
|
||||
term_part = ''
|
||||
term_key_val = vals.get('term_key')
|
||||
if term_key_val:
|
||||
term_part = '-' + ''.join(ch for ch in term_key_val.upper() if ch.isalnum())[:5]
|
||||
base = f"B{course_part}{section_part}{term_part}"[:14]
|
||||
candidate = base
|
||||
idx = 1
|
||||
while Batch.search_count([('code', '=', candidate)]):
|
||||
suffix = f"-{idx}"
|
||||
candidate = (base[: 16 - len(suffix)]) + suffix
|
||||
idx += 1
|
||||
if idx > 999:
|
||||
break
|
||||
vals['code'] = candidate[:16]
|
||||
rec = request.env['op.batch'].sudo().create(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
@@ -819,10 +1201,46 @@ class LmsCoreController(http.Controller):
|
||||
for k in ('name', 'code', 'start_date', 'end_date'):
|
||||
if k in body:
|
||||
vals[k] = body[k]
|
||||
if 'course_id' in body:
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'course_id' in body:
|
||||
vals['course_id'] = _assert_record_in_entity(
|
||||
'op.course', int(body['course_id']), effective_entity, label='Course',
|
||||
)
|
||||
if 'course_section_id' in body:
|
||||
if body['course_section_id']:
|
||||
section = request.env['encoach.course.section'].sudo().browse(int(body['course_section_id']))
|
||||
if not section.exists():
|
||||
return _error_response('course_section_id not found', 400)
|
||||
target_course = vals.get('course_id') or (rec.course_id.id if rec.course_id else None)
|
||||
if target_course and section.course_id.id != target_course:
|
||||
return _error_response('course_section_id does not belong to course_id', 400)
|
||||
_assert_record_in_entity(
|
||||
'encoach.course.section', section.id, effective_entity, label='Course section',
|
||||
)
|
||||
vals['course_section_id'] = section.id
|
||||
else:
|
||||
vals['course_section_id'] = False
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if 'classroom_id' in body:
|
||||
vals['classroom_id'] = (
|
||||
_assert_record_in_entity(
|
||||
'op.classroom', int(body['classroom_id']), effective_entity, label='Classroom',
|
||||
) if body['classroom_id'] else False
|
||||
)
|
||||
if 'teacher_ids' in body:
|
||||
tids = _filter_ids_in_entity(
|
||||
'op.faculty', [int(x) for x in (body['teacher_ids'] or [])],
|
||||
effective_entity, label='Teacher',
|
||||
)
|
||||
vals['teacher_ids'] = [(6, 0, tids)]
|
||||
if 'term_key' in body:
|
||||
vals['term_key'] = (body.get('term_key') or '').strip() or False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
@@ -956,6 +1374,94 @@ class LmsCoreController(http.Controller):
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Per-batch teacher assignment ────────────────────────────────
|
||||
@http.route('/api/batches/<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 ─────────────────────────────
|
||||
|
||||
@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 faq
|
||||
from . import course_ext
|
||||
from . import classroom_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
from . import training
|
||||
from . import paymob_order
|
||||
from . import branch
|
||||
|
||||
49
backend/custom_addons/encoach_lms_api/models/branch.py
Normal file
49
backend/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
backend/custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
backend/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.exceptions import ValidationError
|
||||
|
||||
|
||||
class EncoachCourseSection(models.Model):
|
||||
_name = "encoach.course.section"
|
||||
_description = "Course Section Template"
|
||||
_order = "course_id, sequence, id"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
notes = fields.Text()
|
||||
|
||||
course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
string="Entity",
|
||||
related="course_id.entity_id",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
"encoach.lms.branch",
|
||||
string="Branch",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Optional default branch for this section template.",
|
||||
)
|
||||
|
||||
batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches")
|
||||
batch_count = fields.Integer(compute="_compute_batch_count")
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_course_section_code",
|
||||
"unique(course_id, code)",
|
||||
"Section code must be unique per course.",
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_batch_count(self):
|
||||
for rec in self:
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
|
||||
@api.constrains("branch_id", "course_id")
|
||||
def _check_section_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.course_id
|
||||
and rec.course_id.entity_id
|
||||
and rec.branch_id.entity_id != rec.course_id.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Section branch must belong to the same entity as the course."
|
||||
)
|
||||
|
||||
|
||||
class OpCourseExt(models.Model):
|
||||
@@ -13,6 +70,13 @@ class OpCourseExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
@@ -40,7 +104,11 @@ class OpCourseExt(models.Model):
|
||||
])
|
||||
|
||||
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
|
||||
section_ids = fields.One2many(
|
||||
'encoach.course.section', 'course_id', string='Sections'
|
||||
)
|
||||
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
|
||||
section_count = fields.Integer(compute='_compute_section_count', store=True)
|
||||
objective_count = fields.Integer(compute='_compute_objective_count')
|
||||
resource_count = fields.Integer(compute='_compute_resource_count')
|
||||
|
||||
@@ -49,6 +117,11 @@ class OpCourseExt(models.Model):
|
||||
for rec in self:
|
||||
rec.chapter_count = len(rec.chapter_ids)
|
||||
|
||||
@api.depends('section_ids')
|
||||
def _compute_section_count(self):
|
||||
for rec in self:
|
||||
rec.section_count = len(rec.section_ids)
|
||||
|
||||
def _compute_objective_count(self):
|
||||
for rec in self:
|
||||
rec.objective_count = len(rec.learning_objective_ids)
|
||||
@@ -73,6 +146,76 @@ class OpBatchExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
classroom_id = fields.Many2one(
|
||||
'op.classroom',
|
||||
string='Classroom',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Classroom (homeroom) hosting this batch. When set, the batch '
|
||||
'inherits the classroom roster on course assignment.',
|
||||
)
|
||||
teacher_ids = fields.Many2many(
|
||||
'op.faculty',
|
||||
'op_batch_faculty_rel',
|
||||
'batch_id',
|
||||
'faculty_id',
|
||||
string='Teachers',
|
||||
help='Teachers assigned to this batch. A teacher may be assigned '
|
||||
'to multiple batches.',
|
||||
)
|
||||
student_count = fields.Integer(
|
||||
string='Student Count', compute='_compute_batch_student_count'
|
||||
)
|
||||
course_section_id = fields.Many2one(
|
||||
'encoach.course.section',
|
||||
string='Course Section',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Specific section template of the selected course for this batch.',
|
||||
)
|
||||
term_key = fields.Char(
|
||||
string='Term Key',
|
||||
help='Optional term/semester key used for one-batch-per-term uniqueness.',
|
||||
)
|
||||
|
||||
def _compute_batch_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
for rec in self:
|
||||
rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)])
|
||||
|
||||
@api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id')
|
||||
def _check_batch_entity_consistency(self):
|
||||
for rec in self:
|
||||
ent = rec.entity_id
|
||||
if not ent:
|
||||
continue
|
||||
if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch course must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch classroom must belong to the same entity as the batch."
|
||||
)
|
||||
if (
|
||||
rec.course_section_id
|
||||
and rec.course_section_id.entity_id
|
||||
and rec.course_section_id.entity_id != ent
|
||||
):
|
||||
raise ValidationError(
|
||||
"Batch course section must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.branch_id and rec.branch_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch branch must belong to the same entity as the batch."
|
||||
)
|
||||
|
||||
|
||||
class OpStudentExt(models.Model):
|
||||
@@ -85,6 +228,25 @@ class OpStudentExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_student_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Student branch must belong to the same entity as the student."
|
||||
)
|
||||
|
||||
|
||||
class OpFacultyExt(models.Model):
|
||||
@@ -97,3 +259,22 @@ class OpFacultyExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_faculty_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Teacher branch must belong to the same entity as the teacher."
|
||||
)
|
||||
|
||||
@@ -25,3 +25,5 @@ access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_r
|
||||
access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1
|
||||
access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0
|
||||
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
|
||||
access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1
|
||||
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
|
||||
|
||||
|
190
docs/ASSIGNMENT_WORKFLOW.md
Normal file
190
docs/ASSIGNMENT_WORKFLOW.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# EnCoach LMS — Assignment Workflow
|
||||
|
||||
**Audience:** Entity admins managing students, teachers, classrooms, sections, and batches.
|
||||
**Scope:** Single-entity workflow. Cross-entity isolation is enforced by the API and is documented at the end.
|
||||
**Goal:** Get every student into the right batch (course × section × term) with the right teachers, in the fewest clicks.
|
||||
|
||||
---
|
||||
|
||||
## 1. The mental model
|
||||
|
||||
EnCoach uses **four canonical entities** that build on each other. Read this once — it makes every screen self-explanatory afterwards.
|
||||
|
||||
| Entity | What it represents | Owns |
|
||||
|---|---|---|
|
||||
| Classroom | A homeroom: a physical room repurposed as a class group (e.g. "Grade 7-A") | Roster of students, homeroom teachers, the courses studied here |
|
||||
| Course | A subject taught (e.g. "Physics 102") | Course sections (A/B/C…), syllabus, learning objectives |
|
||||
| Course Section | A timetabled instance of a course (e.g. "Physics 102 — Section A") | Optional capacity, sequence, branch |
|
||||
| Batch | The intersection of a classroom × course × section × term | The actual enrolled students and the teachers who teach this slice |
|
||||
|
||||
**Rule of thumb:** the **classroom is the anchor**. You set up the classroom once (roster + homeroom teachers), then you bind one or more (course, section, term) tuples to it. Each binding becomes a *batch* which automatically inherits the roster and homeroom teachers. Per-batch overrides are available when you need them.
|
||||
|
||||
```
|
||||
Entity (school / academy)
|
||||
└── Classroom (homeroom) ← step 1 & 2
|
||||
├── Roster (students) ← step 1
|
||||
├── Homeroom teachers ← step 2
|
||||
└── Courses ← step 3 (assign a course → cascade)
|
||||
└── Batch (course × section × term) ← step 4 (per-batch tuning)
|
||||
├── Enrolled students (= roster, overridable)
|
||||
└── Teachers (= homeroom teachers, overridable)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The 4-step workflow
|
||||
|
||||
The Classrooms page is the single entry point. Each tab is a numbered step. The workflow guide at the top of the detail pane shows green check marks as you complete each step.
|
||||
|
||||
### Step 1 — Roster
|
||||
|
||||
**Where:** Classrooms → pick a classroom → tab **1. Roster**
|
||||
|
||||
**Action:** Click *Manage Roster* and pick the students who belong to this homeroom.
|
||||
|
||||
**Why it matters:** The roster cascades into every batch you create later. Set this once; downstream steps populate themselves.
|
||||
|
||||
**API:** `POST /api/classrooms/{id}/students` with `{ student_ids: [...], mode: "set" | "add" }`
|
||||
|
||||
### Step 2 — Faculty
|
||||
|
||||
**Where:** tab **2. Faculty**
|
||||
|
||||
**Action:** Click *Manage Faculty* and pick the homeroom teachers.
|
||||
|
||||
**Why it matters:** When you assign a course in step 3, these teachers are automatically attached to the new batch. You can override per batch later.
|
||||
|
||||
**API:** `POST /api/classrooms/{id}/teachers` with `{ teacher_ids: [...], mode: "set" | "add" }`
|
||||
|
||||
### Step 3 — Courses (cascade)
|
||||
|
||||
**Where:** tab **3. Courses**
|
||||
|
||||
**Action:** Click *Assign Course*, then:
|
||||
1. Pick a course from the dropdown.
|
||||
2. (Optional) Pick a section. Sections are loaded dynamically from `/api/courses/{course_id}/sections`. Leave on *Auto* to use the whole course.
|
||||
3. (Optional) Enter a term key (e.g. `2026-T1`).
|
||||
4. Click **Assign + Cascade**.
|
||||
|
||||
**What the server does in one transaction:**
|
||||
- Creates a canonical batch `(classroom × course × section × term)` (or reuses an existing one).
|
||||
- Auto-enrolls every student from the classroom roster into the batch.
|
||||
- Attaches the homeroom teachers to the batch.
|
||||
|
||||
**API:** `POST /api/classrooms/{id}/assign-course` with `{ course_id, section_id?, term_key?, teacher_ids? }`. Cross-entity course/section/teacher IDs are rejected with HTTP 403.
|
||||
|
||||
### Step 4 — Batches (per-batch tuning)
|
||||
|
||||
**Where:** tab **4. Batches**
|
||||
|
||||
**Action:** Each batch row has a **Manage** button. The dialog has two tabs:
|
||||
|
||||
#### 4a. Students
|
||||
|
||||
Pre-checked with the batch's actual enrollments. Use this when you need a *subset* of the homeroom in this batch (e.g. half the homeroom is in section A, the other half is in section B). One click on *reset to classroom roster* re-syncs back to the cascade.
|
||||
|
||||
**APIs:**
|
||||
- `POST /api/batches/{id}/students` — add students
|
||||
- `POST /api/batches/{id}/students/remove` — remove students
|
||||
|
||||
#### 4b. Teachers
|
||||
|
||||
Pre-checked with the batch's current teachers. Use this when sections are taught by different teachers, or when a substitute takes over for one batch. One click on *reset to homeroom teachers* re-syncs.
|
||||
|
||||
**APIs:**
|
||||
- `GET /api/batches/{id}/teachers`
|
||||
- `POST /api/batches/{id}/teachers` with `{ teacher_ids: [...], mode: "set" | "add" }`
|
||||
- `POST /api/batches/{id}/teachers/remove`
|
||||
|
||||
---
|
||||
|
||||
## 3. Worked example
|
||||
|
||||
> Goal: enroll 24 Grade-7 students into Physics 102 (sections A and B), with Ms. Salem teaching A and Mr. Khaled teaching B.
|
||||
|
||||
| # | Step | What you do | Result |
|
||||
|---|---|---|---|
|
||||
| 1 | Create classroom | Classrooms → New Classroom → "Grade 7" | Empty classroom shell. |
|
||||
| 2 | Roster | Tab 1 → Manage Roster → pick all 24 students | Classroom now has 24 students. |
|
||||
| 3 | Homeroom teachers | Tab 2 → Manage Faculty → add Ms. Salem and Mr. Khaled | Both are default teachers. |
|
||||
| 4 | Assign Section A | Tab 3 → Assign Course → Physics 102 → Section A → 2026-T1 → *Assign + Cascade* | Batch *"Grade 7 — Physics 102 — A"* is created with **24 students** and **2 teachers**. |
|
||||
| 5 | Assign Section B | Tab 3 → Assign Course → Physics 102 → Section B → 2026-T1 → *Assign + Cascade* | Batch *"Grade 7 — Physics 102 — B"* is created with **24 students** and **2 teachers**. |
|
||||
| 6 | Split the roster | Tab 4 → on Section A's batch click *Manage* → tab Students → uncheck students 13–24 → Save | Section A has 12 students. |
|
||||
| 7 | Split the roster | Tab 4 → on Section B's batch click *Manage* → tab Students → uncheck students 1–12 → Save | Section B has 12 students. |
|
||||
| 8 | Pin teacher per section | Tab 4 → on Section A's batch click *Manage* → tab Teachers → keep only Ms. Salem → Save | Section A taught by Ms. Salem. |
|
||||
| 9 | Pin teacher per section | Tab 4 → on Section B's batch click *Manage* → tab Teachers → keep only Mr. Khaled → Save | Section B taught by Mr. Khaled. |
|
||||
|
||||
Total: 9 clicks beyond the picker dialogs. Steps 6–9 only happen when you need per-section overrides; if every section has the whole homeroom and the same teachers, you stop at step 5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Where assignments come from (the cascade chain)
|
||||
|
||||
```
|
||||
classroom.student_ids ──┐
|
||||
├─→ batch.student_ids (on assign-course)
|
||||
section binding ─┘ (auto-cascade)
|
||||
↓
|
||||
per-batch override batch.student_ids
|
||||
via Tab 4 → Manage (final state)
|
||||
|
||||
classroom.teacher_ids ──┐
|
||||
├─→ batch.teacher_ids (on assign-course)
|
||||
─┘ (auto-cascade)
|
||||
↓
|
||||
per-batch override batch.teacher_ids
|
||||
via Tab 4 → Manage (final state)
|
||||
```
|
||||
|
||||
- The cascade only runs **on `assign-course`**. After that, the batch and the classroom roster diverge independently. Adding a new student to the classroom **after** a batch exists does *not* enroll them automatically — re-run the assignment or use Tab 4 → Manage.
|
||||
- `mode: "add"` on roster/faculty mutations unions; `mode: "set"` replaces. The UI uses `set` everywhere, so what you see is what you get.
|
||||
|
||||
---
|
||||
|
||||
## 5. Entity isolation guarantees
|
||||
|
||||
Every endpoint above is wrapped in the same multi-tenant guard:
|
||||
|
||||
1. The user's `entity_ids` defines their scope. A user with one or more entities is **always** entity-scoped, even if they hold Odoo system groups.
|
||||
2. Foreign-key IDs sent by the client (course, section, classroom, student, teacher) are validated to belong to the **target entity**. Cross-entity IDs return **HTTP 403**.
|
||||
3. Model-level `@api.constrains` rejects direct ORM writes that mix entities — so even shell scripts can't poison the data.
|
||||
|
||||
This is verified by the `smoke_entity_isolation.py` script, which asserts every cross-entity write attempt returns 403 and every cross-entity read leaks zero records.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reference: endpoints used
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/api/classrooms/{id}/students` | Set/add classroom roster |
|
||||
| DELETE | `/api/classrooms/{id}/students` | Remove from roster |
|
||||
| POST | `/api/classrooms/{id}/teachers` | Set/add homeroom teachers |
|
||||
| GET | `/api/courses/{id}/sections` | List sections for a course |
|
||||
| POST | `/api/courses/{id}/sections/generate-defaults` | Generate A/B/C sections |
|
||||
| POST | `/api/classrooms/{id}/assign-course` | Bind course → create batch + cascade |
|
||||
| DELETE | `/api/classrooms/{id}/courses/{course_id}` | Detach course (keeps existing batch) |
|
||||
| GET | `/api/batches/{id}/students` | List batch enrollments |
|
||||
| POST | `/api/batches/{id}/students` | Add students to a batch |
|
||||
| POST | `/api/batches/{id}/students/remove` | Remove students from a batch |
|
||||
| GET | `/api/batches/{id}/teachers` | List batch teachers |
|
||||
| POST | `/api/batches/{id}/teachers` | Set/add batch teachers |
|
||||
| POST | `/api/batches/{id}/teachers/remove` | Remove batch teachers |
|
||||
|
||||
All endpoints require a Bearer token from `POST /api/login`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Smoke tests shipped with the platform
|
||||
|
||||
| Script | Verifies |
|
||||
|---|---|
|
||||
| `smoke_assignment_workflow.py` | The full 4-step flow against a live backend: classroom → roster → teachers → assign-course → per-batch override. Asserts the cascade and the override are persisted server-side. |
|
||||
| `smoke_entity_isolation.py` | An entity-A admin cannot read or modify entity-B records via any of the endpoints above. |
|
||||
| `seed_dynamic_sections_demo_via_api.py` | Idempotently seeds a demo classroom + course-with-sections + batches using only the public API. |
|
||||
|
||||
Run from the repo root with `.conda-envs/odoo19/bin/python <script>.py`.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 2026. Backend module: `encoach_lms_api`. Frontend page: `frontend/src/pages/ClassroomsPage.tsx`.*
|
||||
174
docs/ASSIGNMENT_WORKFLOW.pdf
Normal file
174
docs/ASSIGNMENT_WORKFLOW.pdf
Normal file
@@ -0,0 +1,174 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 6 0 R /F6 7 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F6 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Author (EnCoach Platform) /CreationDate (D:20260427012439-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260427012439-04'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
||||
/Subject (\(unspecified\)) /Title (EnCoach LMS \204 Assignment Workflow) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2686
|
||||
>>
|
||||
stream
|
||||
Gau/ZBlE#ip3V8-6\&He\d9tRZ8>!7G_4.UM\_ME(`4e^Mh78Y=;+8J7;9D#g]-,#;J.O.:?(EC7)&Qg?)1le2q<DQ%,fqCVXGs1)\srs/ob=i1L)P-IQON1!jrRP^OAtKd&,Y.M!MUFAHVI-cO'b&DA</RZ$S;M+kQg8>i.us$mo'BQ32O;Mm'A[:15&W-^O/pp(64=^CVr"_p?)&_i8NZ?nQ#2RqFi:iJT,P<iOaFn<"j_m?n2)7"-['s1=N@I/*Wu"JshDj;f*CjU"')PC_o.`:eM\;RepdK]9JRc"`mhK%&'W;/Ng:/2%9FUgf#n^'-iOG@pX]oO*4cAbmH:di,XsV8qbVp1Y%-N_d<J,9MW8nC(m)a>Jh`gjg$l%NKjMnrD`("1O&8+I6LN:J"$`^F8nEojS+%o`C>&'dl,)pff>=Eip<o!ge2#PkVuQ/BK2K/8)F`ILEH_@ap5sK9E"DG_)K]PY#!`Ol9rpUG<EAIU[R2931$n8Mkai\fP4BlUYj3-sn3iPb3$7^p[9-JVuicft:(U8=?3*T7H)T;+)Dgh*\a80YdH]PK0M%7$TMO0!\A.niRuVkC[1,#Wj,Q$2b_qmMjh&a\/=)(!5jS1d]((,SF*#3SZUX4gc3P6ck8Qjpc-jEnpi`oU"Y8onm[u/5_m.FB>O0;.oK56/VXsUH=r\^#4-\c1.N3J$u27(F?:N&X=YE$CQ]qH@UO3gLFVV"f'bdgZ]m<hOO+.EXB.I47fZ&Hk<Pfj@P"eC-PA$gi\EnNLmMYOu*BMYd,GYZsQ,W1M8EpUY?);<$mVN,p&X>,Zsq?<,eEfZ;2`A=6KM]RhX1M.<(i[cs*r[L`c5=->L-[^]UMAr5:l136D7U0An$X8/\"F(:^&J%"D%B4hBEmLfAd]HQ:boj3i5a$"l.C(W7.mQu1?&9qd:KqhQ8.%'`%.jU&TFj*5]-6pJ#"U9rZ3fr3"_EBL"b5"t--Fi76M!b5J-lXZYtb[17sR71@2FC1",Mt/!4N.PsT/lame9?V<['$qbK0s-Mkr#p5V6T#dXO(sdQcTD+X+7oRU(JU';>-7Sk4IY\%U2sFpN%:oHJ\brH0D4#dZS=`j<5h<tPp%F@db,%H$W1apW,m"6*lQ1bLP!JbB'8d7Rl:S>bjq>Q#^,hInl\MB$QrK`"PegaAWi?VPtk"6Or>H/iG]%ZbHm8Ke?KFakrrtGUCj#KXHm3JiBkX9Q!'*si:KXr>G</)KC%\ae*m""bNPTNL6/2+o\K0!`jWOL2cq?]V^YhN4bKT$'tL?JKU*nZ`qP5k+5Go1$Eai1:YX)RRYKs$-5eJi1.Bg5$F=7mA"nH/B^2N$B<PrBP?D\(iNU4E1nB'M.&n+Q,.:nljp>&[r`6!Rq@&Ynb-?E6FS&[O]MhhndhY$6_,U5bKeJ1K>4'=19iT`0DJQ=D9AothF\JZ8<*$T%.n$D-XU1Z36bkt?"jK)e_KIM)9Js5J`.E@fT)F>W?QX-*D,5AdVY80D@R`M\_RlqjRt]g:X1,@CAIftb]ChN[YuqD=5[aoX%i5(\_&4d`blSg!\<U68+$)"o4"qFLlg7'Grb5`5n+Jf%d1'1tmct"E^A$D,<Y,Bi=DXV]"+RIO*I,_LPq>N&5F"lcRYB&Ud6Z7lFmMQ%"&7]<U;A-O!`fsq/%:&+7@4c9Zi=f"SZ>G8+Dk5hP\?8-`4&E\eU&Fn"nkgtDk#(]s#t4ecEc0"=9N$LRZmq-PuB2HQ"lUI6B\A0WS)Gu[[1iM)5q#V:(X!sV9#n8`_%^tMr[Hm,L8qqZr%4-:=tS/"KluhDOP0g4].?[M@#N!I:GjK1'/*J3@II?cLi@*%d8]8Yj7&CEE2Ln.$.0ir3'5=%Lsh:AO;5hk%o[1!;.njbT_`iL(u6CZhlBJ9VBe.2ZV+o]RE(5#o?n6S2=D9M[dC88:LVbK2'fS9%<\AV>)Fj4g#!V%cjap2c8Em=q2Br&Qc0skhcpLk`u(Y<^R[GGs1lo&q1g"2UaJ&Xp!I6`ui>80r^[`i0#=$*@TjX"34Ru'A#[sRl1b@Pq%7W1B%p[06ctM+NY_jcudil*YiRQ8p@Q+Sso(b[l2N`Nc/FT/XV[Rh`1#0h6;t?<RX.d9`*UdD<gN)j)Z.i\``S2bQ<-jaqOier+i(/KT3S4W:RFH6Y84;/+-gjYd1C;,ISk4%tbkF!Gn06mB9!P7M<550PNBHj\fngrQpgm0m5HHM?GY(bI-!&Gll$NL5(cW+CiB]<5Sp*i@kLHL+^"S,\gLY.uFO;?W$\*Ifi-MXg-++P.j5N>aq7W>:L<[&"rCckmrW`i#:U5RSAa*?9R&N0kP+X?8-b?I+T^Cl[Fc_!Z]?%G)ALV%TemA<(5&Sp`7nL&pC15qaFfFn34;`-rlmR/a3qr`(AMY/9r_L7+CrNGT*%/Fbjg2\kh=@6i-t6j5/#,9@\BLLq`*<brPktF$jFAmO#d6P?)XWk:FS:"bCGqG,+p&+Vrg;QFJ0gB+T]LfMlU$J#8N'Spo^Tgs(LU#sm&,L\1ZWI;9Us+?`'mlf#?JM=53PbT%&WW17HZr[:T6r0[oVlBB[_^#$`2IN24*Qg<oq=!+U^0P-_ag>2a&Io#&gVl_'.@*ocu4.(J84R^lIQ>b`sKTQ5)kprL;E/L48.Js4E/<3NbcNC>/S:TO(*uO<HdsDj1l->OoSGWPBg=>i~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2205
|
||||
>>
|
||||
stream
|
||||
Gau`UD/\/e&H88.ETi`L?,DEIHbU83Q?F9P1gN3X_`SWl7)s%Y0pCgI7b@[S,:+kGjhu9-V?i83P)d2%md@VIMLpIFeGN&g!&+4gnqmK[clW>^L^3mfE7R8KkFd8:km&pLlMJMZ)>-?>T'sW."6+[A'1Yl(B`VD@maI!1^`qcaJVTs5kah;q!ULBoT:u9aYI*p_EL7]LAVmbk<:XQ&Dr4j9J?]YlP\#$':IMADUF`R'@l\>,8"qD>TarD<I2idtHBa-br;QNmXdU9k\(eE+:ZUOt'C\/G0Y_aG$l9-jd/6ABYSIQJT?oQR&[i#eg-Fm7?Y`=EGUK[3ioOt.LuUu;6WKZ/5uAa]n3SKfYb:`1,RX(ckc\Et-$l6Z-.I..FaB"^/"TGTC>L`@_]=&LHe%^VU,&.?;=nt#-.`N)h<+\uSnh-2eQigJGmZK6;N1g<j?s\[:-]-XZm\OU@3;=ifnWH!&4aqf;_[0&T`T!<78N#!QW)fhrd[?[VaW$WbFqnp#2CG?%,P)g!PAuZVb5sC=i/5>3T03!=UIb>bB#Cj9<OFT@r^s5g`k];a=j89Z;eW!.&qK!bY^;,CkB0V%;(.49bI\+)!Qtbd;5h(2$ZTBi#0m.(\7_P6_0!.2[No]KJ#m]/,*(ZpFn[YZNg:&NX&F0kPY8^dLs=::6I?C6cVrInhj(TSM\uC<gdN6e6V>)_HWY<[!W(-&OQI\Jr*1?1O)e)o`Y8H-Tk+q^c)YV"1O7OXW_f6>ZW]o;C4S4k"*E^USRFZg"11Zn%s`W`H\:-cq5/I&mcnI'n\j]AN]^B&X?!)3a:gip8[;a6rd6N>2>4'8a1Q/qlOgTXY;%7Pr7E?86"27a2ffYs#:!g@ZN-YEP"M%f5,uBFXI6+&T!k7J_<-:?o/QU/2[/H+/O\(8`_M4+;kUL#?jf2@kueLEdNR91_rJ)F9?A#`QPkABsf]mRtr!ThV[Oaio@iue@:d\KGf?!lD!;7Z,GEk+e@eD;A6c1[_k-@F]B<"o!Q((V!4pb/D#_H!cJ8B,+:81o/3e3[:W-f8snnDK^PCjjB0o,[rJ\I:`!2!m;DR#BC0-mU0CdeirZAZ6G&\m]mn@K/@"DHr&oUH0h9M(jGJ%ba;bs8pM&5VK0pSgfl"lW)j+^:-*(3XZp^L%PrsPNe#G$CUXfJR-esF.fF"m3*-#P!]bCWn:ln:#SCjRX*)iNgH-'7oia0U:.,;p7!uco`gsk1j;Z]cb&.7=A=L?Zg3%Y8VQDL_h4I<UUY.EZ[KKZadMiJ/L0(pW7"YnUdHWC+0%)R\_k!r2_W;'=N1t^rJJ$W$aq3?,b<U+hF,If&P.4/,SXle_TOV`%#/jWlEc>d<\VL!i2.dnsjKe,(dAU-E`/C+Rq!963D0]%80WQ(WPr=fOl!^O48HZZk2lSAmk\OXuqTkQ,mjH@;V9WK&Zp2m_>\@V:)<)'X<Fo#9;922U1[)dF'IXYh5r=dc*k=87%dMf3Y_mab/bpeBnM2@o]26U(fF?kaM%c7+]oi?eAN)-[i17J0qn"55f/b.u%E2,Q,AR76Y^!onnL,'aqO"7e@di>@2Aib!c+#fa9A>-YiT)5'&n!q\KoFYF6@NfA<ijp%N1p9Z>]$cpDV0"ZQf-<u/a7@BSTOq(H:g+Q[QdH$LS(g=;D6]PMEA"3bh#_"#hMJJ[*`1),$ZV->9s,;CdST.'LhWT\=PTr\UlS>F!e6Tr+=hFmW)'pDJ[2pM?^0$_;Q)5GEk"g$fP[)\c)qKte=H5fO&o[I%GIP%G[Y3%O@j0#H$T5]9C*;LIBG)tmRc$T6%qQ]FnJigWiN-=]b%AfSgR]II3)PASGJntJmXn[dpToD^ETr;`%,nQ4)_>Pp?b"$e5IUV7tfT=V+jCh2isN_F]ktL<Oh>>C!DHMBUPd_U:IR$55GO=dd:G$?/5>)?p#mV7"/:f!>mZZL\?8HG`Ot5M2Yd8JegHFU$W)9h0CiF(56Y`Rh$8b/6]KCKbU.cN>,XCYr3&H@S-%pE#LJU]HKmAZe,mbPnq'Q(.3QYJ0DY*LY/UG[Um6L;r)$R"oh_E*Hh*;X'9TbV;Hd,C%#KLFa"i\q@?-Ef4XD)<*>!*Cika9mg2g;O6qkAr?iNr,GE:gX3XALE8M3)8/+7>Zbe;bF\g,)83Cg@]e2;\+&sKt[`m*jmj\0Xh^AEGSdBWR-&b:h'4ph_s2+jWr"$#rjY6~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2249
|
||||
>>
|
||||
stream
|
||||
Gb"/)=`<%a&:XAW(o/R[UbTd#GdL7D&>s4%_MJEV76n,TeeW%Y<(T-Rs*\O*l_$d'$lTqI@0u!GB4bekD`E(T0_(37hqf8Z3epcQ":0_W%1mDrR(nEco<Z3W$AkM]Y$i(>%H^P4CbWBWY"?G@>aVYrnZ@7j'\W_1$l>jP0Q(lXF:FEaH)lHYL\[PrJ"\`:2)e4O/@teug[Io_]bS2pSABeT)dXnKIiGOjR)5aL^:O]GLPA"1E@bi40O47jjS6n)Yq>bGmL4I8E#.#:)"tu^fObSEF+]@iXT2sG+0bX-Dg(Z4/R3q<QA]0=Z!e![TkMDe9tUi<Z++XS4)lr?kcVS0TKn/?,W0]a-d9YXTU)8MQKq8<)ZBHX@\0m&H.SoQ!8'l``"CD3%V7tr%tVX/"5FImV%*UQ+]`RR,#d7BD+"I9@,X/_d2+f(1>gtX1AD/T(e6jlW9RY>Qg3@\,e[REcm7P)B`qM<a2I]b+Gc0!,Z.n57puJ&6&&%"/tTh2#<uM1AR`GK#&4UH_QigPgS2f$Cm6ZBr3jHXbIY[BBFe1(UoeTu*2Nk__dmElUG*(B$(OlN0$E9t:>@%$7#.LO6Wg)m1Q+.]#JpW+*.2Ko1DA"kSFk<)RQ_sJ%qqi+H)OGmAnjAtRPB*j1_\qOQY(6M>?OunGa]KoBpGem!2*Q7c3M&Y9MJ&m<0VJ3>a#TJhmJ&n:tK*P9a7YiV/"JZ*OCf8&P`RHs0Rq<4@](LN*;2p>A26e_+$Vgj??+YoUFNTjf"8NUV`+@-RDMkkFZTe8G>*'BC&?oA?*EQA<Ol.[-eu(k1HTb-EPaBBf1Icm]-_2Vo=5pWt=Y^%)8k.m9"-BU?GaGiPBuoNNVdQs)u(I+7$F(Z;O!8T(`h)el\e`%tKXOlsl9U.dd<f0cI.Unet^@!5bQ@FV0tPI(B@g`/X6AJ]nLWZ=)ksX785X1Eh\u?mQKYUnUHFd8?F=Z%ekC^tED@IYlW*lZQ;tWOP+V2<9R9U2bVjEqB_^G9K+sq`GA+S6RZcFlqZBZ3LS()iA?_Ks/G+AuTYnG,lOEA$D`rM%jV\cb!UET!<[c#.(uALY(@\UDD0mD])q,[udY-fP1(67i`P#]#*bld=%O`@A%D]Ig1-o"`0QKC/XAF4t+MhpK;dV*5Wf`@R;C_('NHXdBiBATe*Og8OGM'WOLUUXXm3;omn=LQ)!$f1Kuh`[bKOCp^)1JEOX/)U.(!?m[Y>B0C3K%fjt-)m3LG6i$cRT(l<C0#K91h[R9S!%uTBUiI?_d!IN6/VsN02q".-SS@EA\elgfC+4&SU'DP\,X;&T9V^o\@a.t[:!qW?fGPk9)juUDJ([.:.*np12EutMebbt$CZ8jGe7L/.f%hWI2h&]I6P(Uibkb-SPjQ@TN*YC\<4eno[R/d%W[0S-)RC@OIAMdm!fO?o!&mM758[`<<OC<(mIUj<,5ZMB7!a2$,2l9c\45F-Zc.8.%Q7l.gi.&"-eBJh=I7o7h[pLf[U)"W@k>%ucp*Ft5CEtb@ZaGt"`YJBh[?UCX]_]U1b59pn$J,MDEcaH_XJHE9a)^$=[Kc>8e?tR>rgk!a2Z#Sd?]@#:`"gsChFsEOg$?a'ir"]6Ii^ai5t,tP,2ZI/fA!k4;BN(J1]J?pF6g9fB\7"HU.Far2aqsoMY-%X6_l@cOk:G+L59Y+XY3>0hG8'GoOT>c'!<\#b>P/1ar;OQ=c'F?.(-!5Io>Wc2^G,>Jl%cr'(&85;$Vbp.qcGZ<u897=`+]q/[$d!q1lE--TKW+EJhXC3#QZrVYub$4A,9$'/r\n$!aOO#UeWCWn)MfR.HWX%&-t>Y+M^g!g^"XgmjNX\_3nm*`i'42o\4=Ur5B@?K=\65-$[:QR_eKr#8IWoiP:S72oNZBlafc-r@,tpJ5>Y73^?`=p0)<R7Yh?"#@i&bRNRQ+dFNjN#Qp\1jqtfoBQ[k.dB!h8Y$Xq)#C-/006DW%JYT^<?S>@=:'3h'tB)T/;o>pMO1LcJ[^ek\9QjN!R'%(3n[]tpK+W2M;kp:;mj9$Z0VuGKQ-'0c]>+A3Nh(!2MqS4%tC!4odNeDfQnkQ_rCZ]/e&6Qr3U.=?O)?n#2<HOp*r2_nm[3LbbK[UAa>Y)KIbPYqr(GFOZLn!*>a``()(tc7d%`]2a$:6:Tr^D7EHT7*fuLJ0.ff=2hZO/5,g3-I0mMc:p9C@p,)*]@WLLa1;LOGdmc_]:ML:S\`P>LGi.VX)#ckZSfico`!C#U!,);``W~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2935
|
||||
>>
|
||||
stream
|
||||
Gb!SnD0+Gi')q<+Z.#,13^!ip5S,2f?d;66ZlNeB-G$`@T#i*!A2C)RKr!_n3qqNM!P[lZN5E*L,niAe!fQ1.oB\(4;R#XD2*^7sJ>Y2j5,UX`bSEH(i@qluX4c1HZVodsJPS]03O+!%*>]!*\8O)E%X0J]KCi1tTVZgeHGg'N&Xs/g9:'Vs>2Cae:LNksAj9s'Sa"(%[U@o+=7NUbcL9]fj]f6'b_=4k$ZG(%MKAQ:P27jdjIrE2Gb/<G"2m]V/<7J*V65>-0@''d*mWbW)]PEiF)]D;*6nk3=s$3&p?X_cr7X'<>iaoEW%WO?gY(IjVeV?i9:=rmGV)j>7P)UO)")/7(<utJ"a;Q'jVRj8os7Abo>7bsC;6@XJ3PQL:>X/6*KkeJBc72U,r>[d/+f0#hh=?s3G+B<:SEH0Hk.4@ZKZGXZ0m?,U(hF3rDLWrXNdB,o0jM>#dR^tlK1Yg4SsPA3OMWlhAH#W^I>dJE0aYUFlPl?D&\Gh:tIk7M4f2Rq07Xu0RtI`YZsuA`5P8E]gIb)`JEpW'S-NV&EZ!_*^c5C=T$MTUs4^j9pf&$U>5H]n6hu;D.qsFklM@4!mYG&$Ka+598mBo7iP"NEE5MY<>s/H(`MS(T1QqRaZN_(RB"3uVWS(7(V*-]^Q\Hsr_s%b5fS?mbp-2L-HQnK,h!EZ&0ZaY%-'UT'`Np[BL=8i>5HXl07tC+Da?^k*\b<D4UL[i9Atp,;HpG:+fZtb;SRuiSjAI:qU+\V<h/6_.oi)d;5C[,Jm@IFOt`EL#lSD!7&hTt7kLkm+Ig$+Up*=LD1TCM>stk*GIf(M!Dbb<gP]n,dM%a;rP?QmDpj8SPBe-6BERFmo9k;7UVciNA[^O8`KHml;hs!1a@!Xm4;D4<A09jT1'uHkn=9u>mrfk8/1Iec?-)h&au?P0@h&hZUsqHV^](K2UJbU!ni[mi[>O$MAHXhes7M9S::@2*([nM,8>j]7VIFZK=TT,T_]"N(A.=qnY%?a:d,S>q&&FK<`,3AnM].:VNtoC]W)s,q-t1?+EDY/orV@CMlQ96VS>)ZbSi"d=/Ai0W^f?n[(l'e<+qk6l?"9^iF/?/CcYB]uCuM1sAt-;33ZseNJlNFm)NaEmL7/CDEj\dG-9dX61:^?iS1"mq\Ved8@rq-L^V,ZsC3rb947+8>H\WZJF$Tr,Q>ai[TnrFT;-,L7\<_P@'glT/hs]NY1'l7oa=]m_Kq/8X$Jcc#ONiuS_Qm`P_*aT"QP1m?Y*TH`D%q[`45<aWAb7*(ftKeHGFo`&_$(r/@gEls_bIVGh'GnuYC\$NCkF0I$+FWS?=a3Ul.GpWU<\([DRVWO1l2\Lf5l&3DB8/q#(H@s_b>_+J%mG5Gc4+kk=&a/WCtJnEA*dWISj*oCb(BToB$?MN6ipiK`UmpJSkeNX!kebTkFq9-Z"PhQe609AVBca0@)>=B9PYubY2$N:?10_s#cda#&`,+3X,`5ZG%l7G&QkR47l>bcR-jkA^_C4)-Ad%7JnGI\S)U!r1dPPr_osd1<8[IV_X5*cnn`E&-UXYKdm"U=)VDSc4"b]>V'4)nFq[-VZ4MRB:,"@.I>KgGoEi)q-:qskCCZ59=heS@R^i`'c27r-[eV"U2QaL*S6R_]?m>r/10="7')`nfkh(^ah<"%V8'#1P1=0r_1dXsYSRRfLRqrR6[tL.!p4><(q5,]E@t=<8VlMrLErLS_BOo[sChIl="PpSJ^-YY7[]l=;'j'T(Mi65s%s.Y68&9srcfQ"lOm-1NBTBUq?*.H=nW<aD!":oL+-+(UWN<\)45oFglbd*G@)$TH_`</jh)&2k_*Z=6\gU>bK3J^YsE$$r`QL-L,g[%b>dZaPb1Vll7N'2HiAgU]&ChoDM*ND9'3h!$-8[C!pD+<HSi!kW^T?4r35qbk,JT5etZ4KmV<$G@m)ctW,<$2O4[b!A&@&2pTR>o?BCL4%=/icT/l<8jF5!N3MCRO'#e8Ab8j&+,e?2HZg-(3K@TO"27f9h?L`]X"edL9p:&mM6GH9:G:A$b(_A%=dj]>W=DpQVZ*LgoOukN")CoP*-dA^`l\i4maHD**.9WZs?lUY`"uMPp-<YF?$UJ5JYeNO/FV=4^N[Wr[4?GPp%O1n(K%W22Zf0@E,uih'AV^Q_69f[j>g'VFsG1F,/Z3ND)d?*[Fa7_M9!Qu$Q6h-XCrM,M#mDj5'-`9:&R#M+c!`MmuNLdlI6FdV:@EB/Y?P(JA(W+.R!`K*V;CKQZHh<*/U99X+n2/n+)X#T5/700edKu*DiLk.Q5`@@.`RKR@Kf?p6B4'ISLW+'hXS3EK-K3jcd8\E*_#QEm>K+I*l%=Dt<oBI6r_8gQ<eu0E=1/C8"[!2!/`7C0uI8j:$#CWEl<SXGL]t-s$L+-adbD.uXPlGl&<SV,5AQ2Z4W'SrU`CKAn3WNCE77cP:rJ;.m2s9p-Xg6s+<SujUctqOcDh3V%QMcIo&^>KK8eYY2Tg$(!+4!NUM(#RHbpE,TW!2AbM`ks/W;$1d!]6@40!P_dp=6Vkj\$"P,37F<)Ba6+Hlf,7UIh5Yg:[cZVQC%uT4IgB0j7]%UN6kOF,'+<Keq<ZKCWcjgRlgGiQ(-/>h$or"4#.LIoO;9gO2_g)9k\%(E8"%6_-`,cEQ7)Iq7$Nq^5q'd@sNNdp>$3MZ5j[IGpMQk)J9XY&;qJ\EKX#OK@n".tQ7HfP4,s9f\k;ht=BG335Mog_/k$[7KeI8FT*eSW3u2[>9["#`"[9]=]H<_ce=s,1.%V?'3qU;7@de@3n7A[Nmi4"JR+PNI^nC!KJ)eFe)S@&3C%%[C@87DRZ$8TMNbs5pcQud$:qM2]'1)?TbYV2o/"PloN)&!*I:ug#+6jB5e_WXW-V+I9_Q+=i+)`;uKm<Ej+Jse_#0=5USHDm#I7`0])D=n.qYSm!j0*)i+~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1471
|
||||
>>
|
||||
stream
|
||||
Gau0CD/\/e&H;*)E?Dk0VF?B:UgP%$'[>"!"5:cI^(`7<M,)PkUaF5UrU4s[Rq:2pSKNQI#Sc`fGL,lB4AkV;8"9NM!H>Gsq)&>u@'KdU$&?JIGe]XD46^?$2\?_N]dJ9QBFk7(!KTAJ,iF4i7o8#%#MMTWBLH<;&b3EikZYdoZb03Jr!&)JY^#1Pm?eQJq1A![][uV29^^lg?T7J6Yn/Oic0f\IF:J8;l=#sIVboPh7=p3oO;RTZ3'K@!Tn'=r:9^arM;BlF@m*\>2?a5R',HO5j`!$OK&mNRKq2-Z7O3p]N3M8_'9^5Q;p4GQ6ifEEKX&V=5o]cAI.!!SHU/?#%-PSNN<KkCGj'uIS37,t<:al01iPi;cg`h$$/?6W5s_VhD8G(AAZ8RPb3F>PmhM(tXB28K7e#eVeO(WF3mNVkC_InSo3K`l#+WW5>d;eV^gk,2G"2#8K%1eKl@-DU8o8?raL=&Q%N-`',=W_HbWVF13\THOS:'d%@/mT*,A2\OCP9t;RJ.F(bY5BDNd\?,1GW>YOToVo)mF7D.P'h[:nkOWTU`\Trnj`u<gg7?.sR%d?Pu",=WDVW`>sB3nA<7\2+1FR#bj%d,]`4Zi#TtD9ko.%q^ddY'rKQ=E%-Qj]6ro;/L`Wn##+OLm0,`<+f'Gr/l;/?Fe/]-fij[!;?1ti1MpohOCsU]DIrC3FF!sZNX8e,'[WX8^D;1D^9KqF;gSm\3Rc./'%QT%Vmon"\5SZ@M/S=B/2?J5Wg<i:<AhCR24h;>;,pV^.ag+0`Ih5lG`K;/,EXmHW<0'JB&$UC7*T*NRpl-Wi/3P.0XKea5;=d@#NmS$Kp_UM.hnhJeXL,^%3CIrd:^SMJ#5?-InZH#)VPU3QQXa$dtk<\VT'fk$DrElUtKM3)dVG"`)N^ia%Qt69>(A4C,eKsQR:2&R(Pk::'"/:#!ToimkoYt.UAnj/W2/_>*V)"m4q[q=^kB(=_;</n7iqj^nj\&,K0B^>]L)k\9(4'_-:UIf?@NrZTdW)J[a!B+D"tu1OTKtA#SP$?/OdI!gfND*2;F;rd@#a^9F9p?ePmrfNrW:kW4TpdHDsjS_)`>E+Eu7OLq1+;`O-A`/^&6Y2\nDq#(?UU1MG@%tf>90?6,I!!DmWa'jVI+#bLDUl-(QOLoOI&.uQ$q9X)Nn3S<ie9Ld1[_Cobq48_AE=0utaJT_6kVbT`Dk2&>bId/\g[.G7*WP6pqRY7^W_g#sJ!DJmTRuL<"731@@@L"`@WX)48jD%?8WBG6r&3$qE`:j7%DOjq;nHmfVLPr`gn?FW-ZL6pGZ-Wl-UR`kq#=O^JApRD-hS)A(ACY8HfZg,7`XIR^1alHR"#TST-UihlS=`MHX=L!=_0U:o1T_*C)[h1$*82jnM>Sc-gj?9R2=GFS%XBfV$*qOUMC[A`6YZGStU.c<G+%q<AJc!G5eX/poO@;,IRJi+S6`00_l7ERqE3~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 21
|
||||
0000000000 65535 f
|
||||
0000000073 00000 n
|
||||
0000000154 00000 n
|
||||
0000000261 00000 n
|
||||
0000000373 00000 n
|
||||
0000000488 00000 n
|
||||
0000000593 00000 n
|
||||
0000000676 00000 n
|
||||
0000000753 00000 n
|
||||
0000000958 00000 n
|
||||
0000001163 00000 n
|
||||
0000001369 00000 n
|
||||
0000001575 00000 n
|
||||
0000001781 00000 n
|
||||
0000001851 00000 n
|
||||
0000002161 00000 n
|
||||
0000002248 00000 n
|
||||
0000005026 00000 n
|
||||
0000007323 00000 n
|
||||
0000009664 00000 n
|
||||
0000012691 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<f5c24091d994f2afdef500eb9f389ba5><f5c24091d994f2afdef500eb9f389ba5>]
|
||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
||||
|
||||
/Info 14 0 R
|
||||
/Root 13 0 R
|
||||
/Size 21
|
||||
>>
|
||||
startxref
|
||||
14254
|
||||
%%EOF
|
||||
Binary file not shown.
@@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe
|
||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
|
||||
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||
@@ -320,10 +321,11 @@ const App = () => (
|
||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||
{/* Original platform dashboard */}
|
||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||
{/* LMS pages */}
|
||||
{/* LMS pages (entity-scoped, includes branch management) */}
|
||||
<Route path="/admin/courses" element={<AdminCourses />} />
|
||||
<Route path="/admin/students" element={<AdminStudents />} />
|
||||
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
||||
<Route path="/admin/branches" element={<AdminBranches />} />
|
||||
<Route path="/admin/batches" element={<AdminBatches />} />
|
||||
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
||||
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||
|
||||
@@ -54,6 +54,8 @@ const lmsItems: NavItem[] = [
|
||||
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
|
||||
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||
@@ -97,7 +99,6 @@ const institutionalItems: NavItem[] = [
|
||||
const managementItems: NavItem[] = [
|
||||
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
@@ -167,7 +168,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem
|
||||
const routeLabelKeys: Record<string, string> = {
|
||||
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
|
||||
students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
|
||||
timetable: "nav.timetable", reports: "nav.reports",
|
||||
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||
|
||||
@@ -22,12 +22,12 @@ const mainItems = [
|
||||
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
{ title: "Users", url: "/users", icon: Users },
|
||||
{ title: "Entities", url: "/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const reportItems = [
|
||||
@@ -103,7 +103,7 @@ export function AppSidebar() {
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Academic" items={mainItems} />
|
||||
<SidebarNavGroup label="LMS" items={mainItems} />
|
||||
<SidebarNavGroup label="Management" items={managementItems} />
|
||||
<SidebarNavGroup label="Reports" items={reportItems} />
|
||||
|
||||
|
||||
867
frontend/src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
867
frontend/src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
@@ -0,0 +1,867 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Award,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import type {
|
||||
CoursePlanMaterial,
|
||||
GapFillExercise,
|
||||
MatchPairsExercise,
|
||||
MultipleChoiceExercise,
|
||||
ReorderWordsExercise,
|
||||
ShortAnswerExercise,
|
||||
TransformationExercise,
|
||||
WorkbookAttempt,
|
||||
WorkbookAttemptScoreItem,
|
||||
WorkbookBody,
|
||||
WorkbookExercise,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Workbook renderer for `material_type === "interactive_workbook"`.
|
||||
*
|
||||
* Modes:
|
||||
* - `student` → answers persist server-side (graded, scored, saved).
|
||||
* - `preview` → no persistence, no Submit; "Show answers" toggle reveals
|
||||
* the key so the admin can verify the v2 generator's output.
|
||||
*
|
||||
* The renderer also accepts a `WorkbookBody` directly (for skill bodies
|
||||
* that embed `interactive_workbook: { exercises: [...] }`), so it can be
|
||||
* reused inside other material renderers without duplicating the logic.
|
||||
*/
|
||||
export type WorkbookMode = "student" | "preview";
|
||||
|
||||
interface InteractiveWorkbookProps {
|
||||
material: Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "title" | "summary" | "material_type"
|
||||
>;
|
||||
/** Optional override — when present we read exercises from here instead
|
||||
* of `material.body`. Used by skill bodies that embed a workbook one
|
||||
* level deep. */
|
||||
bodyOverride?: WorkbookBody;
|
||||
mode?: WorkbookMode;
|
||||
}
|
||||
|
||||
type AnswerValue = string | string[] | number[][] | undefined;
|
||||
|
||||
function toExercises(body: unknown): WorkbookExercise[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.exercises)) {
|
||||
return b.exercises as WorkbookExercise[];
|
||||
}
|
||||
// Skill bodies sometimes nest the workbook under `interactive_workbook`.
|
||||
const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined;
|
||||
if (nested && Array.isArray(nested.exercises)) {
|
||||
return nested.exercises;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (b.lesson_plan && typeof b.lesson_plan === "object") {
|
||||
return b.lesson_plan as WorkbookBody["lesson_plan"];
|
||||
}
|
||||
const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined;
|
||||
return nested?.lesson_plan;
|
||||
}
|
||||
|
||||
// Local copies of the backend grading functions for instant per-exercise
|
||||
// feedback while typing. The authoritative score still comes from the
|
||||
// server when we POST — these are only used for the "Check" button.
|
||||
function normText(s: unknown): string {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
function normPunct(s: unknown): string {
|
||||
return normText(s).replace(/[\s.?!,]+$/u, "");
|
||||
}
|
||||
function globMatch(pattern: string, value: string): boolean {
|
||||
const pat = normText(pattern);
|
||||
const val = normText(value);
|
||||
if (!pat) return false;
|
||||
if (!pat.includes("*")) return pat === val;
|
||||
const rx = new RegExp(
|
||||
"^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$",
|
||||
);
|
||||
return rx.test(val);
|
||||
}
|
||||
|
||||
function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean {
|
||||
if (given === undefined) return false;
|
||||
switch (ex.type) {
|
||||
case "gap_fill": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normText(given as string);
|
||||
return accepted.some((a) => normText(a) === n);
|
||||
}
|
||||
case "multiple_choice": {
|
||||
const opts = ex.options ?? [];
|
||||
const ans = ex.answer;
|
||||
const g = normText(given as string);
|
||||
if (g === normText(ans)) return true;
|
||||
if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) {
|
||||
const idx = ans.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === g) return true;
|
||||
}
|
||||
if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) {
|
||||
const idx = given.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === normText(ans)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "match_pairs": {
|
||||
const pairs = (given as number[][]) ?? [];
|
||||
const expected = ex.answer ?? [];
|
||||
const setOf = (rows: number[][]) =>
|
||||
new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`));
|
||||
const a = setOf(expected);
|
||||
const b = setOf(pairs);
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
case "reorder_words": {
|
||||
const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? "");
|
||||
return normText(value) === normText(ex.answer);
|
||||
}
|
||||
case "transformation": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normPunct(given as string);
|
||||
return accepted.some((a) => normPunct(a) === n);
|
||||
}
|
||||
case "short_answer": {
|
||||
const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[];
|
||||
return accepted.some((p) => globMatch(p, given as string));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InteractiveWorkbook({
|
||||
material,
|
||||
bodyOverride,
|
||||
mode = "student",
|
||||
}: InteractiveWorkbookProps) {
|
||||
const exercises = useMemo<WorkbookExercise[]>(
|
||||
() => toExercises(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
const lessonPlan = useMemo(
|
||||
() => toLessonPlan(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
|
||||
const { toast } = useToast();
|
||||
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
|
||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [serverScore, setServerScore] = useState<{
|
||||
correct: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
items?: WorkbookAttemptScoreItem[];
|
||||
is_final?: boolean;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(mode === "student");
|
||||
|
||||
// Load existing attempt (student mode only).
|
||||
useEffect(() => {
|
||||
if (mode !== "student") return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await coursePlanService.myWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const attempt = res.data;
|
||||
if (attempt) {
|
||||
setAnswers((attempt.answers ?? {}) as Record<string, AnswerValue>);
|
||||
if (attempt.score && "items" in attempt.score) {
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score.items,
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — empty workbook on first open.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [material.id, material.plan_id, mode]);
|
||||
|
||||
const isFinal = !!serverScore?.is_final;
|
||||
const liveCorrect = useMemo(
|
||||
() => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length,
|
||||
[answers, exercises],
|
||||
);
|
||||
const livePercent = exercises.length
|
||||
? Math.round((liveCorrect / exercises.length) * 100)
|
||||
: 0;
|
||||
|
||||
const onChange = (id: string, value: AnswerValue) => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const onCheck = (id: string) => {
|
||||
setRevealedIds((prev) => new Set(prev).add(id));
|
||||
if (mode === "student") void persist(false);
|
||||
};
|
||||
|
||||
const persist = async (finalize: boolean) => {
|
||||
if (mode !== "student") return;
|
||||
if (finalize) setSubmitting(true);
|
||||
else setSaving(true);
|
||||
try {
|
||||
const res = await coursePlanService.saveWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
{ answers, finalize },
|
||||
);
|
||||
const attempt: WorkbookAttempt = res.data;
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score && "items" in attempt.score ? attempt.score.items : [],
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
if (finalize) {
|
||||
toast({
|
||||
title: "Submitted",
|
||||
description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: finalize ? "Submit failed" : "Save failed",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce per-keystroke saves: every 1.5s after the last change.
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "student" || isFinal || loading) return;
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
void persist(false);
|
||||
}, 1500);
|
||||
return () => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
const reset = () => {
|
||||
setAnswers({});
|
||||
setRevealedIds(new Set());
|
||||
};
|
||||
|
||||
if (!exercises.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
This workbook has no exercises yet — generate or extract first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{mode === "preview" && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
|
||||
<span>
|
||||
<strong>Preview</strong> — answers won't be saved. Toggle the
|
||||
answer key to verify the generated content.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showKey ? "default" : "outline"}
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
{showKey ? "Hide answers" : "Show answers"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
|
||||
<details className="rounded-lg border bg-background/70 p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Teacher's lesson plan
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
|
||||
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
|
||||
(k) =>
|
||||
lessonPlan[k] ? (
|
||||
<div key={k}>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div className="leading-6">{lessonPlan[k]}</div>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
<strong>{liveCorrect}</strong>
|
||||
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
|
||||
{serverScore && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mode === "student" && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3 w-3" />{" "}
|
||||
{serverScore ? "Auto-saved" : "Auto-save on"}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={livePercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
<ol className="space-y-3 list-none p-0">
|
||||
{exercises.map((ex, idx) => {
|
||||
const value = answers[ex.id];
|
||||
const checked = revealedIds.has(ex.id) || isFinal || showKey;
|
||||
const correct = checked ? gradeOne(ex, value) : null;
|
||||
return (
|
||||
<li
|
||||
key={ex.id || idx}
|
||||
className={cn(
|
||||
"rounded-lg border p-4 bg-background/70 space-y-3",
|
||||
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
|
||||
checked && correct === false && "border-rose-400 bg-rose-50/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{checked && (
|
||||
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
|
||||
{correct ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="mr-1 h-3 w-3" /> Try again
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
|
||||
disabled: mode === "preview" || isFinal,
|
||||
showKey: showKey,
|
||||
})}
|
||||
|
||||
{ex.hint && !checked && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Lightbulb className="h-3 w-3" /> {ex.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checked && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{!correct && (
|
||||
<div>
|
||||
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ex.rationale && <div>{ex.rationale}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFinal && mode !== "preview" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCheck(ex.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{mode === "student" && (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
|
||||
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting || isFinal}
|
||||
onClick={() => persist(true)}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{isFinal ? "Submitted" : "Submit final"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExercise(
|
||||
ex: WorkbookExercise,
|
||||
value: AnswerValue,
|
||||
onChange: (v: AnswerValue) => void,
|
||||
opts: { disabled?: boolean; showKey?: boolean },
|
||||
): React.ReactNode {
|
||||
switch (ex.type) {
|
||||
case "gap_fill":
|
||||
return <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
|
||||
case "multiple_choice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "match_pairs":
|
||||
return (
|
||||
<MatchPairs
|
||||
ex={ex}
|
||||
value={value as number[][]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "reorder_words":
|
||||
return (
|
||||
<ReorderWords
|
||||
ex={ex}
|
||||
value={value as string[]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "transformation":
|
||||
return (
|
||||
<Transformation
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "short_answer":
|
||||
return (
|
||||
<ShortAnswer
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnswer(ex: WorkbookExercise): string {
|
||||
switch (ex.type) {
|
||||
case "match_pairs":
|
||||
return (ex.answer ?? [])
|
||||
.map((p) => `${ex.left[p[0]] ?? "?"} ↔ ${ex.right[p[1]] ?? "?"}`)
|
||||
.join(", ");
|
||||
case "reorder_words":
|
||||
return ex.answer;
|
||||
default:
|
||||
return String((ex as { answer?: unknown }).answer ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-specific input subcomponents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GapFill({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: GapFillExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Replace ___ in the stem with an inline input. Falls back to a stem
|
||||
// label + standalone input when there's no underscore marker.
|
||||
const parts = ex.stem.split(/_{2,}/);
|
||||
if (parts.length === 1) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<span>{part}</span>
|
||||
{i < parts.length - 1 && (
|
||||
<Input
|
||||
type="text"
|
||||
className="inline-block w-32 align-baseline"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="…"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleChoice({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MultipleChoiceExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<RadioGroup
|
||||
value={value ?? ""}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{ex.options.map((opt, i) => {
|
||||
const id = `${ex.id}_opt_${i}`;
|
||||
return (
|
||||
<Label
|
||||
key={id}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span className="text-sm">{opt}</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchPairs({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MatchPairsExercise;
|
||||
value: number[][] | undefined;
|
||||
onChange: (v: number[][]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
|
||||
const pairs = value ?? [];
|
||||
const pairedLeft = new Set(pairs.map((p) => p[0]));
|
||||
const pairedRight = new Set(pairs.map((p) => p[1]));
|
||||
|
||||
const tapLeft = (idx: number) => {
|
||||
if (disabled) return;
|
||||
setSelectedLeft(idx);
|
||||
};
|
||||
const tapRight = (idx: number) => {
|
||||
if (disabled || selectedLeft == null) return;
|
||||
const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx);
|
||||
next.push([selectedLeft, idx]);
|
||||
onChange(next);
|
||||
setSelectedLeft(null);
|
||||
};
|
||||
const clear = (li: number) => {
|
||||
if (disabled) return;
|
||||
onChange(pairs.filter((p) => p[0] !== li));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these…</div>
|
||||
{ex.left.map((item, i) => {
|
||||
const paired = pairs.find((p) => p[0] === i);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`L${i}`}
|
||||
onClick={() => (paired ? clear(i) : tapLeft(i))}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
selectedLeft === i && "border-primary ring-1 ring-primary",
|
||||
paired && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{i + 1}.</span>
|
||||
{item}
|
||||
{paired ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
→ {ex.right[paired[1]]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">…with these</div>
|
||||
{ex.right.map((item, j) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`R${j}`}
|
||||
onClick={() => tapRight(j)}
|
||||
disabled={disabled || selectedLeft == null}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
pairedRight.has(j) && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
Tap a left item, then tap its match on the right. Tap a paired left
|
||||
item to clear it.
|
||||
</div>
|
||||
{!pairedLeft.size && (
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
{ex.left.length} pair(s) to match.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReorderWords({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ReorderWordsExercise;
|
||||
value: string[] | undefined;
|
||||
onChange: (v: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Stateful tap-to-insert: tap a token in the bank to append; tap a
|
||||
// token in the answer strip to remove. Keeps it touch-friendly.
|
||||
const order = value ?? [];
|
||||
const remaining: { tok: string; bankIdx: number }[] = [];
|
||||
const used: number[] = [];
|
||||
// Build maps so duplicate tokens stay independently selectable.
|
||||
const usedCounts = new Map<string, number>();
|
||||
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
|
||||
const seenWhileBuilding = new Map<string, number>();
|
||||
ex.tokens.forEach((tok, idx) => {
|
||||
const seen = seenWhileBuilding.get(tok) ?? 0;
|
||||
const usesLeft = (usedCounts.get(tok) ?? 0) - seen;
|
||||
if (usesLeft > 0) {
|
||||
used.push(idx);
|
||||
seenWhileBuilding.set(tok, seen + 1);
|
||||
} else {
|
||||
remaining.push({ tok, bankIdx: idx });
|
||||
}
|
||||
});
|
||||
|
||||
const append = (tok: string) => {
|
||||
if (disabled) return;
|
||||
onChange([...order, tok]);
|
||||
};
|
||||
const removeAt = (i: number) => {
|
||||
if (disabled) return;
|
||||
onChange(order.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
|
||||
{order.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence…</span>
|
||||
) : (
|
||||
order.map((tok, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`a${i}`}
|
||||
onClick={() => removeAt(i)}
|
||||
disabled={disabled}
|
||||
className="rounded bg-background border px-2 py-1"
|
||||
>
|
||||
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
|
||||
{tok}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{remaining.map(({ tok, bankIdx }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`b${bankIdx}`}
|
||||
onClick={() => append(tok)}
|
||||
disabled={disabled}
|
||||
className="rounded border px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Transformation({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: TransformationExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{ex.instruction}
|
||||
</Badge>
|
||||
<span className="font-medium">{ex.from}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
placeholder="Transformed sentence"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortAnswer({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ShortAnswerExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CoursePlanMaterial } from "@/types";
|
||||
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
||||
|
||||
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
||||
|
||||
export const SKILL_STYLE: Record<string, string> = {
|
||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
@@ -56,23 +58,82 @@ function renderAny(value: unknown): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
|
||||
type MaterialForBook = Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
||||
>;
|
||||
|
||||
export default function MaterialBookView({
|
||||
material,
|
||||
mode = "student",
|
||||
}: {
|
||||
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
// The legacy callsites only pass body/body_text/material_type, so we
|
||||
// tolerate that by making id/plan_id optional in the prop type even
|
||||
// though the types/index file marks them as required. Required props
|
||||
// are validated at runtime when interactive_workbook is dispatched.
|
||||
material: Partial<MaterialForBook> &
|
||||
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
mode?: WorkbookMode;
|
||||
}) {
|
||||
const body = material.body ?? {};
|
||||
const hasStructured = Object.keys(body).length > 0;
|
||||
|
||||
// Dispatch to the interactive renderer for workbook materials. The
|
||||
// skill-bodied workbooks (grammar/vocabulary that embed their own
|
||||
// `interactive_workbook` block) render via the generic walker AND
|
||||
// the embedded workbook below for the exercises portion.
|
||||
if (
|
||||
material.material_type === "interactive_workbook" &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number"
|
||||
) {
|
||||
return (
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For grammar / vocabulary that embed an interactive workbook inside
|
||||
// their body, we render the prose first and then the workbook UI so
|
||||
// students can practise without leaving the lesson.
|
||||
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
|
||||
.interactive_workbook;
|
||||
const hasEmbedded =
|
||||
embeddedWorkbook &&
|
||||
Array.isArray(embeddedWorkbook.exercises) &&
|
||||
embeddedWorkbook.exercises.length > 0 &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||
{hasStructured ? (
|
||||
renderAny(body)
|
||||
renderAny(stripEmbedded(body))
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||
{material.body_text || "No content available yet."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasEmbedded && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-sm font-medium mb-2">Practice</div>
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
bodyOverride={embeddedWorkbook!}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (!("interactive_workbook" in body)) return body;
|
||||
const { interactive_workbook: _drop, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
390
frontend/src/components/coursePlan/PlanReader.tsx
Normal file
390
frontend/src/components/coursePlan/PlanReader.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { withAuthQuery } from "@/lib/api-client";
|
||||
import MaterialBookView, {
|
||||
SkillBadge,
|
||||
} from "@/components/coursePlan/MaterialBookView";
|
||||
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only renderer for a course plan that BOTH the student page and
|
||||
* the admin "View as Student" preview share. The only difference is
|
||||
* the workbook `mode`:
|
||||
*
|
||||
* - `mode="student"` → answers persist server-side (real attempts).
|
||||
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
|
||||
* persistence) and surfaces an "Show answer key" toggle so admins
|
||||
* can verify the v2 generator's output.
|
||||
*
|
||||
* Materials are rendered through `MaterialBookView`, which dispatches
|
||||
* `interactive_workbook` materials into the live workbook component.
|
||||
*/
|
||||
export interface PlanReaderProps {
|
||||
plan: CoursePlan;
|
||||
mode?: WorkbookMode;
|
||||
/** Heading level for accessibility — admins use this inside their own
|
||||
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
|
||||
headingClassName?: string;
|
||||
}
|
||||
|
||||
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const materialsByWeek = useMemo(() => {
|
||||
const map = new Map<number, CoursePlanMaterial[]>();
|
||||
if (!plan.materials) return map;
|
||||
for (const m of plan.materials) {
|
||||
const list = map.get(m.week_number) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.week_number, list);
|
||||
}
|
||||
return map;
|
||||
}, [plan.materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||
{plan.description && (
|
||||
<CardDescription className="max-w-3xl">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.assignment && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{plan.assignment.due_date
|
||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
{plan.assignment.assigned_by_name && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("coursePlan.student.assignedBy", {
|
||||
name: plan.assignment.assigned_by_name,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plan.assignment?.message && (
|
||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||
{plan.assignment.message}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{plan.objectives && plan.objectives.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.objectives")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
{plan.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.delivery")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{plan.weeks.map((w) => (
|
||||
<ReaderWeek
|
||||
key={w.id}
|
||||
week={w}
|
||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||
mode={mode}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderWeek({
|
||||
week,
|
||||
materials,
|
||||
mode,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
const skills = useMemo(
|
||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||
[materials],
|
||||
);
|
||||
const filteredMaterials = useMemo(
|
||||
() =>
|
||||
materials.filter(
|
||||
(m) => skillFilter === "all" || m.skill === skillFilter,
|
||||
),
|
||||
[materials, skillFilter],
|
||||
);
|
||||
return (
|
||||
<AccordionItem value={String(week.week_number)}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("coursePlan.weekN", { n: week.week_number })}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{week.focus || week.unit || "—"}
|
||||
</div>
|
||||
{week.date_label && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{week.date_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skillFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter("all")}
|
||||
>
|
||||
{t("common.all", "All")}
|
||||
</Button>
|
||||
{skills.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={skillFilter === s ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter(s)}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("coursePlan.media.noMedia")}
|
||||
</p>
|
||||
)}
|
||||
{filteredMaterials.map((m) => (
|
||||
<ReaderMaterial key={m.id} material={m} mode={mode} />
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMaterial({
|
||||
material,
|
||||
mode,
|
||||
}: {
|
||||
material: CoursePlanMaterial;
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base flex-1 min-w-0">
|
||||
{material.title}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`coursePlan.materialType.${material.material_type}`,
|
||||
material.material_type,
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
<GroundingBadge material={material} />
|
||||
</div>
|
||||
{material.summary && (
|
||||
<CardDescription>{material.summary}</CardDescription>
|
||||
)}
|
||||
{material.share_date && (
|
||||
<CardDescription>
|
||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(material.media ?? []).map((m) => (
|
||||
<ReaderMediaTile key={m.id} media={m} />
|
||||
))}
|
||||
<MaterialBookView material={material} mode={mode} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||
<span className="capitalize text-xs text-muted-foreground">
|
||||
{media.kind}
|
||||
</span>
|
||||
</div>
|
||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||
{media.kind === "image" && (
|
||||
<img
|
||||
src={url}
|
||||
alt={media.title}
|
||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pill that surfaces RAG / extraction provenance for one material. */
|
||||
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
|
||||
const grounded = material.grounded_on ?? [];
|
||||
const extracted = material.extracted_from ?? null;
|
||||
if (!grounded.length && !extracted) return null;
|
||||
const total =
|
||||
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
|
||||
(extracted ? 1 : 0);
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="cursor-help">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
Grounded on {total} reference{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 text-xs">
|
||||
{grounded.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm">RAG sources</div>
|
||||
<ul className="space-y-1">
|
||||
{grounded.map((g) => (
|
||||
<li key={g.source_id} className="flex justify-between gap-2">
|
||||
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
|
||||
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{extracted && (
|
||||
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
|
||||
<div className="font-medium text-sm">Extracted from</div>
|
||||
<div className="text-muted-foreground">
|
||||
{extracted.source_title || `Source #${extracted.source_id}`}
|
||||
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
|
||||
</div>
|
||||
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ const ar: Translations = {
|
||||
myCoursePlans: "خططي الدراسية",
|
||||
students: "الطلاب",
|
||||
teachers: "المعلمون",
|
||||
branches: "الفروع",
|
||||
batches: "الدفعات",
|
||||
timetable: "الجدول الدراسي",
|
||||
reports: "التقارير",
|
||||
@@ -203,6 +204,8 @@ const ar: Translations = {
|
||||
averageGrade: "متوسط الدرجات",
|
||||
totalChapters: "إجمالي الفصول",
|
||||
myCourses: "دوراتي",
|
||||
myCoursePlans: "خططي الدراسية",
|
||||
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
|
||||
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
||||
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
||||
quickActions: "إجراءات سريعة",
|
||||
|
||||
@@ -110,6 +110,7 @@ const en: Translations = {
|
||||
myCoursePlans: "My Course Plans",
|
||||
students: "Students",
|
||||
teachers: "Teachers",
|
||||
branches: "Branches",
|
||||
batches: "Batches",
|
||||
timetable: "Timetable",
|
||||
reports: "Reports",
|
||||
@@ -250,6 +251,8 @@ const en: Translations = {
|
||||
averageGrade: "Average Grade",
|
||||
totalChapters: "Total Chapters",
|
||||
myCourses: "My Courses",
|
||||
myCoursePlans: "My Course Plans",
|
||||
noCoursePlans: "No course plans assigned yet.",
|
||||
noEnrolledCourses: "No enrolled courses yet.",
|
||||
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
|
||||
quickActions: "Quick Actions",
|
||||
|
||||
@@ -283,9 +283,9 @@ export type QueryParamValue =
|
||||
export type QueryParams = object;
|
||||
|
||||
const ENTITY_QUERY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
const ENTITY_BODY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
@@ -409,10 +409,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,34 +4,106 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useBatches, useCourses } from "@/hooks/queries";
|
||||
import { useBatches, useCourses, useTeachers } from "@/hooks/queries";
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import { classroomsService } from "@/services/classrooms.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Plus, Trash2 } from "lucide-react";
|
||||
import { Search, Plus, Trash2, Pencil } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
type BatchForm = {
|
||||
name: string;
|
||||
course_id: string;
|
||||
course_section_id: string;
|
||||
term_key: string;
|
||||
classroom_id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
max_students: string;
|
||||
teacher_ids: number[];
|
||||
};
|
||||
|
||||
const EMPTY_FORM: BatchForm = {
|
||||
name: "",
|
||||
course_id: "",
|
||||
course_section_id: "",
|
||||
term_key: "",
|
||||
classroom_id: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
max_students: "30",
|
||||
teacher_ids: [],
|
||||
};
|
||||
|
||||
export default function AdminBatches() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingBatchId, setEditingBatchId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<BatchForm>(EMPTY_FORM);
|
||||
const [editForm, setEditForm] = useState<BatchForm>(EMPTY_FORM);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: batchesData, isLoading } = useBatches();
|
||||
const { data: coursesData } = useCourses({ size: 200 });
|
||||
const { data: teachersData } = useTeachers({ size: 200 });
|
||||
const classroomsQ = useQuery({
|
||||
queryKey: ["lms-classrooms-light"],
|
||||
queryFn: async () => (await classroomsService.list({ size: 200 })).items,
|
||||
});
|
||||
const createSectionsQ = useQuery({
|
||||
queryKey: ["course-sections", form.course_id],
|
||||
queryFn: () =>
|
||||
form.course_id
|
||||
? lmsService.listCourseSections(Number(form.course_id))
|
||||
: Promise.resolve({ items: [], total: 0 }),
|
||||
enabled: !!form.course_id,
|
||||
});
|
||||
const editSectionsQ = useQuery({
|
||||
queryKey: ["course-sections", editForm.course_id],
|
||||
queryFn: () =>
|
||||
editForm.course_id
|
||||
? lmsService.listCourseSections(Number(editForm.course_id))
|
||||
: Promise.resolve({ items: [], total: 0 }),
|
||||
enabled: !!editForm.course_id,
|
||||
});
|
||||
const batches = batchesData?.items ?? [];
|
||||
const courses = coursesData?.items ?? [];
|
||||
const teachers = teachersData?.items ?? [];
|
||||
const classrooms = classroomsQ.data ?? [];
|
||||
const createSections = createSectionsQ.data?.items ?? [];
|
||||
const editSections = editSectionsQ.data?.items ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setCreateOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Batch created" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setEditOpen(false);
|
||||
setEditingBatchId(null);
|
||||
setEditForm(EMPTY_FORM);
|
||||
toast({ title: "Batch updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
@@ -47,9 +119,26 @@ export default function AdminBatches() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(batch: (typeof batches)[number]) {
|
||||
setEditingBatchId(batch.id);
|
||||
setEditForm({
|
||||
name: batch.name || "",
|
||||
course_id: String(batch.course_id || ""),
|
||||
course_section_id: batch.course_section_id ? String(batch.course_section_id) : "",
|
||||
term_key: batch.term_key || "",
|
||||
classroom_id: batch.classroom_id ? String(batch.classroom_id) : "",
|
||||
start_date: batch.start_date || "",
|
||||
end_date: batch.end_date || "",
|
||||
max_students: String(batch.capacity || 30),
|
||||
teacher_ids: batch.teacher_ids || [],
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -62,7 +151,81 @@ export default function AdminBatches() {
|
||||
</div>
|
||||
<AiTipBanner context="admin-batches" variant="recommendation" />
|
||||
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
||||
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Section</TableHead>
|
||||
<TableHead>Term</TableHead>
|
||||
<TableHead>Classroom</TableHead>
|
||||
<TableHead>Teachers</TableHead>
|
||||
<TableHead>Students</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-medium">{b.name}</TableCell>
|
||||
<TableCell>{b.course_name}</TableCell>
|
||||
<TableCell>
|
||||
{b.course_section_name ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{b.course_section_code || b.course_section_name}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{b.term_key || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{b.classroom_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{b.teacher_names?.length ? b.teacher_names.join(", ") : b.teacher_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{b.student_count}/{b.capacity}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{b.schedule}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={b.status === "active" ? "default" : "secondary"}
|
||||
className="capitalize"
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={`/admin/batches/${b.id}`}>View</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(b)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(b.id, b.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
|
||||
@@ -70,7 +233,16 @@ export default function AdminBatches() {
|
||||
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course *</Label>
|
||||
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
|
||||
<Select
|
||||
value={form.course_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
course_id: v === "__none__" ? "" : v,
|
||||
course_section_id: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Select —</SelectItem>
|
||||
@@ -78,15 +250,233 @@ export default function AdminBatches() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Section (optional)</Label>
|
||||
<Select
|
||||
value={form.course_section_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, course_section_id: v === "__none__" ? "" : v }))
|
||||
}
|
||||
disabled={!form.course_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Whole course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||
{createSections.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a course section template to keep batches isolated per group.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Term Key (optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 2026-T1"
|
||||
value={form.term_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
One batch per (classroom × course × section × term).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Classroom (optional)</Label>
|
||||
<Select value={form.classroom_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No classroom</SelectItem>
|
||||
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Selecting a classroom links the batch to that homeroom. Use the Classrooms page to auto-enroll the roster.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Teachers (multi-select)</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||
{teachers.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={form.teacher_ids.includes(t.id)}
|
||||
onCheckedChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||
: [...prev.teacher_ids, t.id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
|
||||
<Button
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!form.name ||
|
||||
!form.course_id ||
|
||||
!form.start_date ||
|
||||
!form.end_date
|
||||
}
|
||||
onClick={() =>
|
||||
createMutation.mutate({
|
||||
name: form.name,
|
||||
course_id: Number(form.course_id),
|
||||
course_section_id: form.course_section_id ? Number(form.course_section_id) : undefined,
|
||||
term_key: form.term_key.trim() || undefined,
|
||||
classroom_id: form.classroom_id ? Number(form.classroom_id) : undefined,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date,
|
||||
max_students: Number(form.max_students) || 30,
|
||||
teacher_ids: form.teacher_ids,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Batch</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Batch Name *</Label><Input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course *</Label>
|
||||
<Select
|
||||
value={editForm.course_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setEditForm((f) => ({
|
||||
...f,
|
||||
course_id: v === "__none__" ? "" : v,
|
||||
course_section_id: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Select —</SelectItem>
|
||||
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Section (optional)</Label>
|
||||
<Select
|
||||
value={editForm.course_section_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setEditForm((f) => ({
|
||||
...f,
|
||||
course_section_id: v === "__none__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
disabled={!editForm.course_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Whole course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||
{editSections.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Term Key (optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 2026-T1"
|
||||
value={editForm.term_key}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Classroom (optional)</Label>
|
||||
<Select value={editForm.classroom_id || "__none__"} onValueChange={v => setEditForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No classroom</SelectItem>
|
||||
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={editForm.start_date} onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={editForm.end_date} onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Capacity</Label><Input type="number" value={editForm.max_students} onChange={e => setEditForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Teachers (multi-select)</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||
{teachers.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={editForm.teacher_ids.includes(t.id)}
|
||||
onCheckedChange={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||
: [...prev.teacher_ids, t.id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={updateMutation.isPending || !editingBatchId || !editForm.name || !editForm.course_id || !editForm.start_date || !editForm.end_date}
|
||||
onClick={() => {
|
||||
if (!editingBatchId) return;
|
||||
updateMutation.mutate({
|
||||
id: editingBatchId,
|
||||
data: {
|
||||
name: editForm.name,
|
||||
course_id: Number(editForm.course_id),
|
||||
course_section_id: editForm.course_section_id
|
||||
? Number(editForm.course_section_id)
|
||||
: null,
|
||||
term_key: editForm.term_key.trim(),
|
||||
classroom_id: editForm.classroom_id ? Number(editForm.classroom_id) : null,
|
||||
start_date: editForm.start_date,
|
||||
end_date: editForm.end_date,
|
||||
max_students: Number(editForm.max_students) || 30,
|
||||
teacher_ids: editForm.teacher_ids,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
260
frontend/src/pages/admin/AdminBranches.tsx
Normal file
260
frontend/src/pages/admin/AdminBranches.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import type { LmsBranch } from "@/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type BranchForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
notes: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: BranchForm = { name: "", code: "", notes: "", active: true };
|
||||
|
||||
export default function AdminBranches() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedEntity } = useAuth();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LmsBranch | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [form, setForm] = useState<BranchForm>(EMPTY_FORM);
|
||||
|
||||
const branchesQ = useQuery({
|
||||
queryKey: ["lms-branches", selectedEntity?.id ?? 0, search],
|
||||
queryFn: async () => {
|
||||
const res = await lmsService.listBranches({
|
||||
size: 200,
|
||||
search: search.trim() || undefined,
|
||||
});
|
||||
return res.items;
|
||||
},
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<LmsBranch>) => lmsService.createBranch(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Branch created" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to create branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Partial<LmsBranch> }) =>
|
||||
lmsService.updateBranch(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Branch updated" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to update branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => lmsService.deleteBranch(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
toast({ title: "Branch deleted" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to delete branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const isSaving = createMut.isPending || updateMut.isPending;
|
||||
const rows = useMemo(() => branchesQ.data ?? [], [branchesQ.data]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LmsBranch) => {
|
||||
setEditing(row);
|
||||
setForm({
|
||||
name: row.name || "",
|
||||
code: row.code || "",
|
||||
notes: row.notes || "",
|
||||
active: !!row.active,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const payload: Partial<LmsBranch> = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
notes: form.notes.trim(),
|
||||
active: form.active,
|
||||
};
|
||||
if (!payload.name) return;
|
||||
if (editing) {
|
||||
updateMut.mutate({ id: editing.id, payload });
|
||||
return;
|
||||
}
|
||||
createMut.mutate(payload);
|
||||
};
|
||||
|
||||
const onDelete = (row: LmsBranch) => {
|
||||
if (!window.confirm(`Delete branch "${row.name}"?`)) return;
|
||||
deleteMut.mutate(row.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Branches</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create branches per entity to organize courses, batches, teachers, and students.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Branch
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit Branch" : "Create Branch"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Branch name</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Riyadh Main Campus"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Branch code</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
|
||||
placeholder="e.g. RUH_MAIN"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Optional notes for managers"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Active</p>
|
||||
<p className="text-xs text-muted-foreground">Inactive branches stay in history but are hidden from active workflows.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.active}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" onClick={onSave} disabled={isSaving || !form.name.trim()}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editing ? "Update Branch" : "Create Branch"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search branches by name or code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchesQ.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Shared LMS</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-medium">{row.name}</TableCell>
|
||||
<TableCell>{row.code || "—"}</TableCell>
|
||||
<TableCell>{row.entity_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
C:{row.course_count} B:{row.batch_count} S:{row.student_count} T:{row.teacher_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.active ? (
|
||||
<span className="inline-flex items-center rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Active</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">Inactive</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(row)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onDelete(row)} disabled={deleteMut.isPending}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
<span>No branches found for this entity.</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog
|
||||
import MaterialBookView, {
|
||||
SkillBadge,
|
||||
} from "@/components/coursePlan/MaterialBookView";
|
||||
import PlanReader, {
|
||||
GroundingBadge,
|
||||
} from "@/components/coursePlan/PlanReader";
|
||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||
import type {
|
||||
CoursePlan,
|
||||
@@ -168,6 +171,36 @@ export default function AdminCoursePlanDetail() {
|
||||
|
||||
const plan = planQ.data?.data as CoursePlan | undefined;
|
||||
const [studentPreview, setStudentPreview] = useState(false);
|
||||
const [gateWeek, setGateWeek] = useState<number | null>(null);
|
||||
|
||||
// True as soon as at least one uploaded source is fully indexed in
|
||||
// pgvector. Drives the v2 (RAG-grounded) path on Generate and gates
|
||||
// the soft "upload your book first" modal.
|
||||
const indexedSources = useMemo(
|
||||
() => (sourcesQ.data?.items ?? []).filter((s) => s.status === "indexed"),
|
||||
[sourcesQ.data?.items],
|
||||
);
|
||||
const hasIndexedSources = indexedSources.length > 0;
|
||||
|
||||
const generateV2Mut = useMutation({
|
||||
mutationFn: (weekNumber: number) =>
|
||||
coursePlanService.generateWeekMaterialsV2(planId, weekNumber),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
|
||||
toast.success(
|
||||
t("coursePlan.weekMaterialsGenerated") +
|
||||
(res.rag?.sources_used?.length
|
||||
? ` (${t("coursePlan.rag.groundedOn", {
|
||||
count: res.rag.sources_used.length,
|
||||
defaultValue: "grounded on {{count}} source(s)",
|
||||
})})`
|
||||
: ""),
|
||||
);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
||||
});
|
||||
|
||||
const generateMut = useMutation({
|
||||
mutationFn: (weekNumber: number) =>
|
||||
@@ -181,6 +214,24 @@ export default function AdminCoursePlanDetail() {
|
||||
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
||||
});
|
||||
|
||||
/**
|
||||
* Single entry point for the per-week Generate button:
|
||||
* - if any source is indexed → call the RAG-grounded v2 endpoint
|
||||
* - otherwise → open the upload gate; the admin can still choose
|
||||
* "Generate without RAG" to fall back to the legacy generator
|
||||
*/
|
||||
const requestGenerate = (weekNumber: number) => {
|
||||
if (hasIndexedSources) {
|
||||
generateV2Mut.mutate(weekNumber);
|
||||
} else {
|
||||
setGateWeek(weekNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const generateBusy = (weekNumber: number) =>
|
||||
(generateV2Mut.isPending && generateV2Mut.variables === weekNumber) ||
|
||||
(generateMut.isPending && generateMut.variables === weekNumber);
|
||||
|
||||
const bulkMediaMut = useMutation({
|
||||
mutationFn: (vars: {
|
||||
week: number;
|
||||
@@ -273,7 +324,7 @@ export default function AdminCoursePlanDetail() {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.skills_division && (
|
||||
{plan.skills_division && !studentPreview && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{plan.skills_division}
|
||||
</p>
|
||||
@@ -281,6 +332,19 @@ export default function AdminCoursePlanDetail() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{studentPreview ? (
|
||||
<>
|
||||
<div className="rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-900 dark:text-amber-100 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t(
|
||||
"coursePlan.preview.banner",
|
||||
"Preview — answers won't be saved. Toggle Student view to exit.",
|
||||
)}
|
||||
</div>
|
||||
<PlanReader plan={plan} mode="preview" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeliverablesStrip data={deliverablesQ.data} />
|
||||
|
||||
<SourcesCard
|
||||
@@ -407,11 +471,8 @@ export default function AdminCoursePlanDetail() {
|
||||
key={week.id}
|
||||
week={week}
|
||||
materials={materialsByWeek.get(week.week_number) ?? []}
|
||||
generating={
|
||||
generateMut.isPending &&
|
||||
generateMut.variables === week.week_number
|
||||
}
|
||||
onGenerate={() => generateMut.mutate(week.week_number)}
|
||||
generating={generateBusy(week.week_number)}
|
||||
onGenerate={() => requestGenerate(week.week_number)}
|
||||
onBulkMedia={(kinds) =>
|
||||
bulkMediaMut.mutate({ week: week.week_number, kinds })
|
||||
}
|
||||
@@ -420,18 +481,106 @@ export default function AdminCoursePlanDetail() {
|
||||
bulkMediaMut.variables?.week === week.week_number
|
||||
}
|
||||
studentPreview={studentPreview}
|
||||
hasIndexedSources={hasIndexedSources}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PreGenerationGate
|
||||
open={gateWeek !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setGateWeek(null);
|
||||
}}
|
||||
weekNumber={gateWeek}
|
||||
onUpload={() => {
|
||||
setGateWeek(null);
|
||||
// Scroll to the SourcesCard upload area; the admin uploads
|
||||
// the PDF, watches it index, then clicks Generate again.
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-section="sources-card"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}}
|
||||
onContinueWithoutRag={() => {
|
||||
if (gateWeek !== null) {
|
||||
generateMut.mutate(gateWeek);
|
||||
setGateWeek(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-generation upload gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Soft gate shown before Generate when the plan has no indexed sources.
|
||||
* Encourages the admin to upload reference material first so the v2
|
||||
* generator can ground exercises on real text. Dismissable: clicking
|
||||
* "Generate without RAG" falls back to the legacy v1 endpoint so we
|
||||
* never block the workflow on a missing book.
|
||||
*/
|
||||
function PreGenerationGate({
|
||||
open,
|
||||
onOpenChange,
|
||||
weekNumber,
|
||||
onUpload,
|
||||
onContinueWithoutRag,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
weekNumber: number | null;
|
||||
onUpload: () => void;
|
||||
onContinueWithoutRag: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("coursePlan.gate.title", "Upload your reference book first?")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"coursePlan.gate.subtitle",
|
||||
"Generating week {{n}} works best when grounded on your uploaded PDF / DOCX / link. Upload a reference, wait for indexing, then click Generate again. You can dismiss this and continue without RAG.",
|
||||
{ n: weekNumber ?? 0 },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"coursePlan.gate.hint",
|
||||
"Tip: a typical course book takes 10-30 seconds to index after upload.",
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("coursePlan.gate.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onContinueWithoutRag}>
|
||||
{t("coursePlan.gate.continueWithoutRag", "Generate without RAG")}
|
||||
</Button>
|
||||
<Button onClick={onUpload}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.gate.upload", "Upload now")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase B — Deliverables / progress strip
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -618,14 +767,82 @@ function SourcesCard({
|
||||
),
|
||||
});
|
||||
|
||||
const extractMut = useMutation({
|
||||
mutationFn: () => coursePlanService.extractWorkbooks(planId),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.materials_created > 0) {
|
||||
toast.success(
|
||||
t("coursePlan.sources.extractDone", {
|
||||
materials: res.data.materials_created,
|
||||
exercises: res.data.exercises_total,
|
||||
defaultValue:
|
||||
"Extracted {{exercises}} exercises into {{materials}} workbooks",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.message(
|
||||
res.data.reason
|
||||
? `${t("coursePlan.sources.extractEmpty", { defaultValue: "No new exercises extracted" })} (${res.data.reason})`
|
||||
: t("coursePlan.sources.extractEmpty", {
|
||||
defaultValue: "No new exercises extracted",
|
||||
}),
|
||||
);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
describeApiError(
|
||||
err,
|
||||
t("coursePlan.sources.extractFailed", {
|
||||
defaultValue: "Failed to extract workbooks",
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
// True once at least one source is fully indexed — surfaces the
|
||||
// "Extract workbooks" action only when running it can actually
|
||||
// produce results.
|
||||
const hasIndexed = sources.some((s) => s.status === "indexed");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card data-section="sources-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sources.sectionTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sources.sectionTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("coursePlan.sources.sectionDesc")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => extractMut.mutate()}
|
||||
disabled={extractMut.isPending || !hasIndexed}
|
||||
title={
|
||||
hasIndexed
|
||||
? undefined
|
||||
: t("coursePlan.sources.extractGate", {
|
||||
defaultValue: "Index a source first",
|
||||
})
|
||||
}
|
||||
>
|
||||
{extractMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ClipboardList className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.sources.extractWorkbooks", {
|
||||
defaultValue: "Extract workbooks from sources",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -1241,6 +1458,7 @@ function WeekAccordionItem({
|
||||
onBulkMedia,
|
||||
bulkBusy,
|
||||
studentPreview,
|
||||
hasIndexedSources,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
@@ -1249,6 +1467,7 @@ function WeekAccordionItem({
|
||||
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
|
||||
bulkBusy: boolean;
|
||||
studentPreview: boolean;
|
||||
hasIndexedSources: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
@@ -1344,6 +1563,11 @@ function WeekAccordionItem({
|
||||
: materials.length > 0
|
||||
? t("coursePlan.regenerateMaterials")
|
||||
: t("coursePlan.generateMaterials")}
|
||||
{hasIndexedSources && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
RAG
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
{materials.length > 0 && (
|
||||
<Button
|
||||
@@ -1463,6 +1687,7 @@ function MaterialCard({
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
<GroundingBadge material={material} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1630,6 +1855,46 @@ function MediaDrawer({
|
||||
toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
|
||||
});
|
||||
|
||||
// Admin upload — one mutation handles all three kinds, the file picker
|
||||
// hidden inputs below decide which kind the file is for. The mutation
|
||||
// input is the kind+file pair so React Query can serialize multiple
|
||||
// uploads (e.g. drag two images in a row) without colliding state.
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: ({ kind, file }: { kind: "audio" | "image" | "video"; file: File }) =>
|
||||
coursePlanService.uploadMedia(material.id, kind, file),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(
|
||||
t(`coursePlan.media.uploaded_${vars.kind}`, {
|
||||
defaultValue: `${vars.kind.charAt(0).toUpperCase() + vars.kind.slice(1)} uploaded`,
|
||||
}),
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
describeApiError(
|
||||
err,
|
||||
t("coursePlan.media.uploadFailed", { defaultValue: "Upload failed" }),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleFilePicked(
|
||||
kind: "audio" | "image" | "video",
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = e.target.files?.[0];
|
||||
// Always reset the input value so the same file can be re-selected
|
||||
// after a failed upload — otherwise onChange won't fire again.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
uploadMut.mutate({ kind, file });
|
||||
}
|
||||
|
||||
const items = mediaQ.data?.items ?? [];
|
||||
|
||||
return (
|
||||
@@ -1645,47 +1910,121 @@ function MediaDrawer({
|
||||
</SheetHeader>
|
||||
|
||||
{!studentPreview && (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => audioMut.mutate()}
|
||||
disabled={audioMut.isPending}
|
||||
>
|
||||
{audioMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateAudio")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => imageMut.mutate()}
|
||||
disabled={imageMut.isPending}
|
||||
>
|
||||
{imageMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateImage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => videoMut.mutate()}
|
||||
disabled={videoMut.isPending}
|
||||
>
|
||||
{videoMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Video className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateVideo")}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => audioMut.mutate()}
|
||||
disabled={audioMut.isPending}
|
||||
>
|
||||
{audioMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateAudio")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => imageMut.mutate()}
|
||||
disabled={imageMut.isPending}
|
||||
>
|
||||
{imageMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateImage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => videoMut.mutate()}
|
||||
disabled={videoMut.isPending}
|
||||
>
|
||||
{videoMut.isPending ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Video className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("coursePlan.media.generateVideo")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Manual upload row — admins can attach a real human voiceover,
|
||||
a custom illustration, or a recorded scene to override the
|
||||
AI-generated asset. The hidden inputs are kept off-DOM-flow
|
||||
and triggered by the visible buttons via refs so the styling
|
||||
stays consistent with the Generate row above.
|
||||
*/}
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilePicked("audio", e)}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilePicked("image", e)}
|
||||
/>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilePicked("video", e)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => audioInputRef.current?.click()}
|
||||
disabled={uploadMut.isPending}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.media.uploadAudio", {
|
||||
defaultValue: "Upload audio",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={uploadMut.isPending}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.media.uploadImage", {
|
||||
defaultValue: "Upload image",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
disabled={uploadMut.isPending}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.media.uploadVideo", {
|
||||
defaultValue: "Upload video",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadMut.isPending && (
|
||||
<p className="text-muted-foreground mt-2 inline-flex items-center gap-1 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t("coursePlan.media.uploading", {
|
||||
defaultValue: "Uploading…",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
|
||||
@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
||||
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Course, CourseCreateRequest } from "@/types";
|
||||
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
|
||||
import type { ResourceTag } from "@/types/adaptive";
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
|
||||
};
|
||||
}
|
||||
|
||||
function CourseSectionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
course,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
course: Course;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newCode, setNewCode] = useState("");
|
||||
const [newSeq, setNewSeq] = useState<number>(10);
|
||||
const [editing, setEditing] = useState<CourseSection | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
const [editSeq, setEditSeq] = useState<number>(10);
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sectionsQ = useQuery({
|
||||
queryKey: ["course-sections", course.id],
|
||||
queryFn: () => lmsService.listCourseSections(course.id),
|
||||
enabled: open,
|
||||
});
|
||||
const sections = sectionsQ.data?.items ?? [];
|
||||
|
||||
function refreshAll() {
|
||||
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
|
||||
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
|
||||
}
|
||||
|
||||
async function handleGenerateDefaults() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await lmsService.generateDefaultCourseSections(course.id);
|
||||
toast({
|
||||
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
|
||||
});
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const code = newCode.trim().toUpperCase();
|
||||
const name = newName.trim() || `Section ${code}`;
|
||||
if (!code) {
|
||||
toast({ title: "Code is required", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.createCourseSection(course.id, {
|
||||
name,
|
||||
code,
|
||||
sequence: newSeq || 10,
|
||||
active: true,
|
||||
});
|
||||
toast({ title: `Section ${code} added` });
|
||||
setNewName("");
|
||||
setNewCode("");
|
||||
setNewSeq(10);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Add failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function startEdit(s: CourseSection) {
|
||||
setEditing(s);
|
||||
setEditName(s.name);
|
||||
setEditCode(s.code);
|
||||
setEditSeq(s.sequence || 10);
|
||||
setEditActive(s.active);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.updateCourseSection(course.id, editing.id, {
|
||||
name: editName.trim() || editing.name,
|
||||
code: editCode.trim().toUpperCase() || editing.code,
|
||||
sequence: editSeq || editing.sequence,
|
||||
active: editActive,
|
||||
});
|
||||
toast({ title: "Section updated" });
|
||||
setEditing(null);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleDelete(s: CourseSection) {
|
||||
if ((s.batch_count ?? 0) > 0) {
|
||||
toast({
|
||||
title: "Cannot delete",
|
||||
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.deleteCourseSection(course.id, s.id);
|
||||
toast({ title: "Section deleted" });
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" /> Sections — {course.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Course sections (A, B, C…) are templates. Each section can be hosted by a classroom,
|
||||
which auto-creates a batch and enrolls the classroom roster.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Quick start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create the standard <strong>A · B · C</strong> sections for this course in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateDefaults}
|
||||
disabled={busy || sectionsQ.isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
Generate A / B / C
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Existing sections</Label>
|
||||
{sectionsQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-3">
|
||||
No sections yet. Add one below or click <em>Generate A / B / C</em>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 border rounded-md divide-y">
|
||||
{sections.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{s.code}
|
||||
</Badge>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
seq {s.sequence}
|
||||
{s.batch_count != null && (
|
||||
<span className="ml-2">· {s.batch_count} batch(es)</span>
|
||||
)}
|
||||
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Edit section"
|
||||
onClick={() => startEdit(s)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Delete section"
|
||||
onClick={() => handleDelete(s)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<Label className="text-sm font-medium">Add a new section</Label>
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-3">
|
||||
<Label className="text-[11px] text-muted-foreground">Code *</Label>
|
||||
<Input
|
||||
placeholder="A"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-7">
|
||||
<Label className="text-[11px] text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
placeholder="Section A"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Seq</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newSeq}
|
||||
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
Add section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-4 w-4" /> Edit section
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={editCode}
|
||||
onChange={(e) => setEditCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sequence</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editSeq}
|
||||
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={editActive}
|
||||
onCheckedChange={(v) => setEditActive(Boolean(v))}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveEdit} disabled={busy}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCourses() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
|
||||
const [enrollOpen, setEnrollOpen] = useState(false);
|
||||
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||
@@ -539,10 +864,11 @@ export default function AdminCourses() {
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Subject / Tags</TableHead>
|
||||
<TableHead>Difficulty</TableHead>
|
||||
<TableHead>Sections</TableHead>
|
||||
<TableHead>Chapters</TableHead>
|
||||
<TableHead>Enrolled / Cap</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[150px]" />
|
||||
<TableHead className="w-[180px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -594,6 +920,36 @@ export default function AdminCourses() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
className="group flex items-center gap-1 text-xs hover:underline"
|
||||
title="Manage course sections (A/B/C…)"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
|
||||
<span className="text-muted-foreground">sections</span>
|
||||
{c.sections && c.sections.length > 0 && (
|
||||
<span className="ml-1 flex flex-wrap gap-0.5">
|
||||
{c.sections.slice(0, 4).map((s) => (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-none"
|
||||
>
|
||||
{s.code || s.name}
|
||||
</Badge>
|
||||
))}
|
||||
{c.sections.length > 4 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{c.sections.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -627,6 +983,14 @@ export default function AdminCourses() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Manage sections (A/B/C…)"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
No courses found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{sectionsCourse && (
|
||||
<CourseSectionsDialog
|
||||
open={!!sectionsCourse}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSectionsCourse(null);
|
||||
}}
|
||||
course={sectionsCourse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,60 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import PlanReader from "@/components/coursePlan/PlanReader";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CoursePlan } from "@/types";
|
||||
|
||||
/**
|
||||
* Student detail view for an assigned AI course plan.
|
||||
@@ -63,8 +17,8 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
* returns 403 unless the current user is in the plan's assignment list,
|
||||
* so we can render unconditionally as soon as the query resolves.
|
||||
*
|
||||
* The page is intentionally read-only — students can play audio, view
|
||||
* images, and watch video, but they can't (re)generate content.
|
||||
* Rendering is delegated to the shared `<PlanReader>` so the admin
|
||||
* "View as Student" preview matches the real student experience byte-for-byte.
|
||||
*/
|
||||
export default function StudentCoursePlanDetail() {
|
||||
const { t } = useTranslation();
|
||||
@@ -79,20 +33,14 @@ export default function StudentCoursePlanDetail() {
|
||||
|
||||
const plan = data?.data as CoursePlan | undefined;
|
||||
|
||||
const materialsByWeek = useMemo(() => {
|
||||
const map = new Map<number, CoursePlanMaterial[]>();
|
||||
if (!plan?.materials) return map;
|
||||
for (const m of plan.materials) {
|
||||
const list = map.get(m.week_number) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.week_number, list);
|
||||
}
|
||||
return map;
|
||||
}, [plan?.materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="-ml-2 text-muted-foreground"
|
||||
>
|
||||
<Link to="/student/course-plans" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("coursePlan.student.backToList")}
|
||||
@@ -113,235 +61,7 @@ export default function StudentCoursePlanDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||
{plan.description && (
|
||||
<CardDescription className="max-w-3xl">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.assignment && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{plan.assignment.due_date
|
||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
{plan.assignment.assigned_by_name && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("coursePlan.student.assignedBy", {
|
||||
name: plan.assignment.assigned_by_name,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plan.assignment?.message && (
|
||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||
{plan.assignment.message}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{plan.objectives.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.objectives")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
{plan.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.delivery")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{plan.weeks.map((w) => (
|
||||
<StudentWeek
|
||||
key={w.id}
|
||||
week={w}
|
||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentWeek({
|
||||
week,
|
||||
materials,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
const skills = useMemo(
|
||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||
[materials],
|
||||
);
|
||||
const filteredMaterials = useMemo(
|
||||
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
|
||||
[materials, skillFilter],
|
||||
);
|
||||
return (
|
||||
<AccordionItem value={String(week.week_number)}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("coursePlan.weekN", { n: week.week_number })}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{week.focus || week.unit || "—"}
|
||||
</div>
|
||||
{week.date_label && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{week.date_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skillFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter("all")}
|
||||
>
|
||||
{t("common.all", "All")}
|
||||
</Button>
|
||||
{skills.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={skillFilter === s ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter(s)}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("coursePlan.media.noMedia")}
|
||||
</p>
|
||||
)}
|
||||
{filteredMaterials.map((m) => (
|
||||
<StudentMaterial key={m.id} material={m} />
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base flex-1 min-w-0">
|
||||
{material.title}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`coursePlan.materialType.${material.material_type}`,
|
||||
material.material_type,
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
</div>
|
||||
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||
{material.share_date && (
|
||||
<CardDescription>
|
||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(material.media ?? []).map((m) => (
|
||||
<StudentMediaTile key={m.id} media={m} />
|
||||
))}
|
||||
<MaterialBookView material={material} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||
<span className="capitalize text-xs text-muted-foreground">
|
||||
{media.kind}
|
||||
</span>
|
||||
</div>
|
||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||
{media.kind === "image" && (
|
||||
<img
|
||||
src={url}
|
||||
alt={media.title}
|
||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||
)}
|
||||
{plan && <PlanReader plan={plan} mode="student" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play, Sparkles } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
|
||||
export default function StudentDashboard() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
||||
const { data: gradesData, isLoading: lg } = useGrades();
|
||||
const { data: planData, isLoading: lp } = useQuery({
|
||||
queryKey: ["student-course-plans"],
|
||||
queryFn: () => coursePlanService.studentList(),
|
||||
});
|
||||
const myCourses = enrolledData?.items ?? [];
|
||||
const myPlans = planData?.items ?? [];
|
||||
const gradeRecords = gradesData ?? [];
|
||||
if (lc || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
if (lc || lg || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const recentGrades = gradeRecords.slice(0, 3);
|
||||
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
|
||||
@@ -27,7 +34,7 @@ export default function StudentDashboard() {
|
||||
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
|
||||
|
||||
const stats = [
|
||||
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length + myPlans.length), icon: BookOpen, color: "text-primary" },
|
||||
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
||||
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||
@@ -93,6 +100,42 @@ export default function StudentDashboard() {
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("studentDash.myCoursePlans")}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/student/course-plans">
|
||||
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{myPlans.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noCoursePlans")}</p>
|
||||
) : myPlans.slice(0, 4).map((p) => (
|
||||
<Link to={`/student/course-plans/${p.id}`} key={p.id} className="block">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate" dir="auto">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t("coursePlan.weeksCount", { count: p.total_weeks })} ·{" "}
|
||||
{p.assignment?.due_date
|
||||
? t("coursePlan.student.due", { date: p.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="uppercase shrink-0">
|
||||
{p.cefr_level}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
|
||||
|
||||
@@ -1,36 +1,127 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { Classroom, ClassroomCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
||||
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||
import type {
|
||||
Classroom,
|
||||
ClassroomAssignCourseRequest,
|
||||
ClassroomBatchSummary,
|
||||
ClassroomCourse,
|
||||
ClassroomCreateRequest,
|
||||
ClassroomMember,
|
||||
ClassroomSection,
|
||||
ClassroomStudent,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
ApiSuccessResponse,
|
||||
} from "@/types";
|
||||
|
||||
export interface ClassroomListParams extends PaginationParams {
|
||||
entity_id?: number;
|
||||
branch_id?: number;
|
||||
search?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const classroomsService = {
|
||||
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
|
||||
return api.get<PaginatedResponse<Classroom>>("/groups", params as Record<string, string | number | boolean | undefined>);
|
||||
const raw = await api.get<unknown>(
|
||||
"/classrooms",
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
);
|
||||
return asPaginated<Classroom>(raw);
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<Classroom> {
|
||||
return api.get<Classroom>(`/groups/${id}`);
|
||||
const raw = await api.get<unknown>(`/classrooms/${id}`);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async create(data: ClassroomCreateRequest): Promise<Classroom> {
|
||||
return api.post<Classroom>("/groups", data);
|
||||
const raw = await api.post<unknown>("/classrooms", data);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async update(id: number, data: Partial<ClassroomCreateRequest>): Promise<Classroom> {
|
||||
const raw = await api.patch<unknown>(`/classrooms/${id}`, data);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/groups/${id}`);
|
||||
return api.delete<ApiSuccessResponse>(`/classrooms/${id}`);
|
||||
},
|
||||
|
||||
async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>("/groups/transfer", data);
|
||||
// ---- Roster ----------------------------------------------------------
|
||||
async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/students`);
|
||||
},
|
||||
|
||||
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds });
|
||||
/** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
|
||||
async setStudents(
|
||||
id: number,
|
||||
studentIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<Classroom> {
|
||||
const raw = await api.post<unknown>(`/classrooms/${id}/students`, {
|
||||
student_ids: studentIds,
|
||||
mode,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
async removeMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds });
|
||||
async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
|
||||
const raw = await api.delete<unknown>(`/classrooms/${id}/students`, {
|
||||
student_ids: studentIds,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Homeroom teachers ----------------------------------------------
|
||||
async listTeachers(id: number): Promise<{ items: ClassroomMember[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/teachers`);
|
||||
},
|
||||
|
||||
async setTeachers(
|
||||
id: number,
|
||||
teacherIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<Classroom> {
|
||||
const raw = await api.post<unknown>(`/classrooms/${id}/teachers`, {
|
||||
teacher_ids: teacherIds,
|
||||
mode,
|
||||
});
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Courses + cascade ----------------------------------------------
|
||||
async listCourses(id: number): Promise<{ items: ClassroomCourse[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/courses`);
|
||||
},
|
||||
|
||||
async listSections(
|
||||
id: number,
|
||||
params?: { course_id?: number; active?: boolean },
|
||||
): Promise<{ items: ClassroomSection[]; total: number }> {
|
||||
return api.get(
|
||||
`/classrooms/${id}/sections`,
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
);
|
||||
},
|
||||
|
||||
/** Assign a course → server creates (or reuses) the canonical batch and
|
||||
* enrolls every classroom student into it. */
|
||||
async assignCourse(
|
||||
id: number,
|
||||
payload: ClassroomAssignCourseRequest,
|
||||
): Promise<{ data: Classroom; batch: ClassroomBatchSummary }> {
|
||||
return api.post(`/classrooms/${id}/assign-course`, payload);
|
||||
},
|
||||
|
||||
async detachCourse(id: number, courseId: number): Promise<Classroom> {
|
||||
const raw = await api.delete<unknown>(`/classrooms/${id}/courses/${courseId}`);
|
||||
return asRecordData<Classroom>(raw);
|
||||
},
|
||||
|
||||
// ---- Batches --------------------------------------------------------
|
||||
async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
|
||||
return api.get(`/classrooms/${id}/batches`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
CoursePlanMedia,
|
||||
CoursePlanSource,
|
||||
CoursePlanSourceKind,
|
||||
WorkbookAttempt,
|
||||
WorkbookExtractedFrom,
|
||||
WorkbookGroundedSource,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,55 @@ export const coursePlanService = {
|
||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||
},
|
||||
|
||||
/**
|
||||
* RAG-grounded richer-schema generator. Requires at least one
|
||||
* indexed source on the plan; backend returns 409 otherwise so the
|
||||
* UI can prompt for upload.
|
||||
*/
|
||||
async generateWeekMaterialsV2(
|
||||
planId: number,
|
||||
weekNumber: number,
|
||||
): Promise<{
|
||||
items: CoursePlanMaterial[];
|
||||
count: number;
|
||||
rag: { enabled: boolean; sources_used: number[] };
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/v2`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Mine indexed PDFs for original printed exercises. */
|
||||
async extractWorkbooks(
|
||||
planId: number,
|
||||
payload?: { max_batches?: number },
|
||||
): Promise<{
|
||||
data: {
|
||||
materials_created: number;
|
||||
exercises_total: number;
|
||||
batches_run: number;
|
||||
skipped: number;
|
||||
reason?: string;
|
||||
};
|
||||
plan_id: number;
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/extract-workbooks`,
|
||||
payload ?? {},
|
||||
);
|
||||
},
|
||||
|
||||
/** Source citations for one material — shown as the
|
||||
* "Grounded on N references" badge popover. */
|
||||
async materialGrounding(materialId: number): Promise<{
|
||||
material_id: number;
|
||||
grounded_on: WorkbookGroundedSource[];
|
||||
extracted_from: WorkbookExtractedFrom | null;
|
||||
sources: CoursePlanSource[];
|
||||
}> {
|
||||
return api.get(`/ai/course-plan/material/${materialId}/grounding`);
|
||||
},
|
||||
|
||||
async updateMaterial(
|
||||
materialId: number,
|
||||
payload: {
|
||||
@@ -193,6 +245,29 @@ export const coursePlanService = {
|
||||
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach an admin-supplied media file (audio / image / video) to a
|
||||
* course-plan material. The backend tags the resulting row with
|
||||
* `provider='manual'` so the drawer can distinguish hand-curated
|
||||
* clips from AI-generated ones — and admins can swap a robotic
|
||||
* Polly recording for a real human voiceover whenever they want.
|
||||
*/
|
||||
async uploadMedia(
|
||||
materialId: number,
|
||||
kind: "audio" | "image" | "video",
|
||||
file: File,
|
||||
title?: string,
|
||||
): Promise<{ data: CoursePlanMedia }> {
|
||||
const fd = new FormData();
|
||||
fd.append("kind", kind);
|
||||
fd.append("file", file);
|
||||
if (title) fd.append("title", title);
|
||||
return api.upload(
|
||||
`/ai/course-plan/material/${materialId}/media/upload`,
|
||||
fd,
|
||||
);
|
||||
},
|
||||
|
||||
async generateWeekMedia(
|
||||
planId: number,
|
||||
weekNumber: number,
|
||||
@@ -257,4 +332,42 @@ export const coursePlanService = {
|
||||
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||
return api.get(`/student/course-plans/${planId}`);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase F — Interactive workbook attempts
|
||||
// ---------------------------------------------------------------------
|
||||
/** Save (or finalize) the current student's answers and return the
|
||||
* freshly-graded score. Used by InteractiveWorkbook.tsx on every
|
||||
* "Check answers" click (debounced) and on "Submit final". */
|
||||
async saveWorkbookAttempt(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
payload: { answers: Record<string, unknown>; finalize?: boolean },
|
||||
): Promise<{ data: WorkbookAttempt }> {
|
||||
return api.post(
|
||||
`/student/course-plans/${planId}/materials/${materialId}/attempts`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
|
||||
/** Latest attempt the current user owns on this material — null when
|
||||
* the student hasn't started yet. */
|
||||
async myWorkbookAttempt(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
): Promise<{ data: WorkbookAttempt | null }> {
|
||||
return api.get(
|
||||
`/student/course-plans/${planId}/materials/${materialId}/attempts/me`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Teacher-side roster of latest attempts per student. */
|
||||
async listMaterialAttempts(
|
||||
planId: number,
|
||||
materialId: number,
|
||||
): Promise<{ items: WorkbookAttempt[]; count: number }> {
|
||||
return api.get(
|
||||
`/ai/course-plan/${planId}/materials/${materialId}/attempts`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
MyEnrolledCourse,
|
||||
EnrollStudentRequest,
|
||||
BulkEnrollRequest,
|
||||
LmsBranch,
|
||||
CourseSection,
|
||||
} from "@/types";
|
||||
|
||||
function mapCourseRaw(r: Record<string, unknown>): Course {
|
||||
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record<string, unknown>): Course {
|
||||
chapter_count: Number(r.chapter_count ?? 0),
|
||||
resource_count: Number(r.resource_count ?? 0),
|
||||
objective_count: Number(r.objective_count ?? 0),
|
||||
section_count: Number(r.section_count ?? 0),
|
||||
sections: Array.isArray(r.sections)
|
||||
? (r.sections as Record<string, unknown>[]).map(mapSectionRaw)
|
||||
: [],
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: (r.branch_name as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,13 +71,17 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
||||
active: "active",
|
||||
completed: "completed",
|
||||
};
|
||||
const teacherIds = (r.teacher_ids as number[]) || [];
|
||||
const teacherNames = (r.teacher_names as string[]) || [];
|
||||
return {
|
||||
id: r.id as number,
|
||||
name: String(r.name || ""),
|
||||
course_id: Number(r.course_id || 0),
|
||||
course_name: String(r.course_name || ""),
|
||||
teacher_id: 0,
|
||||
teacher_name: "",
|
||||
teacher_id: teacherIds[0] || 0,
|
||||
teacher_name: teacherNames[0] || "",
|
||||
teacher_ids: teacherIds,
|
||||
teacher_names: teacherNames,
|
||||
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
|
||||
student_count: Number(r.student_count ?? 0),
|
||||
start_date: String(r.start_date || ""),
|
||||
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
||||
schedule: "",
|
||||
status: statusMap[st] || "upcoming",
|
||||
capacity: Number(r.max_students ?? 0),
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: (r.branch_name as string) || "",
|
||||
classroom_id: (r.classroom_id as number | null) ?? null,
|
||||
classroom_name: (r.classroom_name as string) || "",
|
||||
course_section_id: (r.course_section_id as number | null) ?? null,
|
||||
course_section_name: (r.course_section_name as string) || "",
|
||||
course_section_code: (r.course_section_code as string) || "",
|
||||
term_key: (r.term_key as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
function mapSectionRaw(r: Record<string, unknown>): CourseSection {
|
||||
return {
|
||||
id: Number(r.id || 0),
|
||||
name: String(r.name || ""),
|
||||
code: String(r.code || ""),
|
||||
sequence: Number(r.sequence || 0),
|
||||
active: Boolean(r.active ?? true),
|
||||
notes: String(r.notes || ""),
|
||||
course_id: Number(r.course_id || 0),
|
||||
course_name: String(r.course_name || ""),
|
||||
entity_id: (r.entity_id as number | null) ?? null,
|
||||
entity_name: String(r.entity_name || ""),
|
||||
branch_id: (r.branch_id as number | null) ?? null,
|
||||
branch_name: String(r.branch_name || ""),
|
||||
batch_count: Number(r.batch_count || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +174,25 @@ export const lmsService = {
|
||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||
},
|
||||
|
||||
async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise<PaginatedResponse<LmsBranch>> {
|
||||
const raw = await api.get<unknown>("/branches", params as Record<string, string | number | boolean | undefined>);
|
||||
return asPaginated<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async createBranch(data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||
const raw = await api.post<unknown>("/branches", data);
|
||||
return asRecordData<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async updateBranch(id: number, data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||
const raw = await api.patch<unknown>(`/branches/${id}`, data);
|
||||
return asRecordData<LmsBranch>(raw);
|
||||
},
|
||||
|
||||
async deleteBranch(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/branches/${id}`);
|
||||
},
|
||||
|
||||
async getCourse(id: number): Promise<Course> {
|
||||
const raw = await api.get<unknown>(`/courses/${id}`);
|
||||
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
|
||||
@@ -179,6 +236,51 @@ export const lmsService = {
|
||||
return api.delete<ApiSuccessResponse>(`/courses/${id}`);
|
||||
},
|
||||
|
||||
async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
|
||||
const raw = await api.get<unknown>(`/courses/${courseId}/sections`);
|
||||
const p = asPaginated<Record<string, unknown>>(raw);
|
||||
return { ...p, items: p.items.map(mapSectionRaw) };
|
||||
},
|
||||
|
||||
async createCourseSection(
|
||||
courseId: number,
|
||||
data: { name: string; code: string; sequence?: number; active?: boolean; notes?: string; branch_id?: number | null },
|
||||
): Promise<CourseSection> {
|
||||
const raw = await api.post<unknown>(`/courses/${courseId}/sections`, data);
|
||||
return asRecordData<CourseSection>(raw);
|
||||
},
|
||||
|
||||
async updateCourseSection(
|
||||
courseId: number,
|
||||
sectionId: number,
|
||||
data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
|
||||
): Promise<CourseSection> {
|
||||
const raw = await api.patch<unknown>(`/courses/${courseId}/sections/${sectionId}`, data);
|
||||
return asRecordData<CourseSection>(raw);
|
||||
},
|
||||
|
||||
async deleteCourseSection(courseId: number, sectionId: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/courses/${courseId}/sections/${sectionId}`);
|
||||
},
|
||||
|
||||
async generateDefaultCourseSections(
|
||||
courseId: number,
|
||||
data?: { codes?: string[]; branch_id?: number | null },
|
||||
): Promise<{ items: CourseSection[]; created_count: number; created_ids: number[]; total: number }> {
|
||||
const raw = await api.post<{
|
||||
items: Record<string, unknown>[];
|
||||
created_count: number;
|
||||
created_ids: number[];
|
||||
total: number;
|
||||
}>(`/courses/${courseId}/sections/generate-defaults`, data ?? {});
|
||||
return {
|
||||
created_count: raw.created_count ?? 0,
|
||||
created_ids: raw.created_ids ?? [],
|
||||
total: raw.total ?? 0,
|
||||
items: (raw.items ?? []).map(mapSectionRaw),
|
||||
};
|
||||
},
|
||||
|
||||
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
|
||||
return api.post("/courses/ai-generate", data);
|
||||
},
|
||||
@@ -255,6 +357,25 @@ export const lmsService = {
|
||||
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
|
||||
},
|
||||
|
||||
async getBatchTeachers(batchId: number): Promise<{ items: { id: number; name: string; email: string }[]; total: number }> {
|
||||
return api.get(`/batches/${batchId}/teachers`);
|
||||
},
|
||||
|
||||
async setBatchTeachers(
|
||||
batchId: number,
|
||||
teacherIds: number[],
|
||||
mode: "set" | "add" = "set",
|
||||
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||
return api.post(`/batches/${batchId}/teachers`, { teacher_ids: teacherIds, mode });
|
||||
},
|
||||
|
||||
async removeBatchTeachers(
|
||||
batchId: number,
|
||||
teacherIds: number[],
|
||||
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||
return api.post(`/batches/${batchId}/teachers/remove`, { teacher_ids: teacherIds });
|
||||
},
|
||||
|
||||
async deleteStudent(id: number): Promise<ApiSuccessResponse> {
|
||||
return api.delete<ApiSuccessResponse>(`/students/${id}`);
|
||||
},
|
||||
|
||||
@@ -1,24 +1,98 @@
|
||||
/** Classroom = homeroom (physical room repurposed as a class group).
|
||||
*
|
||||
* Owns:
|
||||
* - a roster of students (M2M)
|
||||
* - a set of homeroom teachers (M2M)
|
||||
* - a list of courses taught here (M2M) — assigning a course cascades
|
||||
* into a batch + auto-enrollment of every roster student.
|
||||
* - the resulting batches (one per (classroom × course))
|
||||
*/
|
||||
export interface Classroom {
|
||||
id: number;
|
||||
name: string;
|
||||
admin_id: number;
|
||||
admin_name: string;
|
||||
entity_id: number;
|
||||
code: string;
|
||||
capacity: number;
|
||||
active: boolean;
|
||||
notes?: string;
|
||||
entity_id: number | null;
|
||||
entity_name: string;
|
||||
participant_count: number;
|
||||
participants: ClassroomMember[];
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
student_count: number;
|
||||
teacher_count: number;
|
||||
course_count: number;
|
||||
batch_count: number;
|
||||
student_ids: number[];
|
||||
teacher_ids: number[];
|
||||
course_ids: number[];
|
||||
batch_ids?: number[];
|
||||
/** Only populated by GET /api/classrooms/<id> (full payload). */
|
||||
students?: ClassroomStudent[];
|
||||
teachers?: ClassroomMember[];
|
||||
courses?: ClassroomCourse[];
|
||||
sections?: ClassroomSection[];
|
||||
batches?: ClassroomBatchSummary[];
|
||||
}
|
||||
|
||||
export interface ClassroomMember {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
user_type: string;
|
||||
}
|
||||
|
||||
export interface ClassroomStudent extends ClassroomMember {
|
||||
gr_no?: string;
|
||||
}
|
||||
|
||||
export interface ClassroomCourse {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ClassroomSection {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
sequence: number;
|
||||
course_id: number | null;
|
||||
course_name: string;
|
||||
}
|
||||
|
||||
export interface ClassroomBatchSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
course_id: number | null;
|
||||
course_name: string;
|
||||
course_section_id?: number | null;
|
||||
course_section_name?: string;
|
||||
course_section_code?: string;
|
||||
term_key?: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
student_count: number;
|
||||
teacher_ids: number[];
|
||||
}
|
||||
|
||||
export interface ClassroomCreateRequest {
|
||||
name: string;
|
||||
entity_id: number;
|
||||
admin_id: number;
|
||||
participant_ids?: number[];
|
||||
code?: string;
|
||||
capacity?: number;
|
||||
notes?: string;
|
||||
entity_id?: number;
|
||||
branch_id?: number | null;
|
||||
student_ids?: number[];
|
||||
teacher_ids?: number[];
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassroomAssignCourseRequest {
|
||||
course_id: number;
|
||||
section_id?: number;
|
||||
teacher_ids?: number[];
|
||||
term_key?: string;
|
||||
term_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,129 @@ export type CoursePlanMaterialType =
|
||||
| "grammar_lesson"
|
||||
| "vocabulary_list"
|
||||
| "practice"
|
||||
| "interactive_workbook"
|
||||
| "other";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive workbooks — schema mirrors backend `_WEEK_JSON_HINT_V2` and
|
||||
// `ExerciseExtractor` output. The renderer in `InteractiveWorkbook.tsx`
|
||||
// is a discriminated-union switch on `type`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkbookExerciseType =
|
||||
| "gap_fill"
|
||||
| "multiple_choice"
|
||||
| "match_pairs"
|
||||
| "reorder_words"
|
||||
| "transformation"
|
||||
| "short_answer";
|
||||
|
||||
export interface WorkbookExerciseBase {
|
||||
id: string;
|
||||
type: WorkbookExerciseType;
|
||||
hint?: string;
|
||||
rationale?: string;
|
||||
}
|
||||
export interface GapFillExercise extends WorkbookExerciseBase {
|
||||
type: "gap_fill";
|
||||
stem: string;
|
||||
answer: string;
|
||||
alt?: string[];
|
||||
}
|
||||
export interface MultipleChoiceExercise extends WorkbookExerciseBase {
|
||||
type: "multiple_choice";
|
||||
stem: string;
|
||||
options: string[];
|
||||
answer: string;
|
||||
}
|
||||
export interface MatchPairsExercise extends WorkbookExerciseBase {
|
||||
type: "match_pairs";
|
||||
left: string[];
|
||||
right: string[];
|
||||
answer: number[][];
|
||||
}
|
||||
export interface ReorderWordsExercise extends WorkbookExerciseBase {
|
||||
type: "reorder_words";
|
||||
tokens: string[];
|
||||
answer: string;
|
||||
}
|
||||
export interface TransformationExercise extends WorkbookExerciseBase {
|
||||
type: "transformation";
|
||||
from: string;
|
||||
instruction: string;
|
||||
answer: string;
|
||||
alt?: string[];
|
||||
}
|
||||
export interface ShortAnswerExercise extends WorkbookExerciseBase {
|
||||
type: "short_answer";
|
||||
stem: string;
|
||||
answer: string;
|
||||
accepted?: string[];
|
||||
}
|
||||
export type WorkbookExercise =
|
||||
| GapFillExercise
|
||||
| MultipleChoiceExercise
|
||||
| MatchPairsExercise
|
||||
| ReorderWordsExercise
|
||||
| TransformationExercise
|
||||
| ShortAnswerExercise;
|
||||
|
||||
export interface WorkbookLessonPlan {
|
||||
warmup?: string;
|
||||
presentation?: string;
|
||||
controlled_practice?: string;
|
||||
freer_practice?: string;
|
||||
exit_ticket?: string;
|
||||
}
|
||||
|
||||
export interface WorkbookBody {
|
||||
lesson_plan?: WorkbookLessonPlan;
|
||||
exercises: WorkbookExercise[];
|
||||
}
|
||||
|
||||
export interface WorkbookGroundedSource {
|
||||
source_id: number;
|
||||
title: string;
|
||||
chunks_used: number;
|
||||
}
|
||||
|
||||
export interface WorkbookExtractedFrom {
|
||||
source_id: number;
|
||||
source_title?: string;
|
||||
page_hint?: string;
|
||||
topic_keywords?: string[];
|
||||
chunk_indices?: number[];
|
||||
}
|
||||
|
||||
export interface WorkbookAttemptScoreItem {
|
||||
id: string;
|
||||
correct: boolean;
|
||||
expected: unknown;
|
||||
given: unknown;
|
||||
}
|
||||
export interface WorkbookAttemptScore {
|
||||
items: WorkbookAttemptScoreItem[];
|
||||
correct: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}
|
||||
export interface WorkbookAttempt {
|
||||
id: number;
|
||||
material_id: number;
|
||||
plan_id: number | null;
|
||||
student_id: number;
|
||||
student_name: string;
|
||||
attempt_number: number;
|
||||
is_final: boolean;
|
||||
submitted_at: string | null;
|
||||
last_updated_at: string | null;
|
||||
correct_count: number;
|
||||
total_count: number;
|
||||
percent: number;
|
||||
answers: Record<string, unknown>;
|
||||
score: WorkbookAttemptScore | Record<string, never>;
|
||||
}
|
||||
|
||||
export interface CoursePlanOutcome {
|
||||
code: string;
|
||||
description: string;
|
||||
@@ -83,6 +204,10 @@ export interface CoursePlanMaterial {
|
||||
/** Loose shape: depends on material_type. */
|
||||
body: Record<string, unknown>;
|
||||
body_text: string;
|
||||
/** Citations for materials produced by the v2/RAG generator. */
|
||||
grounded_on?: WorkbookGroundedSource[];
|
||||
/** Provenance for materials mined out of indexed PDFs. */
|
||||
extracted_from?: WorkbookExtractedFrom | null;
|
||||
media?: CoursePlanMedia[];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ export interface Course {
|
||||
id: number;
|
||||
title: string;
|
||||
code: string;
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
subject_id?: number;
|
||||
subject_name?: string;
|
||||
encoach_subject_id?: number | null;
|
||||
@@ -28,6 +30,24 @@ export interface Course {
|
||||
chapter_count?: number;
|
||||
resource_count?: number;
|
||||
objective_count?: number;
|
||||
section_count?: number;
|
||||
sections?: CourseSection[];
|
||||
}
|
||||
|
||||
export interface CourseSection {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
sequence: number;
|
||||
active: boolean;
|
||||
notes?: string;
|
||||
course_id: number;
|
||||
course_name: string;
|
||||
entity_id?: number | null;
|
||||
entity_name?: string;
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
batch_count?: number;
|
||||
}
|
||||
|
||||
export interface CourseModule {
|
||||
@@ -48,10 +68,22 @@ export interface CourseLesson {
|
||||
export interface Batch {
|
||||
id: number;
|
||||
name: string;
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
classroom_id?: number | null;
|
||||
classroom_name?: string;
|
||||
course_section_id?: number | null;
|
||||
course_section_name?: string;
|
||||
course_section_code?: string;
|
||||
term_key?: string;
|
||||
course_id: number;
|
||||
course_name: string;
|
||||
/** @deprecated single teacher kept for back-compat. Use `teacher_ids`. */
|
||||
teacher_id: number;
|
||||
/** @deprecated single teacher kept for back-compat. */
|
||||
teacher_name: string;
|
||||
teacher_ids?: number[];
|
||||
teacher_names?: string[];
|
||||
student_ids: number[];
|
||||
student_count: number;
|
||||
start_date: string;
|
||||
@@ -132,6 +164,8 @@ export interface LmsStudentRecord {
|
||||
batch_name: string;
|
||||
partner_id: number;
|
||||
user_id: number | null;
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
}
|
||||
|
||||
/** OpenEduCat `op.faculty` row from `/api/teachers` */
|
||||
@@ -149,8 +183,25 @@ export interface LmsTeacherRecord {
|
||||
department_name: string;
|
||||
specialization: string;
|
||||
subject_names: string[];
|
||||
branch_id?: number | null;
|
||||
branch_name?: string;
|
||||
}
|
||||
|
||||
export interface LmsBranch {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
active: boolean;
|
||||
notes: string;
|
||||
entity_id: number | null;
|
||||
entity_name: string;
|
||||
course_count: number;
|
||||
batch_count: number;
|
||||
student_count: number;
|
||||
teacher_count: number;
|
||||
}
|
||||
|
||||
|
||||
/** Enrolled course returned by GET /api/student/my-courses with progress data. */
|
||||
export interface MyEnrolledCourse {
|
||||
id: number;
|
||||
@@ -194,6 +245,7 @@ export interface LmsStudentCreateRequest {
|
||||
password?: string;
|
||||
/** Default true: create portal user linked to `op.student`. */
|
||||
create_portal_user?: boolean;
|
||||
branch_id?: number;
|
||||
}
|
||||
|
||||
export interface LmsTeacherCreateRequest {
|
||||
@@ -205,4 +257,5 @@ export interface LmsTeacherCreateRequest {
|
||||
birth_date?: string;
|
||||
department_id?: number;
|
||||
specialization?: string;
|
||||
branch_id?: number;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@ limit_time_real = 900
|
||||
; "submit exam with all generated questions" (≈2-5 MB JSON) no longer fail
|
||||
; with HTTP 413. Default is 4 MB; 128 MB covers videos + bulk question JSON.
|
||||
limit_request = 134217728
|
||||
limit_memory_hard = 2684354560
|
||||
limit_memory_soft = 2147483648
|
||||
; OCR (tesseract + 200dpi rasterization) + sentence-transformers (~80 MB
|
||||
; model + torch tensors) + Odoo baseline routinely cross 2 GB even with
|
||||
; per-page streaming. Bump soft to 4 GB / hard to 5 GB so a 100-page
|
||||
; scanned workbook doesn't trigger a worker reload mid-index.
|
||||
limit_memory_hard = 5368709120
|
||||
limit_memory_soft = 4294967296
|
||||
db_maxconn = 32
|
||||
data_dir = /tmp/odoo-data
|
||||
|
||||
|
||||
418
scripts/render_md_to_pdf.py
Normal file
418
scripts/render_md_to_pdf.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""Render a Markdown file to a polished PDF using reportlab + markdown-it-py.
|
||||
|
||||
Supports the subset of Markdown used in our project docs:
|
||||
- ATX headings (#, ##, ###, ####)
|
||||
- Paragraphs with **bold**, *italic*, and `inline code`
|
||||
- Bullet and ordered lists (single level)
|
||||
- Fenced code blocks (```lang ... ```)
|
||||
- Tables (GFM pipe tables with header separator)
|
||||
- Horizontal rules (---)
|
||||
- Blockquotes (>)
|
||||
|
||||
Usage:
|
||||
python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md
|
||||
python scripts/render_md_to_pdf.py docs/ASSIGNMENT_WORKFLOW.md docs/ASSIGNMENT_WORKFLOW.pdf
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from html import escape as h
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
HRFlowable,
|
||||
KeepTogether,
|
||||
ListFlowable,
|
||||
ListItem,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
Preformatted,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
|
||||
# ── Styles ─────────────────────────────────────────────────────────────
|
||||
|
||||
INK = colors.HexColor("#0f172a")
|
||||
MUTED = colors.HexColor("#64748b")
|
||||
ACCENT = colors.HexColor("#2563eb")
|
||||
BORDER = colors.HexColor("#e2e8f0")
|
||||
SUBTLE = colors.HexColor("#f8fafc")
|
||||
CODE_BG = colors.HexColor("#f1f5f9")
|
||||
CODE_INK = colors.HexColor("#0f172a")
|
||||
|
||||
|
||||
def _styles():
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"h1": ParagraphStyle(
|
||||
"h1", parent=base["Title"], fontName="Helvetica-Bold",
|
||||
fontSize=22, leading=28, textColor=INK, spaceAfter=14, alignment=TA_LEFT,
|
||||
),
|
||||
"h2": ParagraphStyle(
|
||||
"h2", parent=base["Heading2"], fontName="Helvetica-Bold",
|
||||
fontSize=15, leading=20, textColor=INK, spaceBefore=18, spaceAfter=8,
|
||||
),
|
||||
"h3": ParagraphStyle(
|
||||
"h3", parent=base["Heading3"], fontName="Helvetica-Bold",
|
||||
fontSize=12, leading=16, textColor=ACCENT, spaceBefore=12, spaceAfter=4,
|
||||
),
|
||||
"h4": ParagraphStyle(
|
||||
"h4", parent=base["Heading4"], fontName="Helvetica-Bold",
|
||||
fontSize=11, leading=14, textColor=INK, spaceBefore=8, spaceAfter=2,
|
||||
),
|
||||
"body": ParagraphStyle(
|
||||
"body", parent=base["BodyText"], fontName="Helvetica",
|
||||
fontSize=9.5, leading=14, textColor=INK, spaceAfter=6,
|
||||
),
|
||||
"muted": ParagraphStyle(
|
||||
"muted", parent=base["BodyText"], fontName="Helvetica-Oblique",
|
||||
fontSize=8.5, leading=12, textColor=MUTED, spaceAfter=6,
|
||||
),
|
||||
"code": ParagraphStyle(
|
||||
"code", parent=base["Code"], fontName="Courier",
|
||||
fontSize=8, leading=11, textColor=CODE_INK,
|
||||
backColor=CODE_BG, borderPadding=8, leftIndent=0, rightIndent=0,
|
||||
spaceBefore=4, spaceAfter=10,
|
||||
),
|
||||
"tableCell": ParagraphStyle(
|
||||
"tableCell", parent=base["BodyText"], fontName="Helvetica",
|
||||
fontSize=8.5, leading=11, textColor=INK,
|
||||
),
|
||||
"tableHead": ParagraphStyle(
|
||||
"tableHead", parent=base["BodyText"], fontName="Helvetica-Bold",
|
||||
fontSize=8.5, leading=11, textColor=colors.white,
|
||||
),
|
||||
"quote": ParagraphStyle(
|
||||
"quote", parent=base["BodyText"], fontName="Helvetica-Oblique",
|
||||
fontSize=9.5, leading=14, textColor=MUTED,
|
||||
leftIndent=12, borderColor=BORDER, borderWidth=0,
|
||||
backColor=SUBTLE, borderPadding=8, spaceAfter=8,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Inline rendering: turn markdown-it inline tokens into reportlab markup ─
|
||||
|
||||
_RL_ESCAPE = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
return "".join(_RL_ESCAPE.get(c, c) for c in text)
|
||||
|
||||
|
||||
def _render_inline(token) -> str:
|
||||
"""Convert an `inline` markdown-it token's children into rl markup."""
|
||||
if not getattr(token, "children", None):
|
||||
return _esc(token.content or "")
|
||||
out: list[str] = []
|
||||
for child in token.children:
|
||||
t = child.type
|
||||
if t == "text":
|
||||
out.append(_esc(child.content))
|
||||
elif t == "softbreak" or t == "hardbreak":
|
||||
out.append("<br/>")
|
||||
elif t == "code_inline":
|
||||
out.append(
|
||||
f'<font name="Courier" size="8.5" backColor="#f1f5f9">'
|
||||
f' {_esc(child.content)} </font>'
|
||||
)
|
||||
elif t == "strong_open":
|
||||
out.append("<b>")
|
||||
elif t == "strong_close":
|
||||
out.append("</b>")
|
||||
elif t == "em_open":
|
||||
out.append("<i>")
|
||||
elif t == "em_close":
|
||||
out.append("</i>")
|
||||
elif t == "s_open":
|
||||
out.append("<strike>")
|
||||
elif t == "s_close":
|
||||
out.append("</strike>")
|
||||
elif t == "link_open":
|
||||
href = ""
|
||||
for k, v in child.attrs.items() if isinstance(child.attrs, dict) else child.attrs:
|
||||
if k == "href":
|
||||
href = v
|
||||
out.append(f'<a href="{_esc(href)}" color="#2563eb">')
|
||||
elif t == "link_close":
|
||||
out.append("</a>")
|
||||
elif t == "image":
|
||||
alt = _esc(child.content or "image")
|
||||
out.append(f"[{alt}]")
|
||||
else:
|
||||
out.append(_esc(child.content or ""))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ── Block rendering: walk markdown-it tokens and emit reportlab flowables ──
|
||||
|
||||
|
||||
def md_to_flowables(md_text: str, styles):
|
||||
md = MarkdownIt("commonmark", {"breaks": False, "html": False}).enable("table").enable("strikethrough")
|
||||
tokens = md.parse(md_text)
|
||||
flow: list = []
|
||||
|
||||
i = 0
|
||||
n = len(tokens)
|
||||
while i < n:
|
||||
t = tokens[i]
|
||||
ttype = t.type
|
||||
|
||||
if ttype == "heading_open":
|
||||
level = int(t.tag[1])
|
||||
inline = tokens[i + 1]
|
||||
text = _render_inline(inline)
|
||||
style_key = {1: "h1", 2: "h2", 3: "h3"}.get(level, "h4")
|
||||
flow.append(Paragraph(text, styles[style_key]))
|
||||
if level == 1:
|
||||
flow.append(HRFlowable(width="100%", thickness=0.6, color=BORDER, spaceAfter=10))
|
||||
i += 3
|
||||
continue
|
||||
|
||||
if ttype == "paragraph_open":
|
||||
inline = tokens[i + 1]
|
||||
text = _render_inline(inline)
|
||||
flow.append(Paragraph(text, styles["body"]))
|
||||
i += 3
|
||||
continue
|
||||
|
||||
if ttype == "bullet_list_open" or ttype == "ordered_list_open":
|
||||
ordered = ttype == "ordered_list_open"
|
||||
items, consumed = _parse_list_items(tokens, i, styles)
|
||||
flow.append(
|
||||
ListFlowable(
|
||||
items,
|
||||
bulletType="1" if ordered else "bullet",
|
||||
bulletFontName="Helvetica-Bold",
|
||||
bulletFontSize=9,
|
||||
leftIndent=18,
|
||||
bulletDedent=10,
|
||||
spaceAfter=8,
|
||||
)
|
||||
)
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
if ttype == "fence" or ttype == "code_block":
|
||||
content = t.content.rstrip("\n")
|
||||
flow.append(Preformatted(content, styles["code"]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ttype == "hr":
|
||||
flow.append(HRFlowable(width="100%", thickness=0.4, color=BORDER, spaceBefore=4, spaceAfter=10))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ttype == "blockquote_open":
|
||||
inner_md = _collect_blockquote(tokens, i)
|
||||
inner_flow = md_to_flowables(inner_md, styles)
|
||||
for fl in inner_flow:
|
||||
if isinstance(fl, Paragraph):
|
||||
fl.style = styles["quote"]
|
||||
flow.append(KeepTogether(inner_flow))
|
||||
i = _skip_to(tokens, i, "blockquote_close") + 1
|
||||
continue
|
||||
|
||||
if ttype == "table_open":
|
||||
tbl, consumed = _parse_table(tokens, i, styles)
|
||||
flow.append(tbl)
|
||||
flow.append(Spacer(1, 8))
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
return flow
|
||||
|
||||
|
||||
def _parse_list_items(tokens, start, styles):
|
||||
"""Parse bullet_list_open/ordered_list_open block, return (items, consumed)."""
|
||||
items: list = []
|
||||
i = start + 1
|
||||
depth = 1
|
||||
while i < len(tokens) and depth > 0:
|
||||
t = tokens[i]
|
||||
if t.type in ("bullet_list_open", "ordered_list_open"):
|
||||
depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type in ("bullet_list_close", "ordered_list_close"):
|
||||
depth -= 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type == "list_item_open" and depth == 1:
|
||||
j = i + 1
|
||||
chunks: list = []
|
||||
while j < len(tokens) and tokens[j].type != "list_item_close":
|
||||
if tokens[j].type == "paragraph_open":
|
||||
inline = tokens[j + 1]
|
||||
chunks.append(Paragraph(_render_inline(inline), styles["body"]))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
items.append(ListItem(chunks or [Paragraph("", styles["body"])], leftIndent=4))
|
||||
i = j + 1
|
||||
continue
|
||||
i += 1
|
||||
return items, i - start
|
||||
|
||||
|
||||
def _skip_to(tokens, start, target_type):
|
||||
i = start
|
||||
while i < len(tokens) and tokens[i].type != target_type:
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _collect_blockquote(tokens, start) -> str:
|
||||
end = _skip_to(tokens, start, "blockquote_close")
|
||||
parts = []
|
||||
for t in tokens[start + 1 : end]:
|
||||
if t.type == "inline":
|
||||
parts.append(t.content)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _parse_table(tokens, start, styles):
|
||||
headers: list[str] = []
|
||||
rows: list[list[str]] = []
|
||||
i = start + 1
|
||||
while i < len(tokens) and tokens[i].type != "table_close":
|
||||
t = tokens[i]
|
||||
if t.type == "thead_open":
|
||||
i += 1
|
||||
while tokens[i].type != "thead_close":
|
||||
if tokens[i].type == "tr_open":
|
||||
row: list[str] = []
|
||||
j = i + 1
|
||||
while tokens[j].type != "tr_close":
|
||||
if tokens[j].type in ("th_open", "td_open"):
|
||||
inline = tokens[j + 1]
|
||||
row.append(_render_inline(inline))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
headers = row
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
if t.type == "tbody_open":
|
||||
i += 1
|
||||
while tokens[i].type != "tbody_close":
|
||||
if tokens[i].type == "tr_open":
|
||||
row = []
|
||||
j = i + 1
|
||||
while tokens[j].type != "tr_close":
|
||||
if tokens[j].type in ("th_open", "td_open"):
|
||||
inline = tokens[j + 1]
|
||||
row.append(_render_inline(inline))
|
||||
j += 3
|
||||
else:
|
||||
j += 1
|
||||
rows.append(row)
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
consumed = i - start + 1
|
||||
|
||||
head_p = [Paragraph(h, styles["tableHead"]) for h in headers]
|
||||
body_p = [[Paragraph(c, styles["tableCell"]) for c in row] for row in rows]
|
||||
data = [head_p] + body_p
|
||||
|
||||
page_w = A4[0] - 2 * 1.8 * cm
|
||||
n_cols = max(1, len(headers) or (len(rows[0]) if rows else 1))
|
||||
col_w = [page_w / n_cols] * n_cols
|
||||
|
||||
tbl = Table(data, colWidths=col_w, repeatRows=1)
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), ACCENT),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, SUBTLE]),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, BORDER),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
return tbl, consumed
|
||||
|
||||
|
||||
# ── Page decoration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _on_page(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.setFillColor(MUTED)
|
||||
page_num = canvas.getPageNumber()
|
||||
canvas.drawRightString(A4[0] - 1.8 * cm, 1.0 * cm, f"Page {page_num}")
|
||||
canvas.drawString(1.8 * cm, 1.0 * cm, "EnCoach LMS — Assignment Workflow")
|
||||
canvas.setStrokeColor(BORDER)
|
||||
canvas.setLineWidth(0.4)
|
||||
canvas.line(1.8 * cm, 1.4 * cm, A4[0] - 1.8 * cm, 1.4 * cm)
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def render(md_path: Path, pdf_path: Path) -> None:
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
styles = _styles()
|
||||
flow = md_to_flowables(md_text, styles)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(pdf_path),
|
||||
pagesize=A4,
|
||||
leftMargin=1.8 * cm,
|
||||
rightMargin=1.8 * cm,
|
||||
topMargin=2.0 * cm,
|
||||
bottomMargin=2.0 * cm,
|
||||
title="EnCoach LMS — Assignment Workflow",
|
||||
author="EnCoach Platform",
|
||||
)
|
||||
doc.build(flow, onFirstPage=_on_page, onLaterPages=_on_page)
|
||||
print(f"wrote {pdf_path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
src = Path(sys.argv[1]).resolve()
|
||||
dst = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else src.with_suffix(".pdf")
|
||||
if not src.exists():
|
||||
print(f"not found: {src}", file=sys.stderr)
|
||||
return 1
|
||||
render(src, dst)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
254
seed_dynamic_sections_demo.py
Normal file
254
seed_dynamic_sections_demo.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
seed_dynamic_sections_demo.py — Idempotent demo data for the dynamic
|
||||
Course → Sections → Classroom → Batch structure.
|
||||
|
||||
Reproduces the user diagrams literally:
|
||||
|
||||
Courses & Sections Classrooms
|
||||
────────────────── ───────────
|
||||
Math 101 ─┬─ Section A Class A1010 hosts:
|
||||
├─ Section B • Math 101 — Section A
|
||||
└─ Section C • Physics 102 — Section B
|
||||
Physics 102 ─┬─ Section A
|
||||
├─ Section B Class A1030 hosts:
|
||||
└─ Section C • Math 101 — Section B
|
||||
• Physics 102 — Section C
|
||||
|
||||
Plus the entity-aware homeroom layer:
|
||||
Entity (DEMO_ACADEMY)
|
||||
└─ Classroom (A1010 / A1030)
|
||||
├─ Students (homeroom roster) ← Sarah / Omar / Layla / TestUser
|
||||
├─ Teachers (homeroom M2M) ← Dr. Khalid + Ms. Fatima
|
||||
├─ Courses (M2M) ← Math 101 + Physics 102
|
||||
└─ Batches (auto, one per ← cascade from assign_course()
|
||||
classroom × course × section × term)
|
||||
|
||||
Run inside Odoo shell:
|
||||
|
||||
cd /Users/yamenahmad/projects2026/odoo/odoo19
|
||||
.conda-envs/odoo19/bin/python odoo/odoo-bin shell -c odoo.conf -d encoach_v2 \
|
||||
< seed_dynamic_sections_demo.py
|
||||
|
||||
It expects `seed_demo.py` (and ideally `seed_full_demo.py`) to have run first
|
||||
so the demo entity, students, and teachers exist. It is safe to re-run any
|
||||
number of times — every step looks up before creating.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print(" EnCoach — Dynamic course-section demo seed (idempotent)")
|
||||
print("=" * 72)
|
||||
|
||||
Users = env['res.users']
|
||||
Entity = env['encoach.entity']
|
||||
OpCourse = env['op.course']
|
||||
OpStudent = env['op.student']
|
||||
OpFaculty = env['op.faculty']
|
||||
Classroom = env['op.classroom']
|
||||
Section = env['encoach.course.section']
|
||||
Batch = env['op.batch']
|
||||
|
||||
# ── 1. Resolve the demo entity ────────────────────────────────────────────
|
||||
entity = Entity.search([('code', '=', 'DEMO_ACADEMY')], limit=1) \
|
||||
or Entity.search([], limit=1)
|
||||
if not entity:
|
||||
raise SystemExit("✗ No encoach.entity found. Run seed_demo.py first.")
|
||||
print(f"✓ Using entity: {entity.name} (id={entity.id})")
|
||||
|
||||
|
||||
# ── 2. Resolve demo students + homeroom teachers by login ─────────────────
|
||||
def _student_for(login):
|
||||
user = Users.search([('login', '=', login)], limit=1)
|
||||
if not user:
|
||||
return None
|
||||
if user.op_student_id:
|
||||
return user.op_student_id
|
||||
return OpStudent.search([('user_id', '=', user.id)], limit=1) or None
|
||||
|
||||
|
||||
def _faculty_for(login):
|
||||
user = Users.search([('login', '=', login)], limit=1)
|
||||
if not user:
|
||||
return None
|
||||
if user.op_faculty_id:
|
||||
return user.op_faculty_id
|
||||
return OpFaculty.search([('user_id', '=', user.id)], limit=1) or None
|
||||
|
||||
|
||||
sarah = _student_for('sarah@encoach.test')
|
||||
omar = _student_for('omar@encoach.test')
|
||||
layla = _student_for('layla@encoach.test')
|
||||
khalid = _faculty_for('khalid@encoach.test')
|
||||
fatima = _faculty_for('fatima@encoach.test')
|
||||
|
||||
# Pick one extra student for A1030 — first non-(sarah/omar/layla) student available.
|
||||
extra_student = None
|
||||
already = {s.id for s in (sarah, omar, layla) if s}
|
||||
for cand in OpStudent.search([], limit=20):
|
||||
if cand.id not in already:
|
||||
extra_student = cand
|
||||
break
|
||||
|
||||
print("✓ Resolved demo people:")
|
||||
print(f" students: sarah={sarah and sarah.id} omar={omar and omar.id} "
|
||||
f"layla={layla and layla.id} extra={extra_student and extra_student.id}")
|
||||
print(f" homeroom: khalid={khalid and khalid.id} fatima={fatima and fatima.id}")
|
||||
|
||||
if not all([sarah, omar, layla, khalid, fatima]):
|
||||
print("⚠ Missing demo users — please run seed_demo.py first; continuing with what we have.")
|
||||
|
||||
|
||||
# ── 3. Create / reuse Math 101 & Physics 102 (entity-scoped) ──────────────
|
||||
def _ensure_course(name, code, description, capacity=40):
|
||||
course = OpCourse.search([('code', '=', code)], limit=1)
|
||||
if not course:
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': code,
|
||||
'description': description,
|
||||
'max_capacity': capacity,
|
||||
'evaluation_type': 'normal',
|
||||
}
|
||||
if 'entity_id' in OpCourse._fields:
|
||||
vals['entity_id'] = entity.id
|
||||
course = OpCourse.create(vals)
|
||||
print(f" + created course: {name} ({code}) id={course.id}")
|
||||
else:
|
||||
if 'entity_id' in OpCourse._fields and not course.entity_id:
|
||||
course.write({'entity_id': entity.id})
|
||||
print(f" · reused course: {name} ({code}) id={course.id}")
|
||||
return course
|
||||
|
||||
|
||||
print("\n--- Courses ---")
|
||||
math = _ensure_course("Math 101", "MATH101", "Foundations of mathematics.", 40)
|
||||
physics = _ensure_course("Physics 102", "PHYS102", "Introductory physics.", 40)
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ── 4. Generate A/B/C sections per course ─────────────────────────────────
|
||||
def _ensure_sections(course, codes=("A", "B", "C")):
|
||||
out = {}
|
||||
for idx, c in enumerate(codes, start=1):
|
||||
s = Section.search([('course_id', '=', course.id), ('code', '=', c)], limit=1)
|
||||
if not s:
|
||||
s = Section.create({
|
||||
'name': f"Section {c}",
|
||||
'code': c,
|
||||
'course_id': course.id,
|
||||
'sequence': 10 * idx,
|
||||
'active': True,
|
||||
})
|
||||
print(f" + section {course.code}/{c} id={s.id}")
|
||||
else:
|
||||
print(f" · section {course.code}/{c} id={s.id} (existing)")
|
||||
out[c] = s
|
||||
return out
|
||||
|
||||
|
||||
print("\n--- Sections ---")
|
||||
math_secs = _ensure_sections(math)
|
||||
phys_secs = _ensure_sections(physics)
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ── 5. Create / reuse classrooms (A1010, A1030) ───────────────────────────
|
||||
def _ensure_classroom(name, code, capacity=35):
|
||||
rec = Classroom.search([('code', '=', code)], limit=1)
|
||||
if not rec:
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': code,
|
||||
'capacity': capacity,
|
||||
}
|
||||
if 'entity_id' in Classroom._fields:
|
||||
vals['entity_id'] = entity.id
|
||||
rec = Classroom.create(vals)
|
||||
print(f" + created classroom: {name} ({code}) id={rec.id}")
|
||||
else:
|
||||
if 'entity_id' in Classroom._fields and not rec.entity_id:
|
||||
rec.write({'entity_id': entity.id})
|
||||
print(f" · reused classroom: {name} ({code}) id={rec.id}")
|
||||
return rec
|
||||
|
||||
|
||||
print("\n--- Classrooms ---")
|
||||
a1010 = _ensure_classroom("Class A1010", "A1010", 35)
|
||||
a1030 = _ensure_classroom("Class A1030", "A1030", 35)
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ── 6. Roster + homeroom teachers (idempotent M2M add) ────────────────────
|
||||
def _add_roster(classroom, students, teachers):
|
||||
s_cmds = [(4, s.id) for s in students if s]
|
||||
t_cmds = [(4, t.id) for t in teachers if t]
|
||||
vals = {}
|
||||
if s_cmds:
|
||||
vals['student_ids'] = s_cmds
|
||||
if t_cmds:
|
||||
vals['teacher_ids'] = t_cmds
|
||||
if vals:
|
||||
classroom.write(vals)
|
||||
print(f" · {classroom.name}: {len(classroom.student_ids)} student(s), "
|
||||
f"{len(classroom.teacher_ids)} homeroom teacher(s)")
|
||||
|
||||
|
||||
print("\n--- Rosters & homeroom teachers ---")
|
||||
_add_roster(a1010, [sarah, omar], [khalid, fatima]) # 2 students, 2 teachers
|
||||
_add_roster(a1030, [layla, extra_student], [khalid]) # 1-2 students, 1 teacher
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ── 7. Bind sections to classrooms (creates batches + enrolls roster) ─────
|
||||
DIAGRAM = [
|
||||
# (classroom, course, section_letter)
|
||||
(a1010, math, "A"),
|
||||
(a1010, physics, "B"),
|
||||
(a1030, math, "B"),
|
||||
(a1030, physics, "C"),
|
||||
]
|
||||
TERM_NAME = "Semester 1 2026"
|
||||
TERM_KEY = "2026-T1"
|
||||
START = date(2026, 4, 27)
|
||||
END = date(2026, 12, 31)
|
||||
|
||||
print("\n--- Section bindings (cascade → batches + auto-enroll) ---")
|
||||
for classroom, course, section_letter in DIAGRAM:
|
||||
section = (math_secs if course == math else phys_secs)[section_letter]
|
||||
teacher_ids = [t.id for t in (khalid, fatima) if t]
|
||||
batch = classroom.assign_course(
|
||||
course=course,
|
||||
term_name=TERM_NAME,
|
||||
start_date=START,
|
||||
end_date=END,
|
||||
teacher_ids=teacher_ids,
|
||||
section_id=section.id,
|
||||
term_key=TERM_KEY,
|
||||
)
|
||||
print(f" ✓ {classroom.name:<14} → {course.name:<12} / Section {section_letter} "
|
||||
f"batch '{batch.name}' (code {batch.code}, {len(batch.student_ids)} students)")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ── 8. Final summary ──────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 72)
|
||||
print(" Final state (matches the diagrams)")
|
||||
print("=" * 72)
|
||||
for classroom in (a1010, a1030):
|
||||
print(f"\n== {classroom.name} (code {classroom.code}, entity={classroom.entity_id.name}) ==")
|
||||
print(f" roster: {len(classroom.student_ids)} student(s) -> "
|
||||
f"{[s.name for s in classroom.student_ids]}")
|
||||
print(f" teachers: {[t.name for t in classroom.teacher_ids]} (homeroom)")
|
||||
print(f" courses: {[c.name for c in classroom.course_ids]}")
|
||||
for b in classroom.batch_ids.sorted('id'):
|
||||
sec_code = b.course_section_id.code if b.course_section_id else '-'
|
||||
print(f" batch: {b.course_id.name:<12} / Section {sec_code} -> "
|
||||
f"'{b.name}' ({len(b.student_ids)} students, term={b.term_key})")
|
||||
|
||||
print("\n✓ seed_dynamic_sections_demo.py — done.")
|
||||
print(" Try the UI:")
|
||||
print(" /admin/courses -> Math 101 / Physics 102 each show 3 sections (A/B/C)")
|
||||
print(" /admin/classrooms -> Class A1010 + A1030 with rosters and 2 batches each")
|
||||
print(" /admin/batches -> 4 section-aware batches (Section column populated)")
|
||||
310
seed_dynamic_sections_demo_via_api.py
Normal file
310
seed_dynamic_sections_demo_via_api.py
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
seed_dynamic_sections_demo_via_api.py
|
||||
|
||||
Idempotent demo data for the dynamic Course → Sections → Classroom → Batch
|
||||
structure, driven entirely through the LMS REST API the frontend uses.
|
||||
|
||||
Reproduces the user's diagrams:
|
||||
|
||||
Courses & Sections Classrooms
|
||||
────────────────── ───────────
|
||||
Math 101 ─┬─ Section A Class A1010 hosts:
|
||||
├─ Section B • Math 101 — Section A
|
||||
└─ Section C • Physics 102 — Section B
|
||||
Physics 102 ─┬─ Section A
|
||||
├─ Section B Class A1030 hosts:
|
||||
└─ Section C • Math 101 — Section B
|
||||
• Physics 102 — Section C
|
||||
|
||||
Run while the backend is up on http://127.0.0.1:8069 :
|
||||
|
||||
.conda-envs/odoo19/bin/python seed_dynamic_sections_demo_via_api.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API = "http://127.0.0.1:8069"
|
||||
ADMIN_LOGIN = "admin@encoach.test"
|
||||
ADMIN_PASSWORD = "admin123"
|
||||
|
||||
TERM_NAME = "Semester 1 2026"
|
||||
TERM_KEY = "2026-T1"
|
||||
START = "2026-04-27"
|
||||
END = "2026-12-31"
|
||||
|
||||
|
||||
# ── Tiny HTTP helper ──────────────────────────────────────────────────────
|
||||
def _req(method, path, *, token=None, body=None, query=None):
|
||||
url = API + path
|
||||
if query:
|
||||
from urllib.parse import urlencode
|
||||
url += "?" + urlencode(query)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
r = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||
payload = resp.read().decode("utf-8")
|
||||
return resp.status, (json.loads(payload) if payload else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode("utf-8", "replace")
|
||||
try:
|
||||
return e.code, json.loads(body_text)
|
||||
except Exception:
|
||||
return e.code, {"raw": body_text}
|
||||
|
||||
|
||||
def must(status, payload, ctx):
|
||||
if status >= 400:
|
||||
print(f" ✗ {ctx} -> HTTP {status}: {json.dumps(payload)[:280]}")
|
||||
sys.exit(1)
|
||||
return payload
|
||||
|
||||
|
||||
# ── 1. Login as admin and capture the JWT ─────────────────────────────────
|
||||
print("\n" + "=" * 72)
|
||||
print(" EnCoach — dynamic course-section demo (via API)")
|
||||
print("=" * 72)
|
||||
status, payload = _req(
|
||||
"POST", "/api/login",
|
||||
body={"login": ADMIN_LOGIN, "password": ADMIN_PASSWORD},
|
||||
)
|
||||
if status != 200:
|
||||
print(f"✗ login failed ({status}): {payload}")
|
||||
sys.exit(1)
|
||||
token = payload.get("access_token") or payload.get("token")
|
||||
me = payload.get("user") or {}
|
||||
print(f"✓ logged in as {me.get('name') or ADMIN_LOGIN} (uid={me.get('id')})")
|
||||
|
||||
|
||||
# ── 2. Resolve / create Math 101 + Physics 102 ────────────────────────────
|
||||
def _find_course_by_code(code):
|
||||
s, p = _req("GET", "/api/courses", token=token, query={"limit": 200})
|
||||
must(s, p, "list courses")
|
||||
for c in p.get("items", []) or p.get("data", []) or []:
|
||||
if (c.get("code") or "").upper() == code.upper():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_course(name, code, description):
|
||||
found = _find_course_by_code(code)
|
||||
if found:
|
||||
print(f" · reused course {name} ({code}) id={found['id']}")
|
||||
return found
|
||||
s, p = _req("POST", "/api/courses", token=token, body={
|
||||
"name": name,
|
||||
"code": code,
|
||||
"description": description,
|
||||
"max_capacity": 40,
|
||||
"evaluation_type": "normal",
|
||||
})
|
||||
must(s, p, f"create course {code}")
|
||||
c = p.get("data") or p
|
||||
print(f" + created course {name} ({code}) id={c['id']}")
|
||||
return c
|
||||
|
||||
|
||||
print("\n--- Courses ---")
|
||||
math = _ensure_course("Math 101", "MATH101", "Foundations of mathematics.")
|
||||
physics = _ensure_course("Physics 102", "PHYS102", "Introductory physics.")
|
||||
|
||||
|
||||
# ── 3. Generate A/B/C sections per course (idempotent endpoint) ───────────
|
||||
def _ensure_default_sections(course):
|
||||
s, p = _req("POST", f"/api/courses/{course['id']}/sections/generate-defaults", token=token, body={})
|
||||
must(s, p, f"generate-defaults {course['code']}")
|
||||
sections = p.get("items") or p.get("data") or []
|
||||
by_code = {sec["code"]: sec for sec in sections}
|
||||
print(f" · {course['code']} sections: {sorted(by_code.keys())} "
|
||||
f"({sum(1 for sec in sections if sec.get('created'))} created, "
|
||||
f"{sum(1 for sec in sections if not sec.get('created'))} reused)")
|
||||
return by_code
|
||||
|
||||
|
||||
print("\n--- Sections ---")
|
||||
math_secs = _ensure_default_sections(math)
|
||||
phys_secs = _ensure_default_sections(physics)
|
||||
|
||||
|
||||
# ── 4. Resolve students + faculty (homeroom teachers) ─────────────────────
|
||||
def _list_students(limit=200):
|
||||
s, p = _req("GET", "/api/students", token=token, query={"limit": limit})
|
||||
must(s, p, "list students")
|
||||
return p.get("items", []) or p.get("data", []) or []
|
||||
|
||||
|
||||
def _list_teachers(limit=200):
|
||||
for path in ("/api/teachers", "/api/faculty"):
|
||||
s, p = _req("GET", path, token=token, query={"limit": limit})
|
||||
if s == 200:
|
||||
rows = p.get("items", []) or p.get("data", []) or []
|
||||
if rows:
|
||||
return rows
|
||||
return []
|
||||
|
||||
|
||||
def _match(rows, *needles):
|
||||
"""Return the first row whose name/email contains any of the needles."""
|
||||
needles = [n.lower() for n in needles if n]
|
||||
for r in rows:
|
||||
haystack = " ".join(str(r.get(k) or "") for k in ("name", "email", "first_name", "last_name", "login")).lower()
|
||||
if any(n in haystack for n in needles):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
students = _list_students()
|
||||
sarah = _match(students, "sarah ahmed", "sarah@encoach")
|
||||
omar = _match(students, "omar khan", "omar@encoach")
|
||||
layla = _match(students, "layla nasser", "layla@encoach")
|
||||
extra = next(
|
||||
(s for s in students
|
||||
if s and s.get("id") not in {x.get("id") for x in (sarah, omar, layla) if x}),
|
||||
None,
|
||||
)
|
||||
|
||||
print("\n--- Demo people ---")
|
||||
print(f" · /api/students returned {len(students)} student(s)")
|
||||
for label, st in [("sarah", sarah), ("omar", omar), ("layla", layla), ("extra", extra)]:
|
||||
if st:
|
||||
print(f" · {label:<6} id={st.get('id'):<5} name={st.get('name')} email={st.get('email')}")
|
||||
else:
|
||||
print(f" · {label:<6} (not found)")
|
||||
|
||||
teachers = _list_teachers()
|
||||
khalid_rec = _match(teachers, "khalid", "khalid@encoach")
|
||||
fatima_rec = _match(teachers, "fatima", "fatima@encoach")
|
||||
khalid = (khalid_rec or {}).get("id") if khalid_rec else None
|
||||
fatima = (fatima_rec or {}).get("id") if fatima_rec else None
|
||||
print(f" · /api/teachers returned {len(teachers)} faculty record(s)")
|
||||
print(f" · khalid faculty id = {khalid}")
|
||||
print(f" · fatima faculty id = {fatima}")
|
||||
|
||||
|
||||
# ── 5. Resolve / create classrooms ────────────────────────────────────────
|
||||
def _find_classroom_by_code(code):
|
||||
s, p = _req("GET", "/api/classrooms", token=token, query={"limit": 200})
|
||||
must(s, p, "list classrooms")
|
||||
for c in p.get("items", []) or p.get("data", []) or []:
|
||||
if (c.get("code") or "").upper() == code.upper():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_classroom(name, code):
|
||||
found = _find_classroom_by_code(code)
|
||||
if found:
|
||||
print(f" · reused classroom {name} ({code}) id={found['id']}")
|
||||
return found
|
||||
s, p = _req("POST", "/api/classrooms", token=token, body={
|
||||
"name": name,
|
||||
"code": code,
|
||||
"capacity": 35,
|
||||
})
|
||||
must(s, p, f"create classroom {code}")
|
||||
c = p.get("data") or p
|
||||
print(f" + created classroom {name} ({code}) id={c['id']}")
|
||||
return c
|
||||
|
||||
|
||||
print("\n--- Classrooms ---")
|
||||
a1010 = _ensure_classroom("Class A1010", "A1010")
|
||||
a1030 = _ensure_classroom("Class A1030", "A1030")
|
||||
|
||||
|
||||
# ── 6. Roster + homeroom teachers (idempotent: backend de-dupes) ──────────
|
||||
def _add_students(classroom, *students_):
|
||||
ids = [s["id"] for s in students_ if s]
|
||||
if not ids:
|
||||
return
|
||||
s, p = _req("POST", f"/api/classrooms/{classroom['id']}/students",
|
||||
token=token, body={"student_ids": ids})
|
||||
if s >= 400:
|
||||
print(f" ✗ add students {ids} to {classroom['code']}: {p}")
|
||||
else:
|
||||
print(f" · {classroom['code']}: roster now has "
|
||||
f"{(p.get('data') or {}).get('students_count', '?')} student(s)")
|
||||
|
||||
|
||||
def _add_teachers(classroom, *teacher_ids):
|
||||
ids = [t for t in teacher_ids if t]
|
||||
if not ids:
|
||||
return
|
||||
s, p = _req("POST", f"/api/classrooms/{classroom['id']}/teachers",
|
||||
token=token, body={"teacher_ids": ids})
|
||||
if s >= 400:
|
||||
print(f" ⚠ add teachers {ids} to {classroom['code']}: {p}")
|
||||
else:
|
||||
print(f" · {classroom['code']}: homeroom teachers attached")
|
||||
|
||||
|
||||
print("\n--- Rosters & homeroom teachers ---")
|
||||
_add_students(a1010, sarah, omar)
|
||||
_add_students(a1030, layla, extra)
|
||||
_add_teachers(a1010, khalid, fatima)
|
||||
_add_teachers(a1030, khalid)
|
||||
|
||||
|
||||
# ── 7. Bind sections to classrooms (creates batches + auto-enrolls) ───────
|
||||
def _assign_section(classroom, course, section, teacher_ids=None):
|
||||
body = {
|
||||
"course_id": course["id"],
|
||||
"section_id": section["id"],
|
||||
"term_name": TERM_NAME,
|
||||
"term_key": TERM_KEY,
|
||||
"start_date": START,
|
||||
"end_date": END,
|
||||
}
|
||||
if teacher_ids:
|
||||
body["teacher_ids"] = [t for t in teacher_ids if t]
|
||||
s, p = _req("POST", f"/api/classrooms/{classroom['id']}/assign-section",
|
||||
token=token, body=body)
|
||||
must(s, p, f"assign {classroom['code']} ← {course['code']}/{section['code']}")
|
||||
batch = p.get("batch") or {}
|
||||
print(f" ✓ {classroom['code']:<6} ← {course['code']:<8} / Section {section['code']} "
|
||||
f"batch '{batch.get('name')}' (code {batch.get('code')}, "
|
||||
f"{batch.get('students_count', '?')} students)")
|
||||
|
||||
|
||||
print("\n--- Section bindings ---")
|
||||
_assign_section(a1010, math, math_secs["A"], teacher_ids=[khalid])
|
||||
_assign_section(a1010, physics, phys_secs["B"], teacher_ids=[fatima])
|
||||
_assign_section(a1030, math, math_secs["B"], teacher_ids=[khalid])
|
||||
_assign_section(a1030, physics, phys_secs["C"], teacher_ids=[fatima])
|
||||
|
||||
|
||||
# ── 8. Final summary (re-fetch full classroom payload) ────────────────────
|
||||
print("\n" + "=" * 72)
|
||||
print(" Final state (matches the diagrams)")
|
||||
print("=" * 72)
|
||||
for cls in (a1010, a1030):
|
||||
s, p = _req("GET", f"/api/classrooms/{cls['id']}", token=token)
|
||||
if s != 200:
|
||||
print(f" ✗ refetch {cls['code']}: {p}")
|
||||
continue
|
||||
detail = p.get("data") or p
|
||||
print(f"\n== {detail.get('name')} (code {detail.get('code')}) ==")
|
||||
print(f" roster: {detail.get('students_count', '?')} student(s) -> "
|
||||
f"{[s.get('name') for s in detail.get('students', [])][:6]}")
|
||||
print(f" teachers: {[t.get('name') for t in detail.get('teachers', [])]}")
|
||||
s2, p2 = _req("GET", f"/api/classrooms/{cls['id']}/batches", token=token)
|
||||
batches = (p2.get("items") or p2.get("data") or []) if s2 == 200 else []
|
||||
print(f" courses: {sorted({b.get('course_name') for b in batches})}")
|
||||
for b in sorted(batches, key=lambda x: (x.get("course_name") or "", x.get("section_code") or "")):
|
||||
print(f" batch: {(b.get('course_name') or ''):<12} / Section "
|
||||
f"{(b.get('section_code') or '-'):<3} -> '{b.get('name')}' "
|
||||
f"({b.get('students_count', '?')} students, term={b.get('term_key')})")
|
||||
|
||||
print("\n✓ Demo seeded. Try the UI:")
|
||||
print(" /admin/courses -> Math 101 / Physics 102 each show 3 sections (A/B/C)")
|
||||
print(" /admin/classrooms -> Class A1010 + A1030 with rosters + 2 batches each")
|
||||
print(" /admin/batches -> 4 section-aware batches (Section column populated)")
|
||||
195
smoke_assignment_workflow.py
Normal file
195
smoke_assignment_workflow.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""End-to-end smoke test for the assignment workflow.
|
||||
|
||||
Verifies, against a live entity-A admin, that every step of the
|
||||
classroom -> roster -> teachers -> course/section -> per-batch override
|
||||
flow works through the REST API the new UI uses:
|
||||
|
||||
1. List classrooms in scope; pick the first one.
|
||||
2. Set the classroom roster + homeroom teachers.
|
||||
3. Assign a course (with section + term) -> server creates a batch
|
||||
and propagates roster + teachers automatically.
|
||||
4. List the batch's enrolled students and teachers (must equal classroom).
|
||||
5. Override the batch -> remove one student, add a different teacher.
|
||||
6. Verify the per-batch state changed (server is source of truth).
|
||||
|
||||
If anything looks off at any step we abort with a precise error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API = "http://127.0.0.1:8069"
|
||||
LOGIN = "admin@encoach.test"
|
||||
PASSWORD = "admin123"
|
||||
|
||||
|
||||
def _req(method, path, token=None, body=None):
|
||||
url = f"{API}{path}"
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read().decode() or "{}"
|
||||
return r.status, json.loads(raw) if raw.strip() else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = {"raw": raw}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def must(label, code, data, ok=(200, 201)):
|
||||
if code not in ok:
|
||||
print(f" FAIL {label}: expected {ok}, got {code} -> {data}")
|
||||
sys.exit(1)
|
||||
print(f" OK {label} ({code})")
|
||||
|
||||
|
||||
def login():
|
||||
code, data = _req("POST", "/api/login", body={"login": LOGIN, "password": PASSWORD})
|
||||
must("login", code, data)
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
def main():
|
||||
print("== assignment workflow smoke ==")
|
||||
token = login()
|
||||
|
||||
# Step 1: pick a classroom
|
||||
code, data = _req("GET", "/api/classrooms?size=10", token)
|
||||
must("list classrooms", code, data)
|
||||
items = data.get("items") or data.get("data") or []
|
||||
if not items:
|
||||
print(" SKIP no classrooms in scope; nothing to test.")
|
||||
return
|
||||
classroom = items[0]
|
||||
classroom_id = classroom["id"]
|
||||
print(f" -> using classroom #{classroom_id} '{classroom['name']}'")
|
||||
|
||||
# Step 2: set roster + homeroom teachers
|
||||
code, students = _req("GET", "/api/students?size=20", token)
|
||||
must("list students", code, students)
|
||||
student_ids = [s["id"] for s in (students.get("items") or students.get("data") or [])][:5]
|
||||
if len(student_ids) < 2:
|
||||
print(f" SKIP need at least 2 students in scope, found {len(student_ids)}")
|
||||
return
|
||||
|
||||
code, teachers = _req("GET", "/api/teachers?size=20", token)
|
||||
must("list teachers", code, teachers)
|
||||
teacher_ids = [t["id"] for t in (teachers.get("items") or teachers.get("data") or [])][:3]
|
||||
if len(teacher_ids) < 1:
|
||||
print(" SKIP need at least 1 teacher in scope")
|
||||
return
|
||||
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/classrooms/{classroom_id}/students",
|
||||
token,
|
||||
{"student_ids": student_ids, "mode": "set"},
|
||||
)
|
||||
must(f"set classroom roster ({len(student_ids)} students)", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/classrooms/{classroom_id}/teachers",
|
||||
token,
|
||||
{"teacher_ids": teacher_ids, "mode": "set"},
|
||||
)
|
||||
must(f"set homeroom teachers ({len(teacher_ids)})", code, data)
|
||||
|
||||
# Step 3: assign a course + section
|
||||
code, courses = _req("GET", "/api/courses?size=20", token)
|
||||
must("list courses", code, courses)
|
||||
course_items = courses.get("items") or courses.get("data") or []
|
||||
if not course_items:
|
||||
print(" SKIP no courses in scope")
|
||||
return
|
||||
course_id = course_items[0]["id"]
|
||||
|
||||
code, secs = _req("GET", f"/api/courses/{course_id}/sections", token)
|
||||
must("list course sections", code, secs)
|
||||
section_items = secs.get("items") or secs.get("data") or []
|
||||
section_id = section_items[0]["id"] if section_items else None
|
||||
|
||||
payload = {"course_id": course_id, "term_key": "2026-SMOKE"}
|
||||
if section_id:
|
||||
payload["section_id"] = section_id
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/classrooms/{classroom_id}/assign-course",
|
||||
token,
|
||||
payload,
|
||||
)
|
||||
must("assign course (cascade)", code, data)
|
||||
batch = data.get("batch") or {}
|
||||
batch_id = batch.get("id")
|
||||
if not batch_id:
|
||||
print(f" FAIL no batch returned: {data}")
|
||||
sys.exit(1)
|
||||
print(
|
||||
f" -> created batch #{batch_id} '{batch.get('name')}' "
|
||||
f"with {batch.get('student_count')} student(s)"
|
||||
)
|
||||
|
||||
# Step 4: verify the cascade actually populated the batch
|
||||
code, batch_students = _req("GET", f"/api/batches/{batch_id}/students", token)
|
||||
must("get batch students", code, batch_students)
|
||||
enrolled = [s["id"] for s in (batch_students.get("data") or [])]
|
||||
print(f" -> batch enrolled students: {len(enrolled)}")
|
||||
|
||||
code, batch_teachers = _req("GET", f"/api/batches/{batch_id}/teachers", token)
|
||||
must("get batch teachers", code, batch_teachers)
|
||||
enrolled_teachers = [t["id"] for t in (batch_teachers.get("items") or [])]
|
||||
print(f" -> batch teachers: {enrolled_teachers}")
|
||||
|
||||
# Step 5: override the batch -> remove one student, set a single teacher
|
||||
drop_id = student_ids[0]
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/batches/{batch_id}/students/remove",
|
||||
token,
|
||||
{"student_ids": [drop_id]},
|
||||
)
|
||||
must(f"remove student {drop_id} from batch", code, data)
|
||||
|
||||
keep_teacher = teacher_ids[0]
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/batches/{batch_id}/teachers",
|
||||
token,
|
||||
{"teacher_ids": [keep_teacher], "mode": "set"},
|
||||
)
|
||||
must(f"set batch teachers -> [{keep_teacher}]", code, data)
|
||||
|
||||
# Step 6: re-check
|
||||
code, batch_students = _req("GET", f"/api/batches/{batch_id}/students", token)
|
||||
must("re-get batch students", code, batch_students)
|
||||
enrolled_after = {s["id"] for s in (batch_students.get("data") or [])}
|
||||
if drop_id in enrolled_after:
|
||||
print(f" FAIL student {drop_id} still in batch after remove: {enrolled_after}")
|
||||
sys.exit(1)
|
||||
|
||||
code, batch_teachers = _req("GET", f"/api/batches/{batch_id}/teachers", token)
|
||||
must("re-get batch teachers", code, batch_teachers)
|
||||
teachers_after = [t["id"] for t in (batch_teachers.get("items") or [])]
|
||||
if teachers_after != [keep_teacher]:
|
||||
print(
|
||||
f" FAIL batch teachers expected [{keep_teacher}], got {teachers_after}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("\nAll assignment workflow steps passed ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
545
smoke_course_plan_rag.py
Normal file
545
smoke_course_plan_rag.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""End-to-end smoke test for the **professional interactive course plans** flow.
|
||||
|
||||
Run with the live backend running on http://127.0.0.1:8069. We exercise
|
||||
every layer added by the "Professional Interactive Course Plans" change:
|
||||
|
||||
1. Login as the entity-A admin.
|
||||
2. Generate a fresh course plan (LLM call; we accept the deterministic
|
||||
skeleton fallback when no key is configured).
|
||||
3. Upload the Headway elementary workbook PDF as a RAG source and wait
|
||||
for the SourceIndexer to flip it to ``indexed``.
|
||||
4. Run ``extract-workbooks`` — the ExerciseExtractor should produce
|
||||
``interactive_workbook`` materials whose ``extracted_from`` field
|
||||
points back at the uploaded PDF.
|
||||
5. Run ``materials/v2`` for week 1 — the v2 generator must inject RAG
|
||||
passages and persist a non-empty ``grounded_on`` array on every
|
||||
created material.
|
||||
6. As an admin (entity-scope fallback in ``_resolve_attempt_target``),
|
||||
submit a deliberately-correct attempt against the first
|
||||
interactive workbook material and assert the server-side scoring
|
||||
persists the answers + percent.
|
||||
7. Reload the attempt via ``GET .../attempts/me`` and verify it
|
||||
survived the round-trip.
|
||||
|
||||
Expected provider failures:
|
||||
- If ``OPENAI_API_KEY`` / embedding key isn't configured the indexer
|
||||
flips to ``failed`` — the script reports SKIP for the RAG paths and
|
||||
still validates the API contracts (non-RAG generation, attempt
|
||||
persistence). That keeps the smoke test useful in CI without secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
API = os.environ.get("ENCOACH_API", "http://127.0.0.1:8069")
|
||||
LOGIN = os.environ.get("ENCOACH_LOGIN", "admin@encoach.test")
|
||||
PASSWORD = os.environ.get("ENCOACH_PASSWORD", "admin123")
|
||||
|
||||
PDF_PATH = os.environ.get(
|
||||
"HEADWAY_PDF",
|
||||
"docs/toaz.info-headway-elementary-workbook-5th-edition-pr_a611cb12a73c05e5609ef05996a16ea3 (1).pdf",
|
||||
)
|
||||
|
||||
INDEXING_TIMEOUT_S = int(os.environ.get("INDEXING_TIMEOUT_S", "120"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib, identical pattern to smoke_assignment_workflow.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _req(method, path, token=None, body=None, raw=None, content_type=None):
|
||||
url = f"{API}{path}"
|
||||
data = None
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
elif raw is not None:
|
||||
data = raw
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
payload = r.read().decode() or "{}"
|
||||
return r.status, json.loads(payload) if payload.strip() else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
body_raw = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
parsed = json.loads(body_raw)
|
||||
except Exception:
|
||||
parsed = {"raw": body_raw}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def _multipart(path, token, fields, file_field, file_path, file_name=None,
|
||||
file_mime="application/pdf"):
|
||||
"""Tiny stdlib multipart/form-data uploader for the ``/sources`` endpoint.
|
||||
|
||||
We avoid the ``requests`` dependency on purpose — the existing smoke
|
||||
tests stick to ``urllib`` and we want this script to be runnable in a
|
||||
bare CI container.
|
||||
"""
|
||||
boundary = f"----encoachSmoke{uuid.uuid4().hex}"
|
||||
cr = b"\r\n"
|
||||
body = []
|
||||
for name, value in fields.items():
|
||||
body.append(f"--{boundary}".encode())
|
||||
body.append(
|
||||
f'Content-Disposition: form-data; name="{name}"'.encode()
|
||||
)
|
||||
body.append(b"")
|
||||
body.append(str(value).encode())
|
||||
with open(file_path, "rb") as fh:
|
||||
file_bytes = fh.read()
|
||||
body.append(f"--{boundary}".encode())
|
||||
body.append(
|
||||
(
|
||||
f'Content-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{file_name or os.path.basename(file_path)}"'
|
||||
).encode()
|
||||
)
|
||||
body.append(f"Content-Type: {file_mime}".encode())
|
||||
body.append(b"")
|
||||
body.append(file_bytes)
|
||||
body.append(f"--{boundary}--".encode())
|
||||
body.append(b"")
|
||||
payload = cr.join(body)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(payload)),
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{API}{path}", data=payload, method="POST", headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
raw = r.read().decode() or "{}"
|
||||
return r.status, json.loads(raw) if raw.strip() else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = {"raw": raw}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pretty printing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hr(title):
|
||||
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f" PASS {msg}")
|
||||
|
||||
|
||||
def warn(msg):
|
||||
print(f" WARN {msg}")
|
||||
|
||||
|
||||
def skip(msg):
|
||||
print(f" SKIP {msg}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f" FAIL {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def must(label, code, data, ok_codes=(200, 201)):
|
||||
if code not in ok_codes:
|
||||
fail(f"{label}: expected {ok_codes}, got {code} -> {data}")
|
||||
print(f" OK {label} ({code})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def login():
|
||||
code, data = _req("POST", "/api/login", body={"login": LOGIN, "password": PASSWORD})
|
||||
if code != 200:
|
||||
fail(f"login: {code} -> {data}")
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Generate a fresh plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_plan(token):
|
||||
title = f"RAG smoke {uuid.uuid4().hex[:6]}"
|
||||
body = {
|
||||
"title": title,
|
||||
"cefr_level": "A2",
|
||||
"total_weeks": 4,
|
||||
"contact_hours_per_week": 4,
|
||||
"skills_division": "Reading 25% / Writing 25% / Listening 25% / Speaking 25%",
|
||||
"learner_profile": "Adult learners preparing for everyday English use.",
|
||||
"notes": "Use the uploaded Headway elementary workbook as the primary book.",
|
||||
}
|
||||
code, data = _req("POST", "/api/ai/course-plan", token=token, body=body)
|
||||
if code not in (200, 201):
|
||||
fail(f"create plan: {code} -> {data}")
|
||||
plan = data.get("data") or {}
|
||||
pid = plan.get("id")
|
||||
if not pid:
|
||||
fail(f"create plan: missing id -> {data}")
|
||||
ok(f"created plan #{pid} ({plan.get('name')})")
|
||||
return plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Upload + wait for indexing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_pdf(token, plan_id, path):
|
||||
if not os.path.exists(path):
|
||||
skip(f"PDF missing at {path} — skipping upload (RAG paths will be skipped)")
|
||||
return None
|
||||
code, data = _multipart(
|
||||
f"/api/ai/course-plan/{plan_id}/sources",
|
||||
token,
|
||||
fields={"kind": "file", "name": os.path.basename(path), "auto_index": "1"},
|
||||
file_field="file",
|
||||
file_path=path,
|
||||
)
|
||||
if code not in (200, 201):
|
||||
fail(f"upload PDF: {code} -> {data}")
|
||||
src = data.get("data") or {}
|
||||
sid = src.get("id")
|
||||
if not sid:
|
||||
fail(f"upload PDF: missing id -> {data}")
|
||||
ok(f"uploaded source #{sid} (status={src.get('status')})")
|
||||
return src
|
||||
|
||||
|
||||
def wait_for_index(token, plan_id, source_id, timeout=INDEXING_TIMEOUT_S):
|
||||
deadline = time.time() + timeout
|
||||
last_status = None
|
||||
while time.time() < deadline:
|
||||
code, data = _req(
|
||||
"GET", f"/api/ai/course-plan/{plan_id}/sources", token=token,
|
||||
)
|
||||
if code != 200:
|
||||
fail(f"list sources: {code} -> {data}")
|
||||
items = data.get("items") or []
|
||||
src = next((s for s in items if s["id"] == source_id), None)
|
||||
if not src:
|
||||
fail(f"source {source_id} disappeared")
|
||||
if src["status"] != last_status:
|
||||
print(f" source #{source_id} status = {src['status']}"
|
||||
f" chunks={src.get('chunks_count', 0)}")
|
||||
last_status = src["status"]
|
||||
if src["status"] == "indexed" and src.get("chunks_count", 0) > 0:
|
||||
ok(f"indexed in pgvector ({src['chunks_count']} chunks,"
|
||||
f" {src.get('extracted_chars', 0)} chars)")
|
||||
return src
|
||||
if src["status"] == "failed":
|
||||
warn(f"indexing failed: {src.get('error', '?')}")
|
||||
return src
|
||||
time.sleep(2.0)
|
||||
warn(f"indexing did not complete in {timeout}s; last status = {last_status}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Extract workbooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_workbooks(token, plan_id):
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/ai/course-plan/{plan_id}/extract-workbooks",
|
||||
token=token,
|
||||
body={"max_batches": 4},
|
||||
)
|
||||
if code != 200:
|
||||
fail(f"extract-workbooks: {code} -> {data}")
|
||||
stats = (data.get("data") or {})
|
||||
print(
|
||||
" extractor stats:"
|
||||
f" materials_created={stats.get('materials_created')}"
|
||||
f" exercises_total={stats.get('exercises_total')}"
|
||||
f" batches_run={stats.get('batches_run')}"
|
||||
f" reason={stats.get('reason')}"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Generate week 1 v2 (RAG)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_week_v2(token, plan_id, week_number=1):
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/ai/course-plan/{plan_id}/weeks/{week_number}/materials/v2",
|
||||
token=token,
|
||||
)
|
||||
if code == 409:
|
||||
warn("v2 generator returned 409 (no indexed sources) — RAG path skipped")
|
||||
return None
|
||||
if code != 200:
|
||||
fail(f"generate v2 week {week_number}: {code} -> {data}")
|
||||
materials = data.get("items") or []
|
||||
rag = data.get("rag") or {}
|
||||
print(
|
||||
f" week {week_number} produced {len(materials)} materials,"
|
||||
f" RAG sources_used={rag.get('sources_used')}"
|
||||
)
|
||||
return materials
|
||||
|
||||
|
||||
def fallback_generate_v1(token, plan_id, week_number=1):
|
||||
"""Used only when RAG isn't available (no sources / index failed)."""
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/ai/course-plan/{plan_id}/weeks/{week_number}/materials",
|
||||
token=token,
|
||||
)
|
||||
if code != 200:
|
||||
fail(f"generate v1 week {week_number}: {code} -> {data}")
|
||||
return data.get("items") or []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Find a workbook material + assign plan to ourselves so we can submit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_full_plan(token, plan_id):
|
||||
code, data = _req("GET", f"/api/ai/course-plan/{plan_id}", token=token)
|
||||
if code != 200:
|
||||
fail(f"get plan: {code} -> {data}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def pick_workbook_material(plan):
|
||||
"""Return the first material that exposes ``exercises`` we can answer.
|
||||
|
||||
Prefers ``interactive_workbook`` types, but the v2 generator can also
|
||||
embed an ``interactive_workbook`` block inside ``grammar_lesson`` /
|
||||
``vocabulary_list`` bodies so we look there too.
|
||||
"""
|
||||
for m in plan.get("materials") or []:
|
||||
if m.get("material_type") == "interactive_workbook":
|
||||
body = m.get("body") or {}
|
||||
if (body.get("exercises") or []):
|
||||
return m, body["exercises"]
|
||||
body = m.get("body") or {}
|
||||
wb = body.get("interactive_workbook") or {}
|
||||
ex = wb.get("exercises") or []
|
||||
if ex:
|
||||
return m, ex
|
||||
return None, []
|
||||
|
||||
|
||||
def build_correct_answers(exercises):
|
||||
"""Construct the answer key the server should grade as 100%."""
|
||||
out = {}
|
||||
for ex in exercises:
|
||||
eid = ex.get("id")
|
||||
if not eid:
|
||||
continue
|
||||
kind = ex.get("type")
|
||||
if kind == "gap_fill" and ex.get("answer") is not None:
|
||||
out[eid] = ex["answer"]
|
||||
elif kind == "multiple_choice" and ex.get("answer") is not None:
|
||||
out[eid] = ex["answer"]
|
||||
elif kind == "match_pairs" and ex.get("answer"):
|
||||
out[eid] = ex["answer"]
|
||||
elif kind == "reorder_words" and ex.get("answer") is not None:
|
||||
out[eid] = ex["answer"]
|
||||
elif kind == "transformation" and ex.get("answer") is not None:
|
||||
out[eid] = ex["answer"]
|
||||
elif kind == "short_answer":
|
||||
ans = ex.get("answer") or (
|
||||
ex.get("accepted") or [None]
|
||||
)[0]
|
||||
if ans:
|
||||
out[eid] = ans.replace("*", "anything")
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Submit attempt + reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def submit_attempt(token, plan_id, material_id, answers, finalize=False):
|
||||
code, data = _req(
|
||||
"POST",
|
||||
f"/api/student/course-plans/{plan_id}/materials/{material_id}/attempts",
|
||||
token=token,
|
||||
body={"answers": answers, "finalize": finalize},
|
||||
)
|
||||
if code != 200:
|
||||
fail(f"save attempt: {code} -> {data}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def get_my_attempt(token, plan_id, material_id):
|
||||
code, data = _req(
|
||||
"GET",
|
||||
f"/api/student/course-plans/{plan_id}/materials/{material_id}/attempts/me",
|
||||
token=token,
|
||||
)
|
||||
if code != 200:
|
||||
fail(f"get my attempt: {code} -> {data}")
|
||||
return data.get("data")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
hr("0. Login")
|
||||
token = login()
|
||||
ok(f"logged in as {LOGIN}")
|
||||
|
||||
hr("1. Create a fresh plan")
|
||||
plan = create_plan(token)
|
||||
plan_id = plan["id"]
|
||||
|
||||
hr("2. Upload Headway PDF + wait for pgvector indexing")
|
||||
src = upload_pdf(token, plan_id, PDF_PATH)
|
||||
rag_ready = False
|
||||
if src:
|
||||
indexed = wait_for_index(token, plan_id, src["id"])
|
||||
rag_ready = bool(
|
||||
indexed and indexed.get("status") == "indexed"
|
||||
and indexed.get("chunks_count", 0) > 0
|
||||
)
|
||||
|
||||
hr("3. Extract workbook exercises from indexed sources")
|
||||
if rag_ready:
|
||||
stats = extract_workbooks(token, plan_id)
|
||||
if (stats.get("materials_created") or 0) > 0:
|
||||
ok(
|
||||
f"extractor created {stats['materials_created']} workbook(s)"
|
||||
f" with {stats.get('exercises_total', 0)} exercise(s)"
|
||||
)
|
||||
elif stats.get("reason"):
|
||||
warn(f"extractor returned reason='{stats['reason']}'")
|
||||
else:
|
||||
warn("extractor produced 0 workbooks (LLM may be unavailable)")
|
||||
else:
|
||||
skip("no indexed source — extract-workbooks skipped")
|
||||
|
||||
hr("4. Generate week 1 with the RAG-grounded v2 endpoint")
|
||||
materials = None
|
||||
if rag_ready:
|
||||
materials = generate_week_v2(token, plan_id, 1)
|
||||
if materials:
|
||||
grounded_count = sum(
|
||||
1 for m in materials if m.get("grounded_on")
|
||||
)
|
||||
if grounded_count > 0:
|
||||
ok(
|
||||
f"{grounded_count}/{len(materials)} materials carry"
|
||||
f" grounded_on provenance"
|
||||
)
|
||||
else:
|
||||
warn(
|
||||
"no material has grounded_on — pipeline may have"
|
||||
" fallen back to deterministic skeleton"
|
||||
)
|
||||
if not materials:
|
||||
skip("falling back to v1 generator so the rest of the smoke can run")
|
||||
fallback_generate_v1(token, plan_id, 1)
|
||||
|
||||
hr("5. Pick an interactive workbook + submit a correct attempt")
|
||||
plan_full = get_full_plan(token, plan_id)
|
||||
material, exercises = pick_workbook_material(plan_full)
|
||||
if not material or not exercises:
|
||||
skip(
|
||||
"no interactive workbook material on this plan — attempt"
|
||||
" persistence path skipped (extractor + v2 likely returned"
|
||||
" nothing because no LLM/RAG keys were configured)"
|
||||
)
|
||||
hr("DONE — partial smoke (no LLM/RAG path exercised)")
|
||||
return
|
||||
|
||||
print(
|
||||
f" using material #{material['id']} ({material.get('material_type')})"
|
||||
f" with {len(exercises)} exercise(s)"
|
||||
)
|
||||
answers = build_correct_answers(exercises)
|
||||
if not answers:
|
||||
skip("could not build any answers from exercise schema")
|
||||
hr("DONE — partial smoke")
|
||||
return
|
||||
|
||||
attempt = submit_attempt(token, plan_id, material["id"], answers)
|
||||
print(
|
||||
f" server scored {attempt.get('correct_count')}/"
|
||||
f"{attempt.get('total_count')} = {attempt.get('percent')}%"
|
||||
)
|
||||
if (attempt.get("total_count") or 0) <= 0:
|
||||
fail("attempt persisted with total_count == 0 — scoring did not run")
|
||||
ok(
|
||||
f"attempt #{attempt.get('id')} persisted"
|
||||
f" (attempt_number={attempt.get('attempt_number')})"
|
||||
)
|
||||
|
||||
hr("6. Reload the attempt and verify the answers + score round-trip")
|
||||
reloaded = get_my_attempt(token, plan_id, material["id"])
|
||||
if not reloaded:
|
||||
fail("attempt vanished after reload")
|
||||
if reloaded.get("id") != attempt.get("id"):
|
||||
fail(
|
||||
f"reload returned a different attempt"
|
||||
f" ({reloaded.get('id')} vs {attempt.get('id')})"
|
||||
)
|
||||
if reloaded.get("answers") != answers:
|
||||
fail(
|
||||
"answers diverged between save and reload"
|
||||
f"\n saved: {answers}"
|
||||
f"\n reloaded: {reloaded.get('answers')}"
|
||||
)
|
||||
ok("attempt + answers persisted across reload")
|
||||
|
||||
hr("7. Finalise the attempt (locks scoring)")
|
||||
finalised = submit_attempt(
|
||||
token, plan_id, material["id"], answers, finalize=True,
|
||||
)
|
||||
if not finalised.get("is_final"):
|
||||
fail("finalize=true did not flip is_final")
|
||||
ok(f"attempt finalised at {finalised.get('submitted_at')}")
|
||||
|
||||
hr("DONE — Professional interactive course-plan smoke passed")
|
||||
print(
|
||||
"Tip: open the plan in /admin/course-plans/<id>, click 'Student"
|
||||
" view' to see the same workbook the student sees, then exit"
|
||||
" to view the teacher dashboard."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
283
smoke_entity_isolation.py
Normal file
283
smoke_entity_isolation.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""End-to-end smoke test for LMS entity isolation.
|
||||
|
||||
Verifies that an admin scoped to entity A cannot:
|
||||
• see entity B's courses / classrooms / batches / students / teachers
|
||||
• assign an entity-B student into an entity-A classroom
|
||||
• assign an entity-B teacher into an entity-A classroom
|
||||
• create a batch that mixes entities (course/classroom/section/teachers)
|
||||
• assign an entity-B course (or its section) to an entity-A classroom
|
||||
|
||||
We do this entirely through the REST API the admin UI uses. We bootstrap
|
||||
entity B with a *superadmin* (`admin / admin`), then run the cross-entity
|
||||
attempts as the entity-A admin (`admin@encoach.test`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API = "http://127.0.0.1:8069"
|
||||
SUPER_LOGIN = "admin"
|
||||
SUPER_PASSWORD = "admin"
|
||||
A_LOGIN = "admin@encoach.test"
|
||||
A_PASSWORD = "admin123"
|
||||
|
||||
|
||||
def _req(method, path, token=None, body=None):
|
||||
url = f"{API}{path}"
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
payload = r.read().decode() or "{}"
|
||||
return r.status, json.loads(payload) if payload.strip() else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except Exception:
|
||||
parsed = {"raw": body}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def login(login, password):
|
||||
code, data = _req("POST", "/api/login", body={"login": login, "password": password})
|
||||
assert code == 200, f"login {login}: {code} {data}"
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
def expect_ok(label, code, data, ok_codes=(200,)):
|
||||
if code not in ok_codes:
|
||||
raise AssertionError(f"{label}: expected {ok_codes}, got {code} -> {data}")
|
||||
print(f" OK {label} ({code})")
|
||||
|
||||
|
||||
def expect_blocked(label, code, data):
|
||||
if code in (200, 201):
|
||||
raise AssertionError(
|
||||
f"{label}: expected blocked but got {code} -> {data}"
|
||||
)
|
||||
print(f" OK {label} → blocked ({code})")
|
||||
|
||||
|
||||
def main():
|
||||
super_tok = login(SUPER_LOGIN, SUPER_PASSWORD)
|
||||
print(f"[1] superadmin token acquired")
|
||||
|
||||
code, ents = _req("GET", "/api/entities?size=200", token=super_tok)
|
||||
items = ents.get("items", ents.get("data", []))
|
||||
by_code = {e.get("code"): e for e in items}
|
||||
entity_a = next((e for e in items if e.get("id") == 1), items[0] if items else None)
|
||||
if not entity_a:
|
||||
print("FATAL: no entities present")
|
||||
sys.exit(1)
|
||||
print(f"[2] entity A = {entity_a['name']} (id={entity_a['id']})")
|
||||
|
||||
entity_b = next(
|
||||
(e for e in items if e.get("id") != entity_a["id"] and e.get("code", "").startswith("ISO_TEST")),
|
||||
None,
|
||||
)
|
||||
if not entity_b:
|
||||
code, created = _req(
|
||||
"POST", "/api/entities", token=super_tok,
|
||||
body={"name": "Iso Test Entity", "code": "ISO_TEST", "type": "school"},
|
||||
)
|
||||
if code in (200, 201):
|
||||
entity_b = created.get("data") or created
|
||||
else:
|
||||
print(f"FATAL: cannot create entity B: {code} {created}")
|
||||
sys.exit(1)
|
||||
print(f"[3] entity B = {entity_b['name']} (id={entity_b['id']})")
|
||||
|
||||
code, ulist = _req(
|
||||
"GET", f"/api/entities/{entity_b['id']}/users", token=super_tok,
|
||||
)
|
||||
cur = (ulist.get("items") or ulist.get("data") or []) if code == 200 else []
|
||||
cur_ids = [u["id"] for u in cur]
|
||||
if 2 not in cur_ids:
|
||||
cur_ids.append(2)
|
||||
code, _ = _req(
|
||||
"PATCH", f"/api/entities/{entity_b['id']}/users", token=super_tok,
|
||||
body={"user_ids": cur_ids},
|
||||
)
|
||||
if code in (200, 201):
|
||||
print(f" (super admin uid=2 attached to entity B; users now={cur_ids})")
|
||||
else:
|
||||
print(f" WARN: cannot attach uid=2 to entity B: {code}")
|
||||
|
||||
code, listing = _req(
|
||||
"GET", f"/api/courses?entity_id={entity_b['id']}&size=200", token=super_tok,
|
||||
)
|
||||
course_items = (listing.get("items") or listing.get("data") or [])
|
||||
existing = next((c for c in course_items if c.get("code", "").startswith("EB-ISO")), None)
|
||||
if existing:
|
||||
course_b_id = existing["id"]
|
||||
print(f" (reusing existing entity-B course id={course_b_id})")
|
||||
else:
|
||||
import time
|
||||
course_code = f"EB-ISO{int(time.time())%100000}"
|
||||
code, course_b = _req(
|
||||
"POST", "/api/courses", token=super_tok,
|
||||
body={"name": "Entity-B Course", "code": course_code, "entity_id": entity_b["id"]},
|
||||
)
|
||||
expect_ok("create course in entity B (as super)", code, course_b, (200, 201))
|
||||
course_b_id = (course_b.get("data") or course_b)["id"]
|
||||
|
||||
code, sect_b = _req(
|
||||
"POST", f"/api/courses/{course_b_id}/sections/generate-defaults",
|
||||
token=super_tok, body={"codes": ["A"]},
|
||||
)
|
||||
expect_ok("generate section A in entity B (as super)", code, sect_b, (200, 201))
|
||||
sect_b_id = (sect_b.get("items") or sect_b.get("data"))[0]["id"]
|
||||
|
||||
code, slist = _req(
|
||||
"GET", f"/api/students?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
|
||||
)
|
||||
s_items = slist.get("items") or slist.get("data") or []
|
||||
existing_s = next((s for s in s_items if 'EntityB' in (s.get('name') or '')), None)
|
||||
if existing_s:
|
||||
stud_b_id = existing_s["id"]
|
||||
print(f" (reusing existing entity-B student id={stud_b_id})")
|
||||
else:
|
||||
code, stud_b = _req(
|
||||
"POST", "/api/students", token=super_tok,
|
||||
body={
|
||||
"first_name": "EntityB", "last_name": "Student",
|
||||
"email": f"entityb.student.{entity_b['id']}@iso.test",
|
||||
"create_portal_user": False,
|
||||
"gender": "m",
|
||||
"entity_id": entity_b["id"],
|
||||
},
|
||||
)
|
||||
expect_ok("create student in entity B (as super)", code, stud_b, (200, 201))
|
||||
stud_b_id = (stud_b.get("data") or stud_b)["id"]
|
||||
|
||||
code, tlist = _req(
|
||||
"GET", f"/api/teachers?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
|
||||
)
|
||||
t_items = tlist.get("items") or tlist.get("data") or []
|
||||
existing_t = next((t for t in t_items if 'EntityB' in (t.get('name') or '')), None)
|
||||
if existing_t:
|
||||
teach_b_id = existing_t["id"]
|
||||
print(f" (reusing existing entity-B teacher id={teach_b_id})")
|
||||
else:
|
||||
code, teach_b = _req(
|
||||
"POST", "/api/teachers", token=super_tok,
|
||||
body={
|
||||
"first_name": "EntityB", "last_name": "Teacher",
|
||||
"email": f"entityb.teacher.{entity_b['id']}@iso.test",
|
||||
"gender": "female",
|
||||
"entity_id": entity_b["id"],
|
||||
},
|
||||
)
|
||||
expect_ok("create teacher in entity B (as super)", code, teach_b, (200, 201))
|
||||
teach_b_id = (teach_b.get("data") or teach_b)["id"]
|
||||
|
||||
code, rlist = _req(
|
||||
"GET", f"/api/classrooms?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
|
||||
)
|
||||
r_items = rlist.get("items") or rlist.get("data") or []
|
||||
existing_r = next((r for r in r_items if 'EntityB' in (r.get('name') or '')), None)
|
||||
if existing_r:
|
||||
room_b_id = existing_r["id"]
|
||||
print(f" (reusing existing entity-B classroom id={room_b_id})")
|
||||
else:
|
||||
import time as _t
|
||||
code, room_b = _req(
|
||||
"POST", "/api/classrooms", token=super_tok,
|
||||
body={"name": "EntityB Room", "code": f"EBR{int(_t.time())%100000}", "entity_id": entity_b["id"]},
|
||||
)
|
||||
expect_ok("create classroom in entity B (as super)", code, room_b, (200, 201))
|
||||
room_b_id = (room_b.get("data") or room_b)["id"]
|
||||
|
||||
a_tok = login(A_LOGIN, A_PASSWORD)
|
||||
print(f"\n[4] entity-A admin token acquired (entity A only)")
|
||||
|
||||
print(f"\n[5] visibility checks — entity-A admin must NOT see entity B's records")
|
||||
for path in ("/api/courses", "/api/classrooms", "/api/students", "/api/teachers", "/api/batches"):
|
||||
code, data = _req("GET", f"{path}?size=500", token=a_tok)
|
||||
items = data.get("items") or data.get("data") or []
|
||||
leaks = [
|
||||
it for it in items
|
||||
if (it.get("entity_id") or (it.get("entity") or {}).get("id")) == entity_b["id"]
|
||||
]
|
||||
if leaks:
|
||||
raise AssertionError(f"{path} leaked entity-B records: {leaks[:3]}")
|
||||
print(f" OK {path} hides entity-B ({len(items)} entity-A items)")
|
||||
|
||||
print(f"\n[6] write-path checks — entity-A admin must be BLOCKED from cross-entity writes")
|
||||
code, room_a_list = _req(
|
||||
"GET", f"/api/classrooms?entity_id={entity_a['id']}&size=10", token=a_tok,
|
||||
)
|
||||
rooms_a = room_a_list.get("items") or room_a_list.get("data") or []
|
||||
if not rooms_a:
|
||||
code, room_a = _req(
|
||||
"POST", "/api/classrooms", token=a_tok,
|
||||
body={"name": "Iso Test Class A", "code": "ITA1"},
|
||||
)
|
||||
expect_ok("create classroom in entity A", code, room_a, (200, 201))
|
||||
room_a_id = (room_a.get("data") or room_a)["id"]
|
||||
else:
|
||||
room_a_id = rooms_a[0]["id"]
|
||||
print(f" using classroom A id={room_a_id}")
|
||||
|
||||
code, data = _req(
|
||||
"POST", f"/api/classrooms/{room_a_id}/students", token=a_tok,
|
||||
body={"student_ids": [stud_b_id], "mode": "add"},
|
||||
)
|
||||
expect_blocked("attach entity-B student to entity-A classroom", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", f"/api/classrooms/{room_a_id}/teachers", token=a_tok,
|
||||
body={"teacher_ids": [teach_b_id], "mode": "add"},
|
||||
)
|
||||
expect_blocked("attach entity-B teacher to entity-A classroom", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", f"/api/classrooms/{room_a_id}/assign-course", token=a_tok,
|
||||
body={"course_id": course_b_id, "term_key": "2026-T1"},
|
||||
)
|
||||
expect_blocked("assign entity-B course to entity-A classroom", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", f"/api/classrooms/{room_a_id}/assign-section", token=a_tok,
|
||||
body={"course_id": course_b_id, "section_id": sect_b_id, "term_key": "2026-T1"},
|
||||
)
|
||||
expect_blocked("assign entity-B section to entity-A classroom", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", "/api/batches", token=a_tok,
|
||||
body={"name": "Mixed Batch", "course_id": course_b_id, "term_key": "2026-T1"},
|
||||
)
|
||||
expect_blocked("create batch with entity-B course as entity-A admin", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", "/api/batches", token=a_tok,
|
||||
body={
|
||||
"name": "Mixed Batch 2", "course_id": course_b_id,
|
||||
"course_section_id": sect_b_id, "term_key": "2026-T1",
|
||||
},
|
||||
)
|
||||
expect_blocked("create batch with entity-B section as entity-A admin", code, data)
|
||||
|
||||
code, data = _req(
|
||||
"POST", "/api/batches", token=a_tok,
|
||||
body={"name": "Mixed Batch 3", "teacher_ids": [teach_b_id]},
|
||||
)
|
||||
expect_blocked("create batch with entity-B teacher as entity-A admin", code, data)
|
||||
|
||||
print(f"\n[7] sanity — entity-A admin can still operate on its own classroom")
|
||||
code, room_a_full = _req("GET", f"/api/classrooms/{room_a_id}", token=a_tok)
|
||||
expect_ok("read own classroom A", code, room_a_full)
|
||||
|
||||
print("\nALL ENTITY-ISOLATION CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user