api/routers/database/tools/platform_admin_router.py
CC Worker cdc105ae54 feat(seed): expand corpus to 1178 papers + download-only/unseed/granular reset
PRIMARY — corpus breadth (505->1178 papers, 18->60 specs, all URLs HEAD-verified):
- AQA (enumerated): Maths, English Lang/Lit, Geography, Computer Science, Business,
  Psychology, MFL (French/Spanish/German), GCSE + A-level, on top of round-1 sciences.
- Edexcel + OCR (confirmed direct URLs via research): Maths, English, Geography, History,
  Business, Computer Science, GCSE + A-level.
- generate_corpus_manifest.py: _subj/_mfl AQA builders, Edexcel/OCR spec+URL tables,
  derived exam_code (_mk_exam_code) matching the locked convention, concurrent re-verify.
Verified on dev .94: eb_specifications=60, eb_exams=1178, QP=469, doc_type all 'pdf',
seed idempotent (uploaded=673 new, skipped=505), failed=0.

SECONDARY:
- --download-only + persistent bucket-shaped local store (manifests/_corpus_store/, gitignored):
  download-once, seed-many, offline-repeatable; --store-dir/--no-store. (_store_path/_item_bytes/
  download_corpus). Verified: store populated, seed reads offline (download_cached).
- --unseed [--board/--spec]: inverse loader — storage objects (Storage API; protect_delete blocks
  raw SQL), first-sweep seed templates, eb_exams, eb_specifications. Verified reversible on .94.
- Granular admin reset: POST /admin/reset?scope=all|exam-corpus|timetable. reset_environment.reset(scope)
  adds EXAM_CORPUS_TABLES (10) + cc.examboards storage cleanup + TIMETABLE_TABLES (13); 'all' now also
  clears the exam subsystem the legacy reset missed. No schema migration required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:33:20 +00:00

172 lines
5.6 KiB
Python

"""
Platform Admin Router — super_admin / platform_admin operations.
GET /admin/schools — list all institutes with member + calendar counts
GET /admin/stats — platform-level summary
"""
import os
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
from modules.auth.platform_admin import require_platform_admin
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
@router.get("/schools")
async def list_all_schools(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""List every institute with basic counts. Platform admin only."""
sb = _sb()
institutes = (
sb.supabase.table("institutes")
.select("id,name,urn,website,status,created_at,neo4j_uuid_string")
.order("name")
.execute()
.data or []
)
if not institutes:
return {"status": "ok", "schools": [], "total": 0}
inst_ids = [i["id"] for i in institutes]
# Member counts per institute
all_members = (
sb.supabase.table("institute_memberships")
.select("institute_id,role")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
from collections import defaultdict
member_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"staff": 0, "students": 0})
staff_roles = {"teacher", "school_admin", "department_head"}
for m in all_members:
iid = m["institute_id"]
if m["role"] in staff_roles:
member_counts[iid]["staff"] += 1
elif m["role"] == "student":
member_counts[iid]["students"] += 1
# Calendar presence per institute
term_rows = (
sb.supabase.table("academic_terms")
.select("institute_id")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
has_calendar = {r["institute_id"] for r in term_rows}
# Pending invitations count
inv_rows = (
sb.supabase.table("invitations")
.select("institute_id")
.eq("status", "pending")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
from collections import Counter
inv_counts = Counter(r["institute_id"] for r in inv_rows)
schools = []
for inst in institutes:
iid = inst["id"]
mc = member_counts.get(iid, {})
schools.append({
**inst,
"staff_count": mc.get("staff", 0),
"student_count": mc.get("students", 0),
"has_calendar": iid in has_calendar,
"pending_invitations": inv_counts.get(iid, 0),
})
return {"status": "ok", "schools": schools, "total": len(schools)}
@router.get("/stats")
async def platform_stats(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""High-level platform counts. Platform admin only."""
sb = _sb()
inst_count = len(
sb.supabase.table("institutes").select("id").execute().data or []
)
profile_count = len(
sb.supabase.table("profiles").select("id").execute().data or []
)
lesson_count = len(
sb.supabase.table("taught_lessons").select("id").execute().data or []
)
inv_count = len(
sb.supabase.table("invitations").select("id").eq("status", "pending").execute().data or []
)
return {
"status": "ok",
"schools": inst_count,
"profiles": profile_count,
"taught_lessons": lesson_count,
"pending_invitations": inv_count,
}
@router.post("/reset")
async def reset_environment(
scope: str = "all",
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""DESTRUCTIVE: wipe test data. Platform admin only.
scope (query param):
- all : full wipe (Neo4j + Supabase data + auth users) AND exam subsystem + storage.
- exam-corpus : ONLY the exam corpus — eb_*/exam_* tables + cc.examboards storage objects
(load/unload the public corpus without touching schools/users).
- timetable : ONLY timetable/calendar materialization tables.
"""
if scope not in ("all", "exam-corpus", "timetable"):
raise HTTPException(status_code=400, detail="scope must be one of: all, exam-corpus, timetable")
import asyncio
import functools
from run.initialization.reset_environment import reset as _reset
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, functools.partial(_reset, scope))
return {"status": "ok", **result}
@router.post("/seed")
async def seed_environment(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""Idempotent rebuild: both schools, global calendar, 20 test accounts. Platform admin only."""
import asyncio
from run.initialization.seed_environment import seed as _seed
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}
@router.post("/seed-timetable")
async def seed_greenfield_timetable(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""Seed full timetable + taught lessons for Greenfield Academy. Platform admin only."""
import asyncio
from run.initialization.seed_greenfield_timetable import seed as _seed
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}