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
255 lines
9.9 KiB
Python
255 lines
9.9 KiB
Python
#!/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)")
|