""" seed_environment.py — idempotent full-environment rebuild. Assumes reset_environment.py has already been run (or it's the first boot). Safe to re-run: all writes use UPSERT / MERGE. Schools ------- KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test Uniform accounts per school (10 × 2 = 20 total) ------------------------------------------------ admin@{domain} school_admin head@{domain} school_admin physics@{domain} teacher maths@{domain} teacher teacher1@{domain} teacher teacher2@{domain} teacher teacher3@{domain} teacher student1@{domain} student student2@{domain} student student3@{domain} student Run from inside the ccapi container: python3 main.py --mode seed python3 main.py --mode seed-test Or directly: python3 -c "from run.initialization.seed_environment import seed; seed()" python3 -c "from run.initialization.seed_environment import seed; seed(test=True)" """ import os import time import requests from datetime import datetime, timedelta from typing import Dict, Any, List, Optional from modules.logger_tool import initialise_logger logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) # ─── School constants ───────────────────────────────────────────────────────── KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648" KEVLARAI_NAME = "KevlarAI" KEVLARAI_URN = "KEVLARAI-001" KEVLARAI_DOMAIN = "kevlarai.test" GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" GREENFIELD_NAME = "Greenfield Academy" GREENFIELD_URN = "TEST-GFA-001" GREENFIELD_DOMAIN = "greenfieldacademy.test" # ─── Passwords ──────────────────────────────────────────────────────────────── DEFAULT_PLATFORM_ADMIN_PASSWORD = "KevlarAI2025!" DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!" DEFAULT_TEACHER_PASSWORD = "Teacher@Cc2025!" DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!" def get_seed_password(role: str) -> str: """Return the seed password for a role, allowing env overrides.""" normalized = role.lower().strip() env_by_role = { "platform_admin": ("SEED_PLATFORM_ADMIN_PASSWORD", DEFAULT_PLATFORM_ADMIN_PASSWORD), "school_admin": ("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD), "teacher": ("SEED_TEACHER_PASSWORD", DEFAULT_TEACHER_PASSWORD), "student": ("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD), } if normalized not in env_by_role: raise ValueError(f"Unknown seed password role: {role}") env_name, default = env_by_role[normalized] return os.getenv(env_name, default) def get_seed_passwords() -> Dict[str, str]: return { "platform_admin": get_seed_password("platform_admin"), "school_admin": get_seed_password("school_admin"), "teacher": get_seed_password("teacher"), "student": get_seed_password("student"), } # ─── Account template ──────────────────────────────────────────────────────── def _school_accounts(domain: str, institute_id: str) -> List[Dict]: passwords = get_seed_passwords() return [ # school_admin accounts { "prefix": "admin", "email": f"admin@{domain}", "full_name": "Alex Admin", "display_name": "Alex", "username": f"admin.{domain.replace('.', '_')}", "user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"], "institute_id": institute_id, }, { "prefix": "head", "email": f"head@{domain}", "full_name": "Helen Head", "display_name": "Helen", "username": f"head.{domain.replace('.', '_')}", "user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"], "institute_id": institute_id, }, # teacher accounts { "prefix": "physics", "email": f"physics@{domain}", "full_name": "Phil Physics", "display_name": "Phil", "username": f"physics.{domain.replace('.', '_')}", "user_type": "teacher", "role": "teacher", "password": passwords["teacher"], "institute_id": institute_id, }, { "prefix": "maths", "email": f"maths@{domain}", "full_name": "Mary Maths", "display_name": "Mary", "username": f"maths.{domain.replace('.', '_')}", "user_type": "teacher", "role": "teacher", "password": passwords["teacher"], "institute_id": institute_id, }, { "prefix": "teacher1", "email": f"teacher1@{domain}", "full_name": "Tom Teacher", "display_name": "Tom", "username": f"teacher1.{domain.replace('.', '_')}", "user_type": "teacher", "role": "teacher", "password": passwords["teacher"], "institute_id": institute_id, }, { "prefix": "teacher2", "email": f"teacher2@{domain}", "full_name": "Tara Teach", "display_name": "Tara", "username": f"teacher2.{domain.replace('.', '_')}", "user_type": "teacher", "role": "teacher", "password": passwords["teacher"], "institute_id": institute_id, }, { "prefix": "teacher3", "email": f"teacher3@{domain}", "full_name": "Tim Teachwell", "display_name": "Tim", "username": f"teacher3.{domain.replace('.', '_')}", "user_type": "teacher", "role": "teacher", "password": passwords["teacher"], "institute_id": institute_id, }, # student accounts { "prefix": "student1", "email": f"student1@{domain}", "full_name": "Sam Student", "display_name": "Sam", "username": f"student1.{domain.replace('.', '_')}", "user_type": "student", "role": "student", "password": passwords["student"], "institute_id": institute_id, }, { "prefix": "student2", "email": f"student2@{domain}", "full_name": "Sophie Study", "display_name": "Sophie", "username": f"student2.{domain.replace('.', '_')}", "user_type": "student", "role": "student", "password": passwords["student"], "institute_id": institute_id, }, { "prefix": "student3", "email": f"student3@{domain}", "full_name": "Steve Scholar", "display_name": "Steve", "username": f"student3.{domain.replace('.', '_')}", "user_type": "student", "role": "student", "password": passwords["student"], "institute_id": institute_id, }, ] FULL_ACCOUNTS = ( _school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) + _school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID) ) def get_accounts(test: bool = False) -> List[Dict]: """Return full (20-user) or lightweight test (9-user) seed fixtures.""" if not test: return list(FULL_ACCOUNTS) wanted = { f"student1@{KEVLARAI_DOMAIN}", f"student2@{KEVLARAI_DOMAIN}", f"student3@{KEVLARAI_DOMAIN}", f"admin@{GREENFIELD_DOMAIN}", f"physics@{GREENFIELD_DOMAIN}", f"maths@{GREENFIELD_DOMAIN}", f"teacher1@{GREENFIELD_DOMAIN}", f"student1@{GREENFIELD_DOMAIN}", f"student2@{GREENFIELD_DOMAIN}", } return [account for account in FULL_ACCOUNTS if account["email"] in wanted] # ─── Supabase helpers ───────────────────────────────────────────────────────── def _sb_ctx(): url = os.environ["SUPABASE_URL"] key = os.environ["SERVICE_ROLE_KEY"] headers = { "apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json", } return url, headers def _auth_post(url, headers, path, data): return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data) def _auth_get(url, headers, path, params=None): r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params) r.raise_for_status() return r.json() def _rest_upsert(url, headers, table, data, on_conflict): h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"} r = requests.post( f"{url}/rest/v1/{table}", headers=h, json=data, params={"on_conflict": on_conflict}, ) return r def _rest_patch(url, headers, table, match_col, match_val, data): r = requests.patch( f"{url}/rest/v1/{table}", headers={**headers, "Prefer": "return=minimal"}, params={match_col: f"eq.{match_val}"}, json=data, ) return r # ─── Main seed function ─────────────────────────────────────────────────────── def seed(test: bool = False) -> Dict[str, Any]: from modules.database.services.provisioning_service import ProvisioningService from modules.database.services.neo4j_service import Neo4jService from modules.database.init.init_calendar import create_calendar accounts = get_accounts(test=test) url, headers = _sb_ctx() errors: List[str] = [] results: Dict[str, Any] = {"mode": "test" if test else "full", "account_count": len(accounts)} # ── Step 1: Fix KevlarAI institute record ───────────────────────────────── logger.info("=" * 60) logger.info(f"SEED ENVIRONMENT ({'test' if test else 'full'} mode)") logger.info("=" * 60) logger.info("\n[1] KevlarAI institute record...") try: r = _rest_upsert(url, headers, "institutes", { "id": KEVLARAI_ID, "name": KEVLARAI_NAME, "urn": KEVLARAI_URN, "status": "active", "website": "https://kevlarai.test", "address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"}, "metadata": {"headteacher": "Alex Admin", "seeded": True}, }, on_conflict="id") if r.status_code in (200, 201): logger.info(" KevlarAI upserted ✓") else: raise Exception(r.text[:200]) except Exception as e: errors.append(f"kevlarai_institute: {e}") logger.error(f" {e}") # ── Step 2: Create Greenfield Academy if needed ─────────────────────────── logger.info("[2] Greenfield Academy institute record...") try: neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "") r = _rest_upsert(url, headers, "institutes", { "id": GREENFIELD_ID, "name": GREENFIELD_NAME, "urn": GREENFIELD_URN, "status": "active", "website": "https://greenfieldacademy.test", "address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"}, "metadata": {"headteacher": "Alex Admin", "seeded": True}, "neo4j_uuid_string": neo4j_uuid_greenfield, }, on_conflict="id") if r.status_code in (200, 201): logger.info(" Greenfield Academy upserted ✓") else: raise Exception(r.text[:200]) except Exception as e: errors.append(f"greenfield_institute: {e}") logger.error(f" {e}") # ── Step 3: Provision Neo4j for both schools ────────────────────────────── logger.info("[3] Neo4j school provisioning...") provisioner = ProvisioningService() school_dbs: Dict[str, str] = {} for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]: try: result = provisioner.ensure_school(iid) db = result["db_name"] school_dbs[iid] = db logger.info(f" {name}: {db} ✓") except Exception as e: errors.append(f"ensure_school {name}: {e}") logger.error(f" {name}: {e}") # derive fallback db name school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}" # ── Step 4: Rebuild classroomcopilot global calendar ───────────────────── logger.info("[4] classroomcopilot global calendar (2024–2028)...") try: neo4j_svc = Neo4jService() neo4j_svc.create_database("classroomcopilot") logger.info(" DB created, waiting 5s for availability...") time.sleep(5) start_dt = datetime(2024, 1, 1) end_dt = datetime(2028, 12, 31) create_calendar("classroomcopilot", start_dt, end_dt) logger.info(" Calendar built ✓") results["global_calendar"] = "ok" except Exception as e: errors.append(f"global_calendar: {e}") logger.error(f" {e}") results["global_calendar"] = "error" # ── Step 5: Create / verify auth users ──────────────────────────────────── logger.info(f"[5] Creating auth users ({len(accounts)} accounts)...") try: existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", []) existing_by_email = {u["email"]: u for u in existing} except Exception as e: errors.append(f"list_auth_users: {e}") existing_by_email = {} created_users: Dict[str, str] = {} # email → uid for spec in accounts: email = spec["email"] if email in existing_by_email: created_users[email] = existing_by_email[email]["id"] logger.info(f" {email}: exists [{created_users[email][:8]}]") continue r = _auth_post(url, headers, "/users", { "email": email, "password": spec["password"], "email_confirm": True, "user_metadata": { "username": spec["username"], "full_name": spec["full_name"], "display_name": spec["display_name"], "user_type": spec["user_type"], }, }) if r.status_code in (200, 201): uid = r.json()["id"] created_users[email] = uid logger.info(f" {email}: created [{uid[:8]}]") else: errors.append(f"create {email}: {r.text[:150]}") logger.error(f" {email}: {r.text[:150]}") time.sleep(0.2) results["users_created"] = len(created_users) # ── Step 6: Upsert profiles and memberships ─────────────────────────────── logger.info("[6] Upserting profiles and memberships...") for spec in accounts: uid = created_users.get(spec["email"]) if not uid: continue try: _rest_upsert(url, headers, "profiles", { "id": uid, "email": spec["email"], "user_type": spec["user_type"], "username": spec["username"], "full_name": spec["full_name"], "display_name": spec["display_name"], "school_id": spec["institute_id"], "neo4j_sync_status": "pending", }, on_conflict="id") _rest_upsert(url, headers, "institute_memberships", { "profile_id": uid, "institute_id": spec["institute_id"], "role": spec["role"], "metadata": {}, }, on_conflict="profile_id,institute_id") except Exception as e: errors.append(f"profile/membership {spec['email']}: {e}") logger.error(f" {spec['email']}: {e}") logger.info(" Profiles and memberships upserted ✓") # ── Step 7: Merge Neo4j Teacher/Student nodes ───────────────────────────── logger.info("[7] Merging Neo4j worker nodes...") try: from modules.database.tools.neo4j_driver_tools import close_driver, get_driver bolt_url = os.getenv("NEO4J_BOLT_URL") or os.getenv("APP_BOLT_URL") neo4j_user = os.getenv("NEO4J_USER") or os.getenv("USER_NEO4J") neo4j_password = os.getenv("NEO4J_PASSWORD") or os.getenv("PASSWORD_NEO4J") auth = (neo4j_user, neo4j_password) if neo4j_user and neo4j_password else None driver = get_driver(url=bolt_url, auth=auth) if bolt_url else get_driver() if driver is None: raise RuntimeError("Neo4j driver unavailable; check NEO4J_BOLT_URL/APP_BOLT_URL and NEO4J_PASSWORD/PASSWORD_NEO4J") # Group by institute DB by_db: Dict[str, List[Dict]] = {} for spec in accounts: uid = created_users.get(spec["email"]) if not uid: continue db = school_dbs.get(spec["institute_id"]) if not db: continue by_db.setdefault(db, []).append({**spec, "uid": uid}) for db, users in by_db.items(): with driver.session(database=db) as s: for u in users: label = "Teacher" if u["user_type"] == "teacher" else "Student" s.run( f"MERGE (n:{label} {{uuid_string: $uid}}) " "SET n.worker_email = $email, " " n.worker_name = $name, " " n.worker_type = $utype", uid=u["uid"], email=u["email"], name=u["full_name"], utype=u["user_type"], ) logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓") close_driver(driver) results["neo4j_nodes"] = "ok" except Exception as e: errors.append(f"neo4j_nodes: {e}") logger.error(f" {e}") results["neo4j_nodes"] = "error" # ── Ensure kcar is a platform super-admin ───────────────────────────────── logger.info("[8] Ensuring kcar platform admin record...") KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" try: _rest_upsert(url, headers, "admin_profiles", { "id": KCAR_ID, "email": "kcar@kevlarai.com", "display_name": "Kevin Carroll", "admin_role": "super_admin", "is_super_admin": True, "metadata": {"seeded": True}, }, on_conflict="id") logger.info(" kcar → admin_profiles ✓") except Exception as e: errors.append(f"kcar_admin: {e}") logger.error(f" {e}") # Fix kcar's auth user_metadata and profiles.user_type to "platform_admin". # Without this, POST /user/init assigns kcar to the default school on first login. try: r = requests.put( f"{url}/auth/v1/admin/users/{KCAR_ID}", headers=headers, json={"user_metadata": {"user_type": "platform_admin"}}, ) if r.status_code in (200, 201): logger.info(" kcar → auth user_metadata: platform_admin ✓") else: logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}") except Exception as e: errors.append(f"kcar_user_type: {e}") logger.error(f" {e}") try: r = requests.patch( f"{url}/rest/v1/profiles", headers={**headers, "Prefer": "return=minimal"}, params={"id": f"eq.{KCAR_ID}"}, json={"school_id": None}, ) if r.status_code in (200, 204): logger.info(" kcar → profiles.school_id: null ✓") else: logger.warning(f" kcar profiles patch failed ({r.status_code}): {r.text[:120]}") except Exception as e: errors.append(f"kcar_profile: {e}") logger.error(f" {e}") # ── Summary ─────────────────────────────────────────────────────────────── results["success"] = len(errors) == 0 results["errors"] = errors _print_credential_sheet(created_users, accounts) logger.info("\n" + "=" * 60) if errors: logger.info(f"SEED COMPLETE with {len(errors)} error(s)") for e in errors: logger.info(f" ✗ {e}") else: logger.info("SEED COMPLETE — all steps succeeded") logger.info("=" * 60) return results def _print_credential_sheet(created_users: Dict[str, str], accounts: List[Dict]): PAD = 36 include_passwords = os.getenv("PRINT_SEED_CREDENTIALS", "").lower() in {"1", "true", "yes", "on"} logger.info("\n" + "=" * 70) logger.info("CREDENTIAL SHEET" + ("" if include_passwords else " (passwords redacted; set PRINT_SEED_CREDENTIALS=true to print)")) logger.info("=" * 70) logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD") logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------") platform_password = get_seed_password("platform_admin") if include_passwords else "" logger.info(f" {'[platform admin]':<16} {'kcar@kevlarai.com':<{PAD}} {platform_password}") logger.info("") for school_id, domain, label in [ (KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"), (GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"), ]: logger.info(f" [{label}]") for spec in accounts: if spec["institute_id"] != school_id: continue uid = created_users.get(spec["email"], "—") status = f"[{uid[:8]}]" if uid != "—" else "[MISSING]" password = spec["password"] if include_passwords else "" logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {password} {status}") logger.info("") logger.info("=" * 70) if __name__ == "__main__": import json print(json.dumps(seed(test="--test" in os.sys.argv), indent=2, default=str))