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

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