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

284 lines
11 KiB
Python

"""End-to-end smoke test for LMS entity isolation.
Verifies that an admin scoped to entity A cannot:
• see entity B's courses / classrooms / batches / students / teachers
• assign an entity-B student into an entity-A classroom
• assign an entity-B teacher into an entity-A classroom
• create a batch that mixes entities (course/classroom/section/teachers)
• assign an entity-B course (or its section) to an entity-A classroom
We do this entirely through the REST API the admin UI uses. We bootstrap
entity B with a *superadmin* (`admin / admin`), then run the cross-entity
attempts as the entity-A admin (`admin@encoach.test`).
"""
from __future__ import annotations
import json
import sys
import urllib.request
import urllib.error
API = "http://127.0.0.1:8069"
SUPER_LOGIN = "admin"
SUPER_PASSWORD = "admin"
A_LOGIN = "admin@encoach.test"
A_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:
payload = r.read().decode() or "{}"
return r.status, json.loads(payload) if payload.strip() else {}
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
try:
parsed = json.loads(body)
except Exception:
parsed = {"raw": body}
return e.code, parsed
def login(login, password):
code, data = _req("POST", "/api/login", body={"login": login, "password": password})
assert code == 200, f"login {login}: {code} {data}"
return data["access_token"]
def expect_ok(label, code, data, ok_codes=(200,)):
if code not in ok_codes:
raise AssertionError(f"{label}: expected {ok_codes}, got {code} -> {data}")
print(f" OK {label} ({code})")
def expect_blocked(label, code, data):
if code in (200, 201):
raise AssertionError(
f"{label}: expected blocked but got {code} -> {data}"
)
print(f" OK {label} → blocked ({code})")
def main():
super_tok = login(SUPER_LOGIN, SUPER_PASSWORD)
print(f"[1] superadmin token acquired")
code, ents = _req("GET", "/api/entities?size=200", token=super_tok)
items = ents.get("items", ents.get("data", []))
by_code = {e.get("code"): e for e in items}
entity_a = next((e for e in items if e.get("id") == 1), items[0] if items else None)
if not entity_a:
print("FATAL: no entities present")
sys.exit(1)
print(f"[2] entity A = {entity_a['name']} (id={entity_a['id']})")
entity_b = next(
(e for e in items if e.get("id") != entity_a["id"] and e.get("code", "").startswith("ISO_TEST")),
None,
)
if not entity_b:
code, created = _req(
"POST", "/api/entities", token=super_tok,
body={"name": "Iso Test Entity", "code": "ISO_TEST", "type": "school"},
)
if code in (200, 201):
entity_b = created.get("data") or created
else:
print(f"FATAL: cannot create entity B: {code} {created}")
sys.exit(1)
print(f"[3] entity B = {entity_b['name']} (id={entity_b['id']})")
code, ulist = _req(
"GET", f"/api/entities/{entity_b['id']}/users", token=super_tok,
)
cur = (ulist.get("items") or ulist.get("data") or []) if code == 200 else []
cur_ids = [u["id"] for u in cur]
if 2 not in cur_ids:
cur_ids.append(2)
code, _ = _req(
"PATCH", f"/api/entities/{entity_b['id']}/users", token=super_tok,
body={"user_ids": cur_ids},
)
if code in (200, 201):
print(f" (super admin uid=2 attached to entity B; users now={cur_ids})")
else:
print(f" WARN: cannot attach uid=2 to entity B: {code}")
code, listing = _req(
"GET", f"/api/courses?entity_id={entity_b['id']}&size=200", token=super_tok,
)
course_items = (listing.get("items") or listing.get("data") or [])
existing = next((c for c in course_items if c.get("code", "").startswith("EB-ISO")), None)
if existing:
course_b_id = existing["id"]
print(f" (reusing existing entity-B course id={course_b_id})")
else:
import time
course_code = f"EB-ISO{int(time.time())%100000}"
code, course_b = _req(
"POST", "/api/courses", token=super_tok,
body={"name": "Entity-B Course", "code": course_code, "entity_id": entity_b["id"]},
)
expect_ok("create course in entity B (as super)", code, course_b, (200, 201))
course_b_id = (course_b.get("data") or course_b)["id"]
code, sect_b = _req(
"POST", f"/api/courses/{course_b_id}/sections/generate-defaults",
token=super_tok, body={"codes": ["A"]},
)
expect_ok("generate section A in entity B (as super)", code, sect_b, (200, 201))
sect_b_id = (sect_b.get("items") or sect_b.get("data"))[0]["id"]
code, slist = _req(
"GET", f"/api/students?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
)
s_items = slist.get("items") or slist.get("data") or []
existing_s = next((s for s in s_items if 'EntityB' in (s.get('name') or '')), None)
if existing_s:
stud_b_id = existing_s["id"]
print(f" (reusing existing entity-B student id={stud_b_id})")
else:
code, stud_b = _req(
"POST", "/api/students", token=super_tok,
body={
"first_name": "EntityB", "last_name": "Student",
"email": f"entityb.student.{entity_b['id']}@iso.test",
"create_portal_user": False,
"gender": "m",
"entity_id": entity_b["id"],
},
)
expect_ok("create student in entity B (as super)", code, stud_b, (200, 201))
stud_b_id = (stud_b.get("data") or stud_b)["id"]
code, tlist = _req(
"GET", f"/api/teachers?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
)
t_items = tlist.get("items") or tlist.get("data") or []
existing_t = next((t for t in t_items if 'EntityB' in (t.get('name') or '')), None)
if existing_t:
teach_b_id = existing_t["id"]
print(f" (reusing existing entity-B teacher id={teach_b_id})")
else:
code, teach_b = _req(
"POST", "/api/teachers", token=super_tok,
body={
"first_name": "EntityB", "last_name": "Teacher",
"email": f"entityb.teacher.{entity_b['id']}@iso.test",
"gender": "female",
"entity_id": entity_b["id"],
},
)
expect_ok("create teacher in entity B (as super)", code, teach_b, (200, 201))
teach_b_id = (teach_b.get("data") or teach_b)["id"]
code, rlist = _req(
"GET", f"/api/classrooms?entity_id={entity_b['id']}&search=EntityB", token=super_tok,
)
r_items = rlist.get("items") or rlist.get("data") or []
existing_r = next((r for r in r_items if 'EntityB' in (r.get('name') or '')), None)
if existing_r:
room_b_id = existing_r["id"]
print(f" (reusing existing entity-B classroom id={room_b_id})")
else:
import time as _t
code, room_b = _req(
"POST", "/api/classrooms", token=super_tok,
body={"name": "EntityB Room", "code": f"EBR{int(_t.time())%100000}", "entity_id": entity_b["id"]},
)
expect_ok("create classroom in entity B (as super)", code, room_b, (200, 201))
room_b_id = (room_b.get("data") or room_b)["id"]
a_tok = login(A_LOGIN, A_PASSWORD)
print(f"\n[4] entity-A admin token acquired (entity A only)")
print(f"\n[5] visibility checks — entity-A admin must NOT see entity B's records")
for path in ("/api/courses", "/api/classrooms", "/api/students", "/api/teachers", "/api/batches"):
code, data = _req("GET", f"{path}?size=500", token=a_tok)
items = data.get("items") or data.get("data") or []
leaks = [
it for it in items
if (it.get("entity_id") or (it.get("entity") or {}).get("id")) == entity_b["id"]
]
if leaks:
raise AssertionError(f"{path} leaked entity-B records: {leaks[:3]}")
print(f" OK {path} hides entity-B ({len(items)} entity-A items)")
print(f"\n[6] write-path checks — entity-A admin must be BLOCKED from cross-entity writes")
code, room_a_list = _req(
"GET", f"/api/classrooms?entity_id={entity_a['id']}&size=10", token=a_tok,
)
rooms_a = room_a_list.get("items") or room_a_list.get("data") or []
if not rooms_a:
code, room_a = _req(
"POST", "/api/classrooms", token=a_tok,
body={"name": "Iso Test Class A", "code": "ITA1"},
)
expect_ok("create classroom in entity A", code, room_a, (200, 201))
room_a_id = (room_a.get("data") or room_a)["id"]
else:
room_a_id = rooms_a[0]["id"]
print(f" using classroom A id={room_a_id}")
code, data = _req(
"POST", f"/api/classrooms/{room_a_id}/students", token=a_tok,
body={"student_ids": [stud_b_id], "mode": "add"},
)
expect_blocked("attach entity-B student to entity-A classroom", code, data)
code, data = _req(
"POST", f"/api/classrooms/{room_a_id}/teachers", token=a_tok,
body={"teacher_ids": [teach_b_id], "mode": "add"},
)
expect_blocked("attach entity-B teacher to entity-A classroom", code, data)
code, data = _req(
"POST", f"/api/classrooms/{room_a_id}/assign-course", token=a_tok,
body={"course_id": course_b_id, "term_key": "2026-T1"},
)
expect_blocked("assign entity-B course to entity-A classroom", code, data)
code, data = _req(
"POST", f"/api/classrooms/{room_a_id}/assign-section", token=a_tok,
body={"course_id": course_b_id, "section_id": sect_b_id, "term_key": "2026-T1"},
)
expect_blocked("assign entity-B section to entity-A classroom", code, data)
code, data = _req(
"POST", "/api/batches", token=a_tok,
body={"name": "Mixed Batch", "course_id": course_b_id, "term_key": "2026-T1"},
)
expect_blocked("create batch with entity-B course as entity-A admin", code, data)
code, data = _req(
"POST", "/api/batches", token=a_tok,
body={
"name": "Mixed Batch 2", "course_id": course_b_id,
"course_section_id": sect_b_id, "term_key": "2026-T1",
},
)
expect_blocked("create batch with entity-B section as entity-A admin", code, data)
code, data = _req(
"POST", "/api/batches", token=a_tok,
body={"name": "Mixed Batch 3", "teacher_ids": [teach_b_id]},
)
expect_blocked("create batch with entity-B teacher as entity-A admin", code, data)
print(f"\n[7] sanity — entity-A admin can still operate on its own classroom")
code, room_a_full = _req("GET", f"/api/classrooms/{room_a_id}", token=a_tok)
expect_ok("read own classroom A", code, room_a_full)
print("\nALL ENTITY-ISOLATION CHECKS PASSED")
if __name__ == "__main__":
main()