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>
This commit is contained in:
parent
0596ee5e2c
commit
caeee6c9e4
@ -2,15 +2,14 @@
|
|||||||
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
|
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
|
||||||
|
|
||||||
Clears:
|
Clears:
|
||||||
- Neo4j: drops cc.users.*, classroomcopilot; wipes cc.institutes.* content
|
- Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*)
|
||||||
- Supabase: deletes all test auth users + profiles + memberships
|
- Supabase: deletes ALL data tables except gais_local_authorities and gais_schools
|
||||||
- Supabase: detaches kcar from any school
|
- Supabase: deletes all auth users except kcar, then re-seeds kcar profile state
|
||||||
|
|
||||||
Safe invariants (never touched):
|
Safe invariants (never touched):
|
||||||
- gaisdata Neo4j DB
|
- kcar auth account
|
||||||
- system / neo4j Neo4j DBs
|
- gais_local_authorities and gais_schools Supabase tables
|
||||||
- kcar auth account and admin_profiles entry
|
- system / neo4j Neo4j system databases
|
||||||
- institutes rows (schools themselves are kept, just de-seeded)
|
|
||||||
|
|
||||||
Run from inside the ccapi container:
|
Run from inside the ccapi container:
|
||||||
python3 -c "from run.initialization.reset_environment import reset; reset()"
|
python3 -c "from run.initialization.reset_environment import reset; reset()"
|
||||||
@ -21,7 +20,6 @@ import requests
|
|||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from modules.logger_tool import initialise_logger
|
from modules.logger_tool import initialise_logger
|
||||||
from modules.database.services.neo4j_service import Neo4jService
|
|
||||||
import modules.database.tools.neo4j_driver_tools as dt
|
import modules.database.tools.neo4j_driver_tools as dt
|
||||||
|
|
||||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||||
@ -29,59 +27,111 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
|
|||||||
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
||||||
KCAR_EMAIL = "kcar@kevlarai.com"
|
KCAR_EMAIL = "kcar@kevlarai.com"
|
||||||
|
|
||||||
# Databases to fully DROP (content + structure)
|
# Neo4j system databases — never drop these
|
||||||
DBS_TO_DROP = [
|
NEO4J_SYSTEM_DBS = {"system", "neo4j"}
|
||||||
"classroomcopilot",
|
|
||||||
"cc.users",
|
# 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():
|
def _sb_headers():
|
||||||
url = os.environ["SUPABASE_URL"]
|
url = os.environ["SUPABASE_URL"]
|
||||||
key = os.environ["SERVICE_ROLE_KEY"]
|
key = os.environ["SERVICE_ROLE_KEY"]
|
||||||
return url, {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
return url, {
|
||||||
|
"apikey": key,
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "return=minimal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _neo4j_drop_all_matching(pattern: str) -> List[str]:
|
# ─── Neo4j helpers ────────────────────────────────────────────────────────────
|
||||||
"""Drop every Neo4j database whose name starts with pattern."""
|
|
||||||
dropped = []
|
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:
|
with dt.get_session(database="system") as s:
|
||||||
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
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}`")
|
logger.info(f" DROP DATABASE `{db}`")
|
||||||
|
try:
|
||||||
with dt.get_session(database="system") as s:
|
with dt.get_session(database="system") as s:
|
||||||
s.run(f"DROP DATABASE `{db}` IF EXISTS")
|
s.run(f"DROP DATABASE `{db}` IF EXISTS")
|
||||||
dropped.append(db)
|
dropped.append(db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Could not drop `{db}`: {e}")
|
||||||
return dropped
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
def _neo4j_wipe_institute_dbs() -> List[str]:
|
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
||||||
"""MATCH (n) DETACH DELETE on every cc.institutes.* database."""
|
|
||||||
wiped = []
|
# 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.
|
||||||
with dt.get_session(database="system") as s:
|
TABLE_FILTER_COLUMN = {
|
||||||
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
"brain_files": "brain_id",
|
||||||
targets = [db for db in all_dbs
|
}
|
||||||
if any(db.startswith(p) for p in INSTITUTE_DB_PREFIXES)
|
|
||||||
and not db.endswith(".curriculum")]
|
def _sb_clear_table(url: str, headers: dict, table: str) -> int:
|
||||||
for db in targets:
|
"""Delete all rows from a Supabase table. Returns HTTP status."""
|
||||||
logger.info(f" Wipe cc.institutes DB: {db}")
|
col = TABLE_FILTER_COLUMN.get(table, "id")
|
||||||
with dt.get_session(database=db) as s:
|
r = requests.delete(
|
||||||
s.run("MATCH (n) DETACH DELETE n")
|
f"{url}/rest/v1/{table}",
|
||||||
wiped.append(db)
|
headers=headers,
|
||||||
# Also wipe curriculum DBs
|
params={col: "not.is.null"},
|
||||||
with dt.get_session(database="system") as s:
|
)
|
||||||
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
if r.status_code not in (200, 204):
|
||||||
curriculum_dbs = [db for db in all_dbs if db.endswith(".curriculum")]
|
logger.warning(f" Clear {table}: {r.status_code} {r.text[:120]}")
|
||||||
for db in curriculum_dbs:
|
return r.status_code
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
|
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):
|
def _supabase_delete_auth_user(url: str, headers: dict, uid: str):
|
||||||
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
|
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
|
||||||
if r.status_code not in (200, 204):
|
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]:
|
def reset() -> Dict[str, Any]:
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("RESET ENVIRONMENT — destructive wipe starting")
|
logger.info("RESET ENVIRONMENT — full destructive wipe starting")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
results: Dict[str, Any] = {}
|
results: Dict[str, Any] = {}
|
||||||
|
|
||||||
# ── 1. Neo4j: drop cc.users.* and classroomcopilot ───────────────────────
|
# ── 1. Neo4j: drop everything except system + neo4j ──────────────────────
|
||||||
logger.info("\n[Neo4j] Dropping cc.users.* databases...")
|
logger.info("\n[Neo4j] Dropping all non-system databases...")
|
||||||
dropped = _neo4j_drop_all_matching("cc.users")
|
dropped = _neo4j_drop_all_non_system()
|
||||||
logger.info(f" Dropped: {dropped}")
|
logger.info(f" Dropped {len(dropped)}: {dropped}")
|
||||||
|
results["neo4j"] = {"dropped": dropped}
|
||||||
|
|
||||||
logger.info("[Neo4j] Dropping classroomcopilot...")
|
# ── 2. Supabase: clear all data tables (GAIS preserved) ──────────────────
|
||||||
with dt.get_session(database="system") as s:
|
logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...")
|
||||||
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...")
|
|
||||||
url, headers = _sb_headers()
|
url, headers = _sb_headers()
|
||||||
requests.patch(
|
cleared, failed = [], []
|
||||||
f"{url}/rest/v1/profiles",
|
for table in SUPABASE_TABLES_TO_CLEAR:
|
||||||
headers={**headers, "Prefer": "return=minimal"},
|
status = _sb_clear_table(url, headers, table)
|
||||||
params={"id": f"eq.{KCAR_ID}"},
|
if status in (200, 204):
|
||||||
json={"school_id": None},
|
cleared.append(table)
|
||||||
)
|
logger.info(f" ✓ {table}")
|
||||||
requests.delete(
|
else:
|
||||||
f"{url}/rest/v1/institute_memberships",
|
failed.append(table)
|
||||||
headers=headers,
|
logger.info(f" Cleared {len(cleared)} tables, {len(failed)} failed")
|
||||||
params={"profile_id": f"eq.{KCAR_ID}"},
|
|
||||||
)
|
|
||||||
logger.info(" kcar detached")
|
|
||||||
|
|
||||||
# ── 4. Supabase: delete all test users except kcar ────────────────────────
|
# ── 3. Supabase: delete all auth users except kcar ────────────────────────
|
||||||
logger.info("[Supabase] Deleting test auth users...")
|
logger.info("\n[Supabase] Deleting test auth users...")
|
||||||
all_users = _supabase_list_auth_users(url, headers)
|
all_users = _supabase_list_auth_users(url, headers)
|
||||||
deleted_emails = []
|
deleted_emails = []
|
||||||
for u in all_users:
|
for u in all_users:
|
||||||
@ -143,23 +182,42 @@ def reset() -> Dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
_supabase_delete_auth_user(url, headers, u["id"])
|
_supabase_delete_auth_user(url, headers, u["id"])
|
||||||
deleted_emails.append(u["email"])
|
deleted_emails.append(u["email"])
|
||||||
time.sleep(0.1)
|
time.sleep(0.05)
|
||||||
logger.info(f" Deleted {len(deleted_emails)} auth users")
|
logger.info(f" Deleted {len(deleted_emails)} auth users")
|
||||||
|
|
||||||
# profiles + memberships cascade via FK on auth.users deletion (Supabase handles it)
|
# Explicit cleanup in case cascade didn't fire
|
||||||
# but clean up explicitly to be safe
|
requests.delete(f"{url}/rest/v1/profiles", headers=headers,
|
||||||
requests.delete(
|
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",
|
f"{url}/rest/v1/profiles",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
params={"id": f"neq.{KCAR_ID}"},
|
params={"id": f"eq.{KCAR_ID}"},
|
||||||
)
|
json={"school_id": None},
|
||||||
requests.delete(
|
|
||||||
f"{url}/rest/v1/institute_memberships",
|
|
||||||
headers=headers,
|
|
||||||
params={"profile_id": f"neq.{KCAR_ID}"},
|
|
||||||
)
|
)
|
||||||
|
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("\n" + "=" * 60)
|
||||||
logger.info("RESET COMPLETE")
|
logger.info("RESET COMPLETE")
|
||||||
|
|||||||
@ -383,8 +383,9 @@ def seed() -> Dict[str, Any]:
|
|||||||
_rest_upsert(url, headers, "admin_profiles", {
|
_rest_upsert(url, headers, "admin_profiles", {
|
||||||
"id": KCAR_ID,
|
"id": KCAR_ID,
|
||||||
"email": "kcar@kevlarai.com",
|
"email": "kcar@kevlarai.com",
|
||||||
"role": "super_admin",
|
"display_name": "Kevin Carroll",
|
||||||
"permissions": ["all"],
|
"admin_role": "super_admin",
|
||||||
|
"is_super_admin": True,
|
||||||
"metadata": {"seeded": True},
|
"metadata": {"seeded": True},
|
||||||
}, on_conflict="id")
|
}, on_conflict="id")
|
||||||
logger.info(" kcar → admin_profiles ✓")
|
logger.info(" kcar → admin_profiles ✓")
|
||||||
@ -392,7 +393,7 @@ def seed() -> Dict[str, Any]:
|
|||||||
errors.append(f"kcar_admin: {e}")
|
errors.append(f"kcar_admin: {e}")
|
||||||
logger.error(f" {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.
|
# Without this, POST /user/init assigns kcar to the default school on first login.
|
||||||
try:
|
try:
|
||||||
r = requests.put(
|
r = requests.put(
|
||||||
@ -401,12 +402,26 @@ def seed() -> Dict[str, Any]:
|
|||||||
json={"user_metadata": {"user_type": "platform_admin"}},
|
json={"user_metadata": {"user_type": "platform_admin"}},
|
||||||
)
|
)
|
||||||
if r.status_code in (200, 201):
|
if r.status_code in (200, 201):
|
||||||
logger.info(" kcar → user_type: platform_admin ✓")
|
logger.info(" kcar → auth user_metadata: platform_admin ✓")
|
||||||
else:
|
else:
|
||||||
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
|
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"kcar_user_type: {e}")
|
errors.append(f"kcar_user_type: {e}")
|
||||||
logger.error(f" {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 ───────────────────────────────────────────────────────────────
|
# ── Summary ───────────────────────────────────────────────────────────────
|
||||||
results["success"] = len(errors) == 0
|
results["success"] = len(errors) == 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user