Compare commits

..
Author SHA1 Message Date
CC WorkerandClaude Opus 4.8 f3da9f3b59 fix: explicit apikey header + resilient dev-stack seed-count baselines
api-ci-deploy / test-build-deploy (push) Has been cancelled
- client.py: set apikey explicitly in _create_base_client headers (Kong needs it
  on every request; for per-user clients apikey stays anon while Authorization
  carries the user JWT). Fixes the 2 stale header unit tests that asserted apikey
  in options.headers, and is robust against supabase-py default-header changes.
- test_dev_stack: exact == seed counts → >= baselines. The greenfield seed sets a
  floor; additive exam-marker fixtures (S4-4 cohort) legitimately push live .94
  counts above the old snapshot. >= still catches a broken/missing seed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:25:39 +00:00
CC WorkerandClaude Opus 4.8 49f84655f7 merge: exam-marker FastAPI backend (S4-5/6/7)
api-ci-deploy / test-build-deploy (push) Has been cancelled
Brings in the full exam-marker HTTP API on /api/exam (as-user RLS, E1/E2 fixes):
- S4-5 template CRUD (hybrid PUT + PATCH)
- S4-6 batches/scans/marks/results/CSV (A7), roster-from-class_students
- S4-7 Neo4j projection on save + neo4j-sync
Also fixes pre-existing E7: storage.py brace-doubling crash (all uploads).

Verified: 35 unit tests; live as-user RLS smoke .94 (templates 17/17, batches
20/20); live graph smoke .94+.209 (projection 17/17). Reviewed; data-loss guard
added (409 on destructive template PUT once marks recorded).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:16:52 +00:00
CC WorkerandClaude Opus 4.8 e269e67f27 fix(exam): block destructive template PUT once marks recorded (review #1)
PUT full-replace deletes exam_questions, and mark_entries.question_id cascades
ON DELETE — so re-saving the setup canvas after marking began would silently
wipe recorded marks. Guard: 409 if any mark_entry exists for the template's
batches. Mark-scheme edits (PATCH /questions/{id}) are unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:15:35 +00:00
CC WorkerandClaude Opus 4.8 77bb0766ff feat(exam): Neo4j projection on template save + neo4j-sync (S4-7)
modules/database/services/exam_projection.py projects a saved template into
cc.public.exams: ExamPaper -> Question/Part -> Region + Part-[:ASSESSES]->
SpecPoint, joined by shared UUIDs (exam_questions.id, exam_response_areas.id,
exam_code, spec_code). Full re-sync per exam_code (idempotent). Reads via
service role + writes via system Neo4j driver (R3.5.1 documented graph-writer).

Wiring (R3.5.4/R5.3):
- PUT /templates/{id} enqueues project_template_safe via BackgroundTasks
  (swallows failures so a graph hiccup never fails the canvas save).
- POST /templates/{id}/neo4j-sync — manual trigger, as-user auth + owner check,
  runs synchronously and returns projection counts.

Unit tests: projection scheduled on PUT; neo4j-sync owner/403/404.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:02:18 +00:00
CC WorkerandClaude Opus 4.8 98be55ab57 fix(storage): de-double braces in storage.py (set-of-dict crash)
Pre-existing bug (E7): the whole file had doubled braces, so every dict literal
was a set containing a dict -> 'unhashable type: dict' at runtime, and log
f-strings printed literal {braces}. This broke StorageManager.upload_file /
list_bucket_contents / create_bucket / bucket-init for ALL callers (incl.
files.py uploads), not just exam scans. Mechanical de-double; no logic change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 18:46:50 +00:00
CC WorkerandClaude Opus 4.8 62234dbbcb fix(exam): blank total only for absent AND unmarked; flip status on mark
A roster student starts 'absent' and a direct mark would otherwise still show a
blank total. Now total is blank only when absent with no marks; recording a mark
advances the submission out of absent/unmatched to 'marking'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 18:43:09 +00:00
CC WorkerandClaude Opus 4.8 a1d297ac30 test(exam): replace StorageAdmin with fake class in scan tests
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 18:41:23 +00:00
CC WorkerandClaude Opus 4.8 5ad9c01cde feat(exam): batches, scans, marks, results, CSV (S4-6)
Adds routers/exam/batches.py (mounted alongside templates under /api/exam):
- POST/GET /batches — batch creation seeds the cohort from class_students AS
  THE USER (cs_read requires caller teaches/admins the class); each active
  enrollee becomes a student_submissions row (status='absent') so no student
  is ever dropped from results (A7). Display names denormalised via a
  documented service-role profiles read (deny-all as-user, E4).
- GET /batches/{id}/queue — submissions + per-submission mark counts + progress.
- GET /batches/{id}/results + /csv — every roster student incl. absent (blank
  marks/total); CSV row always present (A7 baked into the contract).
- PUT /marks/{id} — upsert; batch_id derived server-side from the submission
  (client never supplies the RLS scoping key).
- POST /batches/{id}/scans — E3 guards: MIME check, hard size ceiling (chunked
  read), %PDF magic-byte sniff; owner-only; stores via service-role storage;
  manual/ordered matching (QR-decode is a follow-on, no QR fixtures yet).

Unit tests cover batch/roster-seed/list, queue, results+CSV A7, mark upsert
round-trip, and all scan guards + owner check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 18:40:10 +00:00
CC WorkerandClaude Opus 4.8 96f9fb2446 test(exam): accept 401 or 403 for unauthenticated request
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 17:50:55 +00:00
CC WorkerandClaude Opus 4.8 f52c3267ca feat(exam): /api/exam template CRUD router (as-user RLS, E1 fix)
S4-5: new routers/exam/ package mounted at /api/exam (R5.1/E5, not under
/database/). Template CRUD with hybrid persistence (R5.2):

- POST/GET/GET{id}/PUT{id}/DELETE{id} /templates + PATCH /questions/{qid}
- Calls Supabase AS THE USER via SupabaseAnonClient.for_user (E1 fix), so the
  RLS in 72-exam-marker.sql is enforced; no service-role for user-facing ops.
- Institute resolved/validated via the user_institute_ids() SECURITY DEFINER
  RPC (institute_memberships is deny-all as-user per E4); client-supplied
  institute_id is validated, never trusted (R5.5).
- Ownership pre-checked before writes (E2); out-of-scope ids read back as 404
  under RLS (IDOR-safe). Soft-delete archives, never hard-deletes.
- PUT full-replace preserves client UUIDs as Neo4j join keys (spec §2).
- eb_exams.exam_code denormalised via a documented service-role catalogue
  lookup (eb_exams is shared reference data, deny-all as-user per E4).

Unit tests cover auth, CRUD, ownership/IDOR, institute validation, soft-delete.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 17:49:58 +00:00
CC Worker 6ce6272a1e merge: feat/exam-marker-exam-paper-seed (exam-marker foundation)
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-06 17:01:49 +00:00
CC Worker b8cb9083ec merge: feat/exam-marker-cohort-seed (exam-marker foundation) 2026-06-06 17:01:49 +00:00
CC Worker 8427063bd1 merge: feat/exam-marker-neo4j-graph (exam-marker foundation) 2026-06-06 17:01:49 +00:00
CC WorkerandClaude Opus 4.8 5f822eaf87 feat(seed): AQA-PHYS-8463 spec + paper for exam-marker test
Adds the real AQA GCSE Physics 8463 specification and the AQA-PHYS-8463-1H-22-JUN
exam paper (Paper 1, Higher, June 2022, QP) to seed_curriculum.py, with storage_loc
pointing at the uploaded PDF in the cc.examboards bucket. spec_code AQA-PHYS-8463
matches the cc.public.exams Specification node (S4-1).

Applied + verified on dev .94: eb_specifications + eb_exams rows present; the real PDF
(3,963,384 bytes) is uploaded to cc.examboards/aqa/physics/8463/AQA-PHYS-8463-1H-22-JUN.pdf
and retrievable (HTTP 200, exact byte match). seed run populated the empty catalogue
(7 specs / 16 exams / 42 Neo4j topics).

NOTE: the PDF upload is a one-time ops step (curl from the host to the Storage API) —
the container can't reach the host file. A reproducible fixture-upload step is a follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 16:19:10 +00:00
CC WorkerandClaude Opus 4.8 c690caa26d feat(exams): cc.public.exams Neo4j graph init + node schemas
- modules/database/schemas/nodes/exams/exam_nodes.py: neontology node classes for
  ExamBoard/Specification/SpecPoint/ExamPaper/Question/Part/Region (uuid_string joins
  to Supabase exam_questions.id / exam_response_areas.id / eb_exams.exam_code).
- run/initialization/init_exam_graph.py: idempotent init — creates the shared public
  cc.public.exams database, 10 uniqueness constraints, and seeds AQA + AQA-PHYS-8463
  (GCSE Physics) with its 8 top-level topic SpecPoints.

Applied + verified on dev Neo4j (192.168.0.209, enterprise): db online, 10 constraints,
AQA-[:PUBLISHES]->AQA-PHYS-8463-[:HAS_SPEC_POINT]->8 points. Full sub-point catalogue is
a later data task. spec_code AQA-PHYS-8463 must match the eb_exams seed (S4-3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 16:14:56 +00:00
CC WorkerandClaude Opus 4.8 0ce654c6c6 feat(seed): markable cohort for exam-marker (9P/Ph1)
Adds seed_cohort_9p_ph1.py — creates N student accounts (default 10) and enrols
them all into one class (Greenfield Year 9 Physics 9P/Ph1) so there is a real
cohort to mark. The canonical timetable seeds enrol one student per year-band,
leaving every class with <=1 student.

Uses the same paths as the canonical seeds (auth admin create user, profiles +
institute_memberships upsert, POST /database/timetable/classes/{id}/students as
school admin). Idempotent. Self-contained HTTP (runs inside the ccapi container).

Verified on dev .94: 10 created + enrolled, 0 errors; 9P/Ph1 roster = 11;
physics teacher sees all 11 under as-user RLS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 15:00:00 +00:00
CC WorkerandClaude Sonnet 4.6 4b296cff74 fix: include school_id in bootstrap profile select query
api-ci-deploy / test-build-deploy (push) Has been cancelled
The _get_profile select list omitted school_id, causing
/me/bootstrap to always return null for that field even after
the column was populated.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-03 07:26:30 +00:00
CC WorkerandClaude Sonnet 4.6 3711b52ea4 Merge fix/r6-timetable-endpoint: R6-D timetable endpoint + R6-E classes fix
api-ci-deploy / test-build-deploy (push) Has been cancelled
- GET /database/timetable/timetables with optional filters (R6-D)
- Return empty collections instead of 400 when user has no school (R6-E)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-03 01:15:00 +00:00
CC WorkerandClaude Sonnet 4.6 d3465eca7b R6-D: add GET /database/timetable/timetables endpoint
- New router at routers/database/timetable/timetables.py
- Accepts optional class_id, type, active query params
- Returns {"timetables": [...]} scoped to caller's school
- Fixed broken import path in run/routers.py (tools → timetable module)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-03 01:14:55 +00:00
CC Worker 9de949d212 R6-E2: return empty collections when user has no school
- _require_institute returns Optional[str] instead of raising 400
- list_classes / my_teaching_classes / my_student_classes / list_school_students
  now return empty arrays when school_id is missing
2026-06-02 23:36:05 +00:00
kcarandClaude Sonnet 4.6 f203f376e9 fix(supabase): remove duplicate apikey header in _create_base_client
api-ci-deploy / test-build-deploy (push) Has been cancelled
supabase-py injects apikey internally via create_client(url, key). Manually
setting headers['apikey'] caused PostgREST to log "Duplicate API key found /
JSON could not be generated" on every bootstrap request.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 21:37:10 +00:00
kcar 52f5ef4ca2 feat: add init service to dev compose, remove redundant build sections
api-ci-deploy / test-build-deploy (push) Has been cancelled
- Add init service behind --profile init with cc-api-dev:latest image
- Remove build sections from backend-dev and backend-test (pre-built images)
- Add env var comments referencing .env.example
- Standardize INIT_MODE=infra default, RUN_INIT=true for init service
2026-05-29 21:15:52 +01:00
kcar ead4452277 feat: add P0 seed scripts for timetable, planned lessons, file cabinets, and curriculum
- seed_kevlarai_timetable.py: Mirror Greenfield timetable structure for KevlarAI
  (8 classes, 2 teachers, 2 students, full slot/materialize/sync pipeline)
- seed_planned_lessons.py: 2-3 planned lessons per teacher across both schools
  (6 plans total, idempotent via title+subject check)
- seed_file_cabinets.py: One file cabinet per class with sample documents
  (14 cabinets, ~28 files, document_artefacts, cabinet_memberships)
- seed_curriculum.py: Exam board specifications and exams (AQA, Edexcel, OCR)
  (6 specs, 12 exam papers, Neo4j curriculum topics per school)
2026-05-29 21:15:05 +01:00
kcar e66c8ec291 t4: consolidate seed scripts, remove demo modes, standardize passwords
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-29 19:51:32 +01:00
kcar abc90fa1b6 test: align Supabase user client header expectation
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 19:19:05 +01:00
kcar 39ad1818ae Merge branch agent/p0-correctness-security
api-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	modules/database/supabase/utils/client.py
2026-05-28 19:17:22 +01:00
kcar 1738af0e3d Merge branch agent/tlsync-token-t_a69128a1 (TLSync JWT token endpoint)
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 18:04:25 +01:00
kcar 7808a0ae56 Add TLSync token endpoint 2026-05-28 17:55:37 +01:00
kcar 54760083b5 fix: tighten API P0 auth and route handling 2026-05-28 12:42:42 +01:00
43 changed files with 4692 additions and 1160 deletions
+7
View File
@@ -132,6 +132,13 @@ OCR_LANG=eng
MEMORY_WARNING_THRESHOLD=80
MEMORY_REJECT_THRESHOLD=95
# =============================================================================
# TLSync Configuration
# =============================================================================
# Server-side secret used to sign short-lived TLSync auth tokens. Do not expose with a VITE_ prefix.
TLSYNC_SECRET=change-me-server-side-only
TLSYNC_TOKEN_TTL_SECONDS=300
# =============================================================================
# CORS Configuration
# =============================================================================
+24 -6
View File
@@ -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:
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17 -40
View File
@@ -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()
sys.exit(0 if success else 1)
elif args.mode == 'demo-users':
# Run demo users creation
success = run_demo_users_mode()
elif args.mode == 'seed':
success = run_seed_mode(test=False)
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
+4
View File
@@ -20,6 +20,10 @@ class SupabaseBearer(HTTPBearer):
token = credentials.credentials
# Decode using the string-based verifier to avoid async dependency conflicts
payload = verify_supabase_jwt_str(token)
# Keep the bearer token available to downstream dependencies that must
# call Supabase as the user (RLS/storage policies), without requiring
# each router to decode the Authorization header again.
payload["_access_token"] = token
return payload
except Exception as e:
logger.error(f"Token verification failed: {str(e)}")
@@ -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}")
+18 -4
View File
@@ -24,12 +24,21 @@ def _create_base_client(url: str, key: str, access_token: Optional[str] = None,
# 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,
}
if options:
headers.update(options.get("headers", {}))
client_options = SyncClientOptions(
schema="public",
storage=SyncMemoryStorage(),
headers={
"Authorization": auth_header
}
headers=headers,
)
return create_client(url, key, options=client_options)
@@ -95,4 +104,9 @@ class SupabaseAnonClient:
This enables per-user RLS enforcement via auth.uid() in the JWT.
"""
return cls(access_token=access_token)
if not access_token or not access_token.strip():
raise ValueError("access_token is required for per-user Supabase clients")
token = access_token.strip()
if token.lower().startswith("bearer "):
token = token.split(None, 1)[1]
return cls(access_token=token)
+49 -49
View File
@@ -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):
+86
View File
@@ -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 []}
+41 -35
View File
@@ -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 = (
@@ -230,6 +233,38 @@ async def my_student_classes(
)
return {"classes": res}
@router.get("/school/students")
async def list_school_students(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""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")
.select("profile_id")
.eq("institute_id", institute_id)
.eq("role", "student")
.execute()
.data or []
)
student_ids = [m["profile_id"] for m in members]
if not student_ids:
return {"students": []}
profiles = (
sb.supabase.table("profiles")
.select("id, full_name, display_name, email, user_type")
.in_("id", student_ids)
.order("full_name")
.execute()
.data or []
)
return {"students": profiles}
@router.get("/{class_id}")
async def get_class(
@@ -459,35 +494,6 @@ async def remove_teacher(
return {"status": "ok"}
@router.get("/school/students")
async def list_school_students(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""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)
sb = _sb()
members = (
sb.supabase.table("institute_memberships")
.select("profile_id")
.eq("institute_id", institute_id)
.eq("role", "student")
.execute()
.data or []
)
student_ids = [m["profile_id"] for m in members]
if not student_ids:
return {"students": []}
profiles = (
sb.supabase.table("profiles")
.select("id, full_name, display_name, email, user_type")
.in_("id", student_ids)
.order("full_name")
.execute()
.data or []
)
return {"students": profiles}
@router.post("/{class_id}/students")
async def add_student(
@@ -60,6 +60,33 @@ def _resolve_institute(
return None, db, teacher_uuid
def _allowed_neo4j_dbs(user_id: str, user_email: str) -> set[str]:
"""Return Neo4j databases this user may request via lazy graph APIs."""
allowed = {f"cc.users.teacher.{user_id.replace('-', '')}"} if user_id else set()
if user_id or user_email:
_, institute_db, _ = _resolve_institute(user_id, user_email)
if institute_db:
allowed.add(institute_db)
allowed.add(f"{institute_db}.curriculum")
return allowed
def _require_allowed_neo4j_db(neo4j_db_name: str, node_type: str, section_id: str, user_id: str, user_email: str) -> None:
"""Reject arbitrary DB traversal from /graph/node/children query params."""
if not neo4j_db_name:
raise HTTPException(status_code=400, detail="neo4j_db_name is required")
if neo4j_db_name == "classroomcopilot":
if node_type.startswith("Calendar") or section_id == "calendar":
return
raise HTTPException(status_code=403, detail="Requested graph database is not allowed for this node")
if neo4j_db_name in _allowed_neo4j_dbs(user_id, user_email):
return
raise HTTPException(status_code=403, detail="Requested graph database is outside the authenticated user's scope")
def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]:
"""Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
if not user_email:
@@ -821,7 +848,11 @@ async def get_node_children(
section_id: str = "",
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
if not user_id:
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
_require_allowed_neo4j_db(neo4j_db_name, node_type, section_id, user_id, user_email)
children = _get_children_for_node(neo4j_node_id, neo4j_db_name, node_type, section_id, user_email)
return {"status": "success", "children": children}
+122 -62
View File
@@ -11,15 +11,117 @@ load_dotenv(find_dotenv())
import os
import json
import logging
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, Tuple
from modules.database.supabase.utils.storage import StorageAdmin
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
from modules.database.supabase.utils.storage import StorageError, StorageUser
from modules.logger_tool import initialise_logger
router = APIRouter()
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
ALLOWED_SNAPSHOT_BUCKETS = {"cc.public.snapshots"}
PERSONAL_NODE_TYPES = {"User", "Teacher", "Developer", "SuperAdmin", "UserTeacherTimetable"}
GLOBAL_READONLY_NODE_TYPES = {"CalendarYear", "CalendarMonth", "CalendarWeek", "CalendarDay", "CalendarTimeChunk"}
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _parse_snapshot_path(path: str) -> Tuple[str, str, str, str]:
"""Parse and validate bucket/node_type/node_id into a storage object path."""
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
path_parts = [part for part in path.split('/') if part]
if len(path_parts) != 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket, node_type, node_id = path_parts
if bucket not in ALLOWED_SNAPSHOT_BUCKETS:
raise HTTPException(status_code=403, detail="Snapshot bucket is not allowed")
if any(part in {".", ".."} or ".." in part for part in path_parts):
raise HTTPException(status_code=400, detail="Invalid path component")
if not node_type.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Invalid node type")
if not node_id.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Invalid node id")
return bucket, node_type, node_id, f"{node_type}/{node_id}/tldraw_file.json"
def _user_scope(user_id: str) -> Dict[str, Any]:
"""Resolve Supabase/Neo4j scope for the authenticated user."""
scope: Dict[str, Any] = {
"user_id": user_id,
"teacher_db": f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else "",
"institute_id": "",
"institute_db": "",
"curriculum_db": "",
}
if not user_id:
return scope
try:
sb = _sb()
prof = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = str((prof.data or {}).get("school_id") or "")
scope["institute_id"] = school_id
if school_id:
inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute()
neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string")
if neo4j_uuid:
scope["institute_db"] = f"cc.institutes.{neo4j_uuid}"
scope["curriculum_db"] = f"cc.institutes.{neo4j_uuid}.curriculum"
except Exception as exc:
logger.warning(f"Could not resolve TLDraw storage scope for user {user_id}: {exc}")
return scope
def _authorize_snapshot_path(path: str, db_name: str, credentials: Dict[str, Any], write: bool) -> Tuple[str, str, str, str]:
"""Authorize TLDraw snapshot access before touching Supabase Storage."""
user_id = credentials.get("sub", "")
if not user_id:
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
bucket, node_type, node_id, file_path = _parse_snapshot_path(path)
scope = _user_scope(user_id)
allowed_dbs = {db for db in (scope["teacher_db"], scope["institute_db"], scope["curriculum_db"]) if db}
if node_type in PERSONAL_NODE_TYPES:
if node_id == user_id or (db_name and db_name == scope["teacher_db"]):
return bucket, node_type, node_id, file_path
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's workspace")
if node_type in GLOBAL_READONLY_NODE_TYPES and db_name == "classroomcopilot":
if write:
raise HTTPException(status_code=403, detail="Global calendar snapshots are read-only")
return bucket, node_type, node_id, file_path
# Institute/curriculum snapshots must be accessed through the caller's institute DB.
if db_name and db_name in allowed_dbs:
return bucket, node_type, node_id, file_path
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's tenant")
def _storage_for_user(credentials: Dict[str, Any]) -> StorageUser:
access_token = credentials.get("_access_token")
if not access_token:
raise HTTPException(status_code=403, detail="User access token is required for storage access")
return StorageUser(user_id=credentials.get("sub"), access_token=access_token)
def _is_valid_tldraw_snapshot(snapshot_data: Any) -> bool:
if not isinstance(snapshot_data, dict):
return False
if not ("document" in snapshot_data and "session" in snapshot_data):
return False
document = snapshot_data.get("document")
if isinstance(document, dict) and "schema" in document:
return True
return "schemaVersion" in snapshot_data
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
@@ -101,7 +203,8 @@ def create_default_tldraw_content():
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file_from_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context")
db_name: str = Query(..., description="Database name for context"),
credentials: dict = Depends(SupabaseBearer()),
):
"""
Load TLDraw snapshot from Supabase Storage.
@@ -116,26 +219,9 @@ async def read_tldraw_node_file_from_supabase(
logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
# Expected format: "cc.public.snapshots/User/user_id" or "cc.public.snapshots/Teacher/teacher_id"
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
# Format: nodetype/node_id/tldraw_file.json
file_path = f"{node_type}/{node_id}/tldraw_file.json"
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=False)
storage = _storage_for_user(credentials)
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
@@ -147,29 +233,17 @@ async def read_tldraw_node_file_from_supabase(
# Parse JSON data
try:
snapshot_data = json.loads(file_data.decode('utf-8'))
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
except (UnicodeDecodeError, json.JSONDecodeError) as e:
logger.warning(f"Malformed TLDraw snapshot {file_path}; returning default content: {e}")
return create_default_tldraw_content()
# Ensure the snapshot has the correct structure for TLDraw
if isinstance(snapshot_data, dict) and 'document' in snapshot_data and 'session' in snapshot_data:
# Check if it has the new format (schemaVersion in document.schema)
if 'document' in snapshot_data and isinstance(snapshot_data['document'], dict) and 'schema' in snapshot_data['document']:
return snapshot_data
# Check if it has the old format (schemaVersion at root level)
elif 'schemaVersion' in snapshot_data:
return snapshot_data
else:
# Use default structure if schema is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing schemaVersion. Using default structure.")
return create_default_tldraw_content()
else:
# Use default structure if basic structure is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing top-level TLDraw keys. Using default structure.")
return create_default_tldraw_content()
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON from Supabase Storage file: {e}")
raise HTTPException(status_code=500, detail="Invalid JSON in file")
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
if _is_valid_tldraw_snapshot(snapshot_data):
return snapshot_data
logger.warning(f"Snapshot data from {file_path} is missing required TLDraw structure. Using default structure.")
return create_default_tldraw_content()
except Exception as e:
except StorageError as e:
# File doesn't exist, create default content
logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}")
@@ -199,7 +273,8 @@ async def read_tldraw_node_file_from_supabase(
async def set_tldraw_node_file_in_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context"),
data: Dict[str, Any] = None
data: Dict[str, Any] = None,
credentials: dict = Depends(SupabaseBearer()),
):
"""
Save TLDraw snapshot to Supabase Storage.
@@ -215,27 +290,12 @@ async def set_tldraw_node_file_in_supabase(
logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
if not data:
raise HTTPException(status_code=400, detail="Data not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
file_path = f"{node_type}/{node_id}/tldraw_file.json"
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=True)
storage = _storage_for_user(credentials)
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
+16
View File
@@ -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"]
+362
View File
@@ -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
+146
View File
@@ -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
+129
View File
@@ -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
+309
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
import os
import time
from typing import Any, Dict
from uuid import uuid4
import jwt
from fastapi import APIRouter, Depends, HTTPException
from modules.auth.supabase_bearer import verify_supabase_token_dep
router = APIRouter()
TLSYNC_TOKEN_AUDIENCE = "tlsync"
DEFAULT_TLSYNC_TOKEN_TTL_SECONDS = 300
def _tlsync_token_ttl_seconds() -> int:
raw_value = os.getenv("TLSYNC_TOKEN_TTL_SECONDS")
if not raw_value:
return DEFAULT_TLSYNC_TOKEN_TTL_SECONDS
try:
ttl = int(raw_value)
except ValueError as exc:
raise HTTPException(status_code=500, detail="TLSync token TTL is misconfigured") from exc
if ttl <= 0 or ttl > 3600:
raise HTTPException(status_code=500, detail="TLSync token TTL is out of range")
return ttl
def create_tlsync_token(user_claims: Dict[str, Any]) -> Dict[str, Any]:
secret = os.getenv("TLSYNC_SECRET")
if not secret:
raise HTTPException(status_code=503, detail="TLSync authentication is not configured")
user_id = user_claims.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing")
ttl_seconds = _tlsync_token_ttl_seconds()
issued_at = int(time.time())
expires_at = issued_at + ttl_seconds
payload = {
"sub": user_id,
"aud": TLSYNC_TOKEN_AUDIENCE,
"iat": issued_at,
"exp": expires_at,
"jti": uuid4().hex,
}
token = jwt.encode(payload, secret, algorithm="HS256")
return {
"token": token,
"token_type": "Bearer",
"expires_in": ttl_seconds,
"expires_at": expires_at,
}
@router.get("/token")
async def get_tlsync_token(user_claims: Dict[str, Any] = Depends(verify_supabase_token_dep)) -> Dict[str, Any]:
"""Issue a short-lived TLSync token for an authenticated Supabase user."""
return create_tlsync_token(user_claims)
-28
View File
@@ -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
@@ -11,7 +9,6 @@ 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()
@@ -20,7 +17,6 @@ def initialize_infrastructure_mode() -> None:
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()
@@ -33,26 +29,6 @@ def initialize_infrastructure_mode() -> None:
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...")
@@ -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'
]
-181
View File
@@ -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
-218
View File
@@ -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)
+128
View File
@@ -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))
+218
View File
@@ -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))
+384
View File
@@ -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))
+92 -31
View File
@@ -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))
+423
View File
@@ -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))
+26 -17
View File
@@ -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))
+385
View File
@@ -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))
+8 -401
View File
@@ -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))
+10
View File
@@ -39,6 +39,8 @@ from routers.transcribe.sessions import router as sessions_router
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...")
@@ -57,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
@@ -128,6 +132,12 @@ def register_routes(app: FastAPI):
# Provisioning Routes
app.include_router(provisioning_router.router)
# 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"])
+28 -56
View File
@@ -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
+9 -5
View File
@@ -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():
+301
View File
@@ -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
+334
View File
@@ -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
+4 -1
View File
@@ -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():
+107
View File
@@ -0,0 +1,107 @@
import pytest
from fastapi import HTTPException
def test_classes_school_students_route_registered_before_dynamic_class_id():
from routers.database.tools.classes_router import router
paths = [route.path for route in router.routes]
assert paths.index('/school/students') < paths.index('/{class_id}')
def test_supabase_anon_for_user_sets_user_authorization_header(monkeypatch):
from modules.database.supabase.utils import client as client_module
captured = {}
def fake_create_client(url, key, options=None):
captured['url'] = url
captured['key'] = key
captured['options'] = options
return object()
monkeypatch.setenv('SUPABASE_URL', 'http://supabase.test')
monkeypatch.setenv('ANON_KEY', 'anon-key')
monkeypatch.setattr(client_module, 'create_client', fake_create_client)
client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
assert captured['key'] == 'anon-key'
assert captured['options'].headers['apikey'] == 'anon-key'
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'
@pytest.mark.parametrize('token', ['', ' '])
def test_supabase_anon_for_user_requires_token(token):
from modules.database.supabase.utils.client import SupabaseAnonClient
with pytest.raises(ValueError):
SupabaseAnonClient.for_user(token)
def test_tldraw_malformed_snapshot_falls_back_to_default():
from routers.database.tools import tldraw_supabase_storage as storage
assert not storage._is_valid_tldraw_snapshot({'document': {}, 'session': {}})
default = storage.create_default_tldraw_content()
assert storage._is_valid_tldraw_snapshot(default)
assert default['document']['schema']['schemaVersion'] == 2
def test_tldraw_rejects_cross_tenant_snapshot_db(monkeypatch):
from routers.database.tools import tldraw_supabase_storage as storage
monkeypatch.setattr(storage, '_user_scope', lambda user_id: {
'user_id': user_id,
'teacher_db': f"cc.users.teacher.{user_id.replace('-', '')}",
'institute_id': 'school-1',
'institute_db': 'cc.institutes.allowed',
'curriculum_db': 'cc.institutes.allowed.curriculum',
})
with pytest.raises(HTTPException) as exc:
storage._authorize_snapshot_path(
'cc.public.snapshots/School/other-school',
'cc.institutes.other',
{'sub': 'user-1'},
write=False,
)
assert exc.value.status_code == 403
def test_graph_node_children_rejects_unscoped_db(monkeypatch):
from routers.database.tools import graph_tree_router
monkeypatch.setattr(graph_tree_router, '_allowed_neo4j_dbs', lambda user_id, email: {'cc.users.teacher.user1'})
with pytest.raises(HTTPException) as exc:
graph_tree_router._require_allowed_neo4j_db(
'cc.institutes.not-mine',
'SubjectClass',
'classes',
'user-1',
'[email protected]',
)
assert exc.value.status_code == 403
def test_graph_node_children_allows_global_calendar_only():
from routers.database.tools import graph_tree_router
graph_tree_router._require_allowed_neo4j_db(
'classroomcopilot',
'CalendarYear',
'',
'user-1',
'[email protected]',
)
with pytest.raises(HTTPException) as exc:
graph_tree_router._require_allowed_neo4j_db(
'classroomcopilot',
'School',
'school',
'user-1',
'[email protected]',
)
assert exc.value.status_code == 403
+69
View File
@@ -0,0 +1,69 @@
import jwt
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from modules.auth.supabase_bearer import verify_supabase_token_dep
from routers.tlsync_token import TLSYNC_TOKEN_AUDIENCE, create_tlsync_token, router
def test_create_tlsync_token_is_short_lived_and_signed(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "120")
response = create_tlsync_token({"sub": "user-123"})
assert response["token_type"] == "Bearer"
assert response["expires_in"] == 120
assert response["token"]
payload = jwt.decode(
response["token"],
"test-tlsync-secret-with-at-least-32-bytes",
algorithms=["HS256"],
audience=TLSYNC_TOKEN_AUDIENCE,
)
assert payload["sub"] == "user-123"
assert payload["exp"] == response["expires_at"]
assert payload["exp"] - payload["iat"] == 120
assert payload["jti"]
def test_create_tlsync_token_requires_tlsync_secret(monkeypatch):
monkeypatch.delenv("TLSYNC_SECRET", raising=False)
with pytest.raises(HTTPException) as excinfo:
create_tlsync_token({"sub": "user-123"})
assert excinfo.value.status_code == 503
def test_create_tlsync_token_requires_authenticated_subject(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
with pytest.raises(HTTPException) as excinfo:
create_tlsync_token({})
assert excinfo.value.status_code == 401
def test_tlsync_token_route_uses_authenticated_user_claims(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "60")
app = FastAPI()
app.include_router(router, prefix="/api/tlsync")
app.dependency_overrides[verify_supabase_token_dep] = lambda: {"sub": "route-user-123"}
response = TestClient(app).get("/api/tlsync/token", headers={"Authorization": "Bearer supabase-jwt"})
assert response.status_code == 200
body = response.json()
payload = jwt.decode(
body["token"],
"test-tlsync-secret-with-at-least-32-bytes",
algorithms=["HS256"],
audience=TLSYNC_TOKEN_AUDIENCE,
)
assert payload["sub"] == "route-user-123"
assert body["expires_in"] == 60