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
311 lines
12 KiB
Python
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)")
|