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