Files
full_encoach_platform/seed_dynamic_sections_demo_via_api.py
Yamen Ahmad 3b62075d7e 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
2026-04-28 00:17:51 +04:00

311 lines
12 KiB
Python

#!/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)")