diff --git a/run/initialization/reset_environment.py b/run/initialization/reset_environment.py index 2d92389..df401f3 100644 --- a/run/initialization/reset_environment.py +++ b/run/initialization/reset_environment.py @@ -2,15 +2,14 @@ reset_environment.py — DESTRUCTIVE wipe of all non-permanent data. Clears: - - Neo4j: drops cc.users.*, classroomcopilot; wipes cc.institutes.* content - - Supabase: deletes all test auth users + profiles + memberships - - Supabase: detaches kcar from any school + - Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*) + - Supabase: deletes ALL data tables except gais_local_authorities and gais_schools + - Supabase: deletes all auth users except kcar, then re-seeds kcar profile state Safe invariants (never touched): - - gaisdata Neo4j DB - - system / neo4j Neo4j DBs - - kcar auth account and admin_profiles entry - - institutes rows (schools themselves are kept, just de-seeded) + - kcar auth account + - gais_local_authorities and gais_schools Supabase tables + - system / neo4j Neo4j system databases Run from inside the ccapi container: python3 -c "from run.initialization.reset_environment import reset; reset()" @@ -21,67 +20,118 @@ import requests from typing import List, Dict, Any from modules.logger_tool import initialise_logger -from modules.database.services.neo4j_service import Neo4jService import modules.database.tools.neo4j_driver_tools as dt logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) -KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" +KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" KCAR_EMAIL = "kcar@kevlarai.com" -# Databases to fully DROP (content + structure) -DBS_TO_DROP = [ - "classroomcopilot", - "cc.users", +# Neo4j system databases — never drop these +NEO4J_SYSTEM_DBS = {"system", "neo4j"} + +# Supabase tables to clear, in FK child-first order. +# gais_local_authorities and gais_schools are intentionally absent. +SUPABASE_TABLES_TO_CLEAR = [ + # ── Transcription (deepest children first) ─────────────────────────────── + "canvas_events", + "keyword_events", + "transcription_summaries", + "transcription_segments", + "keyword_watches", + "transcription_sessions", + # ── Lesson delivery chain ──────────────────────────────────────────────── + "lesson_deliveries", + "lesson_collaborators", + # ── Timetable materialization ──────────────────────────────────────────── + "taught_lessons", + # ── Academic calendar (children → parents) ─────────────────────────────── + "academic_periods", + "academic_days", + "academic_weeks", + "academic_term_breaks", + "academic_terms", + "academic_years", + # ── Teacher timetables ─────────────────────────────────────────────────── + "teacher_timetable_slots", + "teacher_timetables", + "school_timetables", + # ── Lesson plans ───────────────────────────────────────────────────────── + "planned_lessons", + # ── Whiteboard rooms ───────────────────────────────────────────────────── + "whiteboard_rooms", + # ── Classes & enrollment ───────────────────────────────────────────────── + "enrollment_requests", + "class_students", + "class_teachers", + "classes", + # ── Files & brains ─────────────────────────────────────────────────────── + "document_artefacts", + "brain_files", + "cabinet_memberships", + "files", + "file_cabinets", + "brains", + # ── Invitations & memberships ──────────────────────────────────────────── + "invitations", + "institute_memberships", + "institute_membership_requests", + # ── Institutes ─────────────────────────────────────────────────────────── + "institutes", + # ── Profiles (non-kcar cleared separately via auth deletion cascade) ───── + "admin_profiles", ] -# Institute DBs — wipe content only (keep the DB, re-provision in seed) -INSTITUTE_DB_PREFIXES = ["cc.institutes."] -# Supabase connection details (direct REST, no SDK needed for admin auth ops) def _sb_headers(): - url = os.environ["SUPABASE_URL"] - key = os.environ["SERVICE_ROLE_KEY"] - return url, {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"} + url = os.environ["SUPABASE_URL"] + key = os.environ["SERVICE_ROLE_KEY"] + return url, { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Prefer": "return=minimal", + } -def _neo4j_drop_all_matching(pattern: str) -> List[str]: - """Drop every Neo4j database whose name starts with pattern.""" - dropped = [] +# ─── Neo4j helpers ──────────────────────────────────────────────────────────── + +def _neo4j_drop_all_non_system() -> Dict[str, List[str]]: + """Drop every Neo4j DB except the system-reserved ones.""" with dt.get_session(database="system") as s: all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")] - targets = [db for db in all_dbs if db.startswith(pattern)] - for db in targets: + + to_drop = [db for db in all_dbs if db not in NEO4J_SYSTEM_DBS] + dropped = [] + for db in to_drop: logger.info(f" DROP DATABASE `{db}`") - with dt.get_session(database="system") as s: - s.run(f"DROP DATABASE `{db}` IF EXISTS") - dropped.append(db) + try: + with dt.get_session(database="system") as s: + s.run(f"DROP DATABASE `{db}` IF EXISTS") + dropped.append(db) + except Exception as e: + logger.warning(f" Could not drop `{db}`: {e}") return dropped -def _neo4j_wipe_institute_dbs() -> List[str]: - """MATCH (n) DETACH DELETE on every cc.institutes.* database.""" - wiped = [] - with dt.get_session(database="system") as s: - all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")] - targets = [db for db in all_dbs - if any(db.startswith(p) for p in INSTITUTE_DB_PREFIXES) - and not db.endswith(".curriculum")] - for db in targets: - logger.info(f" Wipe cc.institutes DB: {db}") - with dt.get_session(database=db) as s: - s.run("MATCH (n) DETACH DELETE n") - wiped.append(db) - # Also wipe curriculum DBs - with dt.get_session(database="system") as s: - all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")] - curriculum_dbs = [db for db in all_dbs if db.endswith(".curriculum")] - for db in curriculum_dbs: - logger.info(f" Wipe curriculum DB: {db}") - with dt.get_session(database=db) as s: - s.run("MATCH (n) DETACH DELETE n") - wiped.append(db) - return wiped +# ─── Supabase helpers ───────────────────────────────────────────────────────── + +# Tables without an uid=1000(kcar) gid=1000(kcar) groups=1000(kcar),27(sudo),119(docker) column — map to the column to use as the delete filter. +TABLE_FILTER_COLUMN = { + "brain_files": "brain_id", +} + +def _sb_clear_table(url: str, headers: dict, table: str) -> int: + """Delete all rows from a Supabase table. Returns HTTP status.""" + col = TABLE_FILTER_COLUMN.get(table, "id") + r = requests.delete( + f"{url}/rest/v1/{table}", + headers=headers, + params={col: "not.is.null"}, + ) + if r.status_code not in (200, 204): + logger.warning(f" Clear {table}: {r.status_code} {r.text[:120]}") + return r.status_code def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]: @@ -93,49 +143,38 @@ def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]: def _supabase_delete_auth_user(url: str, headers: dict, uid: str): r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers) if r.status_code not in (200, 204): - logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:100]}") + logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:80]}") +# ─── Main reset ─────────────────────────────────────────────────────────────── + def reset() -> Dict[str, Any]: logger.info("=" * 60) - logger.info("RESET ENVIRONMENT — destructive wipe starting") + logger.info("RESET ENVIRONMENT — full destructive wipe starting") logger.info("=" * 60) results: Dict[str, Any] = {} - # ── 1. Neo4j: drop cc.users.* and classroomcopilot ─────────────────────── - logger.info("\n[Neo4j] Dropping cc.users.* databases...") - dropped = _neo4j_drop_all_matching("cc.users") - logger.info(f" Dropped: {dropped}") + # ── 1. Neo4j: drop everything except system + neo4j ────────────────────── + logger.info("\n[Neo4j] Dropping all non-system databases...") + dropped = _neo4j_drop_all_non_system() + logger.info(f" Dropped {len(dropped)}: {dropped}") + results["neo4j"] = {"dropped": dropped} - logger.info("[Neo4j] Dropping classroomcopilot...") - with dt.get_session(database="system") as s: - s.run("DROP DATABASE `classroomcopilot` IF EXISTS") - logger.info(" Done") - - # ── 2. Neo4j: wipe institute DB content ────────────────────────────────── - logger.info("[Neo4j] Wiping cc.institutes.* content...") - wiped = _neo4j_wipe_institute_dbs() - logger.info(f" Wiped: {wiped}") - results["neo4j"] = {"dropped": dropped, "wiped": wiped} - - # ── 3. Supabase: detach kcar from school ────────────────────────────────── - logger.info("\n[Supabase] Detaching kcar from school...") + # ── 2. Supabase: clear all data tables (GAIS preserved) ────────────────── + logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...") url, headers = _sb_headers() - requests.patch( - f"{url}/rest/v1/profiles", - headers={**headers, "Prefer": "return=minimal"}, - params={"id": f"eq.{KCAR_ID}"}, - json={"school_id": None}, - ) - requests.delete( - f"{url}/rest/v1/institute_memberships", - headers=headers, - params={"profile_id": f"eq.{KCAR_ID}"}, - ) - logger.info(" kcar detached") + cleared, failed = [], [] + for table in SUPABASE_TABLES_TO_CLEAR: + status = _sb_clear_table(url, headers, table) + if status in (200, 204): + cleared.append(table) + logger.info(f" ✓ {table}") + else: + failed.append(table) + logger.info(f" Cleared {len(cleared)} tables, {len(failed)} failed") - # ── 4. Supabase: delete all test users except kcar ──────────────────────── - logger.info("[Supabase] Deleting test auth users...") + # ── 3. Supabase: delete all auth users except kcar ──────────────────────── + logger.info("\n[Supabase] Deleting test auth users...") all_users = _supabase_list_auth_users(url, headers) deleted_emails = [] for u in all_users: @@ -143,23 +182,42 @@ def reset() -> Dict[str, Any]: continue _supabase_delete_auth_user(url, headers, u["id"]) deleted_emails.append(u["email"]) - time.sleep(0.1) + time.sleep(0.05) logger.info(f" Deleted {len(deleted_emails)} auth users") - # profiles + memberships cascade via FK on auth.users deletion (Supabase handles it) - # but clean up explicitly to be safe - requests.delete( + # Explicit cleanup in case cascade didn't fire + requests.delete(f"{url}/rest/v1/profiles", headers=headers, + params={"id": f"neq.{KCAR_ID}"}) + + # ── 4. Reset kcar profile to known-good platform_admin state ────────────── + logger.info("\n[Supabase] Resetting kcar profile...") + requests.patch( f"{url}/rest/v1/profiles", headers=headers, - params={"id": f"neq.{KCAR_ID}"}, - ) - requests.delete( - f"{url}/rest/v1/institute_memberships", - headers=headers, - params={"profile_id": f"neq.{KCAR_ID}"}, + params={"id": f"eq.{KCAR_ID}"}, + json={"school_id": None}, ) + logger.info(" kcar → school_id: null ✓") - results["supabase"] = {"deleted_users": deleted_emails} + # Restore admin_profiles row (wiped with other tables above) + requests.post( + f"{url}/rest/v1/admin_profiles", + headers={**headers, "Prefer": "resolution=merge-duplicates"}, + json={ + "id": KCAR_ID, + "email": KCAR_EMAIL, + "display_name": "Kevin Carroll", + "admin_role": "super_admin", + "is_super_admin": True, + }, + ) + logger.info(" kcar → admin_profiles restored ✓") + + results["supabase"] = { + "tables_cleared": cleared, + "tables_failed": failed, + "deleted_users": deleted_emails, + } logger.info("\n" + "=" * 60) logger.info("RESET COMPLETE") diff --git a/run/initialization/seed_environment.py b/run/initialization/seed_environment.py index 4c17eda..0141acc 100644 --- a/run/initialization/seed_environment.py +++ b/run/initialization/seed_environment.py @@ -381,18 +381,19 @@ def seed() -> Dict[str, Any]: KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" try: _rest_upsert(url, headers, "admin_profiles", { - "id": KCAR_ID, - "email": "kcar@kevlarai.com", - "role": "super_admin", - "permissions": ["all"], - "metadata": {"seeded": True}, + "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 so user_type is "platform_admin", not "teacher". + # 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( @@ -401,12 +402,26 @@ def seed() -> Dict[str, Any]: json={"user_metadata": {"user_type": "platform_admin"}}, ) if r.status_code in (200, 201): - logger.info(" kcar → user_type: platform_admin ✓") + 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