Files
full_encoach_platform/smoke_assignment_workflow.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

196 lines
6.8 KiB
Python

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