api/run/initialization/reset_environment.py
kcar caeee6c9e4 fix: correct profiles.user_type constraint and admin_profiles column names in reset/seed
- reset_environment: profiles PATCH now sets only school_id=null (removing invalid
  user_type='platform_admin' that violated profiles_user_type_check constraint)
- seed_environment: same profiles PATCH fix; admin_profiles upsert now uses correct
  column names (admin_role, is_super_admin, display_name) matching 002_schema.sql
- Platform admin status is correctly tracked via admin_profiles.is_super_admin=true
  and JWT user_metadata.user_type='platform_admin', not profiles.user_type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 06:27:23 +01:00

231 lines
9.7 KiB
Python

"""
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
Clears:
- 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):
- 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()"
"""
import os
import time
import requests
from typing import List, Dict, Any
from modules.logger_tool import initialise_logger
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_EMAIL = "kcar@kevlarai.com"
# 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",
]
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",
"Prefer": "return=minimal",
}
# ─── 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")]
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}`")
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
# ─── 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]:
r = requests.get(f"{url}/auth/v1/admin/users", headers=headers, params={"per_page": 200})
r.raise_for_status()
return r.json().get("users", [])
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[:80]}")
# ─── Main reset ───────────────────────────────────────────────────────────────
def reset() -> Dict[str, Any]:
logger.info("=" * 60)
logger.info("RESET ENVIRONMENT — full destructive wipe starting")
logger.info("=" * 60)
results: Dict[str, Any] = {}
# ── 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}
# ── 2. Supabase: clear all data tables (GAIS preserved) ──────────────────
logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...")
url, headers = _sb_headers()
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")
# ── 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:
if u["email"] == KCAR_EMAIL:
continue
_supabase_delete_auth_user(url, headers, u["id"])
deleted_emails.append(u["email"])
time.sleep(0.05)
logger.info(f" Deleted {len(deleted_emails)} auth users")
# 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"eq.{KCAR_ID}"},
json={"school_id": None},
)
logger.info(" kcar → school_id: null ✓")
# 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")
logger.info("=" * 60)
return results
if __name__ == "__main__":
import json
print(json.dumps(reset(), indent=2, default=str))