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
196 lines
6.8 KiB
Python
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()
|