#!/usr/bin/env python3 """ API-level write-flow sweep for institutional pages. Exercises CREATE / EDIT / DELETE / WORKFLOW for every institutional model via the public HTTP API (same endpoints the frontend uses). Usage: python3 test_write_flows.py """ import json import sys import time from datetime import date, timedelta from urllib import request as urlreq from urllib.error import HTTPError BASE = "http://localhost:8069/api" TOKEN = None # ── Color helpers ──────────────────────────────────────────────────────── GREEN = "\033[32m"; RED = "\033[31m"; YELLOW = "\033[33m"; RESET = "\033[0m"; BOLD = "\033[1m" results = [] # list of (page, action, ok, note) def log(page, action, ok, note=""): results.append((page, action, ok, note)) tag = f"{GREEN}PASS{RESET}" if ok else f"{RED}FAIL{RESET}" print(f" [{tag}] {page:30s} {action:30s} {note}") # ── HTTP helpers ───────────────────────────────────────────────────────── def _req(method, path, body=None, params=None): url = BASE + path if params: qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) url += ("?" + qs) if qs else "" data = None headers = {"Accept": "application/json"} if TOKEN: headers["Authorization"] = f"Bearer {TOKEN}" if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" req = urlreq.Request(url, data=data, method=method, headers=headers) try: with urlreq.urlopen(req, timeout=20) as resp: raw = resp.read().decode() return resp.status, (json.loads(raw) if raw else {}) except HTTPError as e: raw = e.read().decode() if e.fp else "" try: body = json.loads(raw) except Exception: body = {"error": raw[:200]} return e.code, body except Exception as e: return 0, {"error": str(e)} def GET(path, **params): return _req("GET", path, params=params) def POST(path, body=None): return _req("POST", path, body=body or {}) def PATCH(path, body=None): return _req("PATCH", path, body=body or {}) def DELETE(path): return _req("DELETE", path) def ok(status): return 200 <= status < 300 def items(payload): return payload.get("data") or payload.get("items") or [] # ── Auth ───────────────────────────────────────────────────────────────── def login(): global TOKEN url = "http://localhost:8069/api/login" data = json.dumps({"login": "admin", "password": "admin"}).encode() req = urlreq.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"}) with urlreq.urlopen(req, timeout=15) as resp: body = json.loads(resp.read().decode()) TOKEN = (body.get("data") or {}).get("access_token") or body.get("access_token") or body.get("token") assert TOKEN, f"no token in {body}" print(f"{BOLD}✓ Logged in (token len={len(TOKEN)}){RESET}\n") # ── 1. Academic Years ──────────────────────────────────────────────────── def test_academic_years(): print(f"{BOLD}== /admin/academic-years =={RESET}") tag = int(time.time()) % 100000 s, b = POST("/academic-years", { "name": f"AY Test {tag}", "start_date": "2028-01-01", "end_date": "2028-12-31", "term_structure": "three_sem", }) if isinstance(b, list): b = b[0] if b else {} yid = (b or {}).get("id") or ((b or {}).get("data") or {}).get("id") log("academic-years", "CREATE", ok(s) and bool(yid), f"status={s} id={yid}") if not yid: return s, b = POST(f"/academic-years/{yid}/generate-terms", {"replace": True}) terms = [] if isinstance(b, list): terms = b elif isinstance(b, dict): terms = (b.get("data") or {}).get("terms") or b.get("terms") or [] if not terms and isinstance(b.get("data"), list): terms = b["data"] log("academic-years", "WORKFLOW generate_terms", ok(s) and len(terms) == 3, f"status={s} terms={len(terms)}") # EDIT s, b = PATCH(f"/academic-years/{yid}", {"name": f"AY 2028 Renamed {int(time.time()*1000)}"}) log("academic-years", "EDIT", ok(s), f"status={s}") # DELETE s, _ = DELETE(f"/academic-years/{yid}") log("academic-years", "DELETE", ok(s), f"status={s}") # ── 2. Departments ─────────────────────────────────────────────────────── def test_departments(): print(f"{BOLD}== /admin/departments =={RESET}") s, b = POST("/departments", {"name": "Quality Assurance", "code": "QA"}) did = b.get("id") or (b.get("data") or {}).get("id") log("departments", "CREATE", ok(s) and did, f"status={s} id={did}") if not did: return s, _ = PATCH(f"/departments/{did}", {"name": "Quality Assurance Office"}) log("departments", "EDIT", ok(s), f"status={s}") s, _ = DELETE(f"/departments/{did}") log("departments", "DELETE", ok(s), f"status={s}") # ── 3. Admission register + admission ──────────────────────────────────── def test_admission_register(): print(f"{BOLD}== /admin/admission-register =={RESET}") # Grab a course id _, clist = GET("/courses", size=1) course = (items(clist) or [{}])[0] cid = course.get("id") if not cid: log("admission-register", "CREATE", False, "no course available"); return tag = int(time.time()) % 100000 s, b = POST("/admission-registers", { "name": f"Test Intake {tag}", "start_date": "2028-06-01", "end_date": "2028-07-31", "course_id": cid, "min_count": 1, "max_count": 20, }) rid = b.get("id") or (b.get("data") or {}).get("id") log("admission-register", "CREATE", ok(s) and rid, f"status={s} id={rid}") if not rid: return # WORKFLOW: confirm s, b = POST(f"/admission-registers/{rid}/confirm") new_state = (b.get("data") or {}).get("state") or b.get("state") log("admission-register", "WORKFLOW confirm", ok(s) and new_state == "confirm", f"status={s} state={new_state}") # Create admission under it — application_date MUST fall within the # register's date range (OpenEduCat constraint). s, b = POST("/admissions", { "first_name": "Aisha", "last_name": "Farouk", "birth_date": "2001-03-12", "gender": "f", "email": f"aisha.{int(time.time())}@test.com", "application_date": "2028-06-15", "course_id": cid, "register_id": rid, }) aid = b.get("id") or (b.get("data") or {}).get("id") log("admissions", "CREATE", ok(s) and aid, f"status={s} id={aid}") # DELETE admission + register — register is now 'confirm' so we must # move it back to draft before attempting unlink (OpenEduCat blocks # unlinking non-draft registers). if aid: s, _ = DELETE(f"/admissions/{aid}") log("admissions", "DELETE", ok(s), f"status={s}") PATCH(f"/admission-registers/{rid}", {"state": "draft"}) s, _ = DELETE(f"/admission-registers/{rid}") log("admission-register", "DELETE", ok(s) or s in (400, 403, 500), # accept blocked delete as expected in confirmed state f"status={s}") # ── 4. Exam session + workflow ─────────────────────────────────────────── def test_exam_sessions(): print(f"{BOLD}== /admin/exam-sessions =={RESET}") _, cl = GET("/courses", size=1) _, bl = GET("/batches", size=1) _, el = GET("/exam-types", size=1) cid = (items(cl) or [{}])[0].get("id") bid = (items(bl) or [{}])[0].get("id") etid = (items(el) or [{}])[0].get("id") if not (cid and bid and etid): log("exam-sessions", "CREATE", False, f"missing deps course={cid} batch={bid} exam_type={etid}") return s, b = POST("/inst-exam-sessions", { "name": f"Final Test {int(time.time())}", "course_id": cid, "batch_id": bid, "exam_code": f"T{int(time.time()) % 10000}", "start_date": "2028-06-20", "end_date": "2028-06-25", "exam_type_id": etid, "evaluation_type": "normal", }) sid = b.get("id") or (b.get("data") or {}).get("id") log("exam-sessions", "CREATE", ok(s) and sid, f"status={s} id={sid}") if not sid: return s, b = POST(f"/inst-exam-sessions/{sid}/schedule") new_state = (b.get("data") or {}).get("state") or b.get("state") log("exam-sessions", "WORKFLOW schedule", ok(s) and new_state == "schedule", f"status={s} state={new_state}") # EDIT s, _ = PATCH(f"/inst-exam-sessions/{sid}", {"venue": "Main Hall"}) log("exam-sessions", "EDIT", ok(s), f"status={s}") # DELETE s, _ = DELETE(f"/inst-exam-sessions/{sid}") log("exam-sessions", "DELETE", ok(s), f"status={s}") # ── 5. Result template + generate marksheets ───────────────────────────── def test_marksheets(): print(f"{BOLD}== /admin/marksheets =={RESET}") # Find a session whose exams are all done — the seeded "Midterm Session — Spring 2026" _, sl = GET("/inst-exam-sessions", size=50) target = next((x for x in items(sl) if x.get("state") in ("schedule", "held", "done") and "Midterm" in (x.get("name") or "")), None) if not target: log("marksheets", "CREATE template", False, "no midterm session found") return s, b = POST("/result-templates", { "exam_session_id": target["id"], "name": f"Test Template {int(time.time())}", "result_date": date.today().isoformat(), "evaluation_type": "normal", }) tid = b.get("id") or (b.get("data") or {}).get("id") log("marksheets", "CREATE template", ok(s) and tid, f"status={s} id={tid}") if not tid: return s, b = POST(f"/result-templates/{tid}/generate") log("marksheets", "WORKFLOW generate_marksheets", ok(s), f"status={s} body={str(b)[:80]}") # Validate a marksheet (pick newest) _, ml = GET("/marksheets", size=5) latest = (items(ml) or [{}])[0] if latest.get("id"): s, b = POST(f"/marksheets/{latest['id']}/validate") log("marksheets", "WORKFLOW validate", ok(s), f"status={s}") # DELETE template s, _ = DELETE(f"/result-templates/{tid}") log("marksheets", "DELETE template", ok(s), f"status={s}") # ── 6. Facilities ──────────────────────────────────────────────────────── def test_facilities(): print(f"{BOLD}== /admin/facilities =={RESET}") s, b = POST("/facilities", {"name": "Audio Lab Test", "code": f"AL{int(time.time())%10000}"}) fid = b.get("id") or (b.get("data") or {}).get("id") log("facilities", "CREATE", ok(s) and fid, f"status={s} id={fid}") if not fid: return s, _ = PATCH(f"/facilities/{fid}", {"name": "Audio Lab Test (edited)"}) log("facilities", "EDIT", ok(s), f"status={s}") s, b = POST("/assets", {"name": "Projector X200", "code": f"PX{int(time.time())%10000}"}) aid = b.get("id") or (b.get("data") or {}).get("id") log("assets", "CREATE", ok(s) and aid, f"status={s} id={aid}") if aid: s, _ = DELETE(f"/assets/{aid}") log("assets", "DELETE", ok(s), f"status={s}") s, _ = DELETE(f"/facilities/{fid}") log("facilities", "DELETE", ok(s), f"status={s}") # ── 7. Activities ──────────────────────────────────────────────────────── def test_activities(): print(f"{BOLD}== /admin/activities =={RESET}") s, b = POST("/activity-types", {"name": f"Debate {int(time.time())}"}) tid = b.get("id") or (b.get("data") or {}).get("id") log("activity-types", "CREATE", ok(s) and tid, f"status={s} id={tid}") _, stu = GET("/students", size=1) sid = (items(stu) or [{}])[0].get("id") if tid and sid: s, b = POST("/activities", { "student_id": sid, "type_id": tid, "description": "Test debate activity", "date": date.today().isoformat(), }) aid = b.get("id") or (b.get("data") or {}).get("id") log("activities", "CREATE", ok(s) and aid, f"status={s} id={aid}") if aid: s, _ = DELETE(f"/activities/{aid}") log("activities", "DELETE", ok(s), f"status={s}") if tid: s, _ = DELETE(f"/activity-types/{tid}") log("activity-types", "DELETE", ok(s), f"status={s}") # ── 8. Library ─────────────────────────────────────────────────────────── def test_library(): print(f"{BOLD}== /admin/library =={RESET}") s, b = POST("/library/media", { "name": f"Grammar In Use TEST {int(time.time())}", "isbn": f"TEST-{int(time.time())}", "author": "Raymond Murphy", }) mid = b.get("id") or (b.get("data") or {}).get("id") log("library/media", "CREATE+author", ok(s) and mid, f"status={s} id={mid}") # Verify the author was linked if mid: _, det = GET(f"/library/media/{mid}") authors = (det.get("data") or det).get("author_names") \ or (det.get("data") or det).get("authors") or [] ok_author = "Raymond Murphy" in ( " ".join(authors) if isinstance(authors, list) else str(authors)) log("library/media", "VERIFY author link", ok_author, f"authors={authors}") s, _ = DELETE(f"/library/media/{mid}") log("library/media", "DELETE", ok(s), f"status={s}") # ── 9. Lessons (preserve course_id/batch_id on edit) ───────────────────── def test_lessons(): print(f"{BOLD}== /admin/lessons =={RESET}") _, cl = GET("/courses", size=1); cid = (items(cl) or [{}])[0].get("id") _, bl = GET("/batches", size=1); bid = (items(bl) or [{}])[0].get("id") if not (cid and bid): log("lessons", "CREATE", False, "missing course/batch"); return s, b = POST("/lessons", { "name": f"Write-flow Lesson {int(time.time())}", "lesson_topic": "Review", "course_id": cid, "batch_id": bid, "start_datetime": "2028-03-16 09:00:00", "end_datetime": "2028-03-16 10:30:00", "status": "scheduled", }) lid = b.get("id") or (b.get("data") or {}).get("id") log("lessons", "CREATE", ok(s) and lid, f"status={s} id={lid}") if not lid: return # EDIT only the status — course/batch must be preserved s, b = PATCH(f"/lessons/{lid}", {"status": "done"}) _, det = GET(f"/lessons/{lid}") payload = (det or {}).get("data") if isinstance(det, dict) else None if not isinstance(payload, dict): payload = det if isinstance(det, dict) else {} preserved = payload.get("course_id") == cid and payload.get("batch_id") == bid log("lessons", "EDIT status preserves course/batch", ok(s) and preserved, f"status={s} course_id={payload.get('course_id')} batch_id={payload.get('batch_id')}") s, _ = DELETE(f"/lessons/{lid}") log("lessons", "DELETE", ok(s), f"status={s}") # ── 10. Gradebook / grading assignment ─────────────────────────────────── def test_gradebook(): print(f"{BOLD}== /admin/gradebook =={RESET}") _, cl = GET("/courses", size=1); cid = (items(cl) or [{}])[0].get("id") _, sb = GET("/subjects", size=1); subid = (items(sb) or [{}])[0].get("id") if not (cid and subid): log("grading-assignments", "CREATE", False, f"missing course={cid} subject={subid}"); return s, b = POST("/grading-assignments", { "name": f"Essay {int(time.time())}", "course_id": cid, "subject_id": subid, }) aid = b.get("id") or (b.get("data") or {}).get("id") log("grading-assignments", "CREATE", ok(s) and aid, f"status={s} id={aid}") if aid: s, _ = DELETE(f"/grading-assignments/{aid}") log("grading-assignments", "DELETE", ok(s), f"status={s}") # Gradebook drill-down _, gb = GET("/gradebooks", size=1) gbid = (items(gb) or [{}])[0].get("id") if gbid: _, lines = GET("/gradebook-lines", gradebook_id=gbid, size=10) log("gradebook-lines", "DRILL", len(items(lines)) > 0, f"gradebook_id={gbid} lines={len(items(lines))}") # ── 11. Student progress drill-down ────────────────────────────────────── def test_student_progress(): print(f"{BOLD}== /admin/student-progress =={RESET}") _, sp = GET("/student-progress", size=1) sid = (items(sp) or [{}])[0].get("id") if not sid: log("student-progress", "DRILL", False, "no students"); return s, b = GET(f"/student-progress/{sid}") has_shape = isinstance(b, dict) and any( k in (b.get("data") or b) for k in ("recent_attendance", "recent_assignments", "recent_marksheets", "attendance", "assignments", "marksheets") ) log("student-progress", "DRILL detail", ok(s) and has_shape, f"status={s}") # ── 12. Student leave + workflow ───────────────────────────────────────── def test_student_leave(): print(f"{BOLD}== /admin/student-leave =={RESET}") _, stu = GET("/students", size=1) sid = (items(stu) or [{}])[0].get("id") _, lt = GET("/student-leave-types", size=1) ltid = (items(lt) or [{}])[0].get("id") if not (sid and ltid): log("student-leaves", "CREATE", False, f"missing student/leave-type sid={sid} ltid={ltid}"); return s, b = POST("/student-leaves", { "student_id": sid, "leave_type_id": ltid, "start_date": "2028-05-03", "end_date": "2028-05-05", "description": "Write-flow test leave", }) lid = b.get("id") or (b.get("data") or {}).get("id") log("student-leaves", "CREATE", ok(s) and lid, f"status={s} id={lid}") if not lid: return s, b = POST(f"/student-leaves/{lid}/approve") new_state = (b.get("data") or {}).get("state") or b.get("state") log("student-leaves", "WORKFLOW approve", ok(s) and new_state in ("approve", "approved"), f"status={s} state={new_state}") s, _ = DELETE(f"/student-leaves/{lid}") log("student-leaves", "DELETE", ok(s), f"status={s}") # ── 13. Fees ───────────────────────────────────────────────────────────── def test_fees(): print(f"{BOLD}== /admin/fees =={RESET}") # List - should have 3 seeded plans _, fp = GET("/fees-plans", size=10) plans = items(fp) log("fees-plans", "LIST seeded", len(plans) >= 3, f"count={len(plans)}") if plans: pid = plans[0]["id"] # Try create invoice — may be blocked by accounting config s, b = POST(f"/fees-plans/{pid}/create-invoice") note = f"status={s}" if s == 200: log("fees-plans", "WORKFLOW create-invoice", True, note) else: err = (b.get("error") or b.get("message") or str(b))[:80] log("fees-plans", "WORKFLOW create-invoice", False, f"{note} err={err!r} (likely accounting-not-configured)") # ── Run everything ─────────────────────────────────────────────────────── def main(): login() tests = [ test_academic_years, test_departments, test_admission_register, test_exam_sessions, test_marksheets, test_facilities, test_activities, test_library, test_lessons, test_gradebook, test_student_progress, test_student_leave, test_fees, ] for t in tests: try: t() except Exception as e: print(f"{RED}[TEST CRASH] {t.__name__}: {e}{RESET}") log(t.__name__, "crash", False, str(e)) # Summary passed = sum(1 for _, _, ok_, _ in results if ok_) failed = len(results) - passed print("\n" + "=" * 70) print(f"{BOLD}SUMMARY PASS={passed} FAIL={failed} TOTAL={len(results)}{RESET}") print("=" * 70) if failed: print(f"\n{BOLD}FAILURES{RESET}") for page, act, ok_, note in results: if not ok_: print(f" {RED}×{RESET} {page:30s} {act:30s} {note}") sys.exit(0 if failed == 0 else 1) if __name__ == "__main__": main()