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:
Yamen Ahmad
2026-04-28 00:17:51 +04:00
parent a6f2140a9d
commit 3b62075d7e
55 changed files with 11150 additions and 878 deletions

47
.cursorindexingignore Normal file
View 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
View File

@@ -26,6 +26,7 @@ pgdata_bak_*/
frontend/.vite/
frontend/dist/
frontend/node_modules/
*.tsbuildinfo
# IDE
.vscode/

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,337 @@
"""Per-student workbook attempt — server-side scoring + persistence.
A student opens an ``interactive_workbook`` material in
``InteractiveWorkbook.tsx``, types/picks/drags answers, and either
clicks "Check answers" (debounced save) or "Submit final" (final save).
Each save creates or updates one ``encoach.course.plan.workbook.attempt``
row keyed by ``(material_id, student_id, attempt_number)``.
The score is always recomputed server-side from the persisted answers
so a tampered client cannot inflate the score. The grading function
handles each of the six exercise types defined in the plan schema:
* ``gap_fill`` — case-insensitive equality, ``alt`` list match.
* ``multiple_choice`` — exact match against ``answer`` (string or letter).
* ``match_pairs`` — set-equality of pair tuples.
* ``reorder_words`` — token-by-token equality (whitespace-tolerant).
* ``transformation`` — equality with normalised punctuation, ``alt``
fallback, capitalisation tolerated.
* ``short_answer`` — glob-style ``accepted`` template (``*`` matches
any phrase) plus exact ``answer`` fallback.
Entity isolation
----------------
Every attempt carries the same ``entity_id`` as the parent plan. A SQL
constraint mirrors the LMS isolation pattern: a student in entity A
cannot create an attempt against a material owned by entity B.
"""
from __future__ import annotations
import json
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Scoring helpers — pure functions so the unit tests can hit them
# directly without spinning up an ORM.
# ----------------------------------------------------------------------
def _norm_text(s) -> str:
if s is None:
return ''
return re.sub(r'\s+', ' ', str(s)).strip().lower()
def _norm_punct(s) -> str:
"""Normalise final punctuation/quotes for transformation checks."""
return re.sub(r'[\s\.\?\!,]+$', '', _norm_text(s))
def _glob_match(pattern: str, value: str) -> bool:
"""Tiny ``*``-only glob matcher used by short-answer templates."""
pat = _norm_text(pattern)
val = _norm_text(value)
if not pat:
return False
if '*' not in pat:
return pat == val
rx = '^' + re.escape(pat).replace(r'\*', r'.*') + '$'
return bool(re.match(rx, val))
def _pairs_equal(answer, given) -> bool:
"""Set-equality check for match_pairs answers."""
def _to_set(p):
out = set()
for it in (p or []):
if isinstance(it, (list, tuple)) and len(it) == 2:
try:
out.add((int(it[0]), int(it[1])))
except (TypeError, ValueError):
return None
return out
a = _to_set(answer)
g = _to_set(given)
if a is None or g is None:
return False
return a == g
def _check_one(ex: dict, given) -> bool:
"""Return True iff ``given`` is a correct answer for ``ex``."""
t = (ex.get('type') or '').lower()
if t == 'gap_fill':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_text(given)
return any(_norm_text(a) == n for a in accepted if a is not None)
if t == 'multiple_choice':
opts = ex.get('options') or []
ans = ex.get('answer')
# The "answer" in the schema can be the literal option string
# ("Paris") OR a single letter ("A","B",…) — accept either.
if ans is None:
return False
if isinstance(given, str):
n = _norm_text(given)
if n == _norm_text(ans):
return True
# Letter-mode: convert "A"/"B" to index → option string.
if isinstance(ans, str) and len(ans) == 1 and ans.isalpha():
idx = ord(ans.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == n:
return True
if isinstance(given, str) and len(given) == 1 and given.isalpha():
idx = ord(given.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == _norm_text(ans):
return True
return False
if t == 'match_pairs':
return _pairs_equal(ex.get('answer'), given)
if t == 'reorder_words':
if given is None:
return False
# Accept either a list of tokens or a joined string.
if isinstance(given, list):
given_str = ' '.join(str(t) for t in given)
else:
given_str = str(given)
return _norm_text(given_str) == _norm_text(ex.get('answer'))
if t == 'transformation':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_punct(given)
return any(_norm_punct(a) == n for a in accepted if a is not None)
if t == 'short_answer':
if given is None or str(given).strip() == '':
return False
accepted = list(ex.get('accepted') or [])
if ex.get('answer'):
accepted.append(str(ex['answer']))
return any(_glob_match(p, given) for p in accepted)
# Unknown / un-checkable type — never auto-mark correct.
return False
def score_exercises(exercises: list[dict], answers: dict) -> dict:
"""Grade ``answers`` against ``exercises``. Pure / safe / deterministic."""
items: list[dict] = []
correct = 0
for ex in exercises or []:
eid = str(ex.get('id') or '')
if not eid:
continue
given = answers.get(eid) if isinstance(answers, dict) else None
ok = _check_one(ex, given)
if ok:
correct += 1
items.append({
'id': eid,
'correct': bool(ok),
'expected': ex.get('answer'),
'given': given,
})
total = len(items)
return {
'items': items,
'correct': correct,
'total': total,
'percent': round((correct / total) * 100, 1) if total else 0.0,
}
# ----------------------------------------------------------------------
# Odoo model
# ----------------------------------------------------------------------
class CoursePlanWorkbookAttempt(models.Model):
_name = 'encoach.course.plan.workbook.attempt'
_description = 'Course-plan interactive workbook attempt'
_order = 'submitted_at desc, id desc'
_rec_name = 'display_name'
material_id = fields.Many2one(
'encoach.course.plan.material',
required=True, ondelete='cascade', index=True,
)
plan_id = fields.Many2one(
related='material_id.plan_id', store=True, index=True,
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
string='Student user',
)
entity_id = fields.Many2one(
'encoach.entity', index=True, string='Entity',
help='Mirrors the parent plan\'s entity for LMS isolation.',
)
answers_json = fields.Text(
default='{}',
help='Per-exercise dict keyed by exercise ID '
'— shape mirrors the schema output by InteractiveWorkbook.tsx.',
)
score_json = fields.Text(
default='{}',
help='Server-side grading output: '
'{items: [{id, correct, expected, given}], correct, total, percent}.',
)
correct_count = fields.Integer(default=0, string='Correct')
total_count = fields.Integer(default=0, string='Total')
percent = fields.Float(default=0.0, digits=(5, 1))
submitted_at = fields.Datetime(
help='Set when the student clicks "Submit final". '
'Until then the row is editable on every "Check answers".',
)
last_updated_at = fields.Datetime(default=fields.Datetime.now)
attempt_number = fields.Integer(default=1)
is_final = fields.Boolean(default=False)
display_name = fields.Char(compute='_compute_display_name', store=False)
_sql_constraints = [
(
'workbook_attempt_unique',
'unique(material_id, student_id, attempt_number)',
'Only one workbook attempt per (material, student, attempt#).',
),
]
# ------------------------------------------------------------------
@api.depends('material_id', 'student_id', 'attempt_number')
def _compute_display_name(self):
for rec in self:
mat = rec.material_id.title or 'Workbook'
who = rec.student_id.name or rec.student_id.login or '?'
rec.display_name = f'{mat}{who} (attempt {rec.attempt_number})'
# ------------------------------------------------------------------
@api.constrains('material_id', 'entity_id')
def _check_entity_isolation(self):
for rec in self:
plan = rec.material_id.plan_id
plan_entity = plan.entity_id.id if (plan and plan.entity_id) else False
if rec.entity_id and plan_entity and rec.entity_id.id != plan_entity:
raise ValidationError(
'Workbook attempt entity must match its plan\'s entity.'
)
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
mat = self.env['encoach.course.plan.material'].sudo().browse(
int(vals.get('material_id') or 0),
)
if mat and mat.exists() and not vals.get('entity_id'):
vals['entity_id'] = (
mat.plan_id.entity_id.id if mat.plan_id and mat.plan_id.entity_id
else False
)
return super().create(vals_list)
# ------------------------------------------------------------------
@staticmethod
def _exercises_from_material(material) -> list[dict]:
try:
body = json.loads(material.body_json or '{}')
except (TypeError, ValueError):
return []
ex = body.get('exercises')
if isinstance(ex, list):
return ex
# Some skill bodies (grammar / vocabulary) embed the workbook
# one level deeper. Search a couple of known keys before giving up.
nested = (
(body.get('interactive_workbook') or {}).get('exercises')
if isinstance(body.get('interactive_workbook'), dict) else None
)
if isinstance(nested, list):
return nested
return []
# ------------------------------------------------------------------
def grade(self, answers: dict, *, finalize: bool = False) -> dict:
"""Re-grade ``answers`` server-side and persist on this row."""
self.ensure_one()
exercises = self._exercises_from_material(self.material_id)
score = score_exercises(exercises, answers or {})
vals = {
'answers_json': json.dumps(answers or {}, ensure_ascii=False),
'score_json': json.dumps(score, ensure_ascii=False),
'correct_count': score['correct'],
'total_count': score['total'],
'percent': score['percent'],
'last_updated_at': fields.Datetime.now(),
}
if finalize:
vals['submitted_at'] = fields.Datetime.now()
vals['is_final'] = True
self.write(vals)
return score
# ------------------------------------------------------------------
def to_api_dict(self) -> dict:
self.ensure_one()
try:
answers = json.loads(self.answers_json or '{}')
except (TypeError, ValueError):
answers = {}
try:
score = json.loads(self.score_json or '{}')
except (TypeError, ValueError):
score = {}
return {
'id': self.id,
'material_id': self.material_id.id,
'plan_id': self.plan_id.id if self.plan_id else None,
'student_id': self.student_id.id,
'student_name': self.student_id.name or self.student_id.login or '',
'attempt_number': self.attempt_number,
'is_final': bool(self.is_final),
'submitted_at': (
self.submitted_at.isoformat() if self.submitted_at else None
),
'last_updated_at': (
self.last_updated_at.isoformat() if self.last_updated_at else None
),
'correct_count': self.correct_count,
'total_count': self.total_count,
'percent': self.percent,
'answers': answers,
'score': score,
}

View File

@@ -7,3 +7,4 @@ access_encoach_course_plan_material_user,encoach.course.plan.material.user,model
access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1
access_encoach_course_plan_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1
10 access_encoach_course_plan_workbook_attempt_user encoach.course.plan.workbook.attempt.user model_encoach_course_plan_workbook_attempt base.group_user 1 1 1 1

View File

@@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .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

View File

@@ -203,6 +203,165 @@ Only include skills present in the week's items list.
"""
# Richer schema used by the RAG-grounded v2 generator. Each skill body now
# carries enough material to fill a real teacher's lesson, plus a
# self-contained ``interactive_workbook`` block whose ``exercises[]`` are
# rendered by ``InteractiveWorkbook.tsx``. The model is instructed to keep
# the structure exact — UI components depend on it.
_WEEK_JSON_HINT_V2 = """
Return JSON with EXACTLY this shape (no extra prose, no surrounding markdown):
{
"materials": [
{
"skill": "reading",
"material_type": "reading_text",
"title": "...",
"summary": "1-2 sentence teacher note (purpose + LOs targeted)",
"body": {
"intro": "lead-in question to activate schema",
"text": "<reading passage 600-900 words at the target CEFR level>",
"glossary": [{"term": "...", "definition": "..."}],
"questions": [
{"id":"r1", "type": "multiple_choice", "stem":"...",
"options":["A","B","C","D"], "answer":"A",
"rationale":"Why this is the correct answer."},
{"id":"r2", "type": "true_false", "stem":"...", "answer": true,
"rationale":"..."},
{"id":"r3", "type": "open", "stem":"...", "model_answer":"..."},
{"id":"r4", "type": "inference", "stem":"...", "model_answer":"..."}
]
}
},
{
"skill": "listening",
"material_type": "listening_script",
"title": "...",
"summary": "...",
"body": {
"context": "Where the conversation/monologue is set.",
"script": "<300-500 word natural script with speaker labels>",
"transcript": "Same as script but cleaned for student handout.",
"comprehension_questions": [
{"id":"l1","type":"multiple_choice","stem":"...","options":["A","B","C","D"],"answer":"A"},
{"id":"l2","type":"open","stem":"...","model_answer":"..."}
],
"listen_and_fill": {
"instructions": "Listen and fill in the missing words.",
"transcript_with_gaps": "I ___ (1) to the ___ (2) every morning.",
"answers": ["go","gym"]
}
}
},
{
"skill": "speaking",
"material_type": "speaking_prompt",
"title": "...",
"summary": "...",
"body": {
"warmup": "30-second pair task to break the ice.",
"lead_in": "Discussion question to set the topic.",
"paired_prompt": "Student A asks; Student B answers; then swap.",
"solo_prompt": "1-minute mini-presentation on the topic.",
"useful_language": ["I usually...", "On the other hand...", "..."],
"model_answer": "Sample 60-90 second answer the teacher can read aloud."
}
},
{
"skill": "writing",
"material_type": "writing_prompt",
"title": "...",
"summary": "...",
"body": {
"brainstorm": ["3-5 prompts to spark ideas before drafting."],
"task": "The actual writing task.",
"word_count": 150,
"model_paragraph": "<a fully written 120-180 word model>",
"annotations": [
{"highlight":"phrase from the model","note":"Why it works."}
],
"checklist": ["Topic sentence", "Linking words", "Range of tenses"]
}
},
{
"skill": "grammar",
"material_type": "grammar_lesson",
"title": "...",
"summary": "...",
"body": {
"rule": "1-2 paragraph rule explanation.",
"form_table": [
{"subject":"I/you/we/they","positive":"work","negative":"don't work","question":"do ... work?"},
{"subject":"he/she/it","positive":"works","negative":"doesn't work","question":"does ... work?"}
],
"examples": ["She works in a hospital.","They don't live here."],
"common_errors": [{"wrong":"He don't like fish.","right":"He doesn't like fish."}],
"interactive_workbook": {
"lesson_plan": {
"warmup":"...","presentation":"...","controlled_practice":"...",
"freer_practice":"...","exit_ticket":"..."
},
"exercises": [
{"id":"g1","type":"gap_fill","stem":"I ___ (be) a student.","answer":"am","alt":["am"],"hint":"present simple of 'be'"},
{"id":"g2","type":"multiple_choice","stem":"She ___ coffee every morning.","options":["drink","drinks","drinking","is drink"],"answer":"drinks","rationale":"3rd person singular adds -s."},
{"id":"g3","type":"transformation","from":"He plays football.","instruction":"Make the sentence negative.","answer":"He doesn't play football.","alt":["He does not play football."]},
{"id":"g4","type":"reorder_words","tokens":["Where","do","you","live","?"],"answer":"Where do you live ?"}
]
}
}
},
{
"skill": "vocabulary",
"material_type": "vocabulary_list",
"title": "...",
"summary": "...",
"body": {
"words": [
{"term":"routine","pos":"n.","definition":"a regular sequence of activities","collocations":["daily routine","morning routine"],"example":"My morning routine is busy."}
],
"interactive_workbook": {
"lesson_plan": {"warmup":"...","presentation":"...","controlled_practice":"...","freer_practice":"...","exit_ticket":"..."},
"exercises": [
{"id":"v1","type":"match_pairs","left":["dog","cat","cow","sheep"],"right":["barks","meows","moos","baas"],"answer":[[0,0],[1,1],[2,2],[3,3]]},
{"id":"v2","type":"short_answer","stem":"Give an example of your daily routine.","answer":"I usually …","accepted":["I usually *","I always *","I often *","I sometimes *"]}
]
}
}
},
{
"skill": "integrated",
"material_type": "interactive_workbook",
"title": "Week N — Practice Workbook",
"summary": "Mixed-skill consolidation tasks — students solve and submit.",
"body": {
"lesson_plan": {
"warmup":"...","presentation":"...","controlled_practice":"...",
"freer_practice":"...","exit_ticket":"..."
},
"exercises": [
{"id":"ex1","type":"gap_fill","stem":"I ___ (live) in London.","answer":"live","alt":["live"]},
{"id":"ex2","type":"multiple_choice","stem":"Which is correct?","options":["He don't","He doesn't","He didn't","He do not"],"answer":"He doesn't","rationale":"3rd person singular negative."},
{"id":"ex3","type":"match_pairs","left":["bus","train","car","plane"],"right":["station","stop","park","airport"],"answer":[[0,1],[1,0],[2,2],[3,3]]},
{"id":"ex4","type":"reorder_words","tokens":["What","time","do","you","get","up","?"],"answer":"What time do you get up ?"},
{"id":"ex5","type":"transformation","from":"She is from Spain.","instruction":"Make a yes/no question.","answer":"Is she from Spain?","alt":["Is she from Spain ?"]},
{"id":"ex6","type":"short_answer","stem":"What's your name?","answer":"My name is …","accepted":["My name is *","I'm *","I am *"]}
]
}
}
]
}
Rules
-----
- Include skills exactly matching the week's items list (do not add or skip).
- Plus ONE additional `interactive_workbook` of skill `integrated` for mixed practice.
- Word counts and CEFR-appropriate vocabulary are MANDATORY — do not output A1 vocabulary in a B2 lesson.
- Every interactive exercise MUST be one of: gap_fill, multiple_choice, match_pairs, reorder_words, transformation, short_answer.
- Provide a clean machine-checkable `answer` (and `alt`/`accepted` where applicable). The frontend grades them server-side.
- Use the `Reference passages` block (when present) as the primary source of vocabulary, examples, and topic. Cite ideas faithfully — do not invent contradicting facts.
- Do not include URLs, copyrighted full pages, or large verbatim quotes. Paraphrase or use only short illustrative phrases.
"""
class CoursePlanPipeline:
"""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
# ------------------------------------------------------------------

View File

@@ -0,0 +1,247 @@
"""Parse a listening-script into per-speaker segments with gender hints.
The course-plan AI emits listening scripts in a "screenplay" form::
Host: Welcome to Travel Tales. Today we have Sarah ...
Sarah: Hi! Yes, I spent two weeks in Italy ...
Host: That sounds amazing. What was your favourite city?
Sarah: Venice was incredible.
Naively feeding that whole blob to a TTS engine reads the speaker
labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one
monotone voice for both characters. That's painful for listening
practice and breaks the very pedagogical signal a dialogue is supposed
to carry — turn-taking, contrasted voices, gendered roles.
This module extracts the labels, classifies each speaker by gender, and
returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS
pipeline can:
1. Strip the labels from the rendered audio (``Host:`` is never spoken).
2. Synthesize each turn with a voice matching the speaker's gender
(Host with a male voice, Sarah with a female voice).
3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real
conversation rather than a single narration.
For monologues (no ``Name:`` prefix anywhere in the script) the parser
returns one segment with ``speaker=None``, so callers can keep the
single-voice fast-path without special-casing.
"""
from __future__ import annotations
import re
from typing import Iterable
# A "speaker tag" is a 13 word capitalized name followed by a colon at
# either start-of-string or right after sentence-final punctuation. We
# anchor with a positive lookbehind so we don't match colons that appear
# inside dialogue ("She said: 'No.'") — only those that actually mark a
# turn boundary.
_SPEAKER_PREFIX = re.compile(
r'(?:^|(?<=[.!?]\s)|(?<=\n))'
r'(?P<name>[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})'
r'\s*:\s*'
)
# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of
# the time when generating English-language listening dialogues. Anything
# missing falls back to alternation by encounter order, which always
# produces alternating male/female voices for the typical 2-speaker
# classroom dialogue.
_FEMALE_NAMES = frozenset({
# English first names
'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane',
'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella',
'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella',
'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica',
'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy',
'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina',
'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve',
# Arabic / regional first names common in the platform's audience
'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma',
'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem',
# Generic role / kinship terms that imply female
'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister',
'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs',
'ms', 'queen', 'princess',
})
_MALE_NAMES = frozenset({
# English first names
'john', 'david', 'michael', 'james', 'robert', 'william', 'richard',
'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew',
'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george',
'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim',
'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah',
'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb',
'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve',
# Arabic / regional first names
'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled',
'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami',
'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal',
# Generic role / kinship terms
'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son',
'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince',
})
# Role nouns that frequently appear as speaker labels in listening
# scripts but carry no inherent gender — we route these through the
# alternation path instead of forcing a default voice on them.
_NEUTRAL_ROLES = frozenset({
'narrator', 'speaker', 'voice', 'person', 'student', 'teacher',
'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer',
'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor',
'patient', 'manager', 'employee', 'colleague', 'friend',
})
def _lookup_gender(name: str) -> str | None:
"""Return ``'male'`` / ``'female'`` from the static name lookup, or
``None`` if the first token isn't recognised.
"""
first = (name or '').strip().lower().split()[0] if name else ''
if first in _FEMALE_NAMES:
return 'female'
if first in _MALE_NAMES:
return 'male'
return None
def parse_dialogue(script: str) -> list[dict]:
"""Split ``script`` into per-speaker segments.
Returns a list of ``{'speaker': str | None, 'gender': str | None,
'text': str}``:
* Multi-speaker dialogue → one entry per turn, with the speaker's
name (preserved as written) and a heuristic gender label.
* Monologue (no ``Name:`` prefix) → a single segment with
``speaker=None`` and ``gender=None`` so callers can short-circuit.
* Empty / whitespace-only script → ``[]``.
The returned ``text`` never includes the speaker label — that's the
whole point: the TTS engine must not say "Host colon ..." aloud.
Gender assignment is two-pass so that unknown / role-only speakers
(Host, Interviewer, etc.) end up *contrasting* with the named
speakers in the same dialogue. Without this a "Host + Sarah"
interview would assign female to both (Sarah by lookup, Host by
default), defeating the whole point of multi-voice rendering.
"""
if not script or not script.strip():
return []
matches = list(_SPEAKER_PREFIX.finditer(script))
if not matches:
return [{'speaker': None, 'gender': None, 'text': script.strip()}]
# Build the list of (name, text) turns first so we can do a global
# gender assignment over the unique speaker set.
turns: list[tuple[str, str]] = []
head = script[: matches[0].start()].strip()
for i, m in enumerate(matches):
name = m.group('name').strip()
end = matches[i + 1].start() if i + 1 < len(matches) else len(script)
text = script[m.end():end].strip()
if text:
turns.append((name, text))
if not turns:
# Edge case: matched some "Name:" labels but every body turned
# out empty. Return the whole pre-match head as a single
# narration, falling back to the script if even that's empty.
return [{'speaker': None, 'gender': None,
'text': head or script.strip()}]
# Pass 1: collect unique speakers in order of first appearance, and
# apply the static name → gender lookup. ``None`` here means
# "we don't know yet — fill in pass 2".
unique_order: list[str] = []
speaker_gender: dict[str, str | None] = {}
for name, _ in turns:
key = name.lower()
if key not in speaker_gender:
unique_order.append(key)
speaker_gender[key] = _lookup_gender(name)
# Pass 2: resolve unknowns. The contract we want is "contrast" —
# any 2-speaker dialogue should produce one male and one female
# voice. So for each unknown speaker we count how many male vs
# female slots are *already* assigned (by lookup or by an earlier
# iteration of this pass) and pick the under-represented gender.
# Pure parity-by-index would re-introduce the Host/Sarah collision.
for key in unique_order:
if speaker_gender[key] is not None:
continue
male_count = sum(1 for v in speaker_gender.values() if v == 'male')
female_count = sum(1 for v in speaker_gender.values() if v == 'female')
if male_count < female_count:
speaker_gender[key] = 'male'
elif female_count < male_count:
speaker_gender[key] = 'female'
else:
# Truly tied (or first unknown in a no-lookup dialogue) —
# alternate based on how many unknowns we've already filled
# so two unknowns in a row get different voices.
assigned_unknowns = sum(
1 for k in unique_order
if speaker_gender[k] is not None and _lookup_gender(k) is None
)
speaker_gender[key] = (
'male' if assigned_unknowns % 2 == 0 else 'female'
)
# Pass 3: emit the segments, including any unattributed pre-match
# narration ("It was a sunny day. Anna: Hi!"). The narration takes
# whichever gender is *under-represented* among the speakers, so it
# contrasts with the dialogue voices instead of duplicating one.
segments: list[dict] = []
if head:
female = sum(1 for v in speaker_gender.values() if v == 'female')
male = sum(1 for v in speaker_gender.values() if v == 'male')
narrator_gender = 'female' if female <= male else 'male'
segments.append(
{'speaker': None, 'gender': narrator_gender, 'text': head},
)
for name, text in turns:
segments.append({
'speaker': name,
'gender': speaker_gender[name.lower()],
'text': text,
})
return segments
def strip_speaker_labels(script: str) -> str:
"""Return ``script`` with all ``Name:`` prefixes removed.
Used when the active TTS provider can't do multi-voice (gTTS only
speaks one voice per call) so we still get a clean recording —
just one narrator reading both turns instead of an awkward
"Host colon ... Sarah colon ...".
"""
if not script:
return ''
return _SPEAKER_PREFIX.sub('', script).strip()
def is_dialogue(segments: Iterable[dict]) -> bool:
"""True if ``segments`` represents a multi-speaker dialogue.
The single-speaker fast-path skips concatenation and just calls the
underlying TTS once with the full text, so this gate has to be
accurate. We require *two distinct named speakers* — a single
speaker who happens to talk in multiple turns (rare but possible
after an intro paragraph) is still a monologue from a TTS
perspective.
"""
seen = set()
for seg in segments:
sp = seg.get('speaker')
if sp:
seen.add(sp.lower())
if len(seen) >= 2:
return True
return False

View File

@@ -0,0 +1,373 @@
"""Mine exercises from indexed reference books and persist them as
``interactive_workbook`` materials on the plan.
Pipeline
--------
1. Walk all chunks belonging to the plan's indexed sources
(``encoach.embedding`` rows with ``content_type='course_plan_source'``
and ``entity_id=plan.id``).
2. Group chunks into N-chunk batches and ask the LLM to identify
exercises in the text — gap-fills, multiple-choice items, matching
pairs, word-reorder, transformation, short-answer.
3. Validate / clean each exercise with cheap heuristics. Drop anything
without a usable answer key. De-dupe against previously-extracted
stems (same plan).
4. For each accepted batch, attach the workbook to the most relevant
week — picked by keyword overlap between the exercise stems and
each week's ``unit + focus``. Falls back to the first week.
5. Persist as one ``encoach.course.plan.material`` per batch with
``material_type='interactive_workbook'`` and an
``extracted_from`` provenance record so the UI can show "Extracted
from <book>".
"""
from __future__ import annotations
import json
import logging
import re
_logger = logging.getLogger(__name__)
# Per LLM call: how many ~2K-char chunks to feed in one go. 6 keeps the
# prompt under ~12K chars, well within a 16K context budget while still
# letting the model see enough surrounding context to identify rubrics.
CHUNKS_PER_BATCH = 6
# Hard cap on total batches per run — protects against runaway cost on a
# very large book. Operators can lift this via ``max_batches`` kwarg.
DEFAULT_MAX_BATCHES = 8
ALLOWED_TYPES = {
'gap_fill', 'multiple_choice', 'match_pairs',
'reorder_words', 'transformation', 'short_answer',
}
_EXTRACT_HINT = """
You are reading raw text extracted from a printed English exercise book.
Identify any drills, controlled-practice exercises, or workbook tasks in
the passage and extract them as machine-checkable JSON.
Return JSON with EXACTLY this shape:
{
"exercises": [
{"id":"e1", "type":"gap_fill", "stem":"I ___ (be) a student.", "answer":"am", "alt":["am"], "hint":"present simple of 'be'"},
{"id":"e2", "type":"multiple_choice", "stem":"...", "options":["A","B","C","D"], "answer":"B", "rationale":"..."},
{"id":"e3", "type":"match_pairs", "left":["dog","cat"], "right":["barks","meows"], "answer":[[0,0],[1,1]]},
{"id":"e4", "type":"reorder_words", "tokens":["I","am","a","student"], "answer":"I am a student"},
{"id":"e5", "type":"transformation", "from":"He plays football.", "instruction":"Make it negative.", "answer":"He doesn't play football.", "alt":["He does not play football."]},
{"id":"e6", "type":"short_answer", "stem":"What's your name?", "answer":"My name is …", "accepted":["My name is *","I'm *","I am *"]}
],
"topic_keywords": ["routine","present simple", "..."],
"page_hint": "if the source mentions 'p. 12' or 'Unit 1', echo it here"
}
Strict rules
------------
- Skip narrative prose, scope-and-sequence pages, answer keys.
- Skip exercises whose answer cannot be inferred from the source.
- Use ONLY the six types listed above; never invent a new type.
- Stems must be self-contained (no "look at exercise 3").
- Answers must be a string, a boolean, or a list of [int,int] pairs as
shown — never a sentence like "answer key on p. 99".
- Output MUST be valid JSON. If the passage contains no usable
exercises, return ``{"exercises": [], "topic_keywords": [], "page_hint": ""}``.
"""
def _normalize_stem(stem: str) -> str:
"""Lowercase + collapse whitespace — used for de-dup keys."""
return re.sub(r'\s+', ' ', (stem or '').strip().lower())
def _is_clean_exercise(ex: dict) -> bool:
"""Cheap structural validation. Drops anything we can't grade."""
t = (ex.get('type') or '').lower()
if t not in ALLOWED_TYPES:
return False
if not (ex.get('stem') or ex.get('from') or ex.get('left') or ex.get('tokens')):
return False
ans = ex.get('answer')
if ans is None or ans == '' or ans == []:
return False
if t == 'multiple_choice':
opts = ex.get('options') or []
if not isinstance(opts, list) or len(opts) < 2:
return False
if isinstance(ans, str) and ans not in opts:
# Some authors use "B" as the answer; that's fine.
if not (len(ans) == 1 and ans.isalpha()):
return False
if t == 'match_pairs':
if not (isinstance(ex.get('left'), list)
and isinstance(ex.get('right'), list)
and isinstance(ans, list)):
return False
if not all(isinstance(p, list) and len(p) == 2 for p in ans):
return False
if t == 'reorder_words':
toks = ex.get('tokens')
if not (isinstance(toks, list) and len(toks) >= 2):
return False
return True
class ExerciseExtractor:
"""Walk the plan's indexed corpus and persist interactive workbooks."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, language: str = 'en'):
self.env = env
self.language = language
try:
from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService,
)
except ImportError:
OpenAIService = None
self._OpenAIService = OpenAIService
self._ai = None # lazy
# ------------------------------------------------------------------
def _service(self):
if self._ai is not None:
return self._ai
if self._OpenAIService is None:
raise RuntimeError('encoach_ai.OpenAIService not available')
self._ai = self._OpenAIService(self.env, language=self.language)
return self._ai
# ------------------------------------------------------------------
def _load_corpus(self, plan) -> list[dict]:
"""Return all indexed chunks for the plan, oldest-first."""
Embedding = self.env['encoach.embedding'].sudo()
rows = Embedding.search([
('content_type', '=', self.CONTENT_TYPE),
('entity_id', '=', plan.id),
], order='content_id asc, chunk_index asc')
chunks = []
for r in rows:
metadata = {}
try:
metadata = json.loads(r.metadata_json or '{}')
except (TypeError, ValueError):
pass
chunks.append({
'source_id': int(metadata.get('source_id') or r.content_id),
'title': metadata.get('title') or '',
'chunk_index': r.chunk_index,
'text': r.content_text or '',
})
return chunks
# ------------------------------------------------------------------
def _existing_stems(self, plan) -> set[str]:
"""Already-stored normalized stems → avoid duplicate workbooks."""
Material = self.env['encoach.course.plan.material'].sudo()
rows = Material.search([
('plan_id', '=', plan.id),
('material_type', '=', 'interactive_workbook'),
])
seen: set[str] = set()
for m in rows:
try:
body = json.loads(m.body_json or '{}')
except (TypeError, ValueError):
continue
for ex in body.get('exercises') or []:
key = _normalize_stem(ex.get('stem') or ex.get('from') or '')
if key:
seen.add(key)
return seen
# ------------------------------------------------------------------
@staticmethod
def _pick_week(plan, topic_keywords: list[str], stems: list[str]):
"""Return the week most relevant to a batch's topic keywords.
Falls back to week 1 when the plan has no overlap signal — that's
better than dropping the workbook entirely.
"""
weeks = list(plan.week_ids.sorted('week_number'))
if not weeks:
return None
haystack = ' '.join(topic_keywords + stems).lower()
if not haystack.strip():
return weeks[0]
def score(week):
text = f'{week.unit or ""} {week.focus or ""}'.lower()
terms = [t for t in re.split(r'\W+', text) if len(t) > 3]
return sum(1 for t in terms if t in haystack)
ranked = sorted(weeks, key=score, reverse=True)
# If nothing scored at all, prefer week 1 for stability.
return ranked[0] if score(ranked[0]) > 0 else weeks[0]
# ------------------------------------------------------------------
def _llm_extract_batch(self, chunks: list[dict]) -> dict:
"""Run one extraction LLM call against a batch of chunks."""
ai = self._service()
joined = '\n\n---\n\n'.join(
f'[chunk {c["chunk_index"]} | source #{c["source_id"]}]\n{c["text"]}'
for c in chunks
)
messages = [
{
'role': 'system',
'content': (
'You extract original printed exercises from English '
'workbook scans. You return STRICT JSON. You never '
'invent exercises that are not on the page.'
),
},
{
'role': 'user',
'content': (
f'Extract the exercises from the passage below.\n\n'
f'{_EXTRACT_HINT}\n\nPassage:\n{joined}'
),
},
]
try:
return ai.chat_json(
messages,
temperature=0.1,
max_tokens=4000,
action='course_plan.extract_exercises',
) or {}
except Exception as exc:
_logger.warning('Exercise extraction LLM call failed: %s', exc)
return {}
# ------------------------------------------------------------------
def run(self, plan, *, max_batches: int = DEFAULT_MAX_BATCHES) -> dict:
"""Extract exercises for ``plan`` and persist workbook materials.
:returns: ``{materials_created, exercises_total, batches_run, skipped}``.
"""
chunks = self._load_corpus(plan)
if not chunks:
return {
'materials_created': 0,
'exercises_total': 0,
'batches_run': 0,
'skipped': 0,
'reason': 'no indexed chunks for this plan',
}
seen_stems = self._existing_stems(plan)
Material = self.env['encoach.course.plan.material'].sudo()
materials_created = 0
exercises_total = 0
batches_run = 0
skipped_dup = 0
# Group chunks per source so a single workbook stays cohesive.
by_source: dict[int, list[dict]] = {}
for ch in chunks:
by_source.setdefault(ch['source_id'], []).append(ch)
for source_id, source_chunks in by_source.items():
for i in range(0, len(source_chunks), CHUNKS_PER_BATCH):
if batches_run >= max_batches:
break
batch = source_chunks[i:i + CHUNKS_PER_BATCH]
batches_run += 1
payload = self._llm_extract_batch(batch)
if not payload or 'error' in payload:
continue
raw_exercises = payload.get('exercises') or []
clean: list[dict] = []
for ex in raw_exercises:
if not isinstance(ex, dict):
continue
if not _is_clean_exercise(ex):
continue
key = _normalize_stem(
ex.get('stem') or ex.get('from') or '',
)
if not key or key in seen_stems:
skipped_dup += 1
continue
seen_stems.add(key)
clean.append(ex)
if not clean:
continue
topic_keywords = [
str(t).strip() for t in (payload.get('topic_keywords') or [])
if str(t).strip()
]
page_hint = (payload.get('page_hint') or '').strip()
stems = [ex.get('stem') or ex.get('from') or '' for ex in clean]
week = self._pick_week(plan, topic_keywords, stems)
if week is None:
continue
title = self._pick_title(batch, topic_keywords, week)
summary = (
f'Extracted from {batch[0].get("title") or "uploaded book"}'
+ (f' ({page_hint})' if page_hint else '')
+ f'{len(clean)} interactive exercise(s).'
)
body = {
'lesson_plan': {
'warmup': '',
'presentation': (
'Originally printed exercises mined from the '
'reference book. Use them as in-class drills '
'or homework.'
),
'controlled_practice': '',
'freer_practice': '',
'exit_ticket': '',
},
'exercises': clean,
}
provenance = {
'source_id': int(source_id),
'source_title': batch[0].get('title') or '',
'page_hint': page_hint,
'topic_keywords': topic_keywords,
'chunk_indices': [c['chunk_index'] for c in batch],
}
Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': 'integrated',
'material_type': 'interactive_workbook',
'title': title,
'summary': summary,
'body_json': json.dumps(body, ensure_ascii=False),
'body_text': '',
'extracted_from_json': json.dumps(
provenance, ensure_ascii=False,
),
})
materials_created += 1
exercises_total += len(clean)
if batches_run >= max_batches:
break
return {
'materials_created': materials_created,
'exercises_total': exercises_total,
'batches_run': batches_run,
'skipped': skipped_dup,
}
# ------------------------------------------------------------------
@staticmethod
def _pick_title(batch, topic_keywords, week) -> str:
"""Compose a friendly material title for the extracted workbook."""
topic = ''
if topic_keywords:
topic = topic_keywords[0]
if not topic and week and (week.unit or week.focus):
topic = (week.unit or week.focus or '').strip()
if not topic:
topic = 'Practice'
return f'Workbook — {topic.title()}'

View File

@@ -31,17 +31,42 @@ import logging
import os
import 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',

View File

@@ -0,0 +1,281 @@
"""Build RAG context for course-plan generation.
Wraps :class:`encoach_vector.services.embedding_service.EmbeddingService` to
return the top-k chunks from a plan's indexed reference sources, scoped to
the canonical ``content_type='course_plan_source'`` and trimmed to a
character budget so the LLM prompt stays within context limits.
Indexer convention
------------------
:class:`SourceIndexer` stores each ``encoach.course.plan.source`` chunk
with metadata ``{plan_id, source_id, kind, title, entity_id=plan_id}``
(see :mod:`source_indexer`). This builder uses that convention to scope
retrieval to a single plan: it filters embeddings on ``entity_id=plan.id``
and ``content_type='course_plan_source'``.
Usage
-----
.. code-block:: python
builder = RAGContextBuilder(env)
ctx = builder.for_week(plan, week, skill='reading', k=6)
if ctx['passages']:
prompt += "\\n\\nReference passages from your uploaded book(s):\\n"
prompt += ctx['as_prompt_block']
"""
from __future__ import annotations
import logging
_logger = logging.getLogger(__name__)
# Soft char budget for the assembled passage block. ~3500 chars is roughly
# 800-1000 tokens, leaving room for the rest of the prompt (~2-3 K tokens)
# under a 16 K context window.
DEFAULT_CHAR_BUDGET = 3500
DEFAULT_K = 6
class RAGContextBuilder:
"""Produce RAG passages for a (plan, week, skill) tuple."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, char_budget: int = DEFAULT_CHAR_BUDGET):
self.env = env
self.char_budget = int(char_budget)
self._svc = None # lazily created on first call
# ------------------------------------------------------------------
def _service(self):
"""Lazy-load the embedding service so import never fails fatally."""
if self._svc is not None:
return self._svc
try:
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
except ImportError:
_logger.warning(
'encoach_vector not installed; RAG context disabled.'
)
return None
self._svc = EmbeddingService(self.env)
return self._svc
# ------------------------------------------------------------------
def has_indexed_sources(self, plan) -> bool:
"""Return True iff the plan has at least one indexed (status='indexed') source."""
try:
count = self.env['encoach.course.plan.source'].sudo().search_count([
('plan_id', '=', plan.id),
('status', '=', 'indexed'),
])
except Exception:
return False
return bool(count)
# ------------------------------------------------------------------
def _build_query(self, plan, week, skill: str) -> str:
"""Synthesize a focused retrieval query for (week, skill).
The query is intentionally dense: skill, week unit + focus, and the
descriptions of the outcomes targeted by this week's items. We feed
the descriptions (not just codes) so semantic search can match book
passages that talk about, say, "personal introductions" even when
no outcome code appears in the source text.
"""
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, []) if week else []
codes_for_skill: list[str] = []
for it in items:
if (it.get('skill') or '').lower() == (skill or '').lower():
codes_for_skill.extend(it.get('outcome_codes') or [])
outcome_descs: list[str] = []
skill_outcomes = outcomes.get((skill or '').lower()) or []
for o in skill_outcomes:
if o.get('code') in set(codes_for_skill):
outcome_descs.append((o.get('description') or '').strip())
parts = [
(skill or '').strip(),
(week.unit or '').strip() if week else '',
(week.focus or '').strip() if week else '',
' '.join(d for d in outcome_descs if d),
]
query = ''.join(p for p in parts if p)
return query or (plan.name or 'general english')
# ------------------------------------------------------------------
def _trim_to_budget(self, results: list[dict]) -> tuple[list[dict], int]:
"""Cut chunks until total chars <= self.char_budget.
Returns the truncated list and the actual char count used.
"""
out: list[dict] = []
used = 0
for r in results:
text = (r.get('text') or '').strip()
if not text:
continue
# Per-chunk soft cap so one giant page can't eat the whole budget.
if len(text) > 1200:
text = text[:1200].rsplit(' ', 1)[0] + ''
if used + len(text) > self.char_budget:
# If we have nothing yet, take a hard slice so the prompt is
# never empty when the budget is tiny.
if not out:
text = text[: max(self.char_budget // 2, 200)]
else:
break
out.append({**r, 'text': text})
used += len(text)
return out, used
# ------------------------------------------------------------------
def for_week(self, plan, week, skill: str, *, k: int = DEFAULT_K) -> dict:
"""Return RAG context for a (plan, week, skill).
:returns: ``{passages, sources, char_budget_used, query, as_prompt_block}``.
Empty dict-shape when the plan has no indexed sources.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None:
return empty
if not self.has_indexed_sources(plan):
return empty
query = self._build_query(plan, week, skill)
try:
raw_results = svc.search(
query,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id, # SourceIndexer stores plan_id under entity_id
)
except Exception:
_logger.exception('RAG search failed for plan=%s skill=%s', plan.id, skill)
return empty
# Decorate with source titles for citation.
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': query,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
def for_plan(self, plan, *, k: int = 12, query: str | None = None) -> dict:
"""Plan-wide retrieval — used by ExerciseExtractor and the workbook
cross-week pass. Falls back to ``plan.name`` when no query is given.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None or not self.has_indexed_sources(plan):
return empty
q = (query or plan.name or 'exercises').strip()
try:
raw_results = svc.search(
q,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id,
)
except Exception:
_logger.exception('RAG plan-wide search failed for plan=%s', plan.id)
return empty
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': q,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
@staticmethod
def _collect_sources(passages: list[dict]) -> list[dict]:
"""Group passages by source_id → ``[{source_id, title, chunks_used}]``."""
agg: dict[int, dict] = {}
for p in passages:
sid = p.get('source_id') or 0
entry = agg.setdefault(sid, {
'source_id': sid,
'title': p.get('source_title') or '',
'chunks_used': 0,
})
entry['chunks_used'] += 1
if not entry['title']:
entry['title'] = p.get('source_title') or ''
return [v for v in agg.values() if v['source_id']]
# ------------------------------------------------------------------
@staticmethod
def _render_prompt_block(passages: list[dict]) -> str:
"""Format passages as a citation-ready prompt block."""
if not passages:
return ''
lines: list[str] = []
for i, p in enumerate(passages, start=1):
title = p.get('source_title') or 'Reference'
lines.append(f'[{i}] ({title})\n{p.get("text", "")}\n')
return '\n'.join(lines).strip()

View File

@@ -20,17 +20,140 @@ from __future__ import annotations
import base64
import 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)

View File

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

View File

@@ -0,0 +1,174 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _entity_scope():
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
user = request.env.user.sudo()
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None):
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', ids)]
def _ser_branch(rec):
return {
'id': rec.id,
'name': rec.name or '',
'code': rec.code or '',
'active': bool(rec.active),
'notes': rec.notes or '',
'entity_id': rec.entity_id.id if rec.entity_id else None,
'entity_name': rec.entity_id.name if rec.entity_id else '',
'course_count': rec.course_count or 0,
'batch_count': rec.batch_count or 0,
'student_count': rec.student_count or 0,
'teacher_count': rec.teacher_count or 0,
}
class LmsBranchController(http.Controller):
@http.route('/api/branches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_branches(self, **kw):
try:
Branch = request.env['encoach.lms.branch'].sudo()
domain = _scoped_entity_domain([])
if kw.get('search'):
q = kw['search']
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Branch.search_count(domain)
recs = Branch.search(domain, offset=offset, limit=limit, order='name asc, id asc')
items = [_ser_branch(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as exc:
_logger.exception('list_branches failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('get_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_branch(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
vals = {
'name': name,
'entity_id': entity_id,
'code': (body.get('code') or '').strip() or False,
'notes': (body.get('notes') or '').strip(),
}
if 'active' in body:
vals['active'] = bool(body.get('active'))
rec = request.env['encoach.lms.branch'].sudo().create(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('create_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip() or False
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
if 'entity_id' in body and body.get('entity_id'):
vals['entity_id'] = _ensure_entity_access(int(body.get('entity_id')))
if vals:
rec.write(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('update_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if rec.exists():
_ensure_entity_access(rec.entity_id.id)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('delete_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

View File

@@ -1,4 +1,30 @@
"""HTTP controller for classrooms (homerooms).
Routes:
- GET /api/classrooms list (entity-scoped)
- GET /api/classrooms/<id> fetch with full nested data
- POST /api/classrooms create
- PATCH /api/classrooms/<id> partial update
- DELETE /api/classrooms/<id> delete
- GET /api/classrooms/<id>/students list roster
- POST /api/classrooms/<id>/students set/add students (body: {student_ids:[..], mode:'set'|'add'})
- DELETE /api/classrooms/<id>/students remove students (body: {student_ids:[..]})
- GET /api/classrooms/<id>/teachers list homeroom teachers
- POST /api/classrooms/<id>/teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'})
- GET /api/classrooms/<id>/courses list courses
- POST /api/classrooms/<id>/assign-course cascade course → batch + enroll students
- DELETE /api/classrooms/<id>/courses/<cid> detach course (does NOT delete batches/enrollments)
- GET /api/classrooms/<id>/batches list classroom batches
The legacy ``/api/groups`` aliases on op.classroom remain for backward
compatibility but new clients should target ``/api/classrooms``.
"""
import logging
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)

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
from odoo import api, fields, models
class EncoachLmsBranch(models.Model):
_name = "encoach.lms.branch"
_description = "EnCoach LMS Branch"
_order = "name asc, id asc"
name = fields.Char(required=True)
code = fields.Char(index=True)
active = fields.Boolean(default=True)
entity_id = fields.Many2one(
"encoach.entity",
required=True,
ondelete="cascade",
index=True,
string="Entity",
)
notes = fields.Text()
course_ids = fields.One2many("op.course", "branch_id")
batch_ids = fields.One2many("op.batch", "branch_id")
student_ids = fields.One2many("op.student", "branch_id")
teacher_ids = fields.One2many("op.faculty", "branch_id")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
_sql_constraints = [
("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."),
]
@api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids")
def _compute_counts(self):
for rec in self:
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("code") and vals.get("name"):
vals["code"] = vals["name"].upper().replace(" ", "_")[:24]
return super().create(vals_list)

View File

@@ -0,0 +1,270 @@
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
class OpClassroomExt(models.Model):
"""Extend the OpenEduCat classroom into a homeroom (class group).
Original ``op.classroom`` represents a physical room (with capacity and
facilities). We keep that semantic and add organisational fields so the
same record also acts as the home of a group of students:
* ``student_ids`` — homeroom roster (M2M).
* ``teacher_ids`` — homeroom teachers (M2M).
* ``course_ids`` — courses taught to this classroom (M2M).
* ``batch_ids`` — auto-created batches (one per classroom × course).
``assign_course`` is idempotent: re-assigning the same course reuses the
existing batch and only enrolls students that aren't already enrolled.
"""
_inherit = "op.classroom"
entity_id = fields.Many2one(
"encoach.entity",
string="Entity",
ondelete="set null",
index=True,
help="Owning entity/organization for LMS isolation.",
)
branch_id = fields.Many2one(
"encoach.lms.branch",
string="Branch",
ondelete="set null",
index=True,
help="Optional branch/campus inside the owning entity.",
)
student_ids = fields.Many2many(
"op.student",
"op_classroom_student_rel",
"classroom_id",
"student_id",
string="Students",
)
teacher_ids = fields.Many2many(
"op.faculty",
"op_classroom_faculty_rel",
"classroom_id",
"faculty_id",
string="Homeroom Teachers",
)
course_ids = fields.Many2many(
"op.course",
"op_classroom_course_rel",
"classroom_id",
"course_id",
string="Courses",
)
batch_ids = fields.One2many(
"op.batch",
"classroom_id",
string="Batches",
)
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
notes = fields.Text(string="Notes")
@api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids")
def _compute_counts(self):
for rec in self:
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
@api.constrains("branch_id", "entity_id")
def _check_branch_entity(self):
for rec in self:
if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id:
raise ValidationError(
"Classroom branch must belong to the same entity as the classroom."
)
# ------------------------------------------------------------------
# Cascade: assign a course to this classroom
# ------------------------------------------------------------------
def _ensure_unique_batch_code(self, base_code):
"""Return a unique batch code derived from ``base_code``.
``op.batch`` has a global ``unique(code)`` constraint, so we suffix
with a counter when needed.
"""
Batch = self.env["op.batch"].sudo()
candidate = base_code
idx = 1
while Batch.search_count([("code", "=", candidate)]):
idx += 1
candidate = f"{base_code}-{idx}"
return candidate
def assign_course(
self,
course,
term_name=None,
start_date=None,
end_date=None,
teacher_ids=None,
section_id=None,
term_key=None,
):
"""Assign ``course`` to this classroom and cascade to a batch.
- Creates (or reuses) the canonical batch for
``(classroom, course, section, term_key)``.
- Enrolls every classroom student into the batch via ``op.student.course``.
- Optionally sets ``teacher_ids`` (M2M) on the batch; if none is
provided we default to the classroom's homeroom teachers.
Returns the ``op.batch`` record.
"""
self.ensure_one()
Course = self.env["op.course"].sudo()
Section = self.env["encoach.course.section"].sudo()
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
if isinstance(course, int):
course = Course.browse(course)
if not course or not course.exists():
raise UserError("Course not found.")
if (
self.entity_id
and course.entity_id
and self.entity_id.id != course.entity_id.id
):
raise ValidationError(
"Course belongs to a different entity than the classroom."
)
section = None
if section_id:
section = Section.browse(int(section_id))
if not section.exists():
raise UserError("Course section not found.")
if section.course_id.id != course.id:
raise ValidationError("Selected section does not belong to the selected course.")
if (
self.entity_id
and section.entity_id
and self.entity_id.id != section.entity_id.id
):
raise ValidationError(
"Course section belongs to a different entity than the classroom."
)
if teacher_ids is not None and self.entity_id:
teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids))
for t in teachers:
if (
t.exists()
and t.entity_id
and t.entity_id.id != self.entity_id.id
):
raise ValidationError(
f"Teacher {t.partner_id.name or t.id} belongs to a "
"different entity than the classroom."
)
# Add to classroom course list (idempotent)
if course.id not in self.course_ids.ids:
self.write({"course_ids": [(4, course.id)]})
# Find or create the canonical batch (classroom × course × section × term)
domain = [
("classroom_id", "=", self.id),
("course_id", "=", course.id),
("course_section_id", "=", section.id if section else False),
("term_key", "=", term_key or False),
]
batch = Batch.search(domain, limit=1)
if not batch:
section_label = f" — Section {section.code}" if section else ""
term_label = f" ({term_name})" if term_name else ""
base_name = f"{self.name}{course.name or course.code or 'Course'}{section_label}{term_label}"
base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12]
section_code = (section.code if section else "").upper().replace(" ", "")
term_code = (term_key or "").upper().replace(" ", "")
base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}"
if section_code:
base_code = f"{base_code}-{section_code[:5]}"
if term_code:
base_code = f"{base_code}-{term_code[:4]}"
today = fields.Date.context_today(self)
vals = {
"name": base_name,
"code": self._ensure_unique_batch_code(base_code[:16]),
"course_id": course.id,
"classroom_id": self.id,
"course_section_id": section.id if section else False,
"term_key": term_key or False,
"start_date": start_date or today,
"end_date": end_date or today.replace(month=12, day=31),
"entity_id": self.entity_id.id if self.entity_id else False,
"branch_id": self.branch_id.id if self.branch_id else False,
}
batch = Batch.create(vals)
# Teacher assignment (M2M) — explicit overrides homeroom defaults
target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids
if target_teachers:
batch.write({"teacher_ids": [(6, 0, list(target_teachers))]})
# Enroll classroom roster into the batch (idempotent)
for student in self.student_ids:
existing = Enroll.search([
("student_id", "=", student.id),
("course_id", "=", course.id),
("batch_id", "=", batch.id),
], limit=1)
if not existing:
Enroll.create({
"student_id": student.id,
"course_id": course.id,
"batch_id": batch.id,
"state": "running",
})
return batch
@api.model
def _add_students_to_open_batches(self, classroom_id, student_ids):
"""When new students join the classroom, enroll them in every existing
course batch under that classroom.
"""
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
classroom = self.browse(classroom_id)
if not classroom.exists():
return
batches = Batch.search([("classroom_id", "=", classroom.id)])
for batch in batches:
for sid in student_ids:
exists = Enroll.search([
("student_id", "=", sid),
("course_id", "=", batch.course_id.id),
("batch_id", "=", batch.id),
], limit=1)
if not exists:
Enroll.create({
"student_id": sid,
"course_id": batch.course_id.id,
"batch_id": batch.id,
"state": "running",
})
def write(self, vals):
"""Cascade roster changes into existing classroom batches."""
old_students = {rec.id: set(rec.student_ids.ids) for rec in self}
res = super().write(vals)
if "student_ids" in vals:
for rec in self:
added = set(rec.student_ids.ids) - old_students.get(rec.id, set())
if added:
self._add_students_to_open_batches(rec.id, list(added))
return res

View File

@@ -1,4 +1,61 @@
from odoo import models, fields, api
from odoo.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."
)

View File

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

190
docs/ASSIGNMENT_WORKFLOW.md Normal file
View 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 1324 → Save | Section A has 12 students. |
| 7 | Split the roster | Tab 4 → on Section B's batch click *Manage* → tab Students → uncheck students 112 → 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 69 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`.*

View 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=<8VlMrLE&#8rLS_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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View File

@@ -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: "إجراءات سريعة",

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`);
},
};

View File

@@ -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`,
);
},
};

View File

@@ -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}`);
},

View File

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

View File

@@ -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[];
}

View File

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

View File

@@ -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
View 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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
}
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())

View 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)")

View 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)")

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