Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3da9f3b59 | ||
|
|
49f84655f7 | ||
|
|
e269e67f27 | ||
|
|
77bb0766ff | ||
|
|
98be55ab57 | ||
|
|
62234dbbcb | ||
|
|
a1d297ac30 | ||
|
|
5ad9c01cde | ||
|
|
96f9fb2446 | ||
|
|
f52c3267ca | ||
|
|
6ce6272a1e | ||
|
|
b8cb9083ec | ||
|
|
8427063bd1 | ||
|
|
5f822eaf87 | ||
|
|
c690caa26d | ||
|
|
0ce654c6c6 | ||
|
|
4b296cff74 | ||
|
|
3711b52ea4 | ||
|
|
d3465eca7b | ||
|
|
9de949d212 | ||
|
|
f203f376e9 | ||
|
|
52f5ef4ca2 | ||
|
|
ead4452277 | ||
|
|
e66c8ec291 | ||
|
|
abc90fa1b6 |
+24
-6
@@ -1,4 +1,9 @@
|
||||
services:
|
||||
# ── Required environment variables (see .env.example) ───────────────────────
|
||||
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
|
||||
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
|
||||
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
|
||||
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
|
||||
redis-dev:
|
||||
image: redis:7-alpine
|
||||
container_name: cc-redis-dev
|
||||
@@ -15,12 +20,28 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
init:
|
||||
image: cc-api-dev:latest
|
||||
container_name: api-init-dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
- REDIS_HOST=redis-dev
|
||||
- RUN_INIT=true
|
||||
- INIT_MODE=${INIT_MODE:-infra}
|
||||
- INIT_ONLY=true
|
||||
command: ["./docker-entrypoint.sh", "init-only"]
|
||||
depends_on:
|
||||
redis-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kevlarai-network
|
||||
profiles:
|
||||
- init
|
||||
|
||||
backend-dev:
|
||||
container_name: cc-api-dev
|
||||
image: cc-api-dev:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
@@ -45,9 +66,6 @@ services:
|
||||
|
||||
backend-test:
|
||||
image: cc-api-dev:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
services:
|
||||
# ── Required environment variables (see .env.example) ───────────────────────
|
||||
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
|
||||
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
|
||||
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
|
||||
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: classroomcopilot-redis
|
||||
|
||||
+12
-14
@@ -29,7 +29,7 @@ print_error() {
|
||||
|
||||
# Check if we should run initialization
|
||||
RUN_INIT="${RUN_INIT:-false}"
|
||||
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'full', or comma-separated list
|
||||
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'seed', 'seed-test', 'full', or comma-separated list
|
||||
|
||||
# If RUN_INIT is true, run initialization tasks
|
||||
if [ "$RUN_INIT" = "true" ]; then
|
||||
@@ -51,21 +51,21 @@ if [ "$RUN_INIT" = "true" ]; then
|
||||
}
|
||||
print_success "Infrastructure setup completed"
|
||||
;;
|
||||
"demo-school")
|
||||
print_status "Creating demo school..."
|
||||
python3 main.py --mode demo-school || {
|
||||
print_error "Demo school creation failed!"
|
||||
"seed")
|
||||
print_status "Seeding canonical full environment..."
|
||||
python3 main.py --mode seed || {
|
||||
print_error "Seed failed!"
|
||||
exit 1
|
||||
}
|
||||
print_success "Demo school creation completed"
|
||||
print_success "Seed completed"
|
||||
;;
|
||||
"demo-users")
|
||||
print_status "Creating demo users..."
|
||||
python3 main.py --mode demo-users || {
|
||||
print_error "Demo users creation failed!"
|
||||
"seed-test")
|
||||
print_status "Seeding lightweight test environment..."
|
||||
python3 main.py --mode seed-test || {
|
||||
print_error "Seed test failed!"
|
||||
exit 1
|
||||
}
|
||||
print_success "Demo users creation completed"
|
||||
print_success "Seed test completed"
|
||||
;;
|
||||
"gais-data")
|
||||
print_status "Importing GAIS data..."
|
||||
@@ -78,9 +78,7 @@ if [ "$RUN_INIT" = "true" ]; then
|
||||
"full")
|
||||
print_status "Running full initialization..."
|
||||
python3 main.py --mode infra || exit 1
|
||||
python3 main.py --mode demo-school || exit 1
|
||||
python3 main.py --mode demo-users || exit 1
|
||||
python3 main.py --mode gais-data || exit 1
|
||||
python3 main.py --mode seed || exit 1
|
||||
print_success "Full initialization completed"
|
||||
;;
|
||||
*)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Helper script to run initialization tasks in production
|
||||
# Usage: ./init-production.sh [mode]
|
||||
# Modes: infra, demo-school, demo-users, gais-data, full
|
||||
# Modes: infra, seed, seed-test, gais-data, full
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -292,33 +292,20 @@ def run_infrastructure_mode():
|
||||
logger.error(f"Infrastructure setup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_demo_school_mode():
|
||||
"""Run demo school creation"""
|
||||
logger.info("Running in demo school mode")
|
||||
logger.info("Starting demo school creation...")
|
||||
|
||||
def run_seed_mode(test: bool = False):
|
||||
"""Run canonical environment seed."""
|
||||
mode = "test" if test else "full"
|
||||
logger.info(f"Running canonical seed mode ({mode})")
|
||||
try:
|
||||
from run.initialization import initialize_demo_school_mode
|
||||
initialize_demo_school_mode()
|
||||
logger.info("Demo school creation completed successfully")
|
||||
return True
|
||||
from run.initialization.seed_environment import seed
|
||||
import json
|
||||
result = seed(test=test)
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return bool(result.get('success'))
|
||||
except Exception as e:
|
||||
logger.error(f"Demo school creation failed: {str(e)}")
|
||||
logger.error(f"Seed mode failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_demo_users_mode():
|
||||
"""Run demo users creation"""
|
||||
logger.info("Running in demo users mode")
|
||||
logger.info("Starting demo users creation...")
|
||||
|
||||
try:
|
||||
from run.initialization import initialize_demo_users_mode
|
||||
initialize_demo_users_mode()
|
||||
logger.info("Demo users creation completed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Demo users creation failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def run_gais_data_mode():
|
||||
"""Run GAIS data import"""
|
||||
@@ -414,9 +401,8 @@ def parse_arguments():
|
||||
epilog="""
|
||||
Startup modes:
|
||||
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
|
||||
demo-school - Create demo school (KevlarAI)
|
||||
demo-users - Create demo users
|
||||
seed-test - Seed full test environment (2 schools, all test users)
|
||||
seed - Seed canonical full environment (20 school users)
|
||||
seed-test - Seed lightweight test environment (9 school users)
|
||||
gais-data - Import GAIS data (Edubase, etc.)
|
||||
dev - Run development server with auto-reload
|
||||
prod - Run production server (for Docker/containerized deployment)
|
||||
@@ -425,7 +411,7 @@ Startup modes:
|
||||
|
||||
parser.add_argument(
|
||||
'--mode', '-m',
|
||||
choices=['infra', 'demo-school', 'demo-users', 'seed-test', 'gais-data', 'dev', 'prod'],
|
||||
choices=['infra', 'seed', 'seed-test', 'gais-data', 'dev', 'prod'],
|
||||
default='dev',
|
||||
help='Startup mode (default: dev)'
|
||||
)
|
||||
@@ -448,22 +434,13 @@ if __name__ == "__main__":
|
||||
success = run_infrastructure_mode()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.mode == 'demo-school':
|
||||
# Run demo school creation
|
||||
success = run_demo_school_mode()
|
||||
elif args.mode == 'seed':
|
||||
success = run_seed_mode(test=False)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.mode == 'demo-users':
|
||||
# Run demo users creation
|
||||
success = run_demo_users_mode()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
elif args.mode == 'seed-test':
|
||||
from run.initialization.seed_test_environment import seed_test_environment
|
||||
import json
|
||||
result = seed_test_environment()
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get('success') else 1)
|
||||
success = run_seed_mode(test=True)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.mode == 'gais-data':
|
||||
# Run GAIS data import
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Neontology node schemas for the cc.public.exams knowledge graph.
|
||||
|
||||
cc.public.exams is a dedicated, shared, public Neo4j database — co-primary/authoritative for the
|
||||
exam knowledge graph (specs, spec-points, paper→question→part→region structure, ASSESSES links).
|
||||
Supabase remains source of truth for operational data (geometry, marks, submissions); the two
|
||||
layers join on shared UUIDs:
|
||||
|
||||
exam_questions.id <-> Question|Part.uuid_string (container -> Question, leaf -> Part)
|
||||
exam_response_areas.id <-> Region.uuid_string
|
||||
eb_exams.exam_code <-> ExamPaper.exam_code
|
||||
eb_specifications.spec_code <-> Specification.spec_code
|
||||
|
||||
Ownership: created by an infra-init step; read by all authenticated API calls; written by the API
|
||||
service role only (no direct client writes).
|
||||
"""
|
||||
from typing import ClassVar, Optional
|
||||
from ..base_nodes import CCBaseNode
|
||||
|
||||
|
||||
class ExamBaseNode(CCBaseNode):
|
||||
__primarylabel__: ClassVar[str] = ''
|
||||
|
||||
|
||||
class ExamBoardNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'ExamBoard'
|
||||
code: str # 'AQA'
|
||||
name: str
|
||||
|
||||
|
||||
class SpecificationNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'Specification'
|
||||
spec_code: str # 'AQA-PHYS-8463' (== Supabase eb_specifications.spec_code)
|
||||
exam_board_code: str
|
||||
subject_code: Optional[str] = None
|
||||
award_code: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class SpecPointNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'SpecPoint'
|
||||
ref: str # '4.1', '4.2.1'
|
||||
description: str
|
||||
spec_code: str
|
||||
exam_board_code: str
|
||||
|
||||
|
||||
class ExamPaperNode(ExamBaseNode):
|
||||
__primarylabel__: ClassVar[str] = 'ExamPaper'
|
||||
exam_code: str # == Supabase eb_exams.exam_code
|
||||
spec_code: str
|
||||
paper_code: Optional[str] = None
|
||||
tier: Optional[str] = None
|
||||
session: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
|
||||
|
||||
class QuestionNode(ExamBaseNode): # roll-up container; uuid_string == exam_questions.id
|
||||
__primarylabel__: ClassVar[str] = 'Question'
|
||||
exam_code: str
|
||||
label: str # '01'
|
||||
order: int
|
||||
max_marks: float
|
||||
|
||||
|
||||
class PartNode(ExamBaseNode): # leaf; uuid_string == exam_questions.id
|
||||
__primarylabel__: ClassVar[str] = 'Part'
|
||||
exam_code: str
|
||||
label: str # '01.1'
|
||||
order: int
|
||||
max_marks: float
|
||||
answer_type: str
|
||||
mark_scheme_type: str
|
||||
|
||||
|
||||
class RegionNode(ExamBaseNode): # uuid_string == exam_response_areas.id
|
||||
__primarylabel__: ClassVar[str] = 'Region'
|
||||
page: int
|
||||
kind: str # 'response' | 'context'
|
||||
response_form: str
|
||||
|
||||
|
||||
# Relationship reference (written by the projection / linker, not modelled as classes here):
|
||||
# (:ExamBoard)-[:PUBLISHES]->(:Specification)
|
||||
# (:Specification)-[:HAS_SPEC_POINT]->(:SpecPoint)
|
||||
# (:Specification)-[:HAS_PAPER]->(:ExamPaper)
|
||||
# (:ExamPaper)-[:HAS_QUESTION]->(:Question)
|
||||
# (:Question)-[:HAS_PART]->(:Part) # nested questions allowed
|
||||
# (:Part)-[:HAS_REGION]->(:Region)
|
||||
# (:Part)-[:ASSESSES]->(:SpecPoint) # from exam_questions.spec_ref
|
||||
# (:SpecPoint)-[:TEACHES]->(:LearningStatement) # DEFERRED cross-db bridge
|
||||
@@ -160,7 +160,7 @@ class BootstrapService:
|
||||
try:
|
||||
result = (
|
||||
self.supabase.table("profiles")
|
||||
.select("id,email,user_type,username,full_name,display_name,user_db_name,school_db_name")
|
||||
.select("id,email,user_type,username,full_name,display_name,user_db_name,school_db_name,school_id")
|
||||
.eq("id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Project a saved exam template into the cc.public.exams Neo4j graph (card S4-7).
|
||||
|
||||
Supabase is source of truth for the operational template (geometry, marks); cc.public.exams is
|
||||
the co-primary knowledge graph (spec §2/S2). On template save the structural skeleton —
|
||||
ExamPaper → Question/Part → Region, plus Part-[:ASSESSES]->SpecPoint — is projected here.
|
||||
|
||||
Ownership model (R3.5.1): the graph is written by the API SERVICE ROLE only (no client writes),
|
||||
so this task reads the template via service role and writes Neo4j with the system driver. It is
|
||||
the sanctioned service-role path (documented in the ADR), distinct from the as-user request path.
|
||||
|
||||
Join keys (never regenerated):
|
||||
exam_questions.id -> Question.uuid_string (container) | Part.uuid_string (leaf)
|
||||
exam_response_areas.id -> Region.uuid_string
|
||||
eb_exams.exam_code -> ExamPaper.exam_code
|
||||
eb_specifications.spec_code -> Specification.spec_code
|
||||
|
||||
Projection is a full re-sync per exam_code (delete this paper's Question/Part/Region, recreate),
|
||||
matching the PUT full-replace semantics — idempotent and safe to re-run.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.tools.neo4j_driver_tools import get_session
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
# MUST match run/initialization/init_exam_graph.py (shared DB name + deterministic uuid namespace).
|
||||
EXAM_DB = "cc.public.exams"
|
||||
NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1")
|
||||
|
||||
|
||||
def _uid(*parts: str) -> str:
|
||||
return str(uuid.uuid5(NS, ":".join(parts)))
|
||||
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def project_template(template_id: str) -> Dict[str, Any]:
|
||||
"""Read the template (service role) and (re)project its structure into cc.public.exams.
|
||||
|
||||
Returns a counts dict. Raises on hard failure (caller decides whether to swallow — a
|
||||
BackgroundTask logs and drops; the manual /neo4j-sync endpoint surfaces the error).
|
||||
"""
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
template = (sb.table("exam_templates").select("*").eq("id", template_id).limit(1).execute().data or [None])[0]
|
||||
if not template:
|
||||
raise ValueError(f"template {template_id} not found")
|
||||
|
||||
questions = _rows(sb.table("exam_questions").select("*").eq("template_id", template_id).order("order").execute())
|
||||
regions = _rows(sb.table("exam_response_areas").select("*").eq("template_id", template_id).execute())
|
||||
|
||||
# Resolve the paper's exam_code + spec metadata. Catalogue paper → from eb_exams; ad-hoc upload
|
||||
# (no exam_code) → a stable synthetic code so the paper still has a unique graph key.
|
||||
exam_code = template.get("exam_code")
|
||||
spec_code = None
|
||||
paper_meta: Dict[str, Any] = {}
|
||||
if template.get("exam_id"):
|
||||
eb = (sb.table("eb_exams").select("exam_code, spec_code, paper_code, tier, session")
|
||||
.eq("id", template["exam_id"]).limit(1).execute().data or [None])[0]
|
||||
if eb:
|
||||
exam_code = exam_code or eb.get("exam_code")
|
||||
spec_code = eb.get("spec_code")
|
||||
paper_meta = eb
|
||||
if not exam_code:
|
||||
exam_code = f"tpl:{template_id}"
|
||||
|
||||
paper_uid = _uid("ExamPaper", exam_code)
|
||||
counts = {"exam_code": exam_code, "questions": 0, "parts": 0, "regions": 0, "assesses": 0, "spec_linked": False}
|
||||
|
||||
with get_session(database=EXAM_DB) as s:
|
||||
# 1. Clean this paper's existing children (full re-sync), keep the ExamPaper node itself.
|
||||
s.run("MATCH (r:Region {exam_code:$ec}) DETACH DELETE r", ec=exam_code).consume()
|
||||
s.run("MATCH (n {exam_code:$ec}) WHERE n:Question OR n:Part DETACH DELETE n", ec=exam_code).consume()
|
||||
|
||||
# 2. ExamPaper node.
|
||||
s.run(
|
||||
"MERGE (p:ExamPaper {uuid_string:$uid}) "
|
||||
"SET p.exam_code=$ec, p.spec_code=$sc, p.title=$title, p.page_count=$pc, "
|
||||
" p.paper_code=$paper_code, p.tier=$tier, p.session=$session, p.node_storage_path=$nsp",
|
||||
uid=paper_uid, ec=exam_code, sc=spec_code, title=template.get("title"),
|
||||
pc=template.get("page_count"), paper_code=paper_meta.get("paper_code"),
|
||||
tier=paper_meta.get("tier"), session=paper_meta.get("session"),
|
||||
nsp=f"{EXAM_DB}/ExamPaper/{exam_code}",
|
||||
).consume()
|
||||
|
||||
# 3. Link to its Specification (seeded separately) when known.
|
||||
if spec_code:
|
||||
r = s.run(
|
||||
"MATCH (sp:Specification {spec_code:$sc}), (p:ExamPaper {exam_code:$ec}) "
|
||||
"MERGE (sp)-[:HAS_PAPER]->(p) RETURN count(*) AS n",
|
||||
sc=spec_code, ec=exam_code,
|
||||
).single()
|
||||
counts["spec_linked"] = bool(r and r["n"])
|
||||
|
||||
# 4. Question/Part nodes — pass 1: create all nodes (so parents exist before linking).
|
||||
for q in questions:
|
||||
label = "Question" if q.get("is_container") else "Part"
|
||||
s.run(
|
||||
f"MERGE (n:{label} {{uuid_string:$uid}}) "
|
||||
"SET n.exam_code=$ec, n.label=$label, n.order=$order, n.max_marks=$mm, "
|
||||
" n.answer_type=$at, n.mark_scheme_type=$mst, n.spec_ref=$sref, "
|
||||
" n.node_storage_path=$nsp",
|
||||
uid=q["id"], ec=exam_code, label=q.get("label"), order=q.get("order") or 0,
|
||||
mm=q.get("max_marks") or 0, at=q.get("answer_type"),
|
||||
mst=(q.get("mark_scheme") or {}).get("type") if isinstance(q.get("mark_scheme"), dict) else None,
|
||||
sref=q.get("spec_ref"), nsp=f"{EXAM_DB}/{label}/{q['id']}",
|
||||
).consume()
|
||||
counts["parts" if label == "Part" else "questions"] += 1
|
||||
|
||||
# 5. Structural + ASSESSES edges — pass 2.
|
||||
for q in questions:
|
||||
if q.get("parent_id"):
|
||||
s.run(
|
||||
"MATCH (parent {uuid_string:$pid}), (n {uuid_string:$uid}) MERGE (parent)-[:HAS_PART]->(n)",
|
||||
pid=q["parent_id"], uid=q["id"],
|
||||
).consume()
|
||||
else:
|
||||
s.run(
|
||||
"MATCH (p:ExamPaper {exam_code:$ec}), (n {uuid_string:$uid}) MERGE (p)-[:HAS_QUESTION]->(n)",
|
||||
ec=exam_code, uid=q["id"],
|
||||
).consume()
|
||||
if q.get("spec_ref"):
|
||||
# SpecPoints are seeded per spec_code; match within this paper's spec when known.
|
||||
r = s.run(
|
||||
"MATCH (n {uuid_string:$uid}), (sp:SpecPoint {ref:$ref}) "
|
||||
+ ("WHERE sp.spec_code=$sc " if spec_code else "")
|
||||
+ "MERGE (n)-[:ASSESSES]->(sp) RETURN count(*) AS n",
|
||||
uid=q["id"], ref=q["spec_ref"], sc=spec_code,
|
||||
).single()
|
||||
counts["assesses"] += (r["n"] if r else 0)
|
||||
|
||||
# 6. Region nodes + HAS_REGION edges.
|
||||
for rg in regions:
|
||||
s.run(
|
||||
"MERGE (r:Region {uuid_string:$uid}) "
|
||||
"SET r.exam_code=$ec, r.page=$page, r.kind=$kind, r.response_form=$rf, r.node_storage_path=$nsp",
|
||||
uid=rg["id"], ec=exam_code, page=rg.get("page"), kind=rg.get("kind"),
|
||||
rf=rg.get("response_form"), nsp=f"{EXAM_DB}/Region/{rg['id']}",
|
||||
).consume()
|
||||
s.run(
|
||||
"MATCH (q {uuid_string:$qid}), (r:Region {uuid_string:$uid}) MERGE (q)-[:HAS_REGION]->(r)",
|
||||
qid=rg["question_id"], uid=rg["id"],
|
||||
).consume()
|
||||
counts["regions"] += 1
|
||||
|
||||
logger.info(f"Projected template {template_id} → cc.public.exams: {counts}")
|
||||
return counts
|
||||
|
||||
|
||||
def project_template_safe(template_id: str) -> None:
|
||||
"""BackgroundTask wrapper: never raises (a failed projection must not break the HTTP save)."""
|
||||
try:
|
||||
project_template(template_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Background Neo4j projection failed for template {template_id}: {exc}")
|
||||
@@ -23,7 +23,11 @@ def _create_base_client(url: str, key: str, access_token: Optional[str] = None,
|
||||
# If an access token is provided, use it for Authorization (enables per-user RLS)
|
||||
# Otherwise fall back to the API key
|
||||
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
|
||||
|
||||
|
||||
# apikey is required by the Supabase gateway (Kong) on every request and is independent of
|
||||
# Authorization: for a per-user client apikey stays the anon key while Authorization carries
|
||||
# the user's JWT (so RLS sees auth.uid()). Set it explicitly rather than relying on
|
||||
# create_client's internal default-header behaviour, which our options.headers override.
|
||||
headers = {
|
||||
"apikey": key,
|
||||
"Authorization": auth_header,
|
||||
|
||||
@@ -23,76 +23,76 @@ class StorageManager:
|
||||
def check_bucket_exists(self, bucket_id: str) -> bool:
|
||||
"""Check if a storage bucket exists"""
|
||||
try:
|
||||
self.logger.info(f"Checking if bucket {{bucket_id}} exists")
|
||||
self.logger.info(f"Checking if bucket {bucket_id} exists")
|
||||
buckets = self.client.supabase.storage.list_buckets()
|
||||
return any(bucket.name == bucket_id for bucket in buckets)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking bucket {{bucket_id}}: {{str(e)}}")
|
||||
self.logger.error(f"Error checking bucket {bucket_id}: {str(e)}")
|
||||
return False
|
||||
|
||||
def list_bucket_contents(self, bucket_id: str, path: str = "") -> Dict:
|
||||
"""List contents of a bucket at specified path"""
|
||||
try:
|
||||
self.logger.info(f"Listing contents of bucket {{bucket_id}} at path {{path}}")
|
||||
self.logger.info(f"Listing contents of bucket {bucket_id} at path {path}")
|
||||
contents = self.client.supabase.storage.from_(bucket_id).list(path)
|
||||
return {{
|
||||
return {
|
||||
"folders": [item for item in contents if item.get("id", "").endswith("/")],
|
||||
"files": [item for item in contents if not item.get("id", "").endswith("/")]
|
||||
}}
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error listing bucket contents: {{str(e)}}")
|
||||
self.logger.error(f"Error listing bucket contents: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def upload_file(self, bucket_id: str, file_path: str, file_data: bytes, content_type: str, upsert: bool = True) -> Any:
|
||||
"""Upload a file to a storage bucket"""
|
||||
try:
|
||||
self.logger.info(f"Uploading file to {{bucket_id}} at path {{file_path}}")
|
||||
self.logger.info(f"Uploading file to {bucket_id} at path {file_path}")
|
||||
return self.client.supabase.storage.from_(bucket_id).upload(
|
||||
path=file_path,
|
||||
file=file_data,
|
||||
file_options={{
|
||||
file_options={
|
||||
"content-type": content_type,
|
||||
"x-upsert": "true" if upsert else "false"
|
||||
}}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error uploading file: {{str(e)}}")
|
||||
self.logger.error(f"Error uploading file: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def download_file(self, bucket_id: str, file_path: str) -> bytes:
|
||||
"""Download a file from a storage bucket"""
|
||||
try:
|
||||
self.logger.info(f"Downloading file from {{bucket_id}} at path {{file_path}}")
|
||||
self.logger.info(f"Downloading file from {bucket_id} at path {file_path}")
|
||||
return self.client.supabase.storage.from_(bucket_id).download(file_path)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error downloading file: {{str(e)}}")
|
||||
self.logger.error(f"Error downloading file: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def delete_file(self, bucket_id: str, file_path: str) -> None:
|
||||
"""Delete a file from a storage bucket"""
|
||||
try:
|
||||
self.logger.info(f"Deleting file from {{bucket_id}} at path {{file_path}}")
|
||||
self.logger.info(f"Deleting file from {bucket_id} at path {file_path}")
|
||||
self.client.supabase.storage.from_(bucket_id).remove([file_path])
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting file: {{str(e)}}")
|
||||
self.logger.error(f"Error deleting file: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def get_public_url(self, bucket_id: str, file_path: str) -> str:
|
||||
"""Get public URL for a file"""
|
||||
try:
|
||||
self.logger.info(f"Getting public URL for file in {{bucket_id}} at path {{file_path}}")
|
||||
self.logger.info(f"Getting public URL for file in {bucket_id} at path {file_path}")
|
||||
return self.client.supabase.storage.from_(bucket_id).get_public_url(file_path)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting public URL: {{str(e)}}")
|
||||
self.logger.error(f"Error getting public URL: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def create_signed_url(self, bucket_id: str, file_path: str, expires_in: int = 3600) -> Any:
|
||||
"""Create a signed URL for temporary file access"""
|
||||
try:
|
||||
self.logger.info(f"Creating signed URL for file in {{bucket_id}} at path {{file_path}}")
|
||||
self.logger.info(f"Creating signed URL for file in {bucket_id} at path {file_path}")
|
||||
return self.client.supabase.storage.from_(bucket_id).create_signed_url(file_path, expires_in)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating signed URL: {{str(e)}}")
|
||||
self.logger.error(f"Error creating signed URL: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
class StorageAdmin(StorageManager):
|
||||
@@ -115,9 +115,9 @@ class StorageAdmin(StorageManager):
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new storage bucket with supported parameters."""
|
||||
try:
|
||||
self.logger.info(f"Creating bucket {{id}} with name {{name}}")
|
||||
self.logger.info(f"Creating bucket {id} with name {name}")
|
||||
|
||||
options: Optional[CreateBucketOptions] = {{}}
|
||||
options: Optional[CreateBucketOptions] = {}
|
||||
if public:
|
||||
options["public"] = public
|
||||
if file_size_limit is not None:
|
||||
@@ -133,7 +133,7 @@ class StorageAdmin(StorageManager):
|
||||
return bucket
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating bucket {{id}}: {{str(e)}}")
|
||||
self.logger.error(f"Error creating bucket {id}: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def initialize_core_buckets(self, admin_user_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
@@ -144,7 +144,7 @@ class StorageAdmin(StorageManager):
|
||||
raise ValueError("Admin user ID is required for bucket initialization")
|
||||
|
||||
core_buckets = [
|
||||
{{
|
||||
{
|
||||
"id": "cc.users",
|
||||
"name": "CC Users",
|
||||
"public": False,
|
||||
@@ -156,8 +156,8 @@ class StorageAdmin(StorageManager):
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
'text/plain', 'text/csv', 'application/json'
|
||||
]
|
||||
}},
|
||||
{{
|
||||
},
|
||||
{
|
||||
"id": "cc.institutes",
|
||||
"name": "CC Institutes",
|
||||
"public": False,
|
||||
@@ -169,7 +169,7 @@ class StorageAdmin(StorageManager):
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
'text/plain', 'text/csv', 'application/json'
|
||||
]
|
||||
}}
|
||||
}
|
||||
]
|
||||
|
||||
results = []
|
||||
@@ -177,30 +177,30 @@ class StorageAdmin(StorageManager):
|
||||
try:
|
||||
bucket_name = bucket.pop("name")
|
||||
result = self.create_bucket(name=bucket_name, **bucket)
|
||||
results.append({{
|
||||
results.append({
|
||||
"bucket": bucket["id"],
|
||||
"status": "success",
|
||||
"result": result
|
||||
}})
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating bucket {{bucket['id']}}: {{str(e)}}")
|
||||
results.append({{
|
||||
self.logger.error(f"Error creating bucket {bucket['id']}: {str(e)}")
|
||||
results.append({
|
||||
"bucket": bucket["id"],
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}})
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing core buckets: {{str(e)}}")
|
||||
self.logger.error(f"Error initializing core buckets: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def create_user_bucket(self, user_id: str, username: str) -> Dict[str, Any]:
|
||||
"""Create a storage bucket for a specific user."""
|
||||
try:
|
||||
bucket_id = f"cc.users.admin.{{username}}"
|
||||
bucket_name = f"User Files - {{username}}"
|
||||
bucket_id = f"cc.users.admin.{username}"
|
||||
bucket_name = f"User Files - {username}"
|
||||
|
||||
return self.create_bucket(
|
||||
id=bucket_id,
|
||||
@@ -217,7 +217,7 @@ class StorageAdmin(StorageManager):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating user bucket for {{username}}: {{str(e)}}")
|
||||
self.logger.error(f"Error creating user bucket for {username}: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
def create_school_buckets(self, school_id: str, school_name: str, admin_user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
@@ -228,9 +228,9 @@ class StorageAdmin(StorageManager):
|
||||
raise ValueError("Admin user ID is required for school bucket creation")
|
||||
|
||||
school_buckets = [
|
||||
{{
|
||||
"id": f"cc.institutes.{{school_id}}.public",
|
||||
"name": f"{{school_name}} - Public Files",
|
||||
{
|
||||
"id": f"cc.institutes.{school_id}.public",
|
||||
"name": f"{school_name} - Public Files",
|
||||
"public": True,
|
||||
"owner": owner_id,
|
||||
"owner_id": school_id,
|
||||
@@ -240,10 +240,10 @@ class StorageAdmin(StorageManager):
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
'text/plain', 'text/csv', 'application/json'
|
||||
]
|
||||
}},
|
||||
{{
|
||||
"id": f"cc.institutes.{{school_id}}.private",
|
||||
"name": f"{{school_name}} - Private Files",
|
||||
},
|
||||
{
|
||||
"id": f"cc.institutes.{school_id}.private",
|
||||
"name": f"{school_name} - Private Files",
|
||||
"public": False,
|
||||
"owner": owner_id,
|
||||
"owner_id": school_id,
|
||||
@@ -253,29 +253,29 @@ class StorageAdmin(StorageManager):
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
|
||||
'text/plain', 'text/csv', 'application/json'
|
||||
]
|
||||
}}
|
||||
}
|
||||
]
|
||||
|
||||
results = {{}}
|
||||
results = {}
|
||||
for bucket in school_buckets:
|
||||
try:
|
||||
bucket_name = bucket.pop("name")
|
||||
result = self.create_bucket(name=bucket_name, **bucket)
|
||||
results[bucket["id"]] = {{
|
||||
results[bucket["id"]] = {
|
||||
"status": "success",
|
||||
"result": result
|
||||
}}
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating school bucket {{bucket['id']}}: {{str(e)}}")
|
||||
results[bucket["id"]] = {{
|
||||
self.logger.error(f"Error creating school bucket {bucket['id']}: {str(e)}")
|
||||
results[bucket["id"]] = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}}
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating school buckets: {{str(e)}}")
|
||||
self.logger.error(f"Error creating school buckets: {str(e)}")
|
||||
raise StorageError(str(e))
|
||||
|
||||
class StorageUser(StorageManager):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
GET /database/timetable/timetables
|
||||
Optional filters: class_id, type, active
|
||||
Returns {"timetables": [...]} for the caller's school.
|
||||
"""
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
ADMIN_TYPES = ("school_admin", "department_head")
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TimetableResponse(BaseModel):
|
||||
timetables: List[Dict[str, Any]]
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _require_institute(user_id: str) -> Optional[str]:
|
||||
try:
|
||||
sb = _sb()
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
return str((p.data or {}).get("school_id") or "")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_admin(user_id: str, institute_id: str) -> bool:
|
||||
try:
|
||||
sb = _sb()
|
||||
r = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("role", list(ADMIN_TYPES))
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
return bool(r.data)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("", response_model=TimetableResponse)
|
||||
async def list_timetables(
|
||||
class_id: Optional[str] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
active: Optional[bool] = Query(None),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
|
||||
if not institute_id:
|
||||
return {"timetables": []}
|
||||
|
||||
sb = _sb()
|
||||
|
||||
if not _is_admin(user_id, institute_id):
|
||||
return {"timetables": []}
|
||||
|
||||
q = (
|
||||
sb.supabase.table("school_timetables")
|
||||
.select("*")
|
||||
.eq("institute_id", institute_id)
|
||||
)
|
||||
|
||||
if class_id:
|
||||
q = q.eq("class_id", class_id)
|
||||
if type:
|
||||
q = q.eq("type", type)
|
||||
if active is not None:
|
||||
q = q.eq("is_active", active)
|
||||
|
||||
res = q.order("created_at", desc=True).execute()
|
||||
return {"timetables": res.data or []}
|
||||
@@ -31,12 +31,9 @@ def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _require_institute(user_id: str) -> str:
|
||||
"""Return institute_id or raise 400."""
|
||||
institute_id = _resolve_institute_id(user_id)
|
||||
if not institute_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
return institute_id
|
||||
def _require_institute(user_id: str) -> Optional[str]:
|
||||
"""Return institute_id, or None if the user has no school membership."""
|
||||
return _resolve_institute_id(user_id)
|
||||
|
||||
|
||||
def _is_school_admin(user_id: str, institute_id: str) -> bool:
|
||||
@@ -105,6 +102,8 @@ async def list_classes(
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": [], "total": 0}
|
||||
sb = _sb()
|
||||
|
||||
q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id)
|
||||
@@ -169,6 +168,8 @@ async def my_teaching_classes(
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": []}
|
||||
sb = _sb()
|
||||
|
||||
assigned = (
|
||||
@@ -204,6 +205,8 @@ async def my_student_classes(
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"classes": []}
|
||||
sb = _sb()
|
||||
|
||||
enrolled = (
|
||||
@@ -237,6 +240,8 @@ async def list_school_students(
|
||||
"""List all students in the caller's school. Used by admin to add students to a class."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
if not institute_id:
|
||||
return {"students": []}
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Exam-marker API package (/api/exam/).
|
||||
|
||||
A clean top-level router group (R5.1/E5), deliberately NOT nested under /database/. Every
|
||||
endpoint authenticates the JWT and calls Supabase as-the-user so the RLS in
|
||||
volumes/db/cc/72-exam-marker.sql is enforced (spec E1/E2 fixes).
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from routers.exam.templates import router as templates_router
|
||||
from routers.exam.batches import router as batches_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(templates_router)
|
||||
router.include_router(batches_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Marking batches, scans, marks, results & CSV (/api/exam/batches..., /api/exam/marks/...) — S4-6.
|
||||
|
||||
As with templates, all user-facing access is as-the-user (RLS-enforced; E1). A batch is owned by
|
||||
the teacher who creates it (R2.4); colleagues in the same institute can read it
|
||||
(marking_batches_read), a teacher in another institute cannot (→ 404, IDOR-safe).
|
||||
|
||||
Roster→cohort (R4.3/A7): creating a batch from a class materialises one student_submissions row
|
||||
per active enrollee (status='absent'), so every enrolled student is present in results/CSV from
|
||||
the start and a no-show is never silently dropped. The roster ids are read AS THE USER from
|
||||
class_students (cs_read requires the caller to teach/admin the class); only the display names are
|
||||
resolved via service role (profiles is deny-all as-user, E4 — see resolve_student_names).
|
||||
|
||||
Scans (R2.3/E3): the upload endpoint enforces a max size and validates that the bytes are a PDF
|
||||
before storing. QR-decode + automatic student-matching is a follow-on (no QR'd fixtures exist
|
||||
until the PrintGenerator card); v1 supports explicit (manual) and ordered matching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, resolve_student_names
|
||||
from routers.exam.schemas import CreateBatchRequest, MarkUpsertRequest
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# E3: bound the upload so a 36-page scan batch can't exhaust memory / be a DoS vector.
|
||||
MAX_SCAN_BYTES = int(os.getenv("EXAM_SCAN_MAX_BYTES", str(50 * 1024 * 1024))) # 50 MB default
|
||||
SCANS_BUCKET = os.getenv("EXAM_SCANS_BUCKET", "cc.users")
|
||||
SCANS_PREFIX = "exam-submissions"
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _fetch_batch_or_404(ctx: ExamContext, batch_id: str) -> Dict[str, Any]:
|
||||
row = _first(ctx.supabase.table("marking_batches").select("*").eq("id", batch_id).limit(1).execute())
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
return row
|
||||
|
||||
|
||||
def _require_owner(ctx: ExamContext, batch: Dict[str, Any]) -> None:
|
||||
if batch.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the batch owner can modify it")
|
||||
|
||||
|
||||
# ─── batches ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batches")
|
||||
async def create_batch(
|
||||
body: CreateBatchRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# The batch inherits the template's institute; reading the template as-user also proves the
|
||||
# caller may see it (RLS) — an unseeable template → 404.
|
||||
template = _first(
|
||||
ctx.supabase.table("exam_templates").select("id, institute_id").eq("id", body.template_id).limit(1).execute()
|
||||
)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
batch_row = {
|
||||
"template_id": body.template_id,
|
||||
"class_id": body.class_id,
|
||||
"institute_id": template["institute_id"],
|
||||
"teacher_id": ctx.user_id,
|
||||
"title": body.title,
|
||||
"status": "open",
|
||||
}
|
||||
batch_row = {k: v for k, v in batch_row.items() if v is not None}
|
||||
batch = _first(ctx.supabase.table("marking_batches").insert(batch_row).execute())
|
||||
if not batch:
|
||||
raise HTTPException(status_code=500, detail="Failed to create batch")
|
||||
batch_id = batch["id"]
|
||||
|
||||
seeded = 0
|
||||
if body.class_id:
|
||||
# Roster read is AS THE USER → cs_read requires the caller to teach/admin the class.
|
||||
roster = _rows(
|
||||
ctx.supabase.table("class_students")
|
||||
.select("student_id")
|
||||
.eq("class_id", body.class_id)
|
||||
.eq("status", "active")
|
||||
.execute()
|
||||
)
|
||||
student_ids = [r["student_id"] for r in roster if r.get("student_id")]
|
||||
names = resolve_student_names(student_ids)
|
||||
if student_ids:
|
||||
sub_rows = [
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"student_id": sid,
|
||||
"student_name": names.get(sid),
|
||||
"status": "absent", # A7: present in results until a scan is matched
|
||||
}
|
||||
for sid in student_ids
|
||||
]
|
||||
ctx.supabase.table("student_submissions").insert(sub_rows).execute()
|
||||
seeded = len(sub_rows)
|
||||
|
||||
logger.info(f"Marking batch {batch_id} created by {ctx.user_id}; {seeded} roster submissions seeded")
|
||||
return {**batch, "submission_count": seeded}
|
||||
|
||||
|
||||
@router.get("/batches")
|
||||
async def list_batches(
|
||||
include_archived: bool = False,
|
||||
template_id: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
q = ctx.supabase.table("marking_batches").select("*")
|
||||
if template_id:
|
||||
q = q.eq("template_id", template_id)
|
||||
if not include_archived:
|
||||
q = q.neq("status", "archived")
|
||||
return {"batches": _rows(q.order("created_at", desc=True).execute())}
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/queue")
|
||||
async def batch_queue(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
submissions = _rows(
|
||||
ctx.supabase.table("student_submissions").select("*").eq("batch_id", batch_id).execute()
|
||||
)
|
||||
marks = _rows(ctx.supabase.table("mark_entries").select("submission_id").eq("batch_id", batch_id).execute())
|
||||
marked_counts: Dict[str, int] = {}
|
||||
for m in marks:
|
||||
sid = m.get("submission_id")
|
||||
marked_counts[sid] = marked_counts.get(sid, 0) + 1
|
||||
|
||||
enriched = [{**s, "mark_entry_count": marked_counts.get(s["id"], 0)} for s in submissions]
|
||||
progress = {
|
||||
"total": len(submissions),
|
||||
"absent": sum(1 for s in submissions if s.get("status") == "absent"),
|
||||
"complete": sum(1 for s in submissions if s.get("status") == "complete"),
|
||||
"in_progress": sum(1 for s in submissions if s.get("status") in ("matched", "marking")),
|
||||
}
|
||||
return {"batch": batch, "submissions": enriched, "progress": progress}
|
||||
|
||||
|
||||
# ─── results & CSV (A7) ──────────────────────────────────────────────────────
|
||||
|
||||
def _assemble_results(ctx: ExamContext, batch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
batch_id = batch["id"]
|
||||
questions = _rows(
|
||||
ctx.supabase.table("exam_questions")
|
||||
.select("id, label, max_marks, order")
|
||||
.eq("template_id", batch["template_id"])
|
||||
.order("order")
|
||||
.execute()
|
||||
)
|
||||
submissions = _rows(
|
||||
ctx.supabase.table("student_submissions").select("*").eq("batch_id", batch_id).execute()
|
||||
)
|
||||
marks = _rows(ctx.supabase.table("mark_entries").select("*").eq("batch_id", batch_id).execute())
|
||||
|
||||
by_sub: Dict[str, Dict[str, float]] = {}
|
||||
for m in marks:
|
||||
by_sub.setdefault(m["submission_id"], {})[m["question_id"]] = m.get("awarded_marks")
|
||||
|
||||
results = []
|
||||
for s in submissions: # every submission incl. absent → A7
|
||||
sub_marks = by_sub.get(s["id"], {})
|
||||
# Blank total ONLY for a genuine no-show (absent AND nothing marked). A student with any
|
||||
# mark gets a real total regardless of status; a present-but-unmarked student totals 0.
|
||||
if sub_marks:
|
||||
total = sum(v or 0 for v in sub_marks.values())
|
||||
elif s.get("status") == "absent":
|
||||
total = None
|
||||
else:
|
||||
total = 0
|
||||
results.append({
|
||||
"submission_id": s["id"],
|
||||
"student_id": s.get("student_id"),
|
||||
"student_name": s.get("student_name"),
|
||||
"status": s.get("status"),
|
||||
"marks": {qid: sub_marks.get(qid) for qid in (q["id"] for q in questions)},
|
||||
"total": total,
|
||||
})
|
||||
return {"batch": batch, "questions": questions, "results": results}
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/results")
|
||||
async def batch_results(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
return _assemble_results(ctx, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/csv")
|
||||
async def batch_csv(
|
||||
batch_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Response:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
data = _assemble_results(ctx, batch)
|
||||
questions = data["questions"]
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["student_name", "student_id", "status"] + [q["label"] for q in questions] + ["total"])
|
||||
for r in data["results"]:
|
||||
# Absent students: blank marks + blank total, but the row is ALWAYS present (A7).
|
||||
cells = [
|
||||
"" if r["marks"].get(q["id"]) is None else r["marks"].get(q["id"])
|
||||
for q in questions
|
||||
]
|
||||
total = "" if r["total"] is None else r["total"]
|
||||
writer.writerow([r.get("student_name") or "", r.get("student_id") or "", r.get("status")] + cells + [total])
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="batch-{batch_id}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/marks/{mark_id}")
|
||||
async def upsert_mark(
|
||||
mark_id: str,
|
||||
body: MarkUpsertRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# Derive batch_id from the submission (as-user read → also enforces the caller owns the batch
|
||||
# the submission belongs to). The client never supplies the RLS scoping key directly.
|
||||
submission = _first(
|
||||
ctx.supabase.table("student_submissions").select("id, batch_id, status").eq("id", body.submission_id).limit(1).execute()
|
||||
)
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
row = {
|
||||
"id": mark_id,
|
||||
"submission_id": body.submission_id,
|
||||
"question_id": body.question_id,
|
||||
"batch_id": submission["batch_id"],
|
||||
"awarded_marks": body.awarded_marks,
|
||||
"marked_by": "teacher",
|
||||
}
|
||||
if body.mark_scheme_detail is not None:
|
||||
row["mark_scheme_detail"] = body.mark_scheme_detail
|
||||
if body.annotation_shape_ids is not None:
|
||||
row["annotation_shape_ids"] = body.annotation_shape_ids
|
||||
if body.comment is not None:
|
||||
row["comment"] = body.comment
|
||||
if body.confirmed is not None:
|
||||
row["confirmed"] = body.confirmed
|
||||
|
||||
upserted = _first(ctx.supabase.table("mark_entries").upsert(row).execute())
|
||||
if not upserted:
|
||||
raise HTTPException(status_code=500, detail="Failed to upsert mark")
|
||||
|
||||
# A marked student is, by definition, not absent — advance the submission out of the
|
||||
# no-submission states so results/queue reflect that marking has started.
|
||||
if submission.get("status") in ("absent", "unmatched"):
|
||||
ctx.supabase.table("student_submissions").update({"status": "marking"}).eq("id", body.submission_id).execute()
|
||||
|
||||
return upserted
|
||||
|
||||
|
||||
# ─── scans (R2.3 / E3) ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batches/{batch_id}/scans")
|
||||
async def upload_scan(
|
||||
batch_id: str,
|
||||
file: UploadFile = File(...),
|
||||
student_id: Optional[str] = Form(default=None),
|
||||
matching_method: str = Form(default="manual"),
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
batch = _fetch_batch_or_404(ctx, batch_id)
|
||||
_require_owner(ctx, batch)
|
||||
|
||||
# E3: validate MIME (client-declared) before reading the body.
|
||||
if (file.content_type or "").lower() not in ("application/pdf", "application/x-pdf"):
|
||||
raise HTTPException(status_code=415, detail="Only application/pdf scans are accepted")
|
||||
|
||||
# E3: read with a hard size ceiling instead of buffering an unbounded upload.
|
||||
chunks: List[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_SCAN_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"Scan exceeds max size ({MAX_SCAN_BYTES} bytes)")
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
# E3: content-sniff — declared type can be spoofed; require the PDF magic header.
|
||||
if not data.startswith(b"%PDF-"):
|
||||
raise HTTPException(status_code=415, detail="Uploaded file is not a valid PDF")
|
||||
|
||||
# Store via service role (documented): no submissions-bucket storage RLS exists yet; the
|
||||
# endpoint already authorised the caller as the batch owner above.
|
||||
storage_path = f"{SCANS_PREFIX}/{batch_id}/{uuid.uuid4()}.pdf"
|
||||
try:
|
||||
StorageAdmin().upload_file(SCANS_BUCKET, storage_path, data, "application/pdf", upsert=True)
|
||||
except Exception as exc:
|
||||
logger.error(f"scan storage upload failed (batch={batch_id}): {exc}")
|
||||
raise HTTPException(status_code=502, detail="Failed to store scan")
|
||||
|
||||
sb = ctx.supabase
|
||||
submission: Optional[Dict[str, Any]] = None
|
||||
if matching_method == "manual" and student_id:
|
||||
submission = _first(
|
||||
sb.table("student_submissions").select("*").eq("batch_id", batch_id).eq("student_id", student_id).limit(1).execute()
|
||||
)
|
||||
elif matching_method == "ordered":
|
||||
# Assign to the next not-yet-submitted roster slot.
|
||||
pending = _rows(
|
||||
sb.table("student_submissions").select("*").eq("batch_id", batch_id).in_("status", ["absent", "unmatched"]).execute()
|
||||
)
|
||||
submission = pending[0] if pending else None
|
||||
|
||||
payload = {
|
||||
"scan_url": storage_path,
|
||||
"qr_code": None,
|
||||
"matching_method": matching_method if (student_id or matching_method == "ordered") else "manual",
|
||||
"page_count": None,
|
||||
"status": "matched" if submission else "unmatched",
|
||||
}
|
||||
|
||||
if submission:
|
||||
updated = _first(sb.table("student_submissions").update(payload).eq("id", submission["id"]).execute())
|
||||
return updated or submission
|
||||
# No roster slot matched → create an unmatched submission to be reconciled later.
|
||||
new_row = {"batch_id": batch_id, **payload}
|
||||
created = _first(sb.table("student_submissions").insert(new_row).execute())
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to record scan submission")
|
||||
return created
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Auth + data-access plumbing for the /api/exam/ router.
|
||||
|
||||
Per the audit (spec S1/E1): the exam API calls Supabase **as the user** so the RLS in
|
||||
72-exam-marker.sql is actually enforced — it does NOT use the service role for user-facing
|
||||
reads/writes the way files.py / classes_router.py do. The bearer already attaches the raw
|
||||
JWT as payload["_access_token"] (supabase_bearer.py) precisely for this.
|
||||
|
||||
Institute resolution is the one wrinkle: institute_memberships and profiles are RLS
|
||||
deny-all to a normal authenticated user (E4), so we cannot read them as-user. Instead we
|
||||
call public.user_institute_ids() — a SECURITY DEFINER function (71-class-management.sql) that
|
||||
PostgREST exposes as an RPC — which returns the caller's institute ids regardless of those
|
||||
table policies. This is the same function the RLS policies themselves key off, so the API's
|
||||
view of "which institutes is this user in" is guaranteed consistent with what RLS will allow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import (
|
||||
SupabaseAnonClient,
|
||||
SupabaseServiceRoleClient,
|
||||
)
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
auth = SupabaseBearer()
|
||||
|
||||
|
||||
class ExamContext:
|
||||
"""The per-request handle every exam endpoint works through.
|
||||
|
||||
Bundles the caller's id, an as-user Supabase client (RLS-enforced), and the set of
|
||||
institute ids the caller belongs to (for R5.5 institute validation on writes).
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str, access_token: str, supabase: Any, institute_ids: List[str]):
|
||||
self.user_id = user_id
|
||||
self.access_token = access_token
|
||||
self.supabase = supabase
|
||||
self.institute_ids = institute_ids
|
||||
|
||||
def resolve_institute(self, requested: Optional[str]) -> str:
|
||||
"""Validate a client-supplied institute_id, or pick the sole membership.
|
||||
|
||||
R5.5: a client-supplied institute_id is never trusted as the authz signal — it must
|
||||
be one the caller actually belongs to. RLS would reject a bad value at write time
|
||||
anyway; resolving here turns that into a clean 400/403 instead of an opaque DB error.
|
||||
"""
|
||||
if requested:
|
||||
if requested not in self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Not a member of the requested institute")
|
||||
return requested
|
||||
if len(self.institute_ids) == 1:
|
||||
return self.institute_ids[0]
|
||||
if not self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Caller has no institute membership")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="institute_id is required when the caller belongs to multiple institutes",
|
||||
)
|
||||
|
||||
|
||||
def _extract_institute_ids(rpc_data: Any) -> List[str]:
|
||||
"""Normalise the user_institute_ids() RPC result to a list of uuid strings.
|
||||
|
||||
A `returns setof uuid` function comes back from PostgREST as a JSON array of scalars,
|
||||
but tolerate the `[{"user_institute_ids": "..."}]` shape too in case of driver quirks.
|
||||
"""
|
||||
out: List[str] = []
|
||||
for row in rpc_data or []:
|
||||
if isinstance(row, dict):
|
||||
val = row.get("user_institute_ids") or next(iter(row.values()), None)
|
||||
else:
|
||||
val = row
|
||||
if val:
|
||||
out.append(str(val))
|
||||
return out
|
||||
|
||||
|
||||
async def get_exam_context(payload: Dict[str, Any] = Depends(auth)) -> ExamContext:
|
||||
user_id = payload.get("sub") or payload.get("user_id")
|
||||
access_token = payload.get("_access_token")
|
||||
if not user_id or not access_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||
|
||||
supabase = SupabaseAnonClient.for_user(access_token).supabase
|
||||
|
||||
try:
|
||||
res = supabase.rpc("user_institute_ids").execute()
|
||||
institute_ids = _extract_institute_ids(getattr(res, "data", None))
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to resolve institute memberships: {exc}")
|
||||
raise HTTPException(status_code=502, detail="Could not resolve institute membership")
|
||||
|
||||
return ExamContext(user_id, access_token, supabase, institute_ids)
|
||||
|
||||
|
||||
def resolve_student_names(student_ids: List[str]) -> Dict[str, str]:
|
||||
"""Map profile id → display name for roster students (batch-creation denormalisation).
|
||||
|
||||
Documented service-role exception (S1, mirrors lookup_exam_code): `profiles` has no as-user
|
||||
SELECT policy (E4), so the roster's display names can't be read as-the-user. The caller's
|
||||
right to the roster itself is already enforced as-user (class_students.cs_read requires the
|
||||
caller to teach/admin the class); this only resolves names for ids already authorised, and
|
||||
the result is denormalised onto student_submissions so later reads need no profiles access.
|
||||
"""
|
||||
if not student_ids:
|
||||
return {}
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = (
|
||||
sb.table("profiles")
|
||||
.select("id, full_name, display_name, email")
|
||||
.in_("id", list(student_ids))
|
||||
.execute()
|
||||
)
|
||||
out: Dict[str, str] = {}
|
||||
for p in getattr(res, "data", None) or []:
|
||||
out[p["id"]] = p.get("full_name") or p.get("display_name") or p.get("email") or ""
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning(f"student name resolution failed: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def lookup_exam_code(exam_id: str) -> Optional[str]:
|
||||
"""Resolve eb_exams.exam_code for a catalogue paper (denormalised onto the template).
|
||||
|
||||
Documented service-role exception (S1): eb_exams is shared exam-board reference data with
|
||||
no as-user SELECT policy (E4), so a normal user cannot read it. This is a read of public
|
||||
catalogue metadata only — not user-scoped data — and is used solely to keep the Neo4j join
|
||||
key (exam_code) correct on the template row.
|
||||
"""
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = sb.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute()
|
||||
rows = getattr(res, "data", None) or []
|
||||
return rows[0].get("exam_code") if rows else None
|
||||
except Exception as exc:
|
||||
logger.warning(f"exam_code lookup failed for exam_id={exam_id}: {exc}")
|
||||
return None
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Pydantic request/response models for the /api/exam/ router (S4-5).
|
||||
|
||||
Templates are saved from the canvas with a full-replace PUT (R5.2): the client owns
|
||||
stable UUIDs for questions / response areas / boundaries so the Supabase ids line up
|
||||
with the Neo4j join keys (exam_questions.id ↔ Question|Part.uuid_string,
|
||||
exam_response_areas.id ↔ Region.uuid_string — see spec §2). Granular mark-scheme edits
|
||||
go through PATCH /api/exam/questions/{qid}.
|
||||
|
||||
Models mirror the columns in volumes/db/cc/72-exam-marker.sql. They are intentionally
|
||||
permissive (most fields optional) so the canvas can round-trip partial state during
|
||||
authoring without the API rejecting work-in-progress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Templates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
title: str
|
||||
subject: Optional[str] = None
|
||||
# Catalogue paper (eb_exams) the template maps, when chosen from the catalogue (R2.2).
|
||||
exam_id: Optional[str] = None
|
||||
# Denormalised onto the template for the Neo4j join (eb_exams.exam_code ↔ ExamPaper.exam_code).
|
||||
# If exam_id is given but exam_code is omitted, the API resolves it from the catalogue.
|
||||
exam_code: Optional[str] = None
|
||||
# Uploaded PDF (files.id) for an ad-hoc paper (R2.2).
|
||||
source_file_id: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
# Active institute (R1.4/R5.5). Validated against the caller's memberships; never trusted
|
||||
# as the authorization signal. Optional when the caller belongs to exactly one institute.
|
||||
institute_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateTemplateMetaRequest(BaseModel):
|
||||
"""Template-level fields that a full-replace PUT may also update alongside the canvas."""
|
||||
title: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
status: Optional[Literal["draft", "ready", "archived"]] = None
|
||||
|
||||
|
||||
# ─── Canvas entities (children of a template) ────────────────────────────────────
|
||||
|
||||
class QuestionPayload(BaseModel):
|
||||
# Client-supplied stable UUID (== Neo4j Question|Part.uuid_string). Optional on first save.
|
||||
id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
label: str
|
||||
order: int = 0
|
||||
max_marks: float = 0
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
||||
is_container: bool = False
|
||||
spec_ref: Optional[str] = None
|
||||
|
||||
|
||||
class ResponseAreaPayload(BaseModel):
|
||||
id: Optional[str] = None # == Neo4j Region.uuid_string
|
||||
question_id: str
|
||||
page: int
|
||||
bounds: Dict[str, Any] # {x,y,w,h}
|
||||
kind: Literal["response", "context"]
|
||||
response_form: Optional[
|
||||
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
||||
] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
confidence: Optional[float] = None
|
||||
|
||||
|
||||
class BoundaryPayload(BaseModel):
|
||||
id: Optional[str] = None
|
||||
question_id: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
page_index: int
|
||||
y: float
|
||||
bounds: Optional[Dict[str, Any]] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class TemplateReplaceRequest(BaseModel):
|
||||
"""Full-replace canvas save (R5.2 primary path). All children are replaced wholesale."""
|
||||
meta: Optional[UpdateTemplateMetaRequest] = None
|
||||
questions: List[QuestionPayload] = Field(default_factory=list)
|
||||
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
||||
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PatchQuestionRequest(BaseModel):
|
||||
"""Incremental mark-scheme / spec-ref edit (R5.2 granular path)."""
|
||||
label: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
max_marks: Optional[float] = None
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Optional[Dict[str, Any]] = None
|
||||
is_container: Optional[bool] = None
|
||||
spec_ref: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Marking batches & marks ─────────────────────────────────────────────────
|
||||
|
||||
class CreateBatchRequest(BaseModel):
|
||||
template_id: str
|
||||
# When a class is given, the roster (class_students, status='active') is materialised as
|
||||
# student_submissions(status='absent') so every enrolled student appears in results (A7).
|
||||
class_id: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class MarkUpsertRequest(BaseModel):
|
||||
"""Upsert one mark entry (PUT /marks/{id}; id is the mark_entry uuid).
|
||||
|
||||
batch_id is derived server-side from the submission, so the client never sets the RLS
|
||||
scoping key. submission_id + question_id identify what is being marked.
|
||||
"""
|
||||
submission_id: str
|
||||
question_id: str
|
||||
awarded_marks: float = 0
|
||||
mark_scheme_detail: Optional[Dict[str, Any]] = None
|
||||
annotation_shape_ids: Optional[Any] = None
|
||||
comment: Optional[str] = None
|
||||
confirmed: Optional[bool] = None
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Template CRUD for the exam-marker (/api/exam/templates...) — card S4-5.
|
||||
|
||||
All access is as-the-user (RLS-enforced; spec E1 fix) via ExamContext. Ownership is also
|
||||
checked explicitly before mutating (E2: never trust a client-supplied id as authorization) —
|
||||
defence in depth on top of RLS. A row the caller cannot see under RLS reads back as absent,
|
||||
so cross-institute access surfaces as 404, never a data leak (IDOR-safe).
|
||||
|
||||
Hybrid persistence (R5.2): PUT /templates/{id} is a full-replace of the canvas children
|
||||
(questions + response areas + boundaries); PATCH /questions/{qid} is the granular mark-scheme
|
||||
edit path. Client-supplied UUIDs are preserved so Supabase ids stay aligned with the Neo4j
|
||||
join keys (spec §2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
|
||||
from modules.database.services.exam_projection import project_template, project_template_safe
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
CreateTemplateRequest,
|
||||
PatchQuestionRequest,
|
||||
TemplateReplaceRequest,
|
||||
)
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _fetch_template_or_404(ctx: ExamContext, template_id: str) -> Dict[str, Any]:
|
||||
"""Load a template the caller can see (RLS-scoped). Missing/forbidden → 404."""
|
||||
res = ctx.supabase.table("exam_templates").select("*").eq("id", template_id).limit(1).execute()
|
||||
row = _first(res)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return row
|
||||
|
||||
|
||||
def _require_owner(ctx: ExamContext, template: Dict[str, Any]) -> None:
|
||||
"""Writes are limited to the owning teacher (R2.4). RLS also enforces this; we pre-check
|
||||
so a colleague who can *read* the template gets a clean 403 instead of a silent no-op."""
|
||||
if template.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
|
||||
|
||||
|
||||
def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool:
|
||||
"""True if any mark_entry exists for a batch of this template (→ destructive PUT is unsafe)."""
|
||||
batches = _rows(
|
||||
ctx.supabase.table("marking_batches").select("id").eq("template_id", template_id).execute()
|
||||
)
|
||||
batch_ids = [b["id"] for b in batches]
|
||||
if not batch_ids:
|
||||
return False
|
||||
marks = _rows(
|
||||
ctx.supabase.table("mark_entries").select("id").in_("batch_id", batch_ids).limit(1).execute()
|
||||
)
|
||||
return bool(marks)
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(
|
||||
body: CreateTemplateRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
institute_id = ctx.resolve_institute(body.institute_id)
|
||||
|
||||
exam_code = body.exam_code
|
||||
if body.exam_id and not exam_code:
|
||||
exam_code = lookup_exam_code(body.exam_id)
|
||||
|
||||
row = {
|
||||
"title": body.title,
|
||||
"subject": body.subject,
|
||||
"exam_id": body.exam_id,
|
||||
"exam_code": exam_code,
|
||||
"source_file_id": body.source_file_id,
|
||||
"page_count": body.page_count,
|
||||
"institute_id": institute_id,
|
||||
"teacher_id": ctx.user_id,
|
||||
"status": "draft",
|
||||
}
|
||||
row = {k: v for k, v in row.items() if v is not None}
|
||||
|
||||
res = ctx.supabase.table("exam_templates").insert(row).execute()
|
||||
created = _first(res)
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to create template")
|
||||
logger.info(f"Exam template created: {created.get('id')} by {ctx.user_id}")
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
include_archived: bool = False,
|
||||
institute_id: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# RLS already scopes to the caller's institutes; the optional filter narrows within that.
|
||||
q = ctx.supabase.table("exam_templates").select("*")
|
||||
if institute_id:
|
||||
q = q.eq("institute_id", institute_id)
|
||||
if not include_archived:
|
||||
q = q.neq("status", "archived")
|
||||
res = q.order("updated_at", desc=True).execute()
|
||||
return {"templates": _rows(res)}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
questions = _rows(
|
||||
ctx.supabase.table("exam_questions").select("*").eq("template_id", template_id).order("order").execute()
|
||||
)
|
||||
response_areas = _rows(
|
||||
ctx.supabase.table("exam_response_areas").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
boundaries = _rows(
|
||||
ctx.supabase.table("exam_boundaries").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
return {
|
||||
**template,
|
||||
"questions": questions,
|
||||
"response_areas": response_areas,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
body: TemplateReplaceRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Full-replace canvas save (R5.2). Replaces questions/response_areas/boundaries wholesale.
|
||||
|
||||
Note: the delete-then-insert spans several PostgREST calls and is therefore not atomic;
|
||||
acceptable for the small (~20-question) payloads this carries. A transactional RPC is a
|
||||
later hardening step if concurrent canvas saves become a concern.
|
||||
"""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
|
||||
# Data-loss guard: the wholesale question delete below cascades to mark_entries
|
||||
# (mark_entries.question_id → exam_questions ON DELETE CASCADE). Refuse a structural
|
||||
# full-replace once any marks have been recorded against this template's batches, so
|
||||
# re-saving the setup canvas mid-marking can't silently wipe a teacher's marking work.
|
||||
# (Mark-scheme tweaks use PATCH /questions/{id}, which is unaffected.)
|
||||
if _template_has_recorded_marks(ctx, template_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Template has recorded marks; structural full-replace is blocked. "
|
||||
"Edit questions individually via PATCH /questions/{id}.",
|
||||
)
|
||||
|
||||
# Optional template-level metadata update alongside the canvas.
|
||||
if body.meta:
|
||||
updates = {k: v for k, v in body.meta.dict().items() if v is not None}
|
||||
if updates:
|
||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
||||
|
||||
sb = ctx.supabase
|
||||
# Clear existing children. Order matters: response_areas/boundaries reference questions, so
|
||||
# remove them first (we delete by template_id rather than rely on cascade for predictability).
|
||||
sb.table("exam_response_areas").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_boundaries").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_questions").delete().eq("template_id", template_id).execute()
|
||||
|
||||
# Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec §2).
|
||||
if body.questions:
|
||||
q_rows = []
|
||||
for q in body.questions:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"parent_id": q.parent_id,
|
||||
"label": q.label,
|
||||
"order": q.order,
|
||||
"max_marks": q.max_marks,
|
||||
"answer_type": q.answer_type,
|
||||
"mcq_options": q.mcq_options,
|
||||
"mark_scheme": q.mark_scheme,
|
||||
"is_container": q.is_container,
|
||||
"spec_ref": q.spec_ref,
|
||||
}
|
||||
if q.id:
|
||||
r["id"] = q.id
|
||||
q_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_questions").insert(q_rows).execute()
|
||||
|
||||
if body.response_areas:
|
||||
ra_rows = []
|
||||
for ra in body.response_areas:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": ra.question_id,
|
||||
"page": ra.page,
|
||||
"bounds": ra.bounds,
|
||||
"kind": ra.kind,
|
||||
"response_form": ra.response_form,
|
||||
"source": ra.source,
|
||||
"confirmed": ra.confirmed,
|
||||
"confidence": ra.confidence,
|
||||
}
|
||||
if ra.id:
|
||||
r["id"] = ra.id
|
||||
ra_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_response_areas").insert(ra_rows).execute()
|
||||
|
||||
if body.boundaries:
|
||||
b_rows = []
|
||||
for b in body.boundaries:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": b.question_id,
|
||||
"label": b.label,
|
||||
"page_index": b.page_index,
|
||||
"y": b.y,
|
||||
"bounds": b.bounds,
|
||||
"source": b.source,
|
||||
"confirmed": b.confirmed,
|
||||
}
|
||||
if b.id:
|
||||
r["id"] = b.id
|
||||
b_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_boundaries").insert(b_rows).execute()
|
||||
|
||||
logger.info(
|
||||
f"Exam template {template_id} replaced: {len(body.questions)} questions, "
|
||||
f"{len(body.response_areas)} regions, {len(body.boundaries)} boundaries"
|
||||
)
|
||||
# R3.5.4: a successful save enqueues a graph projection into cc.public.exams. BackgroundTasks
|
||||
# is acceptable for Sprint 4 (durability via a real queue is a later step); failures are
|
||||
# swallowed so the canvas save itself never fails on a graph hiccup.
|
||||
background_tasks.add_task(project_template_safe, template_id)
|
||||
return await get_template(template_id, ctx)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def archive_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Soft-delete: status='archived' (R5.2). Never hard-deletes a teacher's work."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
ctx.supabase.table("exam_templates").update({"status": "archived"}).eq("id", template_id).execute()
|
||||
return {"status": "archived", "id": template_id}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/neo4j-sync")
|
||||
async def neo4j_sync(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Manual graph-projection trigger (R5.3) for dev/backfill — runs synchronously and returns
|
||||
counts. Auth/ownership is checked as-the-user; the projection itself uses service role
|
||||
(R3.5.1, the documented graph-writer path)."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
try:
|
||||
counts = project_template(template_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Manual neo4j-sync failed for template {template_id}: {exc}")
|
||||
raise HTTPException(status_code=502, detail=f"Projection failed: {exc}")
|
||||
return {"status": "ok", "projection": counts}
|
||||
|
||||
|
||||
# ─── questions (granular edit path, R5.2) ────────────────────────────────────
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: str,
|
||||
body: PatchQuestionRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
updates = {k: v for k, v in body.dict().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
# RLS (exam_questions_all) enforces that the question belongs to a template owned by the
|
||||
# caller; an out-of-scope id updates zero rows → 404, so no explicit pre-fetch is needed.
|
||||
res = ctx.supabase.table("exam_questions").update(updates).eq("id", question_id).execute()
|
||||
updated = _first(res)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
return updated
|
||||
@@ -1,6 +1,4 @@
|
||||
from .infrastructure import initialize_infrastructure
|
||||
from .demo_school import initialize_demo_school
|
||||
from .demo_users import initialize_demo_users
|
||||
from .gais_data import import_gais_data
|
||||
from modules.logger_tool import initialise_logger
|
||||
import os
|
||||
@@ -10,54 +8,32 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
|
||||
def initialize_infrastructure_mode() -> None:
|
||||
"""Initialize infrastructure: Neo4j schema, calendar, and Supabase buckets"""
|
||||
logger.info("Starting infrastructure initialization...")
|
||||
|
||||
# 1. Initialize Neo4j database, schema, and calendar structure
|
||||
|
||||
logger.info("Step 1: Initializing Neo4j infrastructure...")
|
||||
from .neo4j import initialize_neo4j
|
||||
neo4j_result = initialize_neo4j()
|
||||
|
||||
|
||||
if not neo4j_result["success"]:
|
||||
logger.error(f"Neo4j infrastructure initialization failed: {neo4j_result['message']}")
|
||||
return
|
||||
|
||||
# 2. Initialize Supabase storage buckets
|
||||
|
||||
logger.info("Step 2: Initializing Supabase storage buckets...")
|
||||
from .buckets import initialize_buckets
|
||||
buckets_result = initialize_buckets()
|
||||
|
||||
|
||||
if not buckets_result["success"]:
|
||||
logger.error(f"Storage buckets initialization failed: {buckets_result['message']}")
|
||||
return
|
||||
|
||||
|
||||
logger.info("Infrastructure initialization completed successfully!")
|
||||
logger.info(f"Neo4j: {neo4j_result['message']}")
|
||||
logger.info(f"Buckets: {buckets_result['message']}")
|
||||
|
||||
def initialize_demo_school_mode() -> None:
|
||||
"""Initialize demo school (KevlarAI)"""
|
||||
logger.info("Starting demo school initialization...")
|
||||
result = initialize_demo_school()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo school initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo school initialization failed: {result['message']}")
|
||||
|
||||
def initialize_demo_users_mode() -> None:
|
||||
"""Initialize demo users"""
|
||||
logger.info("Starting demo users initialization...")
|
||||
result = initialize_demo_users()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo users initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo users initialization failed: {result['message']}")
|
||||
|
||||
def initialize_gais_data_mode() -> None:
|
||||
"""Initialize GAIS data import (Edubase, etc.)"""
|
||||
logger.info("Starting GAIS data import...")
|
||||
result = import_gais_data()
|
||||
|
||||
|
||||
if result["success"]:
|
||||
logger.info("GAIS data import completed successfully")
|
||||
else:
|
||||
@@ -65,11 +41,7 @@ def initialize_gais_data_mode() -> None:
|
||||
|
||||
__all__ = [
|
||||
'initialize_infrastructure_mode',
|
||||
'initialize_demo_school_mode',
|
||||
'initialize_demo_users_mode',
|
||||
'initialize_gais_data_mode',
|
||||
'initialize_infrastructure',
|
||||
'initialize_demo_school',
|
||||
'initialize_demo_users',
|
||||
'import_gais_data'
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Demo school initialization module for ClassroomCopilot
|
||||
Creates the KevlarAI demo school
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
import time
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
|
||||
class DemoSchoolInitializer:
|
||||
"""Handles demo school creation"""
|
||||
|
||||
def __init__(self, supabase_url: str, service_role_key: str):
|
||||
self.supabase_url = supabase_url
|
||||
self.service_role_key = service_role_key
|
||||
self.supabase_headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.provisioning_service = ProvisioningService()
|
||||
|
||||
def create_kevlarai_school(self) -> Dict[str, Any]:
|
||||
"""Create the KevlarAI demo school"""
|
||||
logger.info("Creating KevlarAI demo school...")
|
||||
|
||||
try:
|
||||
# Check if KevlarAI school already exists
|
||||
response = self._supabase_request_with_retry(
|
||||
'get',
|
||||
f"{self.supabase_url}/rest/v1/institutes",
|
||||
headers=self.supabase_headers,
|
||||
params={
|
||||
"select": "*",
|
||||
"name": "eq.KevlarAI"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
existing_schools = response.json()
|
||||
if existing_schools and len(existing_schools) > 0:
|
||||
logger.info("KevlarAI school already exists")
|
||||
school = existing_schools[0]
|
||||
try:
|
||||
self.provisioning_service.ensure_school(school["id"])
|
||||
except Exception as provisioning_error:
|
||||
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "KevlarAI school already exists",
|
||||
"school": school
|
||||
}
|
||||
|
||||
# Create KevlarAI school
|
||||
school_data = {
|
||||
"name": "KevlarAI",
|
||||
"urn": "KEVLARAI001",
|
||||
"status": "active",
|
||||
"address": {
|
||||
"street": "123 Innovation Drive",
|
||||
"town": "Tech City",
|
||||
"county": "Digital County",
|
||||
"postcode": "TC1 2AI",
|
||||
"country": "United Kingdom"
|
||||
},
|
||||
"website": "https://kevlar.ai",
|
||||
"metadata": {
|
||||
"school_type": "AI and Technology",
|
||||
"phase_of_education": "Secondary and Further Education",
|
||||
"establishment_status": "Open",
|
||||
"specialization": "Artificial Intelligence, Machine Learning, Robotics"
|
||||
}
|
||||
}
|
||||
|
||||
# Insert the school
|
||||
response = self._supabase_request_with_retry('post', f"{self.supabase_url}/rest/v1/institutes", headers={**self.supabase_headers, "Prefer": "return=representation"}, json=school_data, params={"select": "*"})
|
||||
|
||||
logger.info(f"Supabase response status: {response.status_code}")
|
||||
logger.info(f"Supabase response headers: {dict(response.headers)}")
|
||||
logger.info(f"Supabase response text: {response.text}")
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
try:
|
||||
data = response.json()
|
||||
school = data[0] if isinstance(data, list) and data else data
|
||||
logger.info("Successfully created KevlarAI school")
|
||||
# Ensure Neo4j provisioning is in place
|
||||
try:
|
||||
self.provisioning_service.ensure_school(school["id"])
|
||||
except Exception as provisioning_error:
|
||||
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully created KevlarAI school",
|
||||
"school": school
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON response: {str(e)}")
|
||||
logger.error(f"Response text: {response.text}")
|
||||
# If the status code is successful but we can't parse JSON,
|
||||
# the school was likely created successfully
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully created KevlarAI school (response not JSON)",
|
||||
"school": None
|
||||
}
|
||||
else:
|
||||
logger.error(f"Failed to create KevlarAI school: {response.text}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to create KevlarAI school: {response.text}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating KevlarAI school: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error creating KevlarAI school: {str(e)}"
|
||||
}
|
||||
|
||||
def _supabase_request_with_retry(self, method, url, **kwargs):
|
||||
"""Make a request to Supabase with retry logic"""
|
||||
max_retries = 3
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if method.lower() == 'get':
|
||||
response = requests.get(url, **kwargs)
|
||||
elif method.lower() == 'post':
|
||||
response = requests.post(url, **kwargs)
|
||||
elif method.lower() == 'put':
|
||||
response = requests.put(url, **kwargs)
|
||||
elif method.lower() == 'delete':
|
||||
response = requests.delete(url, **kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# If successful or client error (4xx), don't retry
|
||||
if response.status_code < 500:
|
||||
return response
|
||||
|
||||
# Server error (5xx), retry after delay
|
||||
logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}")
|
||||
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}")
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
|
||||
# If we get here, all retries failed with server errors
|
||||
raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}")
|
||||
|
||||
def initialize_demo_school() -> Dict[str, Any]:
|
||||
"""Initialize demo school (KevlarAI)"""
|
||||
logger.info("Starting demo school initialization...")
|
||||
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
service_role_key = os.getenv("SERVICE_ROLE_KEY")
|
||||
|
||||
if not supabase_url or not service_role_key:
|
||||
return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"}
|
||||
|
||||
initializer = DemoSchoolInitializer(supabase_url, service_role_key)
|
||||
|
||||
# Create KevlarAI school
|
||||
result = initializer.create_kevlarai_school()
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Demo school initialization completed successfully")
|
||||
else:
|
||||
logger.error(f"Demo school initialization failed: {result['message']}")
|
||||
|
||||
return result
|
||||
@@ -1,218 +0,0 @@
|
||||
"""
|
||||
Demo users initialization — creates the three canonical @kevlarai.com accounts
|
||||
and links them to the KevlarAI institute in both Supabase and Neo4j.
|
||||
|
||||
Idempotent: existing users are reused, stale .edu demo users are removed.
|
||||
Run via: python3 main.py --mode demo-users
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
|
||||
INSTITUTE_DB = "cc.institutes.6585bf916ae84d72ab54cddf3ba4e648"
|
||||
INSTITUTE_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||
|
||||
DEMO_USERS = [
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "KevlarAI2025!",
|
||||
"username": "kcar",
|
||||
"full_name": "Kevin Carroll",
|
||||
"display_name": "Kevin",
|
||||
"user_type": "teacher",
|
||||
"role": "school_admin",
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Teacher1@KevlarAI!",
|
||||
"username": "teacher1.kevlarai",
|
||||
"full_name": "Sarah Chen",
|
||||
"display_name": "Sarah",
|
||||
"user_type": "teacher",
|
||||
"role": "teacher",
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Teacher2@KevlarAI!",
|
||||
"username": "teacher2.kevlarai",
|
||||
"full_name": "Marcus Rodriguez",
|
||||
"display_name": "Marcus",
|
||||
"user_type": "teacher",
|
||||
"role": "teacher",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def initialize_demo_users() -> Dict[str, Any]:
|
||||
"""Create/refresh canonical @kevlarai.com demo users."""
|
||||
from neo4j import GraphDatabase
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
sb_client = SupabaseServiceRoleClient()
|
||||
supabase_url = os.environ["SUPABASE_URL"]
|
||||
service_key = os.environ["SERVICE_ROLE_KEY"]
|
||||
auth_headers = {
|
||||
"apikey": service_key,
|
||||
"Authorization": f"Bearer {service_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def auth_get(path, params=None):
|
||||
r = requests.get(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def auth_post(path, data):
|
||||
return requests.post(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers, json=data)
|
||||
|
||||
def auth_delete(path):
|
||||
return requests.delete(f"{supabase_url}/auth/v1/admin{path}", headers=auth_headers)
|
||||
|
||||
def sb_upsert(table, data, on_conflict=None):
|
||||
params = {}
|
||||
if on_conflict:
|
||||
params["on_conflict"] = on_conflict
|
||||
hdrs = {**auth_headers, "Prefer": "resolution=merge-duplicates,return=representation"}
|
||||
r = requests.post(f"{supabase_url}/rest/v1/{table}", headers=hdrs, json=data, params=params)
|
||||
return r
|
||||
|
||||
errors = []
|
||||
|
||||
# ── Step 1: delete stale .edu users ─────────────────────────────────────
|
||||
logger.info("Removing stale .edu demo users...")
|
||||
try:
|
||||
existing = auth_get("/users", params={"per_page": 100}).get("users", [])
|
||||
edu_users = [u for u in existing if u.get("email", "").endswith("@kevlarai.edu")]
|
||||
for u in edu_users:
|
||||
# Try direct delete first
|
||||
r = auth_delete(f"/users/{u['id']}")
|
||||
if r.status_code not in (200, 204):
|
||||
# Profile has dependent rows — clean via SQL
|
||||
_purge_profile_rows(supabase_url, service_key, u["id"])
|
||||
auth_delete(f"/users/{u['id']}")
|
||||
logger.info(f" Removed: {u['email']}")
|
||||
except Exception as e:
|
||||
logger.warning(f" .edu cleanup warning: {e}")
|
||||
|
||||
# ── Step 2: create @kevlarai.com users ───────────────────────────────────
|
||||
logger.info("Creating @kevlarai.com demo users...")
|
||||
created_users = {}
|
||||
for spec in DEMO_USERS:
|
||||
email = spec["email"]
|
||||
all_users = auth_get("/users", params={"per_page": 100}).get("users", [])
|
||||
existing_user = next((u for u in all_users if u.get("email") == email), None)
|
||||
|
||||
if existing_user:
|
||||
uid = existing_user["id"]
|
||||
logger.info(f" {email}: already exists [{uid[:8]}]")
|
||||
created_users[email] = {"id": uid, **spec}
|
||||
continue
|
||||
|
||||
r = auth_post("/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"]
|
||||
logger.info(f" {email}: created [{uid[:8]}]")
|
||||
created_users[email] = {"id": uid, **spec}
|
||||
else:
|
||||
msg = f"Failed to create {email}: {r.text[:200]}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── Step 3: upsert profiles ──────────────────────────────────────────────
|
||||
logger.info("Upserting profiles...")
|
||||
for spec in DEMO_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u:
|
||||
continue
|
||||
sb_upsert("profiles", {
|
||||
"id": u["id"],
|
||||
"email": spec["email"],
|
||||
"user_type": spec["user_type"],
|
||||
"username": spec["username"],
|
||||
"full_name": spec["full_name"],
|
||||
"display_name": spec["display_name"],
|
||||
"school_id": INSTITUTE_ID,
|
||||
"neo4j_sync_status": "pending",
|
||||
}, on_conflict="id")
|
||||
|
||||
# ── Step 4: upsert memberships ───────────────────────────────────────────
|
||||
logger.info("Upserting institute memberships...")
|
||||
for spec in DEMO_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u:
|
||||
continue
|
||||
sb_upsert("institute_memberships", {
|
||||
"profile_id": u["id"],
|
||||
"institute_id": INSTITUTE_ID,
|
||||
"role": spec["role"],
|
||||
}, on_conflict="profile_id,institute_id")
|
||||
|
||||
# ── Step 5: Teacher nodes in Neo4j ───────────────────────────────────────
|
||||
logger.info("Creating Neo4j Teacher nodes...")
|
||||
try:
|
||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||
new_emails = {spec["email"] for spec in DEMO_USERS}
|
||||
with driver.session(database=INSTITUTE_DB) as s:
|
||||
# Remove stale Teacher nodes
|
||||
stale = s.run("MATCH (t:Teacher) WHERE NOT t.worker_email IN $emails RETURN t.worker_email as e, t.uuid_string as u", emails=list(new_emails)).data()
|
||||
for t in stale:
|
||||
s.run("MATCH (t:Teacher {uuid_string: $u}) DETACH DELETE t", u=t["u"])
|
||||
logger.info(f" Removed stale Teacher: {t['e']}")
|
||||
# Remove duplicate Teacher nodes (same email, different UUID)
|
||||
for spec in DEMO_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u:
|
||||
continue
|
||||
dupes = s.run("MATCH (t:Teacher {worker_email: $e}) WHERE t.uuid_string <> $u RETURN t.uuid_string as uid", e=spec["email"], u=u["id"]).data()
|
||||
for d in dupes:
|
||||
s.run("MATCH (t:Teacher {uuid_string: $u}) DETACH DELETE t", u=d["uid"])
|
||||
logger.info(f" Removed duplicate Teacher UUID {d['uid'][:8]} for {spec['email']}")
|
||||
# Upsert Teacher nodes
|
||||
for spec in DEMO_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u:
|
||||
continue
|
||||
s.run("""
|
||||
MERGE (t:Teacher {uuid_string: $uuid})
|
||||
SET t.worker_email = $email,
|
||||
t.worker_name = $name,
|
||||
t.unique_id = $uid,
|
||||
t.user_type = 'teacher',
|
||||
t.worker_type = 'teacher'
|
||||
""", uuid=u["id"], email=spec["email"], name=spec["full_name"], uid=u["id"])
|
||||
logger.info(f" Teacher node: {spec['email']} [{u['id'][:8]}]")
|
||||
driver.close()
|
||||
except Exception as e:
|
||||
msg = f"Neo4j Teacher node setup failed: {e}"
|
||||
logger.error(msg)
|
||||
errors.append(msg)
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"created": list(created_users.keys()),
|
||||
"errors": errors,
|
||||
"message": "Demo users initialized" if not errors else f"{len(errors)} errors: {errors[0]}",
|
||||
}
|
||||
|
||||
|
||||
def _purge_profile_rows(supabase_url: str, service_key: str, profile_id: str) -> None:
|
||||
"""Delete all rows referencing a profile before deleting the auth user."""
|
||||
hdrs = {"apikey": service_key, "Authorization": f"Bearer {service_key}"}
|
||||
for table, col in [("files", "uploaded_by"), ("whiteboard_rooms", "user_id"),
|
||||
("cabinet_memberships", "profile_id"), ("institute_memberships", "profile_id")]:
|
||||
requests.delete(f"{supabase_url}/rest/v1/{table}?{col}=eq.{profile_id}", headers=hdrs)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
init_exam_graph.py — Initialise the cc.public.exams Neo4j knowledge graph.
|
||||
|
||||
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam
|
||||
board + AQA GCSE Physics (8463) specification with its 8 top-level topic SpecPoints. Idempotent
|
||||
(CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT EXISTS / MERGE).
|
||||
|
||||
Run inside the ccapi container:
|
||||
python3 -c "from run.initialization.init_exam_graph import init; import json; print(json.dumps(init()))"
|
||||
|
||||
NOTE: the 8 SpecPoints seeded here are the real AQA GCSE Physics *top-level* topics. The full
|
||||
sub-point breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA
|
||||
spec PDF via Docling). spec_code AQA-PHYS-8463 is the standalone GCSE Physics code that matches
|
||||
"AQA Physics Paper 1H"; the eb_exams/eb_specifications seed (card S4-3) must use the same code.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
|
||||
from modules.database.tools.neo4j_driver_tools import get_driver
|
||||
|
||||
EXAM_DB = "cc.public.exams"
|
||||
NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1") # stable namespace for deterministic uuids
|
||||
|
||||
BOARD = {"code": "AQA", "name": "AQA"}
|
||||
SPEC = {
|
||||
"spec_code": "AQA-PHYS-8463",
|
||||
"exam_board_code": "AQA",
|
||||
"subject_code": "PHYS",
|
||||
"award_code": "GCSE",
|
||||
"title": "AQA GCSE Physics (8463)",
|
||||
}
|
||||
# Real AQA GCSE Physics (8463) top-level topics (ref = topic number).
|
||||
SPEC_POINTS = [
|
||||
("4.1", "Energy"),
|
||||
("4.2", "Electricity"),
|
||||
("4.3", "Particle model of matter"),
|
||||
("4.4", "Atomic structure"),
|
||||
("4.5", "Forces"),
|
||||
("4.6", "Waves"),
|
||||
("4.7", "Magnetism and electromagnetism"),
|
||||
("4.8", "Space physics"),
|
||||
]
|
||||
|
||||
CONSTRAINTS = [
|
||||
"CREATE CONSTRAINT exam_board_uid IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT spec_uid IF NOT EXISTS FOR (n:Specification) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT specpoint_uid IF NOT EXISTS FOR (n:SpecPoint) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT exampaper_uid IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT question_uid IF NOT EXISTS FOR (n:Question) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT part_uid IF NOT EXISTS FOR (n:Part) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT region_uid IF NOT EXISTS FOR (n:Region) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT spec_code_unique IF NOT EXISTS FOR (n:Specification) REQUIRE n.spec_code IS UNIQUE",
|
||||
"CREATE CONSTRAINT exam_code_unique IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.exam_code IS UNIQUE",
|
||||
"CREATE CONSTRAINT board_code_unique IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.code IS UNIQUE",
|
||||
]
|
||||
|
||||
|
||||
def _uid(*parts: str) -> str:
|
||||
return str(uuid.uuid5(NS, ":".join(parts)))
|
||||
|
||||
|
||||
def init() -> Dict[str, Any]:
|
||||
driver = get_driver()
|
||||
result: Dict[str, Any] = {"db": EXAM_DB, "constraints": 0, "spec_points": 0}
|
||||
|
||||
# 1. database
|
||||
with driver.session(database="system") as s:
|
||||
s.run(f"CREATE DATABASE `{EXAM_DB}` IF NOT EXISTS").consume()
|
||||
# wait for availability
|
||||
import time
|
||||
for _ in range(30):
|
||||
with driver.session(database="system") as s:
|
||||
st = s.run("SHOW DATABASE $n YIELD currentStatus RETURN currentStatus", n=EXAM_DB).single()
|
||||
if st and st["currentStatus"] == "online":
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
with driver.session(database=EXAM_DB) as s:
|
||||
# 2. constraints
|
||||
for c in CONSTRAINTS:
|
||||
s.run(c).consume()
|
||||
result["constraints"] += 1
|
||||
|
||||
# 3. board + spec
|
||||
board_uid = _uid("ExamBoard", BOARD["code"])
|
||||
spec_uid = _uid("Specification", SPEC["spec_code"])
|
||||
s.run(
|
||||
"MERGE (b:ExamBoard {uuid_string:$uid}) "
|
||||
"SET b.code=$code, b.name=$name, b.node_storage_path=$nsp",
|
||||
uid=board_uid, code=BOARD["code"], name=BOARD["name"],
|
||||
nsp=f"{EXAM_DB}/ExamBoard/{BOARD['code']}",
|
||||
).consume()
|
||||
s.run(
|
||||
"MERGE (sp:Specification {uuid_string:$uid}) "
|
||||
"SET sp.spec_code=$sc, sp.exam_board_code=$ebc, sp.subject_code=$subj, "
|
||||
" sp.award_code=$award, sp.title=$title, sp.node_storage_path=$nsp "
|
||||
"WITH sp MATCH (b:ExamBoard {code:$ebc}) MERGE (b)-[:PUBLISHES]->(sp)",
|
||||
uid=spec_uid, sc=SPEC["spec_code"], ebc=SPEC["exam_board_code"],
|
||||
subj=SPEC["subject_code"], award=SPEC["award_code"], title=SPEC["title"],
|
||||
nsp=f"{EXAM_DB}/Specification/{SPEC['spec_code']}",
|
||||
).consume()
|
||||
|
||||
# 4. spec points
|
||||
for ref, desc in SPEC_POINTS:
|
||||
sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref)
|
||||
s.run(
|
||||
"MERGE (p:SpecPoint {uuid_string:$uid}) "
|
||||
"SET p.ref=$ref, p.description=$desc, p.spec_code=$sc, "
|
||||
" p.exam_board_code=$ebc, p.node_storage_path=$nsp "
|
||||
"WITH p MATCH (s:Specification {spec_code:$sc}) MERGE (s)-[:HAS_SPEC_POINT]->(p)",
|
||||
uid=sp_uid, ref=ref, desc=desc, sc=SPEC["spec_code"],
|
||||
ebc=SPEC["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{SPEC['spec_code']}/{ref}",
|
||||
).consume()
|
||||
result["spec_points"] += 1
|
||||
|
||||
counts = s.run(
|
||||
"MATCH (b:ExamBoard) WITH count(b) AS boards "
|
||||
"MATCH (sp:Specification) WITH boards, count(sp) AS specs "
|
||||
"MATCH (p:SpecPoint) RETURN boards, specs, count(p) AS spec_points"
|
||||
).single()
|
||||
result["verify"] = dict(counts) if counts else {}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(init(), indent=2, default=str))
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
seed_cohort_9p_ph1.py — Markable cohort for exam-marker testing.
|
||||
|
||||
Creates N student accounts and enrols them ALL into a single class (default the
|
||||
Greenfield Year 9 Physics class `9P/Ph1`), so there is a real cohort to mark.
|
||||
|
||||
Why: the canonical timetable seeds enrol "one student per year-group band"
|
||||
(seed_greenfield_timetable.py), so every class has <=1 student — too few for a
|
||||
results table / per-question stats. This seeder fills one class to a usable size.
|
||||
|
||||
Mechanics (identical paths to the canonical seeds — nothing bespoke server-side):
|
||||
- auth user: POST {SUPABASE_URL}/auth/v1/admin/users
|
||||
- profile: upsert public.profiles (school_id = institute)
|
||||
- membership: upsert public.institute_memberships (role 'student')
|
||||
- enrolment: POST {API_BASE_URL}/database/timetable/classes/{class_id}/students
|
||||
(as a school_admin; class_students upsert → idempotent)
|
||||
|
||||
Idempotent: re-running skips existing auth users and upserts everything else.
|
||||
|
||||
Env required: SUPABASE_URL, SERVICE_ROLE_KEY (API_BASE_URL defaults to api-dev)
|
||||
Optional env: COHORT_COUNT, COHORT_CLASS_CODE, COHORT_INSTITUTE_ID,
|
||||
SEED_STUDENT_PASSWORD, SEED_SCHOOL_ADMIN_PASSWORD
|
||||
|
||||
Run (dev):
|
||||
SUPABASE_URL=... SERVICE_ROLE_KEY=... API_BASE_URL=http://192.168.0.64:18000 \
|
||||
python3 -c "from run.initialization.seed_cohort_9p_ph1 import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Greenfield Academy (the school actually populated on dev .94)
|
||||
GREENFIELD_ID = os.getenv("COHORT_INSTITUTE_ID", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
GREENFIELD_DOMAIN = "greenfieldacademy.test"
|
||||
GREENFIELD_ADMIN_EMAIL = f"admin@{GREENFIELD_DOMAIN}"
|
||||
|
||||
CLASS_CODE = os.getenv("COHORT_CLASS_CODE", "9P/Ph1")
|
||||
COHORT_COUNT = int(os.getenv("COHORT_COUNT", "10"))
|
||||
|
||||
# Realistic-ish names so the results table doesn't read "Pupil 01..10".
|
||||
COHORT_NAMES = [
|
||||
("Amelia", "Clarke"), ("Noah", "Bennett"), ("Olivia", "Foster"), ("Leo", "Hughes"),
|
||||
("Ava", "Patel"), ("Jacob", "Reid"), ("Mia", "Turner"), ("Harry", "Ellis"),
|
||||
("Isla", "Morgan"), ("Oscar", "Khan"), ("Freya", "Walsh"), ("Theo", "Ndlovu"),
|
||||
]
|
||||
|
||||
DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!"
|
||||
DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!"
|
||||
|
||||
|
||||
def _ctx() -> Dict[str, str]:
|
||||
return {
|
||||
"supa_url": os.environ["SUPABASE_URL"].rstrip("/"),
|
||||
"service_key": os.environ["SERVICE_ROLE_KEY"],
|
||||
"api_base": os.environ.get("API_BASE_URL", "http://192.168.0.64:18000").rstrip("/"),
|
||||
"student_pw": os.getenv("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD),
|
||||
"admin_pw": os.getenv("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD),
|
||||
}
|
||||
|
||||
|
||||
def _sb_headers(ctx: Dict[str, str]) -> Dict[str, str]:
|
||||
return {
|
||||
"apikey": ctx["service_key"],
|
||||
"Authorization": f"Bearer {ctx['service_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(ctx: Dict[str, str], email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{ctx['supa_url']}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": ctx["service_key"], "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _resolve_class_id(ctx: Dict[str, str]) -> Optional[str]:
|
||||
r = requests.get(
|
||||
f"{ctx['supa_url']}/rest/v1/classes",
|
||||
headers=_sb_headers(ctx),
|
||||
params={"class_code": f"eq.{CLASS_CODE}",
|
||||
"institute_id": f"eq.{GREENFIELD_ID}",
|
||||
"select": "id,name", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _existing_auth_users(ctx: Dict[str, str]) -> Dict[str, str]:
|
||||
r = requests.get(
|
||||
f"{ctx['supa_url']}/auth/v1/admin/users",
|
||||
headers=_sb_headers(ctx), params={"per_page": 200},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return {u["email"]: u["id"] for u in r.json().get("users", [])}
|
||||
|
||||
|
||||
def _create_auth_user(ctx: Dict[str, str], spec: Dict) -> Optional[str]:
|
||||
r = requests.post(
|
||||
f"{ctx['supa_url']}/auth/v1/admin/users",
|
||||
headers=_sb_headers(ctx),
|
||||
json={
|
||||
"email": spec["email"], "password": ctx["student_pw"], "email_confirm": True,
|
||||
"user_metadata": {
|
||||
"username": spec["username"], "full_name": spec["full_name"],
|
||||
"display_name": spec["display_name"], "user_type": "student",
|
||||
},
|
||||
},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json()["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _upsert(ctx: Dict[str, str], table: str, row: Dict, on_conflict: str) -> bool:
|
||||
h = {**_sb_headers(ctx), "Prefer": "resolution=merge-duplicates,return=minimal"}
|
||||
r = requests.post(f"{ctx['supa_url']}/rest/v1/{table}",
|
||||
headers=h, json=row, params={"on_conflict": on_conflict})
|
||||
return r.ok
|
||||
|
||||
|
||||
def _cohort_specs() -> List[Dict]:
|
||||
specs = []
|
||||
for i in range(1, COHORT_COUNT + 1):
|
||||
first, last = COHORT_NAMES[(i - 1) % len(COHORT_NAMES)]
|
||||
prefix = f"cohort{i:02d}"
|
||||
specs.append({
|
||||
"email": f"{prefix}@{GREENFIELD_DOMAIN}",
|
||||
"username": f"{prefix}.{GREENFIELD_DOMAIN.replace('.', '_')}",
|
||||
"full_name": f"{first} {last}",
|
||||
"display_name": first,
|
||||
})
|
||||
return specs
|
||||
|
||||
|
||||
def seed(count: Optional[int] = None) -> Dict[str, Any]:
|
||||
global COHORT_COUNT
|
||||
if count is not None:
|
||||
COHORT_COUNT = count
|
||||
ctx = _ctx()
|
||||
results: Dict[str, Any] = {"class_code": CLASS_CODE, "requested": COHORT_COUNT,
|
||||
"created": 0, "reused": 0, "enrolled": 0, "errors": []}
|
||||
|
||||
print(f"COHORT SEED → {CLASS_CODE} @ {GREENFIELD_DOMAIN} (target {COHORT_COUNT} students)")
|
||||
|
||||
class_id = _resolve_class_id(ctx)
|
||||
if not class_id:
|
||||
results["errors"].append(f"class {CLASS_CODE} not found for institute {GREENFIELD_ID}")
|
||||
print(f" ✗ {results['errors'][-1]}")
|
||||
return results
|
||||
print(f" class_id = {class_id}")
|
||||
|
||||
existing = _existing_auth_users(ctx)
|
||||
specs = _cohort_specs()
|
||||
|
||||
# 1) accounts: auth user + profile + membership
|
||||
uids: Dict[str, str] = {}
|
||||
for spec in specs:
|
||||
email = spec["email"]
|
||||
uid = existing.get(email)
|
||||
if uid:
|
||||
results["reused"] += 1
|
||||
else:
|
||||
uid = _create_auth_user(ctx, spec)
|
||||
if not uid:
|
||||
results["errors"].append(f"create auth user {email}")
|
||||
print(f" ✗ create {email}")
|
||||
continue
|
||||
results["created"] += 1
|
||||
time.sleep(0.15)
|
||||
uids[email] = uid
|
||||
ok_p = _upsert(ctx, "profiles", {
|
||||
"id": uid, "email": email, "user_type": "student",
|
||||
"username": spec["username"], "full_name": spec["full_name"],
|
||||
"display_name": spec["display_name"], "school_id": GREENFIELD_ID,
|
||||
"neo4j_sync_status": "pending",
|
||||
}, on_conflict="id")
|
||||
ok_m = _upsert(ctx, "institute_memberships", {
|
||||
"profile_id": uid, "institute_id": GREENFIELD_ID, "role": "student", "metadata": {},
|
||||
}, on_conflict="profile_id,institute_id")
|
||||
if not (ok_p and ok_m):
|
||||
results["errors"].append(f"profile/membership {email} (p={ok_p} m={ok_m})")
|
||||
|
||||
# 2) enrol all into the class (via API, as school admin)
|
||||
admin_token = _sign_in(ctx, GREENFIELD_ADMIN_EMAIL, ctx["admin_pw"])
|
||||
for spec in specs:
|
||||
uid = uids.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
r = requests.post(
|
||||
f"{ctx['api_base']}/database/timetable/classes/{class_id}/students",
|
||||
headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"},
|
||||
json={"student_id": uid},
|
||||
)
|
||||
body = {}
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
if r.ok and (body.get("status") == "ok" or body.get("row") or body.get("id")):
|
||||
results["enrolled"] += 1
|
||||
print(f" ✓ {spec['email'].split('@')[0]} → {CLASS_CODE}")
|
||||
else:
|
||||
results["errors"].append(f"enrol {spec['email']}: {r.status_code} {str(body)[:120]}")
|
||||
print(f" ✗ enrol {spec['email']}: {r.status_code}")
|
||||
time.sleep(0.1)
|
||||
|
||||
print(f"\nDONE: created {results['created']}, reused {results['reused']}, "
|
||||
f"enrolled {results['enrolled']}/{COHORT_COUNT}, errors {len(results['errors'])}")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
seed_curriculum.py — Create curriculum data: exam board specifications and exams.
|
||||
|
||||
Seeds eb_specifications and eb_exams tables with realistic UK exam board data
|
||||
(AQA, Edexcel, OCR) for Physics, Maths, and Computer Science across both schools.
|
||||
|
||||
Also seeds curriculum_topics in Neo4j for the school databases.
|
||||
|
||||
Tables: eb_specifications, eb_exams
|
||||
Neo4j: curriculum topic nodes in school databases
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_curriculum import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── School constants ────────────────────────────────────────────────────────
|
||||
|
||||
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
|
||||
# ─── Exam board specifications ───────────────────────────────────────────────
|
||||
# Realistic UK exam board data for the subjects we teach.
|
||||
|
||||
SPECIFICATIONS = [
|
||||
# AQA Physics
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8201",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8201",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.3",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8201_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8203",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8203",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.3",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8203_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# AQA GCSE Physics 8463 (standalone) — the real spec for the exam-marker test paper
|
||||
# (AQA Physics Paper 1H 2022). Spec graph: cc.public.exams Specification AQA-PHYS-8463.
|
||||
{
|
||||
"spec_code": "AQA-PHYS-8463",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "8463",
|
||||
"subject_code": "PHYSICS",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.0",
|
||||
"storage_loc": "cc.examboards/aqa/physics/8463/8463_spec.pdf", # placeholder (no file yet)
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# Edexcel Maths
|
||||
{
|
||||
"spec_code": "EDX-MATH-1MA1",
|
||||
"exam_board_code": "EDexcel",
|
||||
"award_code": "1MA1",
|
||||
"subject_code": "MATHEMATICS",
|
||||
"first_teach": "2015",
|
||||
"spec_ver": "2.0",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/edexcel/maths/1MA1_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# OCR Maths
|
||||
{
|
||||
"spec_code": "OCR-MATH-FMH1",
|
||||
"exam_board_code": "OCR",
|
||||
"award_code": "FMH1",
|
||||
"subject_code": "MATHEMATICS",
|
||||
"first_teach": "2017",
|
||||
"spec_ver": "1.1",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/ocr/maths/FMH1_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# AQA Computer Science
|
||||
{
|
||||
"spec_code": "AQA-COMP-7516",
|
||||
"exam_board_code": "AQA",
|
||||
"award_code": "7516",
|
||||
"subject_code": "COMPUTER SCIENCE",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.2",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/cs/7516_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
# Edexcel Computer Science
|
||||
{
|
||||
"spec_code": "EDX-COMP-X042",
|
||||
"exam_board_code": "Edexcel",
|
||||
"award_code": "X042",
|
||||
"subject_code": "COMPUTER SCIENCE",
|
||||
"first_teach": "2016",
|
||||
"spec_ver": "1.0",
|
||||
"storage_loc": "cc.public.snapshots/curriculum/edexcel/cs/X042_spec.pdf",
|
||||
"doc_type": "pdf",
|
||||
},
|
||||
]
|
||||
|
||||
# ─── Exam papers ─────────────────────────────────────────────────────────────
|
||||
# Realistic exam paper references linked to specifications.
|
||||
|
||||
EXAMS = [
|
||||
# AQA GCSE Physics 8463/1 Higher — the exam-marker test paper (real PDF uploaded to
|
||||
# cc.examboards). Join key for cc.public.exams ExamPaper.exam_code.
|
||||
{"exam_code": "AQA-PHYS-8463-1H-22-JUN", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/1",
|
||||
"tier": "higher", "session": "June", "type_code": "QP",
|
||||
"storage_loc": "cc.examboards/aqa/physics/8463/AQA-PHYS-8463-1H-22-JUN.pdf"},
|
||||
|
||||
# AQA Physics 8201/1 (Foundation)
|
||||
{"exam_code": "AQA-PHYS-8201-1-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-PHYS-8201-MS-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "MS"},
|
||||
{"exam_code": "AQA-PHYS-8201-ER-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||
"tier": "foundation", "session": "June", "type_code": "ER"},
|
||||
|
||||
# AQA Physics 8201/2 (Higher)
|
||||
{"exam_code": "AQA-PHYS-8201-2-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-PHYS-8201-MS-23-JUN-H", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# Edexcel Maths 1MA1/1 (Foundation)
|
||||
{"exam_code": "EDX-MATH-1MA1-1-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F",
|
||||
"tier": "foundation", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "EDX-MATH-1MA1-MS-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F",
|
||||
"tier": "foundation", "session": "June", "type_code": "MS"},
|
||||
|
||||
# Edexcel Maths 1MA1/2 (Higher)
|
||||
{"exam_code": "EDX-MATH-1MA1-2-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "EDX-MATH-1MA1-MS-24-JUN-H", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# OCR Maths FMH1/1
|
||||
{"exam_code": "OCR-MATH-FMH1-1-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1",
|
||||
"tier": "higher", "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "OCR-MATH-FMH1-MS-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1",
|
||||
"tier": "higher", "session": "June", "type_code": "MS"},
|
||||
|
||||
# AQA CS 7516/1
|
||||
{"exam_code": "AQA-COMP-7516-1-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1",
|
||||
"tier": None, "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-COMP-7516-MS-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1",
|
||||
"tier": None, "session": "June", "type_code": "MS"},
|
||||
|
||||
# AQA CS 7516/2
|
||||
{"exam_code": "AQA-COMP-7516-2-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2",
|
||||
"tier": None, "session": "June", "type_code": "QP"},
|
||||
{"exam_code": "AQA-COMP-7516-ER-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2",
|
||||
"tier": None, "session": "June", "type_code": "ER"},
|
||||
]
|
||||
|
||||
|
||||
# ─── Neo4j curriculum topics ─────────────────────────────────────────────────
|
||||
# Curriculum topics stored in Neo4j school databases (not Supabase).
|
||||
|
||||
CURRICULUM_TOPICS = {
|
||||
"Physics": [
|
||||
{"topic_code": "PHYS-KS3-01", "title": "Forces", "year_group": "9", "key_stage": "3",
|
||||
"description": "Contact and non-contact forces, resultant forces, moments"},
|
||||
{"topic_code": "PHYS-KS3-02", "title": "Energy", "year_group": "9", "key_stage": "3",
|
||||
"description": "Energy stores, transfers, conservation, dissipation"},
|
||||
{"topic_code": "PHYS-KS3-03", "title": "Waves", "year_group": "9", "key_stage": "3",
|
||||
"description": "Transverse and longitudinal waves, reflection, refraction, diffraction"},
|
||||
{"topic_code": "PHYS-KS4-01", "title": "Electricity", "year_group": "10", "key_stage": "4",
|
||||
"description": "Circuits, current, potential difference, resistance, power"},
|
||||
{"topic_code": "PHYS-KS4-02", "title": "Magnetism and Electromagnetism", "year_group": "10", "key_stage": "4",
|
||||
"description": "Magnetic fields, electromagnets, motors, generators"},
|
||||
{"topic_code": "PHYS-KS4-03", "title": "Atomic Structure", "year_group": "10", "key_stage": "4",
|
||||
"description": "Atoms, isotopes, radioactivity, half-life"},
|
||||
{"topic_code": "PHYS-KS4-04", "title": "Particle Physics", "year_group": "11", "key_stage": "4",
|
||||
"description": "Standard model, quarks, leptons, bosons"},
|
||||
{"topic_code": "PHYS-KS4-05", "title": "Cosmology", "year_group": "11", "key_stage": "4",
|
||||
"description": "Big Bang, stellar evolution, redshift"},
|
||||
],
|
||||
"Mathematics": [
|
||||
{"topic_code": "MATH-KS3-01", "title": "Number", "year_group": "9", "key_stage": "3",
|
||||
"description": "Integers, fractions, decimals, percentages, ratio, proportion"},
|
||||
{"topic_code": "MATH-KS3-02", "title": "Algebra", "year_group": "9", "key_stage": "3",
|
||||
"description": "Expressions, equations, inequalities, sequences"},
|
||||
{"topic_code": "MATH-KS3-03", "title": "Geometry", "year_group": "9", "key_stage": "3",
|
||||
"description": "Angles, polygons, circles, transformations, constructions"},
|
||||
{"topic_code": "MATH-KS4-01", "title": "Number and Algebra", "year_group": "10", "key_stage": "4",
|
||||
"description": "Surds, indices, standard form, expanding brackets, factorising"},
|
||||
{"topic_code": "MATH-KS4-02", "title": "Graphs and Functions", "year_group": "10", "key_stage": "4",
|
||||
"description": "Linear, quadratic, cubic graphs, gradients, intercepts"},
|
||||
{"topic_code": "MATH-KS4-03", "title": "Statistics and Probability", "year_group": "10", "key_stage": "4",
|
||||
"description": "Data types, charts, expected frequency, tree diagrams, two-way tables"},
|
||||
{"topic_code": "MATH-KS4-04", "title": "Geometry and Measures", "year_group": "10", "key_stage": "4",
|
||||
"description": "Area, volume, surface area, Pythagoras, trigonometry, bearings"},
|
||||
{"topic_code": "MATH-KS4-05", "title": "Simultaneous Equations and Quadratics", "year_group": "11", "key_stage": "4",
|
||||
"description": "Solving simultaneous equations, completing the square, quadratic formula"},
|
||||
],
|
||||
"Computer Science": [
|
||||
{"topic_code": "CS-KS4-01", "title": "Data Representation", "year_group": "10", "key_stage": "4",
|
||||
"description": "Binary, hexadecimal, bit operations, compression, encryption"},
|
||||
{"topic_code": "CS-KS4-02", "title": "Computer Systems", "year_group": "10", "key_stage": "4",
|
||||
"description": "CPU architecture, memory, storage, networks, topologies"},
|
||||
{"topic_code": "CS-KS4-03", "title": "Algorithms and Programming", "year_group": "10", "key_stage": "4",
|
||||
"description": "Algorithms, flowcharts, pseudocode, debugging, testing"},
|
||||
{"topic_code": "CS-KS4-04", "title": "Data Types and Structures", "year_group": "11", "key_stage": "4",
|
||||
"description": "Strings, arrays, lists, records, 2D arrays"},
|
||||
{"topic_code": "CS-KS4-05", "title": "Boolean Logic and Search", "year_group": "11", "key_stage": "4",
|
||||
"description": "Boolean operators, linear search, binary search, sorting"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Curriculum seed — exam board specs and exams")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── [1] Seed eb_specifications ──────────────────────────────────────────
|
||||
print("\n[1] Seeding exam board specifications...")
|
||||
specs_created = 0
|
||||
specs_skipped = 0
|
||||
|
||||
for spec in SPECIFICATIONS:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/eb_specifications",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
**spec,
|
||||
"id": str(uuid.uuid4()),
|
||||
"doc_details": {},
|
||||
"docling_docs": {},
|
||||
},
|
||||
params={"on_conflict": "spec_code"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
specs_created += 1
|
||||
print(f" ✓ {spec['spec_code']} ({spec['exam_board_code']}/{spec['subject_code']})")
|
||||
elif r.status_code == 409:
|
||||
specs_skipped += 1
|
||||
print(f" ~ SKIP (exists): {spec['spec_code']}")
|
||||
else:
|
||||
err = f"spec {spec['spec_code']}: {r.status_code} {r.text[:100]}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
results["specifications"] = {"created": specs_created, "skipped": specs_skipped}
|
||||
|
||||
# ── [2] Seed eb_exams ───────────────────────────────────────────────────
|
||||
print("\n[2] Seeding exam papers...")
|
||||
exams_created = 0
|
||||
exams_skipped = 0
|
||||
|
||||
for exam in EXAMS:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/eb_exams",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
**exam,
|
||||
"id": str(uuid.uuid4()),
|
||||
"doc_details": {},
|
||||
"docling_docs": {},
|
||||
},
|
||||
params={"on_conflict": "exam_code"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
exams_created += 1
|
||||
print(f" ✓ {exam['exam_code']} ({exam['type_code']})")
|
||||
elif r.status_code == 409:
|
||||
exams_skipped += 1
|
||||
print(f" ~ SKIP (exists): {exam['exam_code']}")
|
||||
else:
|
||||
err = f"exam {exam['exam_code']}: {r.status_code} {r.text[:100]}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
results["exams"] = {"created": exams_created, "skipped": exams_skipped}
|
||||
|
||||
# ── [3] Seed Neo4j curriculum topics ────────────────────────────────────
|
||||
print("\n[3] Seeding Neo4j curriculum topics...")
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||
|
||||
topics_created = 0
|
||||
topics_skipped = 0
|
||||
|
||||
for school_id, school_name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
|
||||
db_name = f"cc.institutes.{school_id.replace('-', '')}"
|
||||
print(f"\n [{school_name}] -> {db_name}")
|
||||
|
||||
with driver.session(database=db_name) as s:
|
||||
for subject, topics in CURRICULUM_TOPICS.items():
|
||||
# Create subject node
|
||||
s.run(
|
||||
"MERGE (s:Subject {code: $subject}) "
|
||||
"SET s.title = $title, s.school_id = $school_id",
|
||||
subject=subject, title=subject, school_id=school_id,
|
||||
)
|
||||
|
||||
for topic in topics:
|
||||
result = s.run(
|
||||
"MERGE (t:CurriculumTopic {code: $code}) "
|
||||
"SET t.title = $title, "
|
||||
" t.year_group = $year_group, "
|
||||
" t.key_stage = $key_stage, "
|
||||
" t.description = $description, "
|
||||
" t.subject_code = $subject, "
|
||||
" t.school_id = $school_id "
|
||||
"MERGE (s:Subject {code: $subject}) "
|
||||
"MERGE (s)-[:CONTAINS_TOPIC]->(t)",
|
||||
code=topic["topic_code"],
|
||||
title=topic["title"],
|
||||
year_group=topic["year_group"],
|
||||
key_stage=topic["key_stage"],
|
||||
description=topic["description"],
|
||||
subject=subject,
|
||||
school_id=school_id,
|
||||
)
|
||||
# Check if it was created or matched
|
||||
topics_created += 1
|
||||
|
||||
print(f" ✓ {school_name}: {len(CURRICULUM_TOPICS) * len(list(CURRICULUM_TOPICS.values())[0])} topic nodes")
|
||||
|
||||
driver.close()
|
||||
results["neo4j_topics"] = {"created": topics_created}
|
||||
|
||||
except Exception as e:
|
||||
err = f"neo4j_topics: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["neo4j_topics"] = {"error": str(e)}
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
print(f"COMPLETE — {specs_created} specs, {exams_created} exams, "
|
||||
f"{results.get('neo4j_topics', {}).get('created', '?')} topics")
|
||||
if errors:
|
||||
print(f"Errors ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -23,7 +23,12 @@ Uniform accounts per school (10 × 2 = 20 total)
|
||||
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
|
||||
@@ -49,27 +54,54 @@ GREENFIELD_DOMAIN = "greenfieldacademy.test"
|
||||
|
||||
# ─── Passwords ────────────────────────────────────────────────────────────────
|
||||
|
||||
PWD_ADMIN = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
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": PWD_ADMIN,
|
||||
"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": PWD_ADMIN,
|
||||
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
# teacher accounts
|
||||
@@ -77,35 +109,35 @@ def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
|
||||
"prefix": "physics", "email": f"physics@{domain}",
|
||||
"full_name": "Phil Physics", "display_name": "Phil",
|
||||
"username": f"physics.{domain.replace('.', '_')}",
|
||||
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||
"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": PWD_TEACHER,
|
||||
"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": PWD_TEACHER,
|
||||
"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": PWD_TEACHER,
|
||||
"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": PWD_TEACHER,
|
||||
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
# student accounts
|
||||
@@ -113,30 +145,49 @@ def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
|
||||
"prefix": "student1", "email": f"student1@{domain}",
|
||||
"full_name": "Sam Student", "display_name": "Sam",
|
||||
"username": f"student1.{domain.replace('.', '_')}",
|
||||
"user_type": "student", "role": "student", "password": PWD_STUDENT,
|
||||
"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": PWD_STUDENT,
|
||||
"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": PWD_STUDENT,
|
||||
"user_type": "student", "role": "student", "password": passwords["student"],
|
||||
"institute_id": institute_id,
|
||||
},
|
||||
]
|
||||
|
||||
ALL_ACCOUNTS = (
|
||||
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():
|
||||
@@ -183,18 +234,19 @@ def _rest_patch(url, headers, table, match_col, match_val, data):
|
||||
|
||||
# ─── Main seed function ───────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
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] = {}
|
||||
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("SEED ENVIRONMENT")
|
||||
logger.info(f"SEED ENVIRONMENT ({'test' if test else 'full'} mode)")
|
||||
logger.info("=" * 60)
|
||||
logger.info("\n[1] KevlarAI institute record...")
|
||||
try:
|
||||
@@ -272,7 +324,7 @@ def seed() -> Dict[str, Any]:
|
||||
results["global_calendar"] = "error"
|
||||
|
||||
# ── Step 5: Create / verify auth users ────────────────────────────────────
|
||||
logger.info("[5] Creating auth users (20 accounts)...")
|
||||
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}
|
||||
@@ -281,7 +333,7 @@ def seed() -> Dict[str, Any]:
|
||||
existing_by_email = {}
|
||||
|
||||
created_users: Dict[str, str] = {} # email → uid
|
||||
for spec in ALL_ACCOUNTS:
|
||||
for spec in accounts:
|
||||
email = spec["email"]
|
||||
if email in existing_by_email:
|
||||
created_users[email] = existing_by_email[email]["id"]
|
||||
@@ -311,7 +363,7 @@ def seed() -> Dict[str, Any]:
|
||||
|
||||
# ── Step 6: Upsert profiles and memberships ───────────────────────────────
|
||||
logger.info("[6] Upserting profiles and memberships...")
|
||||
for spec in ALL_ACCOUNTS:
|
||||
for spec in accounts:
|
||||
uid = created_users.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
@@ -341,12 +393,18 @@ def seed() -> Dict[str, Any]:
|
||||
# ── Step 7: Merge Neo4j Teacher/Student nodes ─────────────────────────────
|
||||
logger.info("[7] Merging Neo4j worker nodes...")
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||
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 ALL_ACCOUNTS:
|
||||
for spec in accounts:
|
||||
uid = created_users.get(spec["email"])
|
||||
if not uid:
|
||||
continue
|
||||
@@ -369,7 +427,7 @@ def seed() -> Dict[str, Any]:
|
||||
)
|
||||
logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓")
|
||||
|
||||
driver.close()
|
||||
close_driver(driver)
|
||||
results["neo4j_nodes"] = "ok"
|
||||
except Exception as e:
|
||||
errors.append(f"neo4j_nodes: {e}")
|
||||
@@ -427,7 +485,7 @@ def seed() -> Dict[str, Any]:
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
|
||||
_print_credential_sheet(created_users)
|
||||
_print_credential_sheet(created_users, accounts)
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
if errors:
|
||||
@@ -440,14 +498,16 @@ def seed() -> Dict[str, Any]:
|
||||
return results
|
||||
|
||||
|
||||
def _print_credential_sheet(created_users: Dict[str, str]):
|
||||
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")
|
||||
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)} -----------")
|
||||
logger.info(f" {'[platform admin]':<16} {'[email protected]':<{PAD}} KevlarAI2025!")
|
||||
platform_password = get_seed_password("platform_admin") if include_passwords else "<redacted>"
|
||||
logger.info(f" {'[platform admin]':<16} {'[email protected]':<{PAD}} {platform_password}")
|
||||
logger.info("")
|
||||
|
||||
for school_id, domain, label in [
|
||||
@@ -455,16 +515,17 @@ def _print_credential_sheet(created_users: Dict[str, str]):
|
||||
(GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"),
|
||||
]:
|
||||
logger.info(f" [{label}]")
|
||||
for spec in ALL_ACCOUNTS:
|
||||
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]"
|
||||
logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {spec['password']} {status}")
|
||||
password = spec["password"] if include_passwords else "<redacted>"
|
||||
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(), indent=2, default=str))
|
||||
print(json.dumps(seed(test="--test" in os.sys.argv), indent=2, default=str))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
seed_file_cabinets.py — Create one file cabinet per class with sample document references.
|
||||
|
||||
Creates file_cabinets, files, and cabinet_memberships rows via Supabase REST API
|
||||
using the service role key. Also creates document_artefacts entries for sample files.
|
||||
|
||||
Each cabinet is owned by the class's primary teacher and shared with students
|
||||
in that class via cabinet_memberships.
|
||||
|
||||
Tables: file_cabinets, files, cabinet_memberships, document_artefacts
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_file_cabinets import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── Passwords (standardized from T4) ────────────────────────────────────────
|
||||
|
||||
PWD_ADMIN = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
|
||||
|
||||
# ─── Sample file data ────────────────────────────────────────────────────────
|
||||
# Each cabinet gets 2-3 sample files with realistic paths.
|
||||
|
||||
SAMPLE_FILES = {
|
||||
# Physics cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "forces_motion_plan.pdf", "path": "cc.public.snapshots/lesson_plans/forces_motion.pdf", "mime_type": "application/pdf", "size": "245KB"},
|
||||
{"name": "electric_circuits_plan.pdf", "path": "cc.public.snapshots/lesson_plans/electric_circuits.pdf", "mime_type": "application/pdf", "size": "312KB"},
|
||||
],
|
||||
"worksheets": [
|
||||
{"name": "worksheet_fma.pdf", "path": "cc.public.snapshots/worksheets/fma_practice.pdf", "mime_type": "application/pdf", "size": "128KB"},
|
||||
{"name": "worksheet_resistance.pdf", "path": "cc.public.snapshots/worksheets/resistance_calc.pdf", "mime_type": "application/pdf", "size": "95KB"},
|
||||
],
|
||||
"presentations": [
|
||||
{"name": "intro_forces.pptx", "path": "cc.public.snapshots/presentations/forces_intro.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "size": "2.1MB"},
|
||||
],
|
||||
# Maths cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "quadratic_factorisation_plan.pdf", "path": "cc.public.snapshots/lesson_plans/quadratics.pdf", "mime_type": "application/pdf", "size": "278KB"},
|
||||
],
|
||||
"worksheets": [
|
||||
{"name": "worksheet_quadratics.pdf", "path": "cc.public.snapshots/worksheets/quadratic_practice.pdf", "mime_type": "application/pdf", "size": "156KB"},
|
||||
{"name": "worksheet_tree_diagrams.pdf", "path": "cc.public.snapshots/worksheets/tree_diagrams.pdf", "mime_type": "application/pdf", "size": "134KB"},
|
||||
],
|
||||
# CS cabinets
|
||||
"lesson_plans": [
|
||||
{"name": "intro_python_plan.pdf", "path": "cc.public.snapshots/lesson_plans/intro_python.pdf", "mime_type": "application/pdf", "size": "198KB"},
|
||||
],
|
||||
"code_samples": [
|
||||
{"name": "hello_world.py", "path": "cc.public.snapshots/code_samples/hello_world.py", "mime_type": "text/x-python", "size": "0.5KB"},
|
||||
{"name": "variables.py", "path": "cc.public.snapshots/code_samples/variables.py", "mime_type": "text/x-python", "size": "1.2KB"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_class_info(admin_token: str, class_code: str) -> Optional[Dict]:
|
||||
"""Get class info including teacher and students."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
params={"class_code": class_code},
|
||||
)
|
||||
if not r.ok:
|
||||
return None
|
||||
data = r.json()
|
||||
if isinstance(data, list) and data:
|
||||
return data[0]
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _get_class_students(admin_token: str, class_id: str) -> List[str]:
|
||||
"""Get student profile IDs enrolled in a class."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes/{class_id}/students",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
if r.ok:
|
||||
data = r.json()
|
||||
if isinstance(data, list):
|
||||
return [s.get("student_id") or s.get("id") for s in data if s.get("student_id") or s.get("id")]
|
||||
return []
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("File cabinets seed — both schools")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── Sign in as both school admins ───────────────────────────────────────
|
||||
print("\n[1] Signing in as school admins...")
|
||||
admin_tokens = {}
|
||||
for school, email, pwd in [
|
||||
("KevlarAI", "[email protected]", PWD_ADMIN),
|
||||
("Greenfield", "[email protected]", PWD_ADMIN),
|
||||
]:
|
||||
try:
|
||||
token = _sign_in(email, pwd)
|
||||
admin_tokens[school] = token
|
||||
print(f" ✓ {school} admin signed in")
|
||||
except Exception as e:
|
||||
print(f" ✗ {school} admin login failed: {e}")
|
||||
errors.append(f"{school}_admin_login: {e}")
|
||||
|
||||
if not admin_tokens:
|
||||
return {"success": False, "error": "No admin tokens obtained"}
|
||||
|
||||
# ── Resolve class codes per school ──────────────────────────────────────
|
||||
print("\n[2] Resolving classes per school...")
|
||||
|
||||
# KevlarAI classes
|
||||
kevlarai_classes = [
|
||||
("10K/Ph1", "[email protected]"),
|
||||
("11K/Ph1", "[email protected]"),
|
||||
("10K/Ma1", "[email protected]"),
|
||||
("11K/Ma1", "[email protected]"),
|
||||
("10K/CS1", "[email protected]"),
|
||||
("11K/CS1", "[email protected]"),
|
||||
("9K/Ph1", "[email protected]"),
|
||||
("9K/Ma1", "[email protected]"),
|
||||
]
|
||||
|
||||
# Greenfield classes (subset — just a few for cabinet seeding)
|
||||
greenfield_classes = [
|
||||
("9P/Ph1", "[email protected]"),
|
||||
("10P/Ph2", "[email protected]"),
|
||||
("9M/Ma1", "[email protected]"),
|
||||
("10M/Ma1", "[email protected]"),
|
||||
("9En/1", "[email protected]"),
|
||||
("10Hs/1", "[email protected]"),
|
||||
]
|
||||
|
||||
# ── Seed KevlarAI cabinets ──────────────────────────────────────────────
|
||||
print("\n[3] Seeding KevlarAI file cabinets...")
|
||||
results["kevlarai"] = {"cabinets": 0, "files": 0, "memberships": 0}
|
||||
|
||||
for class_code, teacher_email in kevlarai_classes:
|
||||
try:
|
||||
# Get class info
|
||||
class_info = _get_class_info(admin_tokens["KevlarAI"], class_code)
|
||||
if not class_info:
|
||||
print(f" ✗ class not found: {class_code}")
|
||||
errors.append(f"class_not_found: {class_code}")
|
||||
continue
|
||||
|
||||
class_id = class_info.get("id") or class_info
|
||||
teacher_pid = _get_profile_id(teacher_email)
|
||||
if not teacher_pid:
|
||||
print(f" ✗ teacher profile not found: {teacher_email}")
|
||||
errors.append(f"teacher_profile_not_found: {teacher_email}")
|
||||
continue
|
||||
|
||||
# Get students in this class
|
||||
student_ids = _get_class_students(admin_tokens["KevlarAI"], str(class_id))
|
||||
|
||||
# Determine file category based on subject
|
||||
subject = (class_info.get("subject") or "").lower()
|
||||
if "physics" in subject:
|
||||
file_category = "lesson_plans"
|
||||
elif "math" in subject:
|
||||
file_category = "worksheets"
|
||||
elif "cs" in subject or "computer" in subject:
|
||||
file_category = "code_samples"
|
||||
else:
|
||||
file_category = "lesson_plans"
|
||||
|
||||
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
|
||||
|
||||
# Create cabinet
|
||||
cabinet_id = str(uuid.uuid4())
|
||||
cabinet_name = f"{class_code} — {class_info.get('name', class_code)}"
|
||||
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/file_cabinets",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
print(f" ✓ Cabinet: {cabinet_name}")
|
||||
results["kevlarai"]["cabinets"] += 1
|
||||
else:
|
||||
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
|
||||
errors.append(f"cabinet_create: {class_code}")
|
||||
continue
|
||||
|
||||
# Create files in cabinet
|
||||
for fi in files_list:
|
||||
file_id = str(uuid.uuid4())
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/files",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": file_id,
|
||||
"cabinet_id": cabinet_id,
|
||||
"name": fi["name"],
|
||||
"path": fi["path"],
|
||||
"bucket": "file-cabinets",
|
||||
"mime_type": fi.get("mime_type"),
|
||||
"size": fi.get("size"),
|
||||
"metadata": {},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
results["kevlarai"]["files"] += 1
|
||||
|
||||
# Create document_artefact for this file
|
||||
artefact_id = str(uuid.uuid4())
|
||||
requests.post(
|
||||
f"{SUPA_URL}/rest/v1/document_artefacts",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": artefact_id,
|
||||
"file_id": file_id,
|
||||
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
|
||||
"rel_path": fi["path"],
|
||||
"status": "processed",
|
||||
"extra": {"seeded": True, "source": "seed_file_cabinets"},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
# Create cabinet memberships for students
|
||||
for sid in student_ids:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/cabinet_memberships",
|
||||
headers={**_sb_headers(), "Prefer": "return=minimal"},
|
||||
json={
|
||||
"cabinet_id": cabinet_id,
|
||||
"profile_id": sid,
|
||||
"role": "viewer",
|
||||
},
|
||||
params={"on_conflict": "cabinet_id,profile_id"},
|
||||
)
|
||||
if r.status_code in (200, 201, 409):
|
||||
results["kevlarai"]["memberships"] += 1
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
err = f"cabinet seed {class_code}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
# ── Seed Greenfield cabinets ────────────────────────────────────────────
|
||||
print("\n[4] Seeding Greenfield file cabinets...")
|
||||
results["greenfield"] = {"cabinets": 0, "files": 0, "memberships": 0}
|
||||
|
||||
for class_code, teacher_email in greenfield_classes:
|
||||
try:
|
||||
class_info = _get_class_info(admin_tokens["Greenfield"], class_code)
|
||||
if not class_info:
|
||||
print(f" ✗ class not found: {class_code}")
|
||||
errors.append(f"class_not_found: {class_code}")
|
||||
continue
|
||||
|
||||
class_id = class_info.get("id") or class_info
|
||||
teacher_pid = _get_profile_id(teacher_email)
|
||||
if not teacher_pid:
|
||||
print(f" ✗ teacher profile not found: {teacher_email}")
|
||||
errors.append(f"teacher_profile_not_found: {teacher_email}")
|
||||
continue
|
||||
|
||||
student_ids = _get_class_students(admin_tokens["Greenfield"], str(class_id))
|
||||
|
||||
subject = (class_info.get("subject") or "").lower()
|
||||
if "physics" in subject:
|
||||
file_category = "lesson_plans"
|
||||
elif "math" in subject:
|
||||
file_category = "worksheets"
|
||||
elif "english" in subject:
|
||||
file_category = "presentations"
|
||||
elif "history" in subject:
|
||||
file_category = "lesson_plans"
|
||||
else:
|
||||
file_category = "lesson_plans"
|
||||
|
||||
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
|
||||
|
||||
cabinet_id = str(uuid.uuid4())
|
||||
cabinet_name = f"{class_code} — {class_info.get('name', class_code)}"
|
||||
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/file_cabinets",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
print(f" ✓ Cabinet: {cabinet_name}")
|
||||
results["greenfield"]["cabinets"] += 1
|
||||
else:
|
||||
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
|
||||
errors.append(f"cabinet_create: {class_code}")
|
||||
continue
|
||||
|
||||
for fi in files_list:
|
||||
file_id = str(uuid.uuid4())
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/files",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": file_id,
|
||||
"cabinet_id": cabinet_id,
|
||||
"name": fi["name"],
|
||||
"path": fi["path"],
|
||||
"bucket": "file-cabinets",
|
||||
"mime_type": fi.get("mime_type"),
|
||||
"size": fi.get("size"),
|
||||
"metadata": {},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
results["greenfield"]["files"] += 1
|
||||
|
||||
artefact_id = str(uuid.uuid4())
|
||||
requests.post(
|
||||
f"{SUPA_URL}/rest/v1/document_artefacts",
|
||||
headers={**_sb_headers(), "Prefer": "return=representation"},
|
||||
json={
|
||||
"id": artefact_id,
|
||||
"file_id": file_id,
|
||||
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
|
||||
"rel_path": fi["path"],
|
||||
"status": "processed",
|
||||
"extra": {"seeded": True, "source": "seed_file_cabinets"},
|
||||
},
|
||||
params={"on_conflict": "id"},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
for sid in student_ids:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/rest/v1/cabinet_memberships",
|
||||
headers={**_sb_headers(), "Prefer": "return=minimal"},
|
||||
json={
|
||||
"cabinet_id": cabinet_id,
|
||||
"profile_id": sid,
|
||||
"role": "viewer",
|
||||
},
|
||||
params={"on_conflict": "cabinet_id,profile_id"},
|
||||
)
|
||||
if r.status_code in (200, 201, 409):
|
||||
results["greenfield"]["memberships"] += 1
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
err = f"cabinet seed {class_code}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
total_cabinets = results["kevlarai"]["cabinets"] + results["greenfield"]["cabinets"]
|
||||
total_files = results["kevlarai"]["files"] + results["greenfield"]["files"]
|
||||
total_memberships = results["kevlarai"]["memberships"] + results["greenfield"]["memberships"]
|
||||
print(f"COMPLETE — {total_cabinets} cabinets, {total_files} files, {total_memberships} memberships")
|
||||
if errors:
|
||||
print(f"Errors ({len(errors)}):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -20,14 +20,17 @@ import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
from run.initialization.seed_environment import get_seed_password
|
||||
|
||||
GREENFIELD_ADMIN_EMAIL = "[email protected]"
|
||||
GREENFIELD_ADMIN_PWD = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
|
||||
|
||||
def _runtime_context() -> Dict[str, str]:
|
||||
return {
|
||||
"supa_url": os.environ["SUPABASE_URL"],
|
||||
"service_key": os.environ["SERVICE_ROLE_KEY"],
|
||||
"api_base": os.environ.get("API_BASE_URL", "http://localhost:8000"),
|
||||
}
|
||||
|
||||
# ─── Period templates ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -145,18 +148,20 @@ STUDENT_ENROLLMENTS = {
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
service_key = _runtime_context()["service_key"]
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"apikey": service_key,
|
||||
"Authorization": f"Bearer {service_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
ctx = _runtime_context()
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
f"{ctx['supa_url']}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": ctx["service_key"], "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -164,8 +169,9 @@ def _sign_in(email: str, password: str) -> str:
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Dict = None) -> Dict:
|
||||
api_base = _runtime_context()["api_base"]
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body)
|
||||
r = getattr(requests, method)(f"{api_base}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
@@ -174,8 +180,9 @@ def _api(token: str, method: str, path: str, body: Dict = None) -> Dict:
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
f"{supa_url}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
@@ -185,8 +192,9 @@ def _get_profile_id(email: str) -> Optional[str]:
|
||||
|
||||
def _get_teacher_timetable_id(profile_id: str) -> Optional[str]:
|
||||
"""Return the Supabase teacher_timetables.id for a given profile."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetables",
|
||||
f"{supa_url}/rest/v1/teacher_timetables",
|
||||
headers=_sb_headers(),
|
||||
params={"profile_id": f"eq.{profile_id}", "select": "id", "limit": "1"},
|
||||
)
|
||||
@@ -196,10 +204,11 @@ def _get_teacher_timetable_id(profile_id: str) -> Optional[str]:
|
||||
|
||||
def _patch_slot_class_ids(teacher_tt_sb_id: str, class_code_to_id: Dict[str, str]) -> int:
|
||||
"""Update class_id FK on teacher_timetable_slots rows via Supabase service role."""
|
||||
supa_url = _runtime_context()["supa_url"]
|
||||
patched = 0
|
||||
for code, class_uuid in class_code_to_id.items():
|
||||
r = requests.patch(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetable_slots",
|
||||
f"{supa_url}/rest/v1/teacher_timetable_slots",
|
||||
headers=_sb_headers(),
|
||||
params={
|
||||
"teacher_timetable_id": f"eq.{teacher_tt_sb_id}",
|
||||
@@ -224,7 +233,7 @@ def seed() -> Dict[str, Any]:
|
||||
# ── [1] Sign in as Greenfield admin ───────────────────────────────────────
|
||||
print("\n[1] Signing in as [email protected]...")
|
||||
try:
|
||||
admin_token = _sign_in(GREENFIELD_ADMIN_EMAIL, GREENFIELD_ADMIN_PWD)
|
||||
admin_token = _sign_in(GREENFIELD_ADMIN_EMAIL, get_seed_password("school_admin"))
|
||||
print(" ✓ signed in")
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -331,7 +340,7 @@ def seed() -> Dict[str, Any]:
|
||||
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
teacher_token = _sign_in(teacher_email, get_seed_password("teacher"))
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
@@ -439,7 +448,7 @@ def seed() -> Dict[str, Any]:
|
||||
results["materialize"] = {}
|
||||
for teacher_email in TEACHER_SLOTS:
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
teacher_token = _sign_in(teacher_email, get_seed_password("teacher"))
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
seed_kevlarai_timetable.py — Full timetable + class + student seed for KevlarAI school.
|
||||
|
||||
Mirrors Greenfield's structure so both schools are testable.
|
||||
KevlarAI gets 8 classes across 3 subjects (Physics, Maths, Computer Science),
|
||||
2 teachers, and 2 students.
|
||||
|
||||
Flow:
|
||||
1. POST /timetable/setup — academic year, 3 terms, periods → Supabase
|
||||
2. POST /timetable/materialize-periods — academic_periods rows (days x template)
|
||||
3. Create classes — 8 classes with correct metadata
|
||||
4. Add teachers to classes — primary teacher per class
|
||||
5. POST /timetable/init + slots — TeacherTimetable + slot assignments
|
||||
6. Patch slot class_ids — write class_id FK onto teacher_timetable_slots
|
||||
7. Enroll students in classes — student1→Yr10, student2→Yr11
|
||||
8. POST /timetable/materialize — taught_lessons with class_id populated
|
||||
9. POST /timetable/sync-lessons — Neo4j TaughtLesson nodes (B.10)
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_kevlarai_timetable import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
KEVLARAI_ADMIN_EMAIL = "[email protected]"
|
||||
KEVLARAI_ADMIN_PWD = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
PWD_STUDENT = "Student@Cc2025!"
|
||||
|
||||
# ─── Period templates (same as Greenfield) ─────────────────────────────────────
|
||||
|
||||
PERIODS = [
|
||||
{"code": "REG", "name": "Registration", "start_time": "08:45", "end_time": "09:00", "period_type": "registration"},
|
||||
{"code": "P1", "name": "Period 1", "start_time": "09:00", "end_time": "10:00", "period_type": "lesson"},
|
||||
{"code": "P2", "name": "Period 2", "start_time": "10:00", "end_time": "11:00", "period_type": "lesson"},
|
||||
{"code": "BRK", "name": "Break", "start_time": "11:00", "end_time": "11:20", "period_type": "break"},
|
||||
{"code": "P3", "name": "Period 3", "start_time": "11:20", "end_time": "12:20", "period_type": "lesson"},
|
||||
{"code": "P4", "name": "Period 4", "start_time": "12:20", "end_time": "13:20", "period_type": "lesson"},
|
||||
{"code": "LUN", "name": "Lunch", "start_time": "13:20", "end_time": "14:00", "period_type": "break"},
|
||||
{"code": "P5", "name": "Period 5", "start_time": "14:00", "end_time": "15:00", "period_type": "lesson"},
|
||||
]
|
||||
|
||||
PERIOD_TIMES = {p["code"]: (p["start_time"], p["end_time"]) for p in PERIODS}
|
||||
|
||||
# ─── Academic year ─────────────────────────────────────────────────────────────
|
||||
|
||||
TERMS = [
|
||||
{"name": "Autumn Term", "term_number": 1, "start_date": "2025-09-03", "end_date": "2025-12-19"},
|
||||
{"name": "Spring Term", "term_number": 2, "start_date": "2026-01-05", "end_date": "2026-04-01"},
|
||||
{"name": "Summer Term", "term_number": 3, "start_date": "2026-04-20", "end_date": "2026-07-17"},
|
||||
]
|
||||
|
||||
# ─── Class definitions ─────────────────────────────────────────────────────────
|
||||
# KevlarAI: 8 classes across Physics, Maths, Computer Science
|
||||
|
||||
CLASSES = [
|
||||
# Physics
|
||||
{"class_code": "10K/Ph1", "name": "Year 10 Physics Group 1", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Maths
|
||||
{"class_code": "10K/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/Ma1", "name": "Year 11 Maths Group 1", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Computer Science
|
||||
{"class_code": "10K/CS1", "name": "Year 10 CS Group 1", "subject": "Computer Science", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
|
||||
{"class_code": "11K/CS1", "name": "Year 11 CS Group 1", "subject": "Computer Science", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
|
||||
# Additional KS3 classes for breadth
|
||||
{"class_code": "9K/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
{"class_code": "9K/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
|
||||
]
|
||||
|
||||
# ─── Teacher slot assignments ──────────────────────────────────────────────────
|
||||
|
||||
TEACHER_SLOTS = {
|
||||
"[email protected]": [
|
||||
("Monday", "P1", "10K/Ph1"),
|
||||
("Monday", "P3", "11K/Ph1"),
|
||||
("Tuesday", "P2", "10K/CS1"),
|
||||
("Tuesday", "P4", "9K/Ph1"),
|
||||
("Wednesday", "P1", "11K/Ph1"),
|
||||
("Wednesday", "P5", "10K/Ph1"),
|
||||
("Thursday", "P3", "10K/CS1"),
|
||||
("Thursday", "P5", "9K/Ph1"),
|
||||
],
|
||||
"[email protected]": [
|
||||
("Monday", "P2", "10K/Ma1"),
|
||||
("Monday", "P4", "11K/Ma1"),
|
||||
("Tuesday", "P1", "9K/Ma1"),
|
||||
("Tuesday", "P3", "10K/Ma1"),
|
||||
("Wednesday", "P2", "11K/Ma1"),
|
||||
("Wednesday", "P4", "9K/Ma1"),
|
||||
("Thursday", "P1", "10K/Ma1"),
|
||||
("Thursday", "P4", "11K/Ma1"),
|
||||
("Friday", "P1", "9K/Ma1"),
|
||||
("Friday", "P3", "10K/Ma1"),
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Student enrollments ───────────────────────────────────────────────────────
|
||||
|
||||
STUDENT_ENROLLMENTS = {
|
||||
"[email protected]": ["10K/Ph1", "10K/Ma1", "10K/CS1"],
|
||||
"[email protected]": ["11K/Ph1", "11K/Ma1"],
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sb_headers() -> Dict:
|
||||
return {
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Optional[Dict] = None) -> Dict:
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text, "_status": r.status_code}
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers=_sb_headers(),
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_teacher_timetable_id(profile_id: str) -> Optional[str]:
|
||||
"""Return the Supabase teacher_timetables.id for a given profile."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetables",
|
||||
headers=_sb_headers(),
|
||||
params={"profile_id": f"eq.{profile_id}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _patch_slot_class_ids(teacher_tt_sb_id: str, class_code_to_id: Dict[str, str]) -> int:
|
||||
"""Update class_id FK on teacher_timetable_slots rows via Supabase service role."""
|
||||
patched = 0
|
||||
for code, class_uuid in class_code_to_id.items():
|
||||
r = requests.patch(
|
||||
f"{SUPA_URL}/rest/v1/teacher_timetable_slots",
|
||||
headers=_sb_headers(),
|
||||
params={
|
||||
"teacher_timetable_id": f"eq.{teacher_tt_sb_id}",
|
||||
"subject_class": f"eq.{code}",
|
||||
},
|
||||
json={"class_id": class_uuid},
|
||||
)
|
||||
if r.ok:
|
||||
patched += 1
|
||||
return patched
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("KevlarAI — full timetable + class + student seed")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── [1] Sign in as KevlarAI admin ───────────────────────────────────────
|
||||
print("\n[1] Signing in as [email protected]...")
|
||||
try:
|
||||
admin_token = _sign_in(KEVLARAI_ADMIN_EMAIL, KEVLARAI_ADMIN_PWD)
|
||||
print(" ✓ signed in")
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── [2] POST /timetable/setup ─────────────────────────────────────────────
|
||||
print("\n[2] Setting up school timetable (academic year + terms + periods)...")
|
||||
r = _api(admin_token, "post", "/timetable/setup", {
|
||||
"year_start": "2025-09-03",
|
||||
"year_end": "2026-07-17",
|
||||
"terms": TERMS,
|
||||
"periods": PERIODS,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("school_timetable_id") or r.get("timetable_id"):
|
||||
print(f" ✓ timetable: {r.get('school_timetable_id') or r.get('timetable_id')}")
|
||||
results["setup"] = "ok"
|
||||
else:
|
||||
err = f"timetable/setup: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["setup"] = "error"
|
||||
|
||||
# ── [3] POST /timetable/materialize-periods ───────────────────────────────
|
||||
print("\n[3] Materializing academic_periods (days x periods_template)...")
|
||||
r = _api(admin_token, "post", "/timetable/materialize-periods", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {r.get('created')} periods created across {r.get('academic_days')} academic days")
|
||||
results["materialize_periods"] = "ok"
|
||||
else:
|
||||
err = f"materialize-periods: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize_periods"] = "error"
|
||||
|
||||
# ── [5] Build profile-ID lookup for all teachers + students ───────────────
|
||||
print("\n[3] Resolving profile IDs for teachers and students...")
|
||||
all_emails = (
|
||||
list(TEACHER_SLOTS.keys())
|
||||
+ list(STUDENT_ENROLLMENTS.keys())
|
||||
)
|
||||
profile_ids: Dict[str, str] = {}
|
||||
for email in all_emails:
|
||||
pid = _get_profile_id(email)
|
||||
if pid:
|
||||
profile_ids[email] = pid
|
||||
print(f" ✓ {email} -> {pid[:8]}...")
|
||||
else:
|
||||
print(f" ✗ profile not found for {email}")
|
||||
errors.append(f"profile_not_found: {email}")
|
||||
|
||||
# ── [6] Create classes ────────────────────────────────────────────────────
|
||||
print(f"\n[4] Creating {len(CLASSES)} classes...")
|
||||
results["classes"] = {}
|
||||
class_code_to_id: Dict[str, str] = {}
|
||||
|
||||
for cls in CLASSES:
|
||||
r = _api(admin_token, "post", "/database/timetable/classes", {
|
||||
"name": cls["name"],
|
||||
"class_code": cls["class_code"],
|
||||
"subject": cls["subject"],
|
||||
"year_group": cls["year_group"],
|
||||
"key_stage": cls["key_stage"],
|
||||
"academic_year": "2025-2026",
|
||||
})
|
||||
class_id = r.get("id") or (r.get("class", {}) or {}).get("id")
|
||||
if class_id:
|
||||
class_code_to_id[cls["class_code"]] = class_id
|
||||
results["classes"][cls["class_code"]] = "ok"
|
||||
print(f" ✓ {cls['class_code']} -> {class_id[:8]}...")
|
||||
else:
|
||||
err = f"create class {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["classes"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [7] Add teachers to their classes ────────────────────────────────────
|
||||
print("\n[5] Adding teachers to classes...")
|
||||
results["class_teachers"] = {}
|
||||
for cls in CLASSES:
|
||||
class_id = class_code_to_id.get(cls["class_code"])
|
||||
teacher_pid = profile_ids.get(cls["teacher"])
|
||||
if not class_id or not teacher_pid:
|
||||
results["class_teachers"][cls["class_code"]] = "skip"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/teachers", {
|
||||
"teacher_id": teacher_pid,
|
||||
"is_primary": True,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {cls['teacher'].split('@')[0]} -> {cls['class_code']}")
|
||||
results["class_teachers"][cls["class_code"]] = "ok"
|
||||
else:
|
||||
err = f"add teacher {cls['class_code']}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["class_teachers"][cls["class_code"]] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [8] Teacher timetable init + slots ────────────────────────────────────
|
||||
print("\n[6] Initialising TeacherTimetable and setting slots for each teacher...")
|
||||
results["init"] = {}
|
||||
results["slots"] = {}
|
||||
teacher_tt_sb_ids: Dict[str, str] = {} # email -> teacher_timetables.id
|
||||
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["init"][teacher_email] = "error"
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6a: init TeacherTimetable
|
||||
r = _api(teacher_token, "post", "/timetable/init", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ init {teacher_email}")
|
||||
results["init"][teacher_email] = "ok"
|
||||
else:
|
||||
print(f" ~ init {teacher_email}: {r.get('message', r)} (may already exist)")
|
||||
results["init"][teacher_email] = "warn"
|
||||
|
||||
# 6b: get timetable_id (Neo4j uuid_string for slot FK)
|
||||
status_r = _api(teacher_token, "get", "/timetable/status", None)
|
||||
timetable_id = status_r.get("timetable_id")
|
||||
if not timetable_id:
|
||||
err = f"no timetable_id for {teacher_email}: {status_r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
continue
|
||||
|
||||
# 6c: save slots (subject_class text; class_id patched separately)
|
||||
slot_list = [
|
||||
{
|
||||
"day_of_week": day,
|
||||
"period_code": code,
|
||||
"subject_class": cls,
|
||||
"start_time": PERIOD_TIMES[code][0],
|
||||
"end_time": PERIOD_TIMES[code][1],
|
||||
}
|
||||
for day, code, cls in slot_tuples
|
||||
]
|
||||
r = _api(teacher_token, "post", "/timetable/slots", {
|
||||
"timetable_id": timetable_id,
|
||||
"slots": slot_list,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("created") is not None:
|
||||
count = r.get("created") or len(slot_list)
|
||||
print(f" ✓ {teacher_email}: {count} slots")
|
||||
results["slots"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"slots {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["slots"][teacher_email] = "error"
|
||||
|
||||
# record Supabase teacher_timetable FK for patching
|
||||
teacher_pid = profile_ids.get(teacher_email)
|
||||
if teacher_pid:
|
||||
tt_sb_id = _get_teacher_timetable_id(teacher_pid)
|
||||
if tt_sb_id:
|
||||
teacher_tt_sb_ids[teacher_email] = tt_sb_id
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [9] Patch teacher_timetable_slots.class_id ────────────────────────────
|
||||
print("\n[7] Patching class_id onto teacher_timetable_slots...")
|
||||
results["slot_patch"] = {}
|
||||
for teacher_email, slot_tuples in TEACHER_SLOTS.items():
|
||||
tt_sb_id = teacher_tt_sb_ids.get(teacher_email)
|
||||
if not tt_sb_id:
|
||||
results["slot_patch"][teacher_email] = "skip"
|
||||
continue
|
||||
teacher_codes = {cls for _, _, cls in slot_tuples}
|
||||
relevant_map = {code: uid for code, uid in class_code_to_id.items() if code in teacher_codes}
|
||||
n = _patch_slot_class_ids(tt_sb_id, relevant_map)
|
||||
print(f" ✓ {teacher_email}: {n} slots patched")
|
||||
results["slot_patch"][teacher_email] = n
|
||||
|
||||
# ── [10] Enroll students in classes ───────────────────────────────────────
|
||||
print("\n[8] Enrolling students in classes...")
|
||||
results["enrollments"] = {}
|
||||
for student_email, class_codes in STUDENT_ENROLLMENTS.items():
|
||||
student_pid = profile_ids.get(student_email)
|
||||
results["enrollments"][student_email] = {}
|
||||
if not student_pid:
|
||||
results["enrollments"][student_email] = "no_profile"
|
||||
continue
|
||||
for code in class_codes:
|
||||
class_id = class_code_to_id.get(code)
|
||||
if not class_id:
|
||||
results["enrollments"][student_email][code] = "no_class"
|
||||
continue
|
||||
r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/students", {
|
||||
"student_id": student_pid,
|
||||
})
|
||||
if r.get("status") == "ok" or r.get("id"):
|
||||
print(f" ✓ {student_email.split('@')[0]} -> {code}")
|
||||
results["enrollments"][student_email][code] = "ok"
|
||||
else:
|
||||
err = f"enroll {student_email} -> {code}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["enrollments"][student_email][code] = "error"
|
||||
time.sleep(0.1)
|
||||
|
||||
# ── [11] Materialize taught lessons ────────────────────────────────────────
|
||||
print("\n[9] Materializing taught lessons for each teacher...")
|
||||
results["materialize"] = {}
|
||||
for teacher_email in TEACHER_SLOTS:
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
continue
|
||||
r = _api(teacher_token, "post", "/timetable/materialize", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ {teacher_email}: {r.get('lessons_upserted', '?')} lessons, "
|
||||
f"{r.get('whiteboard_rooms_created', '?')} rooms")
|
||||
results["materialize"][teacher_email] = "ok"
|
||||
else:
|
||||
err = f"materialize {teacher_email}: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["materialize"][teacher_email] = "error"
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── [12] Neo4j sync (B.10) ────────────────────────────────────────────────
|
||||
print("\n[10] Syncing Neo4j TaughtLesson nodes (B.10)...")
|
||||
r = _api(admin_token, "post", "/timetable/sync-lessons", None)
|
||||
if r.get("status") == "ok":
|
||||
print(f" ✓ Neo4j sync: {r.get('taught_lessons')} lessons, "
|
||||
f"{r.get('teacher_timetables')} timetables, {r.get('slots')} slots")
|
||||
results["neo4j_sync"] = "ok"
|
||||
else:
|
||||
err = f"sync-lessons: {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
results["neo4j_sync"] = "error"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
if errors:
|
||||
print(f"COMPLETE with {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
else:
|
||||
print("COMPLETE — all steps succeeded")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
seed_planned_lessons.py — Create 2-3 planned lessons per teacher across both schools.
|
||||
|
||||
Uses the /lessons/plans API endpoint (POST) to create lesson plans.
|
||||
Each plan is linked to a class, subject, and year group where possible.
|
||||
Plans are idempotent: checks for existing plans by title+subject before creating.
|
||||
|
||||
Tables: planned_lessons, lesson_collaborators, lesson_deliveries
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 -c "from run.initialization.seed_planned_lessons import seed; seed()"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
SUPA_URL = os.environ["SUPABASE_URL"]
|
||||
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
|
||||
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# ─── Passwords (standardized from T4) ────────────────────────────────────────
|
||||
|
||||
PWD_ADMIN = "Admin@Cc2025!"
|
||||
PWD_TEACHER = "Teacher@Cc2025!"
|
||||
|
||||
# ─── Planned lesson templates per school ──────────────────────────────────────
|
||||
# Each entry: (teacher_email, title, subject, year_group, class_code, objectives, activities)
|
||||
|
||||
KEVLARAI_PLANS = [
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Introduction to Forces and Motion",
|
||||
"subject": "Physics",
|
||||
"year_group": "10",
|
||||
"class_code": "10K/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Define force, mass, and acceleration", "bloom": "remember"},
|
||||
{"text": "Apply F=ma to solve simple problems", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Demonstrate forces with spring scales"},
|
||||
{"type": "worksheet", "description": "F=ma calculation practice (10 problems)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Electric Circuits Basics",
|
||||
"subject": "Physics",
|
||||
"year_group": "11",
|
||||
"class_code": "11K/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Identify series and parallel circuit components", "bloom": "understand"},
|
||||
{"text": "Calculate total resistance in series circuits", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "lab", "description": "Build series circuit with resistors"},
|
||||
{"type": "quiz", "description": "Resistance calculation quiz (5 questions)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Quadratic Equations — Factorisation Method",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "10",
|
||||
"class_code": "10K/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Factorise quadratic expressions of the form x²+bx+c", "bloom": "apply"},
|
||||
{"text": "Solve quadratic equations by factorisation", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Walk through 3 worked examples"},
|
||||
{"type": "pair_work", "description": "Factorise 8 quadratics with a partner"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Probability — Tree Diagrams",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "11",
|
||||
"class_code": "11K/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Construct tree diagrams for two-stage events", "bloom": "apply"},
|
||||
{"text": "Calculate combined probabilities from tree diagrams", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Coin toss tree diagram on whiteboard"},
|
||||
{"type": "worksheet", "description": "5 tree diagram probability problems"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
]
|
||||
|
||||
GREENFIELD_PLANS = [
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Waves and Sound",
|
||||
"subject": "Physics",
|
||||
"year_group": "9",
|
||||
"class_code": "9P/Ph1",
|
||||
"objectives": [
|
||||
{"text": "Describe properties of transverse and longitudinal waves", "bloom": "remember"},
|
||||
{"text": "Calculate wave speed using v=fλ", "bloom": "apply"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "demo", "description": "Slinky wave demonstrations"},
|
||||
{"type": "worksheet", "description": "Wave speed calculations (8 problems)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Energy Transfers and Conservation",
|
||||
"subject": "Physics",
|
||||
"year_group": "10",
|
||||
"class_code": "10P/Ph2",
|
||||
"objectives": [
|
||||
{"text": "Identify energy stores and transfer pathways", "bloom": "understand"},
|
||||
{"text": "Apply conservation of energy to real-world scenarios", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "group_work", "description": "Energy audit of a household"},
|
||||
{"type": "presentation", "description": "Present findings on energy efficiency"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Algebra — Expanding Brackets",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "9",
|
||||
"class_code": "9M/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Expand single brackets: a(b+c)", "bloom": "apply"},
|
||||
{"text": "Expand double brackets: (a+b)(c+d)", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Area model for expanding brackets"},
|
||||
{"type": "worksheet", "description": "15 expansion problems (graded difficulty)"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Simultaneous Equations — Elimination Method",
|
||||
"subject": "Mathematics",
|
||||
"year_group": "10",
|
||||
"class_code": "10M/Ma1",
|
||||
"objectives": [
|
||||
{"text": "Solve simultaneous equations by elimination", "bloom": "apply"},
|
||||
{"text": "Choose between substitution and elimination strategically", "bloom": "evaluate"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "direct_instruction", "description": "Walk through 3 elimination examples"},
|
||||
{"type": "pair_work", "description": "Solve 6 simultaneous equation pairs"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "Shakespeare — Macbeth Act 1 Analysis",
|
||||
"subject": "English",
|
||||
"year_group": "9",
|
||||
"class_code": "9En/1",
|
||||
"objectives": [
|
||||
{"text": "Identify key themes in Act 1", "bloom": "understand"},
|
||||
{"text": "Analyse Shakespeare's use of imagery and language", "bloom": "analyse"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "close_reading", "description": "Close read Act 1, Scene 3 (witches' prophecy)"},
|
||||
{"type": "essay", "description": "Short paragraph: How does Shakespeare create tension?"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
{
|
||||
"teacher": "[email protected]",
|
||||
"title": "The Tudors — Henry VIII's Reforms",
|
||||
"subject": "History",
|
||||
"year_group": "10",
|
||||
"class_code": "10Hs/1",
|
||||
"objectives": [
|
||||
{"text": "Describe Henry VIII's religious reforms", "bloom": "remember"},
|
||||
{"text": "Evaluate the political motivations behind the reforms", "bloom": "evaluate"},
|
||||
],
|
||||
"activities": [
|
||||
{"type": "source_analysis", "description": "Analyze Act of Supremacy 1534"},
|
||||
{"type": "debate", "description": "Was Henry's break with Rome politically necessary?"},
|
||||
],
|
||||
"duration_minutes": 60,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sign_in(email: str, password: str) -> str:
|
||||
r = requests.post(
|
||||
f"{SUPA_URL}/auth/v1/token?grant_type=password",
|
||||
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _api(token: str, method: str, path: str, body: Optional[Dict] = None) -> Dict:
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text, "_status": r.status_code}
|
||||
|
||||
|
||||
def _get_profile_id(email: str) -> Optional[str]:
|
||||
"""Look up a profile's UUID by email via Supabase service role."""
|
||||
r = requests.get(
|
||||
f"{SUPA_URL}/rest/v1/profiles",
|
||||
headers={
|
||||
"apikey": SERVICE_KEY,
|
||||
"Authorization": f"Bearer {SERVICE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
return data[0]["id"] if data else None
|
||||
|
||||
|
||||
def _get_class_id(class_code: str, admin_token: str) -> Optional[str]:
|
||||
"""Look up a class UUID by class_code via the API."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/database/timetable/classes",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
params={"class_code": class_code},
|
||||
)
|
||||
data = r.json() if r.ok else []
|
||||
if isinstance(data, list) and data:
|
||||
return data[0].get("id") or data[0]
|
||||
if isinstance(data, dict):
|
||||
return data.get("id") or data.get("class", {}).get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _existing_plans_for_teacher(token: str, teacher_email: str) -> List[str]:
|
||||
"""Return list of existing plan titles for a teacher (to check idempotency)."""
|
||||
r = requests.get(
|
||||
f"{API_BASE}/lessons/plans",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if r.ok:
|
||||
plans = r.json().get("plans", [])
|
||||
return [p.get("title", "") for p in plans]
|
||||
return []
|
||||
|
||||
|
||||
# ─── Main seed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def seed() -> Dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Planned lessons seed — both schools")
|
||||
print("=" * 60)
|
||||
results: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# ── Sign in as both school admins ───────────────────────────────────────
|
||||
print("\n[1] Signing in as school admins...")
|
||||
admin_tokens = {}
|
||||
for school, email, pwd in [
|
||||
("KevlarAI", "[email protected]", PWD_ADMIN),
|
||||
("Greenfield", "[email protected]", PWD_ADMIN),
|
||||
]:
|
||||
try:
|
||||
token = _sign_in(email, pwd)
|
||||
admin_tokens[school] = token
|
||||
print(f" ✓ {school} admin signed in")
|
||||
except Exception as e:
|
||||
print(f" ✗ {school} admin login failed: {e}")
|
||||
errors.append(f"{school}_admin_login: {e}")
|
||||
|
||||
if not admin_tokens:
|
||||
return {"success": False, "error": "No admin tokens obtained"}
|
||||
|
||||
# ── Resolve class IDs ───────────────────────────────────────────────────
|
||||
print("\n[2] Resolving class IDs...")
|
||||
all_class_codes = set()
|
||||
for plans in [KEVLARAI_PLANS, GREENFIELD_PLANS]:
|
||||
for p in plans:
|
||||
if p.get("class_code"):
|
||||
all_class_codes.add(p["class_code"])
|
||||
|
||||
class_code_to_id: Dict[str, str] = {}
|
||||
for code in all_class_codes:
|
||||
# Try KevlarAI first, then Greenfield
|
||||
for school in ["KevlarAI", "Greenfield"]:
|
||||
cid = _get_class_id(code, admin_tokens[school])
|
||||
if cid:
|
||||
class_code_to_id[code] = cid
|
||||
print(f" ✓ {code} -> {cid[:8]}...")
|
||||
break
|
||||
else:
|
||||
print(f" ✗ class not found: {code}")
|
||||
errors.append(f"class_not_found: {code}")
|
||||
|
||||
# ── Seed planned lessons ────────────────────────────────────────────────
|
||||
print("\n[3] Creating planned lessons...")
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for school, plans in [("KevlarAI", KEVLARAI_PLANS), ("Greenfield", GREENFIELD_PLANS)]:
|
||||
admin_token = admin_tokens[school]
|
||||
print(f"\n [{school}]")
|
||||
|
||||
for plan_spec in plans:
|
||||
teacher_email = plan_spec["teacher"]
|
||||
title = plan_spec["title"]
|
||||
|
||||
# Check idempotency
|
||||
try:
|
||||
teacher_token = _sign_in(teacher_email, PWD_TEACHER)
|
||||
except Exception as e:
|
||||
err = f"login {teacher_email}: {e}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
continue
|
||||
|
||||
existing = _existing_plans_for_teacher(teacher_token, teacher_email)
|
||||
if title in existing:
|
||||
print(f" ~ SKIP (exists): {title}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Build class_id
|
||||
class_id = None
|
||||
cc = plan_spec.get("class_code")
|
||||
if cc:
|
||||
class_id = class_code_to_id.get(cc)
|
||||
|
||||
body = {
|
||||
"title": title,
|
||||
"subject": plan_spec["subject"],
|
||||
"year_group": plan_spec["year_group"],
|
||||
"estimated_duration_minutes": plan_spec.get("duration_minutes", 60),
|
||||
"objectives": plan_spec["objectives"],
|
||||
"activities": plan_spec["activities"],
|
||||
"status": "draft",
|
||||
"tags": [plan_spec["subject"].lower(), f"yr{plan_spec['year_group']}"],
|
||||
}
|
||||
if class_id:
|
||||
body["class_id"] = class_id
|
||||
|
||||
r = _api(teacher_token, "post", "/lessons/plans", body)
|
||||
plan_id = r.get("id") or (r.get("planned_lesson", {}) or {}).get("id")
|
||||
if plan_id:
|
||||
print(f" ✓ {title} [{plan_id[:8]}...]")
|
||||
created_count += 1
|
||||
else:
|
||||
err = f"create plan '{title}': {r}"
|
||||
print(f" ✗ {err}")
|
||||
errors.append(err)
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
results["created"] = created_count
|
||||
results["skipped"] = skipped_count
|
||||
if errors:
|
||||
print(f"COMPLETE with {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(f" ✗ {e}")
|
||||
else:
|
||||
print(f"COMPLETE — {created_count} created, {skipped_count} skipped (idempotent)")
|
||||
print("=" * 60)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(json.dumps(seed(), indent=2, default=str))
|
||||
@@ -1,408 +1,15 @@
|
||||
"""
|
||||
Seed Test Environment — idempotent full-environment setup for CC development.
|
||||
"""Compatibility wrapper for the canonical seed environment test mode."""
|
||||
from typing import Any, Dict
|
||||
|
||||
Creates:
|
||||
- [email protected] → platform super-admin (admin_profiles)
|
||||
- KevlarAI school → already exists; adds 3 student users
|
||||
- Greenfield Academy → new second school with full staff + students
|
||||
|
||||
Run inside ccapi container:
|
||||
python3 main.py --mode seed-test
|
||||
|
||||
Or directly:
|
||||
cd ~/api && python3 -c "
|
||||
from run.initialization.seed_test_environment import seed_test_environment
|
||||
import json; print(json.dumps(seed_test_environment(), indent=2))
|
||||
"
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional, List
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
# ─── Existing KevlarAI school ────────────────────────────────────────────────
|
||||
KEVLARAI_INSTITUTE_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||
KEVLARAI_INSTITUTE_DB = "cc.institutes.6585bf916ae84d72ab54cddf3ba4e648"
|
||||
|
||||
# ─── Second test school ──────────────────────────────────────────────────────
|
||||
GREENFIELD_INSTITUTE_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # deterministic UUID
|
||||
GREENFIELD_URN = "TEST-GFA-001"
|
||||
GREENFIELD_NAME = "Greenfield Academy"
|
||||
|
||||
# ─── User definitions ────────────────────────────────────────────────────────
|
||||
# Format: email, password, username, full_name, display_name, user_type, role, institute_id
|
||||
TEST_USERS: List[Dict] = [
|
||||
# ── KevlarAI students ────────────────────────────────────────────────────
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student1@KevlarAI!",
|
||||
"username": "student1.kevlarai",
|
||||
"full_name": "Alice Nguyen",
|
||||
"display_name": "Alice",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
||||
"metadata": {"year_group": "Year 10"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student2@KevlarAI!",
|
||||
"username": "student2.kevlarai",
|
||||
"full_name": "Ben Okafor",
|
||||
"display_name": "Ben",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
||||
"metadata": {"year_group": "Year 10"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student3@KevlarAI!",
|
||||
"username": "student3.kevlarai",
|
||||
"full_name": "Chloe Park",
|
||||
"display_name": "Chloe",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
||||
"metadata": {"year_group": "Year 11"},
|
||||
},
|
||||
# ── Greenfield Academy admin ─────────────────────────────────────────────
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Admin@Greenfield1!",
|
||||
"username": "head.greenfield",
|
||||
"full_name": "Dr James Whitmore",
|
||||
"display_name": "Dr Whitmore",
|
||||
"user_type": "teacher",
|
||||
"role": "school_admin",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None, # populated after school provisioning
|
||||
"metadata": {},
|
||||
},
|
||||
# ── Greenfield teachers ──────────────────────────────────────────────────
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Teacher1@Greenfield1!",
|
||||
"username": "physics.greenfield",
|
||||
"full_name": "Priya Sharma",
|
||||
"display_name": "Priya",
|
||||
"user_type": "teacher",
|
||||
"role": "teacher",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None,
|
||||
"metadata": {"subject": "Physics"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Teacher2@Greenfield1!",
|
||||
"username": "english.greenfield",
|
||||
"full_name": "Tom Bradley",
|
||||
"display_name": "Tom",
|
||||
"user_type": "teacher",
|
||||
"role": "teacher",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None,
|
||||
"metadata": {"subject": "English"},
|
||||
},
|
||||
# ── Greenfield students ──────────────────────────────────────────────────
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student1@Greenfield1!",
|
||||
"username": "alice.greenfield",
|
||||
"full_name": "Alice Thornton",
|
||||
"display_name": "Alice T",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None,
|
||||
"metadata": {"year_group": "Year 9"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student2@Greenfield1!",
|
||||
"username": "bob.greenfield",
|
||||
"full_name": "Bob Ivanov",
|
||||
"display_name": "Bob",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None,
|
||||
"metadata": {"year_group": "Year 9"},
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"password": "Student3@Greenfield1!",
|
||||
"username": "carol.greenfield",
|
||||
"full_name": "Carol Mensah",
|
||||
"display_name": "Carol",
|
||||
"user_type": "student",
|
||||
"role": "student",
|
||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
||||
"institute_db": None,
|
||||
"metadata": {"year_group": "Year 10"},
|
||||
},
|
||||
]
|
||||
from run.initialization.seed_environment import seed
|
||||
|
||||
|
||||
def seed_test_environment() -> Dict[str, Any]:
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.services.provisioning_service import ProvisioningService
|
||||
"""Seed the lightweight 9-user test environment."""
|
||||
return seed(test=True)
|
||||
|
||||
sb_client = SupabaseServiceRoleClient()
|
||||
supabase_url = os.environ["SUPABASE_URL"]
|
||||
service_key = os.environ["SERVICE_ROLE_KEY"]
|
||||
|
||||
headers = {
|
||||
"apikey": service_key,
|
||||
"Authorization": f"Bearer {service_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
def auth_get(path, params=None):
|
||||
r = requests.get(f"{supabase_url}/auth/v1/admin{path}", headers=headers, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def auth_post(path, data):
|
||||
r = requests.post(f"{supabase_url}/auth/v1/admin{path}", headers=headers, json=data)
|
||||
return r
|
||||
|
||||
def sb_upsert(table, data, on_conflict):
|
||||
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
|
||||
r = requests.post(
|
||||
f"{supabase_url}/rest/v1/{table}",
|
||||
headers=h,
|
||||
json=data,
|
||||
params={"on_conflict": on_conflict},
|
||||
)
|
||||
return r
|
||||
|
||||
def sb_select(table, eq_col, eq_val):
|
||||
r = requests.get(
|
||||
f"{supabase_url}/rest/v1/{table}",
|
||||
headers=headers,
|
||||
params={"select": "*", eq_col: f"eq.{eq_val}"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
errors: List[str] = []
|
||||
results: Dict[str, Any] = {"steps": {}}
|
||||
|
||||
# ── Step 1: Ensure kcar is a platform super-admin ─────────────────────────
|
||||
logger.info("Step 1: Platform super-admin setup...")
|
||||
try:
|
||||
kcar_id = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" # known from profiles
|
||||
r = sb_upsert("admin_profiles", {
|
||||
"id": kcar_id,
|
||||
"email": "[email protected]",
|
||||
"display_name": "Kevin Carroll",
|
||||
"admin_role": "super_admin",
|
||||
"is_super_admin": True,
|
||||
"metadata": {"seeded": True},
|
||||
}, on_conflict="id")
|
||||
if r.status_code in (200, 201):
|
||||
logger.info(" kcar → admin_profiles super_admin ✓")
|
||||
results["steps"]["super_admin"] = "ok"
|
||||
else:
|
||||
raise Exception(f"Upsert failed: {r.text[:200]}")
|
||||
except Exception as e:
|
||||
msg = f"super_admin setup: {e}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
results["steps"]["super_admin"] = "error"
|
||||
|
||||
# ── Step 2: Provision Greenfield Academy ──────────────────────────────────
|
||||
logger.info("Step 2: Greenfield Academy school provisioning...")
|
||||
greenfield_db = None
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = sb_select("institutes", "id", GREENFIELD_INSTITUTE_ID)
|
||||
if not existing:
|
||||
# Determine neo4j_uuid_string (same sanitization as provisioning_service)
|
||||
neo4j_uuid = GREENFIELD_INSTITUTE_ID.replace("-", "")
|
||||
r = sb_upsert("institutes", {
|
||||
"id": GREENFIELD_INSTITUTE_ID,
|
||||
"name": GREENFIELD_NAME,
|
||||
"urn": GREENFIELD_URN,
|
||||
"status": "active",
|
||||
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
|
||||
"website": "https://greenfieldacademy.test",
|
||||
"metadata": {"headteacher": "Dr James Whitmore", "seeded": True},
|
||||
"neo4j_uuid_string": neo4j_uuid,
|
||||
}, on_conflict="id")
|
||||
if r.status_code not in (200, 201):
|
||||
raise Exception(f"Institute upsert: {r.text[:200]}")
|
||||
logger.info(f" Greenfield Academy created [{GREENFIELD_INSTITUTE_ID[:8]}]")
|
||||
|
||||
# Provision Neo4j DB
|
||||
provisioner = ProvisioningService()
|
||||
prov_result = provisioner.ensure_school(GREENFIELD_INSTITUTE_ID)
|
||||
greenfield_db = prov_result.get("db_name")
|
||||
logger.info(f" Neo4j DB provisioned: {greenfield_db}")
|
||||
else:
|
||||
neo4j_uuid = existing[0].get("neo4j_uuid_string") or GREENFIELD_INSTITUTE_ID.replace("-", "")
|
||||
greenfield_db = f"cc.institutes.{neo4j_uuid}"
|
||||
logger.info(f" Greenfield Academy already exists → {greenfield_db}")
|
||||
|
||||
results["steps"]["greenfield_school"] = greenfield_db
|
||||
except Exception as e:
|
||||
msg = f"greenfield_school: {e}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
results["steps"]["greenfield_school"] = "error"
|
||||
greenfield_db = f"cc.institutes.{GREENFIELD_INSTITUTE_ID.replace('-', '')}"
|
||||
|
||||
# Update institute_db for Greenfield users
|
||||
for u in TEST_USERS:
|
||||
if u["institute_id"] == GREENFIELD_INSTITUTE_ID:
|
||||
u["institute_db"] = greenfield_db
|
||||
|
||||
# ── Step 3: Create / verify all test users ────────────────────────────────
|
||||
logger.info("Step 3: Creating test users...")
|
||||
created_users: Dict[str, Dict] = {}
|
||||
try:
|
||||
all_users = auth_get("/users", params={"per_page": 200}).get("users", [])
|
||||
existing_by_email = {u["email"]: u for u in all_users}
|
||||
except Exception as e:
|
||||
msg = f"auth/users list: {e}"
|
||||
logger.error(msg)
|
||||
errors.append(msg)
|
||||
existing_by_email = {}
|
||||
|
||||
for spec in TEST_USERS:
|
||||
email = spec["email"]
|
||||
if email in existing_by_email:
|
||||
uid = existing_by_email[email]["id"]
|
||||
logger.info(f" {email}: exists [{uid[:8]}]")
|
||||
created_users[email] = {"id": uid, **spec}
|
||||
continue
|
||||
|
||||
r = auth_post("/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"]
|
||||
logger.info(f" {email}: created [{uid[:8]}]")
|
||||
created_users[email] = {"id": uid, **spec}
|
||||
else:
|
||||
msg = f"create {email}: {r.text[:200]}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
time.sleep(0.25)
|
||||
|
||||
results["steps"]["users_created"] = list(created_users.keys())
|
||||
|
||||
# ── Step 4: Upsert profiles + memberships ─────────────────────────────────
|
||||
logger.info("Step 4: Upserting profiles and memberships...")
|
||||
for spec in TEST_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u:
|
||||
continue
|
||||
try:
|
||||
sb_upsert("profiles", {
|
||||
"id": u["id"],
|
||||
"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")
|
||||
|
||||
sb_upsert("institute_memberships", {
|
||||
"profile_id": u["id"],
|
||||
"institute_id": spec["institute_id"],
|
||||
"role": spec["role"],
|
||||
"metadata": spec.get("metadata", {}),
|
||||
}, on_conflict="profile_id,institute_id")
|
||||
except Exception as e:
|
||||
msg = f"profile/membership {spec['email']}: {e}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
|
||||
results["steps"]["profiles_memberships"] = "ok"
|
||||
|
||||
# ── Step 5: Neo4j Teacher/Student nodes for all users ────────────────────
|
||||
logger.info("Step 5: Creating Neo4j worker nodes...")
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||
|
||||
# Group users by institute DB
|
||||
by_db: Dict[str, List[Dict]] = {}
|
||||
for spec in TEST_USERS:
|
||||
u = created_users.get(spec["email"])
|
||||
if not u or not spec.get("institute_db"):
|
||||
continue
|
||||
by_db.setdefault(spec["institute_db"], []).append({**spec, "uid": u["id"]})
|
||||
|
||||
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.unique_id = $uid, "
|
||||
" n.user_type = $user_type, "
|
||||
" n.worker_type = $user_type",
|
||||
uid=u["uid"], email=u["email"],
|
||||
name=u["full_name"], user_type=u["user_type"],
|
||||
)
|
||||
logger.info(f" [{db[:30]}] {label}: {u['email']}")
|
||||
|
||||
driver.close()
|
||||
results["steps"]["neo4j_nodes"] = "ok"
|
||||
except Exception as e:
|
||||
msg = f"neo4j_nodes: {e}"
|
||||
logger.error(f" {msg}")
|
||||
errors.append(msg)
|
||||
results["steps"]["neo4j_nodes"] = "error"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
results["success"] = len(errors) == 0
|
||||
results["errors"] = errors
|
||||
results["message"] = (
|
||||
f"Seed complete — {len(created_users)} users across 2 schools"
|
||||
if not errors
|
||||
else f"{len(errors)} error(s): {errors[0]}"
|
||||
)
|
||||
|
||||
# Print credential sheet
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("TEST CREDENTIAL SHEET")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"{'ROLE':<20} {'EMAIL':<40} {'PASSWORD'}")
|
||||
logger.info("-" * 90)
|
||||
logger.info(f"{'[PLATFORM ADMIN]':<20} {'[email protected]':<40} KevlarAI2025!")
|
||||
logger.info("-" * 90)
|
||||
logger.info(f"[KevlarAI School]")
|
||||
for spec in TEST_USERS:
|
||||
if spec["institute_id"] == KEVLARAI_INSTITUTE_ID:
|
||||
logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}")
|
||||
logger.info("-" * 90)
|
||||
logger.info(f"[Greenfield Academy]")
|
||||
for spec in TEST_USERS:
|
||||
if spec["institute_id"] == GREENFIELD_INSTITUTE_ID:
|
||||
logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return results
|
||||
print(json.dumps(seed_test_environment(), indent=2, default=str))
|
||||
|
||||
@@ -40,6 +40,7 @@ from routers.transcribe.canvas_events import router as canvas_events_router
|
||||
from routers.transcribe.keywords import router as keywords_router
|
||||
from routers.me.bootstrap_router import router as me_bootstrap_router
|
||||
from routers import tlsync_token as tlsync_token_router
|
||||
from routers.exam import router as exam_router
|
||||
|
||||
def register_routes(app: FastAPI):
|
||||
logger.info("Starting to register routes...")
|
||||
@@ -58,7 +59,9 @@ def register_routes(app: FastAPI):
|
||||
app.include_router(entity_init.router, prefix="/database/entity", tags=["Entity"])
|
||||
app.include_router(calendar.router, prefix="/database/calendar", tags=["Calendar"])
|
||||
app.include_router(schools.router, prefix="/database/schools", tags=["Schools"])
|
||||
from routers.database.timetable.timetables import router as timetable_router
|
||||
app.include_router(timetables.router, prefix="/database/timetables", tags=["Timetables"])
|
||||
app.include_router(timetable_router, prefix="/database/timetable/timetables", tags=["Timetables"])
|
||||
app.include_router(curriculum.router, prefix="/database/curriculum", tags=["Curriculum"])
|
||||
|
||||
# Navigation Routes
|
||||
@@ -132,6 +135,9 @@ def register_routes(app: FastAPI):
|
||||
# TLSync auth token route
|
||||
app.include_router(tlsync_token_router.router, prefix="/api/tlsync", tags=["TLSync"])
|
||||
|
||||
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
|
||||
app.include_router(exam_router, prefix="/api/exam", tags=["Exam"])
|
||||
|
||||
# Transcription Routes (CIS Phase 1)
|
||||
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
|
||||
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# ClassroomCopilot Startup Script
|
||||
# Usage: ./start.sh [start_mode]
|
||||
# start_mode options: infra, demo-school, demo-users, gais-data, full, dev, prod
|
||||
# start_mode options: infra, seed, seed-test, gais-data, full, dev, prod
|
||||
|
||||
set -e
|
||||
|
||||
@@ -14,10 +14,10 @@ show_help() {
|
||||
echo ""
|
||||
echo "Start modes:"
|
||||
echo " infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)"
|
||||
echo " demo-school - Create demo school (KevlarAI)"
|
||||
echo " demo-users - Create demo users"
|
||||
echo " seed - Seed canonical full environment (20 school users)"
|
||||
echo " seed-test - Seed lightweight test environment (9 school users)"
|
||||
echo " gais-data - Import GAIS data (Edubase, etc.)"
|
||||
echo " full - Run full initialization (infra → demo-school → demo-users → gais-data)"
|
||||
echo " full - Run full initialization (infra → seed)"
|
||||
echo " nuke - 💥 NUKE Redis - Clear all queue data for fresh start"
|
||||
echo " dev - Run development server with auto-reload"
|
||||
echo " prod - Run production server (for Docker/containerized deployment)"
|
||||
@@ -25,8 +25,8 @@ show_help() {
|
||||
echo "Examples:"
|
||||
echo " ./start.sh # Run in dev mode (default)"
|
||||
echo " ./start.sh infra # Setup infrastructure"
|
||||
echo " ./start.sh demo-school # Create demo school"
|
||||
echo " ./start.sh demo-users # Create demo users"
|
||||
echo " ./start.sh seed # Seed canonical full environment"
|
||||
echo " ./start.sh seed-test # Seed lightweight test environment"
|
||||
echo " ./start.sh gais-data # Import GAIS data"
|
||||
echo " ./start.sh full # Run full initialization"
|
||||
echo " ./start.sh full --yes # Run full initialization without prompts"
|
||||
@@ -133,54 +133,32 @@ run_infra() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run demo school creation
|
||||
run_demo_school() {
|
||||
print_status "Running demo school creation mode..."
|
||||
print_status "This will create the KevlarAI demo school."
|
||||
# Function to run canonical environment seed
|
||||
run_seed() {
|
||||
local mode=${1:-seed}
|
||||
if [[ "$mode" == "seed-test" ]]; then
|
||||
print_status "Running lightweight seed-test mode (9 school users)..."
|
||||
else
|
||||
print_status "Running canonical seed mode (20 school users)..."
|
||||
fi
|
||||
|
||||
# Check if we should proceed
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
read -p "Do you want to continue with demo school creation? (y/N): " -n 1 -r
|
||||
read -p "Do you want to continue with $mode? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Demo school creation cancelled."
|
||||
print_status "$mode cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Starting demo school creation process..."
|
||||
$PYTHON_CMD main.py --mode demo-school
|
||||
print_status "Starting $mode process..."
|
||||
$PYTHON_CMD main.py --mode "$mode"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Demo school creation completed successfully!"
|
||||
print_success "$mode completed successfully!"
|
||||
else
|
||||
print_error "Demo school creation failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run demo users creation
|
||||
run_demo_users() {
|
||||
print_status "Running demo users creation mode..."
|
||||
print_status "This will create demo users for testing."
|
||||
|
||||
# Check if we should proceed
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
read -p "Do you want to continue with demo users creation? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Demo users creation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Starting demo users creation process..."
|
||||
$PYTHON_CMD main.py --mode demo-users
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Demo users creation completed successfully!"
|
||||
else
|
||||
print_error "Demo users creation failed!"
|
||||
print_error "$mode failed!"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -274,7 +252,7 @@ except Exception as e:
|
||||
|
||||
# Function to run full initialization (all steps in order)
|
||||
run_full() {
|
||||
print_status "Running full initialization (infra → demo-school → demo-users → gais-data)..."
|
||||
print_status "Running full initialization (infra → seed)..."
|
||||
|
||||
# Single confirmation for the whole flow
|
||||
if [[ "$AUTO_YES" != true ]]; then
|
||||
@@ -289,14 +267,8 @@ run_full() {
|
||||
# Run infra
|
||||
run_infra || { print_error "Full init aborted during infra."; exit 1; }
|
||||
|
||||
# Run demo school
|
||||
run_demo_school || { print_error "Full init aborted during demo-school."; exit 1; }
|
||||
|
||||
# Run demo users
|
||||
run_demo_users || { print_error "Full init aborted during demo-users."; exit 1; }
|
||||
|
||||
# Run GAIS data import
|
||||
run_gais_data || { print_error "Full init aborted during gais-data."; exit 1; }
|
||||
# Run canonical full seed
|
||||
run_seed seed || { print_error "Full init aborted during seed."; exit 1; }
|
||||
|
||||
print_success "Full initialization completed successfully!"
|
||||
}
|
||||
@@ -383,11 +355,11 @@ main() {
|
||||
"infra")
|
||||
run_infra
|
||||
;;
|
||||
"demo-school")
|
||||
run_demo_school
|
||||
"seed")
|
||||
run_seed seed
|
||||
;;
|
||||
"demo-users")
|
||||
run_demo_users
|
||||
"seed-test")
|
||||
run_seed seed-test
|
||||
;;
|
||||
"gais-data")
|
||||
run_gais_data
|
||||
@@ -406,7 +378,7 @@ main() {
|
||||
;;
|
||||
*)
|
||||
print_error "Invalid start mode: $START_MODE"
|
||||
print_status "Valid modes: infra, demo-school, demo-users, gais-data, nuke, dev, prod"
|
||||
print_status "Valid modes: infra, seed, seed-test, gais-data, full, nuke, dev, prod"
|
||||
print_status "Usage: ./start.sh [start_mode]"
|
||||
print_status "Use './start.sh --help' for more information"
|
||||
exit 1
|
||||
|
||||
@@ -55,15 +55,19 @@ def test_dev_api_health_endpoint_is_healthy():
|
||||
assert payload['services']['redis']['database'] == 0
|
||||
|
||||
|
||||
# NOTE: these are >= baselines, not exact counts. The greenfield seed produces this floor;
|
||||
# additive exam-marker fixtures (S4-4 cohort adds ~10 students/memberships; ad-hoc classes) push
|
||||
# the live .94 counts above it. Exact == froze a snapshot that any new fixture breaks, while >=
|
||||
# still catches a broken or missing seed.
|
||||
def test_supabase_dev_seed_core_counts():
|
||||
assert _rest_count('profiles') == 21
|
||||
assert _rest_count('institute_memberships') == 21
|
||||
assert _rest_count('institutes') == 2
|
||||
assert _rest_count('profiles') >= 21
|
||||
assert _rest_count('institute_memberships') >= 21
|
||||
assert _rest_count('institutes') >= 2
|
||||
|
||||
|
||||
def test_supabase_dev_seed_timetable_counts():
|
||||
assert _rest_count('classes') == 17
|
||||
assert _rest_count('taught_lessons') == 1462
|
||||
assert _rest_count('classes') >= 17
|
||||
assert _rest_count('taught_lessons') >= 1462
|
||||
|
||||
|
||||
def test_runtime_identity_does_not_expose_secret_values():
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Tests for /api/exam/batches, /marks, /scans (card S4-6).
|
||||
|
||||
FakeSupabase emulates RLS by pre-filtering the visible store slice (same approach as
|
||||
test_exam_templates). Service-role helpers (name resolution, storage) are monkeypatched; live
|
||||
as-user RLS is covered by the .94 smoke.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.batches as batches_mod
|
||||
from routers.exam.batches import router
|
||||
from routers.exam.dependencies import ExamContext
|
||||
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
TPL = "t-1"
|
||||
CLASS = "c-1"
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, store, table):
|
||||
self.store = store
|
||||
self.table = table
|
||||
self.rows = list(store.get(table, []))
|
||||
self._filters = []
|
||||
self._op = None
|
||||
self._payload = None
|
||||
self._limit = None
|
||||
|
||||
def select(self, *_a, **_k):
|
||||
self._op = "select"; return self
|
||||
|
||||
def insert(self, payload):
|
||||
self._op = "insert"; self._payload = payload; return self
|
||||
|
||||
def update(self, payload):
|
||||
self._op = "update"; self._payload = payload; return self
|
||||
|
||||
def upsert(self, payload):
|
||||
self._op = "upsert"; self._payload = payload; return self
|
||||
|
||||
def delete(self):
|
||||
self._op = "delete"; return self
|
||||
|
||||
def eq(self, k, v):
|
||||
self._filters.append(("eq", k, v)); self.rows = [r for r in self.rows if r.get(k) == v]; return self
|
||||
|
||||
def neq(self, k, v):
|
||||
self._filters.append(("neq", k, v)); self.rows = [r for r in self.rows if r.get(k) != v]; return self
|
||||
|
||||
def in_(self, k, vals):
|
||||
vals = set(vals); self._filters.append(("in", k, vals)); self.rows = [r for r in self.rows if r.get(k) in vals]; return self
|
||||
|
||||
def order(self, *_a, **_k):
|
||||
return self
|
||||
|
||||
def limit(self, n):
|
||||
self._limit = n; return self
|
||||
|
||||
def _match(self, row):
|
||||
for op, k, v in self._filters:
|
||||
if op == "eq" and row.get(k) != v:
|
||||
return False
|
||||
if op == "neq" and row.get(k) == v:
|
||||
return False
|
||||
if op == "in" and row.get(k) not in v:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self):
|
||||
backing = self.store.setdefault(self.table, [])
|
||||
if self._op in ("insert", "upsert"):
|
||||
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
|
||||
out = []
|
||||
for p in payloads:
|
||||
row = dict(p)
|
||||
if self._op == "upsert" and row.get("id") is not None:
|
||||
existing = next((r for r in backing if r.get("id") == row["id"]), None)
|
||||
if existing:
|
||||
existing.update(row); out.append(existing); continue
|
||||
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
|
||||
backing.append(row); out.append(row)
|
||||
return FakeResult(out)
|
||||
if self._op == "update":
|
||||
out = []
|
||||
for r in backing:
|
||||
if self._match(r):
|
||||
r.update(self._payload); out.append(r)
|
||||
return FakeResult(out)
|
||||
if self._op == "delete":
|
||||
self.store[self.table] = [r for r in backing if not self._match(r)]
|
||||
return FakeResult([r for r in backing if self._match(r)])
|
||||
rows = self.rows[: self._limit] if self._limit is not None else self.rows
|
||||
return FakeResult(rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def make_client(store, user_id=TEACHER, institute_ids=(INST_A,)):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
from routers.exam.dependencies import get_exam_context
|
||||
app.dependency_overrides[get_exam_context] = lambda: ExamContext(user_id, "tok", FakeSupabase(store), list(institute_ids))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def base_store(**extra):
|
||||
store = {"exam_templates": [{"id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "draft"}]}
|
||||
store.update(extra)
|
||||
return store
|
||||
|
||||
|
||||
# ─── batches ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_batch_no_class():
|
||||
store = base_store()
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches", json={"template_id": TPL, "title": "Mock 1"})
|
||||
assert r.status_code == 200
|
||||
b = r.json()
|
||||
assert b["teacher_id"] == TEACHER and b["institute_id"] == INST_A
|
||||
assert b["status"] == "open" and b["submission_count"] == 0
|
||||
|
||||
|
||||
def test_create_batch_template_404():
|
||||
c = make_client(base_store())
|
||||
assert c.post("/api/exam/batches", json={"template_id": "nope"}).status_code == 404
|
||||
|
||||
|
||||
def test_create_batch_seeds_roster_as_absent(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "resolve_student_names",
|
||||
lambda ids: {sid: f"Name {sid}" for sid in ids})
|
||||
store = base_store(class_students=[
|
||||
{"class_id": CLASS, "student_id": "s1", "status": "active"},
|
||||
{"class_id": CLASS, "student_id": "s2", "status": "active"},
|
||||
{"class_id": CLASS, "student_id": "s3", "status": "inactive"}, # excluded
|
||||
])
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches", json={"template_id": TPL, "class_id": CLASS})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["submission_count"] == 2
|
||||
subs = store["student_submissions"]
|
||||
assert {s["student_id"] for s in subs} == {"s1", "s2"}
|
||||
assert all(s["status"] == "absent" for s in subs)
|
||||
assert all(s["student_name"].startswith("Name ") for s in subs)
|
||||
|
||||
|
||||
def test_list_batches_excludes_archived():
|
||||
store = base_store(marking_batches=[
|
||||
{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"},
|
||||
{"id": "b2", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "archived"},
|
||||
])
|
||||
c = make_client(store)
|
||||
ids = {b["id"] for b in c.get("/api/exam/batches").json()["batches"]}
|
||||
assert ids == {"b1"}
|
||||
|
||||
|
||||
# ─── queue / results / csv (A7) ──────────────────────────────────────────────
|
||||
|
||||
def _batch_with_cohort():
|
||||
return base_store(
|
||||
marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"}],
|
||||
exam_questions=[
|
||||
{"id": "q1", "template_id": TPL, "label": "01", "max_marks": 3, "order": 0},
|
||||
{"id": "q2", "template_id": TPL, "label": "02", "max_marks": 5, "order": 1},
|
||||
],
|
||||
student_submissions=[
|
||||
{"id": "sub1", "batch_id": "b1", "student_id": "s1", "student_name": "Alice", "status": "complete"},
|
||||
{"id": "sub2", "batch_id": "b1", "student_id": "s2", "student_name": "Bob", "status": "absent"},
|
||||
],
|
||||
mark_entries=[
|
||||
{"id": "m1", "batch_id": "b1", "submission_id": "sub1", "question_id": "q1", "awarded_marks": 2},
|
||||
{"id": "m2", "batch_id": "b1", "submission_id": "sub1", "question_id": "q2", "awarded_marks": 4},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_queue_progress_counts():
|
||||
c = make_client(_batch_with_cohort())
|
||||
body = c.get("/api/exam/batches/b1/queue").json()
|
||||
assert body["progress"]["total"] == 2
|
||||
assert body["progress"]["absent"] == 1 and body["progress"]["complete"] == 1
|
||||
counts = {s["id"]: s["mark_entry_count"] for s in body["submissions"]}
|
||||
assert counts == {"sub1": 2, "sub2": 0}
|
||||
|
||||
|
||||
def test_results_includes_absent_with_blank(monkeypatch):
|
||||
c = make_client(_batch_with_cohort())
|
||||
body = c.get("/api/exam/batches/b1/results").json()
|
||||
by_id = {r["submission_id"]: r for r in body["results"]}
|
||||
assert by_id["sub1"]["total"] == 6
|
||||
assert by_id["sub2"]["total"] is None # absent → blank total (A7)
|
||||
assert set(by_id["sub2"]["marks"].values()) == {None}
|
||||
assert {r["student_name"] for r in body["results"]} == {"Alice", "Bob"} # absent NOT dropped
|
||||
|
||||
|
||||
def test_csv_includes_absent_row():
|
||||
c = make_client(_batch_with_cohort())
|
||||
text = c.get("/api/exam/batches/b1/csv").text
|
||||
lines = [l for l in text.strip().splitlines() if l]
|
||||
assert lines[0].split(",")[:3] == ["student_name", "student_id", "status"]
|
||||
assert "01" in lines[0] and "02" in lines[0] # question labels as columns
|
||||
assert any(l.startswith("Bob,") and ",absent," in l for l in lines) # absent present
|
||||
assert len(lines) == 3 # header + 2 students (incl. absent)
|
||||
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_upsert_mark_derives_batch_and_roundtrips():
|
||||
store = _batch_with_cohort()
|
||||
c = make_client(store)
|
||||
r = c.put("/api/exam/marks/mk-1", json={"submission_id": "sub1", "question_id": "q1", "awarded_marks": 3})
|
||||
assert r.status_code == 200
|
||||
row = r.json()
|
||||
assert row["batch_id"] == "b1" and row["awarded_marks"] == 3 and row["id"] == "mk-1"
|
||||
# upsert again → same id updated, not duplicated
|
||||
c.put("/api/exam/marks/mk-1", json={"submission_id": "sub1", "question_id": "q1", "awarded_marks": 1})
|
||||
assert sum(1 for m in store["mark_entries"] if m["id"] == "mk-1") == 1
|
||||
|
||||
|
||||
def test_upsert_mark_flips_absent_submission_to_marking():
|
||||
store = _batch_with_cohort() # sub2 starts 'absent'
|
||||
c = make_client(store)
|
||||
c.put("/api/exam/marks/mk-2", json={"submission_id": "sub2", "question_id": "q1", "awarded_marks": 2})
|
||||
sub2 = next(s for s in store["student_submissions"] if s["id"] == "sub2")
|
||||
assert sub2["status"] == "marking"
|
||||
# results now show a real total for the (formerly absent) marked student
|
||||
res = {r["submission_id"]: r for r in c.get("/api/exam/batches/b1/results").json()["results"]}
|
||||
assert res["sub2"]["total"] == 2
|
||||
|
||||
|
||||
def test_upsert_mark_submission_404():
|
||||
c = make_client(_batch_with_cohort())
|
||||
assert c.put("/api/exam/marks/mk-x", json={"submission_id": "nope", "question_id": "q1", "awarded_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
# ─── scans (E3 guards) ───────────────────────────────────────────────────────
|
||||
|
||||
def _batch_store():
|
||||
return base_store(marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "open"}],
|
||||
student_submissions=[{"id": "sub1", "batch_id": "b1", "student_id": "s1", "status": "absent"}])
|
||||
|
||||
|
||||
def test_scan_rejects_non_pdf_mime():
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.png", b"\x89PNG", "image/png")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
def test_scan_rejects_spoofed_pdf():
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"not really a pdf", "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 415 # magic-byte sniff
|
||||
|
||||
|
||||
def test_scan_rejects_oversize(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "MAX_SCAN_BYTES", 8)
|
||||
c = make_client(_batch_store())
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"%PDF-" + b"0" * 100, "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class _FakeStorage:
|
||||
def upload_file(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def test_scan_manual_match_happy(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "StorageAdmin", _FakeStorage)
|
||||
store = _batch_store()
|
||||
c = make_client(store)
|
||||
r = c.post("/api/exam/batches/b1/scans",
|
||||
files={"file": ("x.pdf", b"%PDF-1.7 minimal", "application/pdf")},
|
||||
data={"matching_method": "manual", "student_id": "s1"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "matched"
|
||||
assert store["student_submissions"][0]["status"] == "matched"
|
||||
assert store["student_submissions"][0]["scan_url"].startswith("exam-submissions/b1/")
|
||||
|
||||
|
||||
def test_scan_denied_for_non_owner(monkeypatch):
|
||||
monkeypatch.setattr(batches_mod, "StorageAdmin", _FakeStorage)
|
||||
store = _batch_store()
|
||||
c = make_client(store, user_id="someone-else")
|
||||
r = c.post("/api/exam/batches/b1/scans", files={"file": ("x.pdf", b"%PDF-1.7", "application/pdf")}, data={"matching_method": "manual"})
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Tests for the /api/exam/templates router (card S4-5).
|
||||
|
||||
Mirrors the FakeSupabase + dependency_overrides pattern from test_me_bootstrap.py. The
|
||||
ExamContext dependency is overridden with an in-memory fake, so these tests exercise the
|
||||
router's auth/ownership/institute logic without a live Supabase — the as-user RLS itself is
|
||||
verified separately against .94 (see the evidence note).
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.templates as templates_mod
|
||||
from routers.exam.templates import router
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context
|
||||
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
OTHER_TEACHER = "00000000-0000-0000-0000-000000000002"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
INST_B = "10000000-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_projection(monkeypatch):
|
||||
"""Record projection scheduling and never touch Neo4j/service-role in unit tests."""
|
||||
calls = []
|
||||
monkeypatch.setattr(templates_mod, "project_template_safe", lambda tid: calls.append(tid))
|
||||
monkeypatch.setattr(templates_mod, "project_template", lambda tid: {"exam_code": "X", "questions": 1})
|
||||
return calls
|
||||
|
||||
|
||||
# ─── in-memory fake supabase ─────────────────────────────────────────────────
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
"""Models the subset of the supabase-py builder the router uses, against a row list.
|
||||
|
||||
Crucially it emulates RLS: the backing store is pre-filtered to the rows the caller can
|
||||
see, so cross-institute / non-owner access naturally reads back empty (→ 404)."""
|
||||
|
||||
def __init__(self, store, table):
|
||||
self.store = store
|
||||
self.table = table
|
||||
self.rows = list(store.get(table, []))
|
||||
self._filters = []
|
||||
self._op = None
|
||||
self._payload = None
|
||||
self._limit = None
|
||||
|
||||
def select(self, *_a, **_k):
|
||||
self._op = "select"
|
||||
return self
|
||||
|
||||
def insert(self, payload):
|
||||
self._op = "insert"
|
||||
self._payload = payload
|
||||
return self
|
||||
|
||||
def update(self, payload):
|
||||
self._op = "update"
|
||||
self._payload = payload
|
||||
return self
|
||||
|
||||
def delete(self):
|
||||
self._op = "delete"
|
||||
return self
|
||||
|
||||
def eq(self, key, value):
|
||||
self._filters.append(("eq", key, value))
|
||||
self.rows = [r for r in self.rows if r.get(key) == value]
|
||||
return self
|
||||
|
||||
def neq(self, key, value):
|
||||
self._filters.append(("neq", key, value))
|
||||
self.rows = [r for r in self.rows if r.get(key) != value]
|
||||
return self
|
||||
|
||||
def in_(self, key, values):
|
||||
values = set(values)
|
||||
self._filters.append(("in", key, values))
|
||||
self.rows = [r for r in self.rows if r.get(key) in values]
|
||||
return self
|
||||
|
||||
def order(self, *_a, **_k):
|
||||
return self
|
||||
|
||||
def limit(self, n):
|
||||
self._limit = n
|
||||
return self
|
||||
|
||||
def _matches(self, row):
|
||||
for op, key, value in self._filters:
|
||||
if op == "eq" and row.get(key) != value:
|
||||
return False
|
||||
if op == "neq" and row.get(key) == value:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self):
|
||||
backing = self.store.setdefault(self.table, [])
|
||||
if self._op == "insert":
|
||||
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
|
||||
inserted = []
|
||||
for p in payloads:
|
||||
row = dict(p)
|
||||
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
|
||||
backing.append(row)
|
||||
inserted.append(row)
|
||||
return FakeResult(inserted)
|
||||
if self._op == "update":
|
||||
updated = []
|
||||
for row in backing:
|
||||
if self._matches(row):
|
||||
row.update(self._payload)
|
||||
updated.append(row)
|
||||
return FakeResult(updated)
|
||||
if self._op == "delete":
|
||||
kept = [r for r in backing if not self._matches(r)]
|
||||
removed = [r for r in backing if self._matches(r)]
|
||||
self.store[self.table] = kept
|
||||
return FakeResult(removed)
|
||||
# select
|
||||
rows = self.rows[: self._limit] if self._limit is not None else self.rows
|
||||
return FakeResult(rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def make_client(user_id=TEACHER, institute_ids=(INST_A,), store=None):
|
||||
store = store if store is not None else {}
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
|
||||
def _ctx():
|
||||
return ExamContext(user_id, "fake-token", FakeSupabase(store), list(institute_ids))
|
||||
|
||||
app.dependency_overrides[get_exam_context] = _ctx
|
||||
return TestClient(app), store
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_requires_auth_when_not_overridden():
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
# No dependency override → real SupabaseBearer runs and rejects the missing token.
|
||||
resp = TestClient(app).get("/api/exam/templates")
|
||||
assert resp.status_code in (401, 403) # unauthenticated, not processed
|
||||
|
||||
|
||||
def test_create_template_sets_owner_and_institute():
|
||||
client, store = make_client()
|
||||
resp = client.post("/api/exam/templates", json={"title": "AQA Physics 1H", "subject": "Physics"})
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()
|
||||
assert row["title"] == "AQA Physics 1H"
|
||||
assert row["teacher_id"] == TEACHER
|
||||
assert row["institute_id"] == INST_A
|
||||
assert row["status"] == "draft"
|
||||
|
||||
|
||||
def test_create_template_rejects_foreign_institute():
|
||||
client, _ = make_client(institute_ids=(INST_A,))
|
||||
resp = client.post("/api/exam/templates", json={"title": "X", "institute_id": INST_B})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_create_template_requires_institute_when_ambiguous():
|
||||
client, _ = make_client(institute_ids=(INST_A, INST_B))
|
||||
resp = client.post("/api/exam/templates", json={"title": "X"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_list_excludes_archived_by_default():
|
||||
store = {
|
||||
"exam_templates": [
|
||||
{"id": "t1", "title": "live", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER},
|
||||
{"id": "t2", "title": "gone", "status": "archived", "institute_id": INST_A, "teacher_id": TEACHER},
|
||||
]
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
titles = [t["title"] for t in client.get("/api/exam/templates").json()["templates"]]
|
||||
assert titles == ["live"]
|
||||
all_titles = {t["title"] for t in client.get("/api/exam/templates?include_archived=true").json()["templates"]}
|
||||
assert all_titles == {"live", "gone"}
|
||||
|
||||
|
||||
def test_get_template_bundles_children():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
|
||||
"exam_response_areas": [{"id": "r1", "template_id": "t1", "question_id": "q1", "page": 1}],
|
||||
"exam_boundaries": [{"id": "b1", "template_id": "t1", "page_index": 0, "y": 10}],
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
body = client.get("/api/exam/templates/t1").json()
|
||||
assert len(body["questions"]) == 1
|
||||
assert len(body["response_areas"]) == 1
|
||||
assert len(body["boundaries"]) == 1
|
||||
|
||||
|
||||
def test_get_other_institute_template_is_404():
|
||||
# RLS emulation: a template the caller can't see isn't in their visible store slice.
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_B, "teacher_id": OTHER_TEACHER}]}
|
||||
client, _ = make_client(institute_ids=(INST_A,), store=store)
|
||||
# The fake store doesn't model institute filtering on read, so simulate the RLS-hidden row
|
||||
# by querying an id the caller's store doesn't contain.
|
||||
assert client.get("/api/exam/templates/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_put_replace_persists_children_with_client_ids():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, store = make_client(store=store)
|
||||
payload = {
|
||||
"questions": [{"id": "q-uuid-1", "label": "01.1", "order": 0, "max_marks": 3}],
|
||||
"response_areas": [{"id": "r-uuid-1", "question_id": "q-uuid-1", "page": 1, "bounds": {"x": 1}, "kind": "response"}],
|
||||
"boundaries": [{"id": "b-uuid-1", "page_index": 0, "y": 12.5}],
|
||||
}
|
||||
resp = client.put("/api/exam/templates/t1", json=payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["questions"][0]["id"] == "q-uuid-1" # client UUID preserved (Neo4j join key)
|
||||
assert body["response_areas"][0]["id"] == "r-uuid-1"
|
||||
assert body["boundaries"][0]["id"] == "b-uuid-1"
|
||||
|
||||
|
||||
def test_put_replace_clears_previous_children():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"exam_questions": [{"id": "old", "template_id": "t1", "label": "stale", "order": 0}],
|
||||
}
|
||||
client, store = make_client(store=store)
|
||||
client.put("/api/exam/templates/t1", json={"questions": [{"id": "new", "label": "fresh", "order": 0}]})
|
||||
ids = {q["id"] for q in store["exam_questions"]}
|
||||
assert ids == {"new"} # old row replaced, not appended
|
||||
|
||||
|
||||
def test_put_replace_blocked_when_marks_recorded():
|
||||
# Re-saving the structure after marking began would cascade-delete mark_entries → guard 409.
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
|
||||
"mark_entries": [{"id": "m1", "batch_id": "b1", "submission_id": "s1", "question_id": "q1", "awarded_marks": 2}],
|
||||
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
|
||||
}
|
||||
client, store = make_client(store=store)
|
||||
r = client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]})
|
||||
assert r.status_code == 409
|
||||
# original question untouched (no destructive delete happened)
|
||||
assert {q["id"] for q in store["exam_questions"]} == {"q1"}
|
||||
|
||||
|
||||
def test_put_replace_allowed_when_batch_has_no_marks():
|
||||
store = {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||
"marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}],
|
||||
"mark_entries": [],
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
assert client.put("/api/exam/templates/t1", json={"questions": [{"id": "q2", "label": "new", "order": 0}]}).status_code == 200
|
||||
|
||||
|
||||
def test_put_replace_denied_for_non_owner():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
|
||||
# Caller is a colleague in the same institute (can read), but not the owner → 403.
|
||||
client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
resp = client.put("/api/exam/templates/t1", json={"questions": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_archive_soft_deletes():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, store = make_client(store=store)
|
||||
resp = client.delete("/api/exam/templates/t1")
|
||||
assert resp.status_code == 200
|
||||
assert store["exam_templates"][0]["status"] == "archived" # not hard-deleted
|
||||
|
||||
|
||||
def test_patch_question_updates_fields():
|
||||
store = {"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "max_marks": 0}]}
|
||||
client, store = make_client(store=store)
|
||||
resp = client.patch("/api/exam/questions/q1", json={"max_marks": 5, "spec_ref": "8.1.2"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["max_marks"] == 5
|
||||
assert store["exam_questions"][0]["spec_ref"] == "8.1.2"
|
||||
|
||||
|
||||
def test_patch_question_missing_is_404():
|
||||
client, _ = make_client(store={"exam_questions": []})
|
||||
assert client.patch("/api/exam/questions/nope", json={"max_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_question_empty_body_is_400():
|
||||
store = {"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01"}]}
|
||||
client, _ = make_client(store=store)
|
||||
assert client.patch("/api/exam/questions/q1", json={}).status_code == 400
|
||||
|
||||
|
||||
# ─── Neo4j projection (S4-7) ─────────────────────────────────────────────────
|
||||
|
||||
def test_put_schedules_projection(_stub_projection):
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
client.put("/api/exam/templates/t1", json={"questions": []})
|
||||
assert _stub_projection == ["t1"] # projection enqueued for the saved template
|
||||
|
||||
|
||||
def test_neo4j_sync_owner_runs():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||
client, _ = make_client(store=store)
|
||||
r = client.post("/api/exam/templates/t1/neo4j-sync")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["projection"]["exam_code"] == "X"
|
||||
|
||||
|
||||
def test_neo4j_sync_non_owner_403():
|
||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]}
|
||||
client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
assert client.post("/api/exam/templates/t1/neo4j-sync").status_code == 403
|
||||
|
||||
|
||||
def test_neo4j_sync_404():
|
||||
client, _ = make_client(store={"exam_templates": []})
|
||||
assert client.post("/api/exam/templates/does-not-exist/neo4j-sync").status_code == 404
|
||||
@@ -123,7 +123,10 @@ def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
|
||||
assert anon.access_token == "user-token"
|
||||
assert captured["url"] == "http://supabase.test"
|
||||
assert captured["key"] == "anon-key"
|
||||
assert captured["options_kwargs"]["headers"] == {"Authorization": "Bearer user-token"}
|
||||
assert captured["options_kwargs"]["headers"] == {
|
||||
"apikey": "anon-key",
|
||||
"Authorization": "Bearer user-token",
|
||||
}
|
||||
|
||||
|
||||
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
|
||||
|
||||
Reference in New Issue
Block a user