diff --git a/run/initialization/seed_cohort_9p_ph1.py b/run/initialization/seed_cohort_9p_ph1.py new file mode 100644 index 0000000..acba058 --- /dev/null +++ b/run/initialization/seed_cohort_9p_ph1.py @@ -0,0 +1,218 @@ +""" +seed_cohort_9p_ph1.py — Markable cohort for exam-marker testing. + +Creates N student accounts and enrols them ALL into a single class (default the +Greenfield Year 9 Physics class `9P/Ph1`), so there is a real cohort to mark. + +Why: the canonical timetable seeds enrol "one student per year-group band" +(seed_greenfield_timetable.py), so every class has <=1 student — too few for a +results table / per-question stats. This seeder fills one class to a usable size. + +Mechanics (identical paths to the canonical seeds — nothing bespoke server-side): + - auth user: POST {SUPABASE_URL}/auth/v1/admin/users + - profile: upsert public.profiles (school_id = institute) + - membership: upsert public.institute_memberships (role 'student') + - enrolment: POST {API_BASE_URL}/database/timetable/classes/{class_id}/students + (as a school_admin; class_students upsert → idempotent) + +Idempotent: re-running skips existing auth users and upserts everything else. + +Env required: SUPABASE_URL, SERVICE_ROLE_KEY (API_BASE_URL defaults to api-dev) +Optional env: COHORT_COUNT, COHORT_CLASS_CODE, COHORT_INSTITUTE_ID, + SEED_STUDENT_PASSWORD, SEED_SCHOOL_ADMIN_PASSWORD + +Run (dev): + SUPABASE_URL=... SERVICE_ROLE_KEY=... API_BASE_URL=http://192.168.0.64:18000 \ + python3 -c "from run.initialization.seed_cohort_9p_ph1 import seed; seed()" +""" +import os +import time +import requests +from typing import Dict, Any, List, Optional + +# Greenfield Academy (the school actually populated on dev .94) +GREENFIELD_ID = os.getenv("COHORT_INSTITUTE_ID", "a1b2c3d4-e5f6-7890-abcd-ef1234567890") +GREENFIELD_DOMAIN = "greenfieldacademy.test" +GREENFIELD_ADMIN_EMAIL = f"admin@{GREENFIELD_DOMAIN}" + +CLASS_CODE = os.getenv("COHORT_CLASS_CODE", "9P/Ph1") +COHORT_COUNT = int(os.getenv("COHORT_COUNT", "10")) + +# Realistic-ish names so the results table doesn't read "Pupil 01..10". +COHORT_NAMES = [ + ("Amelia", "Clarke"), ("Noah", "Bennett"), ("Olivia", "Foster"), ("Leo", "Hughes"), + ("Ava", "Patel"), ("Jacob", "Reid"), ("Mia", "Turner"), ("Harry", "Ellis"), + ("Isla", "Morgan"), ("Oscar", "Khan"), ("Freya", "Walsh"), ("Theo", "Ndlovu"), +] + +DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!" +DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!" + + +def _ctx() -> Dict[str, str]: + return { + "supa_url": os.environ["SUPABASE_URL"].rstrip("/"), + "service_key": os.environ["SERVICE_ROLE_KEY"], + "api_base": os.environ.get("API_BASE_URL", "http://192.168.0.64:18000").rstrip("/"), + "student_pw": os.getenv("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD), + "admin_pw": os.getenv("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD), + } + + +def _sb_headers(ctx: Dict[str, str]) -> Dict[str, str]: + return { + "apikey": ctx["service_key"], + "Authorization": f"Bearer {ctx['service_key']}", + "Content-Type": "application/json", + } + + +def _sign_in(ctx: Dict[str, str], email: str, password: str) -> str: + r = requests.post( + f"{ctx['supa_url']}/auth/v1/token?grant_type=password", + headers={"apikey": ctx["service_key"], "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def _resolve_class_id(ctx: Dict[str, str]) -> Optional[str]: + r = requests.get( + f"{ctx['supa_url']}/rest/v1/classes", + headers=_sb_headers(ctx), + params={"class_code": f"eq.{CLASS_CODE}", + "institute_id": f"eq.{GREENFIELD_ID}", + "select": "id,name", "limit": "1"}, + ) + data = r.json() if r.ok else [] + return data[0]["id"] if data else None + + +def _existing_auth_users(ctx: Dict[str, str]) -> Dict[str, str]: + r = requests.get( + f"{ctx['supa_url']}/auth/v1/admin/users", + headers=_sb_headers(ctx), params={"per_page": 200}, + ) + r.raise_for_status() + return {u["email"]: u["id"] for u in r.json().get("users", [])} + + +def _create_auth_user(ctx: Dict[str, str], spec: Dict) -> Optional[str]: + r = requests.post( + f"{ctx['supa_url']}/auth/v1/admin/users", + headers=_sb_headers(ctx), + json={ + "email": spec["email"], "password": ctx["student_pw"], "email_confirm": True, + "user_metadata": { + "username": spec["username"], "full_name": spec["full_name"], + "display_name": spec["display_name"], "user_type": "student", + }, + }, + ) + if r.status_code in (200, 201): + return r.json()["id"] + return None + + +def _upsert(ctx: Dict[str, str], table: str, row: Dict, on_conflict: str) -> bool: + h = {**_sb_headers(ctx), "Prefer": "resolution=merge-duplicates,return=minimal"} + r = requests.post(f"{ctx['supa_url']}/rest/v1/{table}", + headers=h, json=row, params={"on_conflict": on_conflict}) + return r.ok + + +def _cohort_specs() -> List[Dict]: + specs = [] + for i in range(1, COHORT_COUNT + 1): + first, last = COHORT_NAMES[(i - 1) % len(COHORT_NAMES)] + prefix = f"cohort{i:02d}" + specs.append({ + "email": f"{prefix}@{GREENFIELD_DOMAIN}", + "username": f"{prefix}.{GREENFIELD_DOMAIN.replace('.', '_')}", + "full_name": f"{first} {last}", + "display_name": first, + }) + return specs + + +def seed(count: Optional[int] = None) -> Dict[str, Any]: + global COHORT_COUNT + if count is not None: + COHORT_COUNT = count + ctx = _ctx() + results: Dict[str, Any] = {"class_code": CLASS_CODE, "requested": COHORT_COUNT, + "created": 0, "reused": 0, "enrolled": 0, "errors": []} + + print(f"COHORT SEED → {CLASS_CODE} @ {GREENFIELD_DOMAIN} (target {COHORT_COUNT} students)") + + class_id = _resolve_class_id(ctx) + if not class_id: + results["errors"].append(f"class {CLASS_CODE} not found for institute {GREENFIELD_ID}") + print(f" ✗ {results['errors'][-1]}") + return results + print(f" class_id = {class_id}") + + existing = _existing_auth_users(ctx) + specs = _cohort_specs() + + # 1) accounts: auth user + profile + membership + uids: Dict[str, str] = {} + for spec in specs: + email = spec["email"] + uid = existing.get(email) + if uid: + results["reused"] += 1 + else: + uid = _create_auth_user(ctx, spec) + if not uid: + results["errors"].append(f"create auth user {email}") + print(f" ✗ create {email}") + continue + results["created"] += 1 + time.sleep(0.15) + uids[email] = uid + ok_p = _upsert(ctx, "profiles", { + "id": uid, "email": email, "user_type": "student", + "username": spec["username"], "full_name": spec["full_name"], + "display_name": spec["display_name"], "school_id": GREENFIELD_ID, + "neo4j_sync_status": "pending", + }, on_conflict="id") + ok_m = _upsert(ctx, "institute_memberships", { + "profile_id": uid, "institute_id": GREENFIELD_ID, "role": "student", "metadata": {}, + }, on_conflict="profile_id,institute_id") + if not (ok_p and ok_m): + results["errors"].append(f"profile/membership {email} (p={ok_p} m={ok_m})") + + # 2) enrol all into the class (via API, as school admin) + admin_token = _sign_in(ctx, GREENFIELD_ADMIN_EMAIL, ctx["admin_pw"]) + for spec in specs: + uid = uids.get(spec["email"]) + if not uid: + continue + r = requests.post( + f"{ctx['api_base']}/database/timetable/classes/{class_id}/students", + headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"}, + json={"student_id": uid}, + ) + body = {} + try: + body = r.json() + except Exception: + pass + if r.ok and (body.get("status") == "ok" or body.get("row") or body.get("id")): + results["enrolled"] += 1 + print(f" ✓ {spec['email'].split('@')[0]} → {CLASS_CODE}") + else: + results["errors"].append(f"enrol {spec['email']}: {r.status_code} {str(body)[:120]}") + print(f" ✗ enrol {spec['email']}: {r.status_code}") + time.sleep(0.1) + + print(f"\nDONE: created {results['created']}, reused {results['reused']}, " + f"enrolled {results['enrolled']}/{COHORT_COUNT}, errors {len(results['errors'])}") + return results + + +if __name__ == "__main__": + import json + print(json.dumps(seed(), indent=2, default=str))