Compare commits

...
Author SHA1 Message Date
kcar 28aafaa60f feat(exam): add metadata patch for templates 2026-06-07 00:33:01 +01:00
CC WorkerandClaude Opus 4.8 115ecd2351 test(exam): mock service-role files read in source-pdf download test (S4-8.1)
api-ci-deploy / test-build-deploy (push) Has been cancelled
The download path now resolves the files row via SupabaseServiceRoleClient (to
sidestep the cabinet_memberships RLS recursion); the test must mock it like the
upload test does. Test-only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 22:56:45 +00:00
CC WorkerandClaude Opus 4.8 a37bcaa935 fix(exam): source-pdf download reads files row via service role (S4-8.1 merge-gate fix 2)
Pre-merge smoke caught a second issue: the source_file_id download path read `files`
as-the-user, tripping a PRE-EXISTING broken RLS policy on cabinet_memberships
(42P17 infinite recursion). Authz is already enforced (template fetch + source
visibility), and source_file_id is the template's own file, so resolve the row via
service role (documented exception, same as the catalogue lookup). Flagged the
cabinet_memberships RLS recursion separately as infra bug E8.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 22:54:24 +00:00
CC WorkerandClaude Opus 4.8 c0775f3be1 fix(exam): source-PDF upload uses shared cc.users bucket (S4-8.1 merge-gate fix)
Pre-merge live smoke on .94 caught 'Bucket not found': the upload wrote to a
per-institute bucket cc.institutes.<id>.private that isn't provisioned on dev.
Use the shared SOURCE_BUCKET_FALLBACK (cc.users); institute is namespaced in the
storage path + enforced by the files-row RLS. Per-institute buckets are a future
multi-tenant concern. Catalogue path + cross-institute 404 already verified green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 22:51:51 +00:00
CC WorkerandClaude Opus 4.8 c58df6715c feat(exam): template source PDF at create + GET /templates/{id}/source-pdf (S4-8.1)
Recovered from cc-worker WIP that was left uncommitted in the dev-centre clone
(card t_0055b89b). Multipart source_pdf upload at create -> source_file_id;
source-pdf download endpoint resolves from exam_id (catalogue) or source_file_id.
NOT yet human-reviewed/merged; preserving + verifying so it isn't clobbered.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 22:29:32 +00:00
CC WorkerandClaude Opus 4.8 9c1aee28e2 feat(exam): persist S4-9 region kinds + Part geometry; keep metadata out of graph
api-ci-deploy / test-build-deploy (push) Has been cancelled
Backend follow-on to migration 73:
- schemas: ResponseAreaPayload.kind extended to response|context|question_number|
  mark_area|reference|furniture + context_type; QuestionPayload gains bounds+page.
- PUT serialization persists Part bounds/page and region context_type.
- Neo4j projection only emits Region nodes for response/context regions; the
  metadata kinds (question_number/mark_area/reference/furniture) are physical-layer
  only and stay out of cc.public.exams.
- Unit test: new kinds + Part geometry + context_type round-trip.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 21:14:20 +00:00
CC WorkerandClaude Opus 4.8 93972a62f7 fix: revert explicit apikey header (caused Kong duplicate-apikey 401)
api-ci-deploy / test-build-deploy (push) Has been cancelled
The previous commit added apikey to _create_base_client headers, but supabase-py
already sets apikey from the key arg → two apikey headers → Kong rejected every
as-user call with 401 'Duplicate API key found' (exam API 502'd on auth). Revert
to Authorization-only; fix the two header unit tests to assert the real contract
(apikey via the key arg; options.headers carries only the user Authorization).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:30:36 +00:00
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
27 changed files with 4429 additions and 74 deletions
+24 -6
View File
@@ -1,4 +1,9 @@
services: 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: redis-dev:
image: redis:7-alpine image: redis:7-alpine
container_name: cc-redis-dev container_name: cc-redis-dev
@@ -15,12 +20,28 @@ services:
timeout: 3s timeout: 3s
retries: 5 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: backend-dev:
container_name: cc-api-dev container_name: cc-api-dev
image: cc-api-dev:latest image: cc-api-dev:latest
build:
context: .
dockerfile: Dockerfile
env_file: env_file:
- .env.dev - .env.dev
environment: environment:
@@ -45,9 +66,6 @@ services:
backend-test: backend-test:
image: cc-api-dev:latest image: cc-api-dev:latest
build:
context: .
dockerfile: Dockerfile
env_file: env_file:
- .env.dev - .env.dev
environment: environment:
+5
View File
@@ -1,4 +1,9 @@
services: 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: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: classroomcopilot-redis container_name: classroomcopilot-redis
@@ -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: try:
result = ( result = (
self.supabase.table("profiles") 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) .eq("id", user_id)
.single() .single()
.execute() .execute()
@@ -0,0 +1,168 @@
"""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.
# Only response/context regions are part of the knowledge graph (RegionNode.kind). The other
# S4-9 kinds (question_number, mark_area, reference, furniture) are physical-layer metadata
# about the paper, not curriculum structure — they stay in Supabase, out of cc.public.exams.
for rg in regions:
if rg.get("kind") not in ("response", "context"):
continue
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}")
+5 -2
View File
@@ -23,9 +23,12 @@ def _create_base_client(url: str, key: str, access_token: Optional[str] = None,
# If an access token is provided, use it for Authorization (enables per-user RLS) # If an access token is provided, use it for Authorization (enables per-user RLS)
# Otherwise fall back to the API key # Otherwise fall back to the API key
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}" auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
# Only override Authorization here. apikey is supplied to create_client via the `key` arg and
# set by supabase-py itself; setting it again here sends a DUPLICATE apikey header that the
# Supabase gateway (Kong) rejects with 401 "Duplicate API key found". For a per-user client
# apikey stays the anon key (from `key`) while this Authorization carries the user JWT.
headers = { headers = {
"apikey": key,
"Authorization": auth_header, "Authorization": auth_header,
} }
if options: if options:
+49 -49
View File
@@ -23,76 +23,76 @@ class StorageManager:
def check_bucket_exists(self, bucket_id: str) -> bool: def check_bucket_exists(self, bucket_id: str) -> bool:
"""Check if a storage bucket exists""" """Check if a storage bucket exists"""
try: 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() buckets = self.client.supabase.storage.list_buckets()
return any(bucket.name == bucket_id for bucket in buckets) return any(bucket.name == bucket_id for bucket in buckets)
except Exception as e: 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 return False
def list_bucket_contents(self, bucket_id: str, path: str = "") -> Dict: def list_bucket_contents(self, bucket_id: str, path: str = "") -> Dict:
"""List contents of a bucket at specified path""" """List contents of a bucket at specified path"""
try: 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) contents = self.client.supabase.storage.from_(bucket_id).list(path)
return {{ return {
"folders": [item for item in contents if item.get("id", "").endswith("/")], "folders": [item for item in contents if item.get("id", "").endswith("/")],
"files": [item for item in contents if not item.get("id", "").endswith("/")] "files": [item for item in contents if not item.get("id", "").endswith("/")]
}} }
except Exception as e: 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)) raise StorageError(str(e))
def upload_file(self, bucket_id: str, file_path: str, file_data: bytes, content_type: str, upsert: bool = True) -> Any: 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""" """Upload a file to a storage bucket"""
try: 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( return self.client.supabase.storage.from_(bucket_id).upload(
path=file_path, path=file_path,
file=file_data, file=file_data,
file_options={{ file_options={
"content-type": content_type, "content-type": content_type,
"x-upsert": "true" if upsert else "false" "x-upsert": "true" if upsert else "false"
}} }
) )
except Exception as e: 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)) raise StorageError(str(e))
def download_file(self, bucket_id: str, file_path: str) -> bytes: def download_file(self, bucket_id: str, file_path: str) -> bytes:
"""Download a file from a storage bucket""" """Download a file from a storage bucket"""
try: 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) return self.client.supabase.storage.from_(bucket_id).download(file_path)
except Exception as e: 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)) raise StorageError(str(e))
def delete_file(self, bucket_id: str, file_path: str) -> None: def delete_file(self, bucket_id: str, file_path: str) -> None:
"""Delete a file from a storage bucket""" """Delete a file from a storage bucket"""
try: 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]) self.client.supabase.storage.from_(bucket_id).remove([file_path])
except Exception as e: 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)) raise StorageError(str(e))
def get_public_url(self, bucket_id: str, file_path: str) -> str: def get_public_url(self, bucket_id: str, file_path: str) -> str:
"""Get public URL for a file""" """Get public URL for a file"""
try: 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) return self.client.supabase.storage.from_(bucket_id).get_public_url(file_path)
except Exception as e: 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)) raise StorageError(str(e))
def create_signed_url(self, bucket_id: str, file_path: str, expires_in: int = 3600) -> Any: def create_signed_url(self, bucket_id: str, file_path: str, expires_in: int = 3600) -> Any:
"""Create a signed URL for temporary file access""" """Create a signed URL for temporary file access"""
try: 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) return self.client.supabase.storage.from_(bucket_id).create_signed_url(file_path, expires_in)
except Exception as e: 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)) raise StorageError(str(e))
class StorageAdmin(StorageManager): class StorageAdmin(StorageManager):
@@ -115,9 +115,9 @@ class StorageAdmin(StorageManager):
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a new storage bucket with supported parameters.""" """Create a new storage bucket with supported parameters."""
try: 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: if public:
options["public"] = public options["public"] = public
if file_size_limit is not None: if file_size_limit is not None:
@@ -133,7 +133,7 @@ class StorageAdmin(StorageManager):
return bucket return bucket
except Exception as e: 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)) raise StorageError(str(e))
def initialize_core_buckets(self, admin_user_id: Optional[str] = None) -> List[Dict[str, Any]]: 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") raise ValueError("Admin user ID is required for bucket initialization")
core_buckets = [ core_buckets = [
{{ {
"id": "cc.users", "id": "cc.users",
"name": "CC Users", "name": "CC Users",
"public": False, "public": False,
@@ -156,8 +156,8 @@ class StorageAdmin(StorageManager):
'application/msword', 'application/vnd.openxmlformats-officedocument.*', 'application/msword', 'application/vnd.openxmlformats-officedocument.*',
'text/plain', 'text/csv', 'application/json' 'text/plain', 'text/csv', 'application/json'
] ]
}}, },
{{ {
"id": "cc.institutes", "id": "cc.institutes",
"name": "CC Institutes", "name": "CC Institutes",
"public": False, "public": False,
@@ -169,7 +169,7 @@ class StorageAdmin(StorageManager):
'application/msword', 'application/vnd.openxmlformats-officedocument.*', 'application/msword', 'application/vnd.openxmlformats-officedocument.*',
'text/plain', 'text/csv', 'application/json' 'text/plain', 'text/csv', 'application/json'
] ]
}} }
] ]
results = [] results = []
@@ -177,30 +177,30 @@ class StorageAdmin(StorageManager):
try: try:
bucket_name = bucket.pop("name") bucket_name = bucket.pop("name")
result = self.create_bucket(name=bucket_name, **bucket) result = self.create_bucket(name=bucket_name, **bucket)
results.append({{ results.append({
"bucket": bucket["id"], "bucket": bucket["id"],
"status": "success", "status": "success",
"result": result "result": result
}}) })
except Exception as e: except Exception as e:
self.logger.error(f"Error creating bucket {{bucket['id']}}: {{str(e)}}") self.logger.error(f"Error creating bucket {bucket['id']}: {str(e)}")
results.append({{ results.append({
"bucket": bucket["id"], "bucket": bucket["id"],
"status": "error", "status": "error",
"error": str(e) "error": str(e)
}}) })
return results return results
except Exception as e: 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)) raise StorageError(str(e))
def create_user_bucket(self, user_id: str, username: str) -> Dict[str, Any]: def create_user_bucket(self, user_id: str, username: str) -> Dict[str, Any]:
"""Create a storage bucket for a specific user.""" """Create a storage bucket for a specific user."""
try: try:
bucket_id = f"cc.users.admin.{{username}}" bucket_id = f"cc.users.admin.{username}"
bucket_name = f"User Files - {{username}}" bucket_name = f"User Files - {username}"
return self.create_bucket( return self.create_bucket(
id=bucket_id, id=bucket_id,
@@ -217,7 +217,7 @@ class StorageAdmin(StorageManager):
) )
except Exception as e: 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)) raise StorageError(str(e))
def create_school_buckets(self, school_id: str, school_name: str, admin_user_id: Optional[str] = None) -> Dict[str, Any]: 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") raise ValueError("Admin user ID is required for school bucket creation")
school_buckets = [ school_buckets = [
{{ {
"id": f"cc.institutes.{{school_id}}.public", "id": f"cc.institutes.{school_id}.public",
"name": f"{{school_name}} - Public Files", "name": f"{school_name} - Public Files",
"public": True, "public": True,
"owner": owner_id, "owner": owner_id,
"owner_id": school_id, "owner_id": school_id,
@@ -240,10 +240,10 @@ class StorageAdmin(StorageManager):
'application/msword', 'application/vnd.openxmlformats-officedocument.*', 'application/msword', 'application/vnd.openxmlformats-officedocument.*',
'text/plain', 'text/csv', 'application/json' 'text/plain', 'text/csv', 'application/json'
] ]
}}, },
{{ {
"id": f"cc.institutes.{{school_id}}.private", "id": f"cc.institutes.{school_id}.private",
"name": f"{{school_name}} - Private Files", "name": f"{school_name} - Private Files",
"public": False, "public": False,
"owner": owner_id, "owner": owner_id,
"owner_id": school_id, "owner_id": school_id,
@@ -253,29 +253,29 @@ class StorageAdmin(StorageManager):
'application/msword', 'application/vnd.openxmlformats-officedocument.*', 'application/msword', 'application/vnd.openxmlformats-officedocument.*',
'text/plain', 'text/csv', 'application/json' 'text/plain', 'text/csv', 'application/json'
] ]
}} }
] ]
results = {{}} results = {}
for bucket in school_buckets: for bucket in school_buckets:
try: try:
bucket_name = bucket.pop("name") bucket_name = bucket.pop("name")
result = self.create_bucket(name=bucket_name, **bucket) result = self.create_bucket(name=bucket_name, **bucket)
results[bucket["id"]] = {{ results[bucket["id"]] = {
"status": "success", "status": "success",
"result": result "result": result
}} }
except Exception as e: except Exception as e:
self.logger.error(f"Error creating school bucket {{bucket['id']}}: {{str(e)}}") self.logger.error(f"Error creating school bucket {bucket['id']}: {str(e)}")
results[bucket["id"]] = {{ results[bucket["id"]] = {
"status": "error", "status": "error",
"error": str(e) "error": str(e)
}} }
return results return results
except Exception as e: 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)) raise StorageError(str(e))
class StorageUser(StorageManager): 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 []}
+11 -6
View File
@@ -31,12 +31,9 @@ def _resolve_institute_id(user_id: str) -> Optional[str]:
return None return None
def _require_institute(user_id: str) -> str: def _require_institute(user_id: str) -> Optional[str]:
"""Return institute_id or raise 400.""" """Return institute_id, or None if the user has no school membership."""
institute_id = _resolve_institute_id(user_id) return _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 _is_school_admin(user_id: str, institute_id: str) -> bool: def _is_school_admin(user_id: str, institute_id: str) -> bool:
@@ -105,6 +102,8 @@ async def list_classes(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
user_id = credentials.get("sub", "") user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id) institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": [], "total": 0}
sb = _sb() sb = _sb()
q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id) 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]: ) -> Dict[str, Any]:
user_id = credentials.get("sub", "") user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id) institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": []}
sb = _sb() sb = _sb()
assigned = ( assigned = (
@@ -204,6 +205,8 @@ async def my_student_classes(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
user_id = credentials.get("sub", "") user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id) institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": []}
sb = _sb() sb = _sb()
enrolled = ( enrolled = (
@@ -237,6 +240,8 @@ async def list_school_students(
"""List all students in the caller's school. Used by admin to add students to a class.""" """List all students in the caller's school. Used by admin to add students to a class."""
user_id = credentials.get("sub", "") user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id) institute_id = _require_institute(user_id)
if not institute_id:
return {"students": []}
sb = _sb() sb = _sb()
members = ( members = (
sb.supabase.table("institute_memberships") sb.supabase.table("institute_memberships")
+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
+136
View File
@@ -0,0 +1,136 @@
"""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
# Drawn Part box geometry (73-exam-marker-regions.sql). Null for derived main questions.
bounds: Optional[Dict[str, Any]] = None # {x,y,w,h}
page: Optional[int] = None
class ResponseAreaPayload(BaseModel):
id: Optional[str] = None # == Neo4j Region.uuid_string (only response/context project)
question_id: str
page: int
bounds: Dict[str, Any] # {x,y,w,h}
# S4-9 taxonomy (73-exam-marker-regions.sql): response/context graded-or-stimulus;
# question_number/mark_area = physical metadata; reference = student resource; furniture = ignore.
kind: Literal["response", "context", "question_number", "mark_area", "reference", "furniture"]
response_form: Optional[
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
] = None
# Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage).
context_type: Optional[str] = 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
+566
View File
@@ -0,0 +1,566 @@
"""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
import uuid
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile
from fastapi.responses import Response
from modules.database.services.exam_projection import project_template, project_template_safe
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, lookup_exam_code
from routers.exam.schemas import (
CreateTemplateRequest,
PatchQuestionRequest,
TemplateReplaceRequest,
UpdateTemplateMetaRequest,
)
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
router = APIRouter()
SOURCE_CABINET_NAME = "Exam Marker Template Sources"
SOURCE_BUCKET_FALLBACK = "cc.users"
# ─── 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)."""
if template.get("teacher_id") != ctx.user_id:
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
def _require_source_visibility_or_404(ctx: ExamContext, template: Dict[str, Any]) -> None:
"""Template source reads must not leak existence across institutes or non-owners."""
if template.get("teacher_id") != ctx.user_id:
raise HTTPException(status_code=404, detail="Template not found")
if template.get("institute_id") not in ctx.institute_ids:
raise HTTPException(status_code=404, detail="Template not found")
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)
def _model_fields_set(model: Any) -> set[str]:
"""Return fields explicitly provided by the client (Pydantic v1/v2 compatible)."""
if hasattr(model, "model_fields_set"):
return set(model.model_fields_set)
return set(getattr(model, "__fields_set__", set()))
def _model_dump(model: Any) -> Dict[str, Any]:
"""Dump a Pydantic model without pinning this router to v1/v2 APIs."""
if hasattr(model, "model_dump"):
return model.model_dump()
return model.dict()
def _template_meta_updates(body: UpdateTemplateMetaRequest, *, include_explicit_nulls: bool = False) -> Dict[str, Any]:
data = _model_dump(body)
if include_explicit_nulls:
fields = _model_fields_set(body)
return {k: data[k] for k in fields if k in data}
return {k: v for k, v in data.items() if v is not None}
def _parse_storage_loc(storage_loc: str) -> Tuple[str, str]:
bucket, sep, path = (storage_loc or "").partition("/")
if not bucket or not sep or not path:
raise ValueError(f"Invalid storage_loc: {storage_loc!r}")
return bucket, path
def _lookup_exam_storage_loc(exam_id: str) -> Optional[str]:
try:
sb = SupabaseServiceRoleClient().supabase
res = sb.table("eb_exams").select("storage_loc").eq("id", exam_id).limit(1).execute()
row = _first(res)
return row.get("storage_loc") if row else None
except Exception as exc:
logger.warning(f"storage_loc lookup failed for exam_id={exam_id}: {exc}")
return None
async def _parse_create_template_request(request: Request) -> tuple[CreateTemplateRequest, Optional[UploadFile]]:
content_type = request.headers.get("content-type", "")
if "multipart/form-data" in content_type:
form = await request.form()
payload: Dict[str, Any] = {}
for key in ("title", "subject", "exam_id", "exam_code", "source_file_id", "page_count", "institute_id"):
value = form.get(key)
if value is not None and value != "":
payload[key] = value
upload = form.get("source_pdf")
if upload is not None and not hasattr(upload, "read"):
raise HTTPException(status_code=400, detail="source_pdf must be a file upload")
if upload is not None and payload.get("source_file_id"):
raise HTTPException(status_code=400, detail="Use either source_file_id or source_pdf, not both")
return CreateTemplateRequest(**payload), upload
try:
data = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid request body: {exc}")
return CreateTemplateRequest(**data), None
async def _upload_template_source_file(
ctx: ExamContext,
institute_id: str,
upload: UploadFile,
) -> str:
file_bytes = await upload.read()
if not file_bytes:
raise HTTPException(status_code=400, detail="Uploaded PDF is empty")
if upload.content_type and upload.content_type != "application/pdf":
raise HTTPException(status_code=400, detail="Uploaded file must be a PDF")
service = SupabaseServiceRoleClient()
storage = StorageAdmin()
cabinet_name = SOURCE_CABINET_NAME
existing = _first(
service.supabase.table("file_cabinets")
.select("id")
.eq("user_id", ctx.user_id)
.eq("name", cabinet_name)
.limit(1)
.execute()
)
if existing:
cabinet_id = existing["id"]
else:
created_cabinet = _first(
service.supabase.table("file_cabinets")
.insert({"user_id": ctx.user_id, "name": cabinet_name})
.execute()
)
if not created_cabinet:
raise HTTPException(status_code=500, detail="Failed to create upload cabinet")
cabinet_id = created_cabinet["id"]
file_id = str(uuid.uuid4())
safe_name = os.path.basename(upload.filename or "template.pdf")
# Use the shared users bucket (exists on all envs). Per-institute private buckets
# (cc.institutes.<id>.private) are a future multi-tenant provisioning concern and are NOT
# created on dev .94 — using one here failed with "Bucket not found". The institute is already
# namespaced in the storage path + enforced by RLS on the files row.
bucket = SOURCE_BUCKET_FALLBACK
storage_path = f"exam-marker/{institute_id or 'noinst'}/{cabinet_id}/{file_id}/{safe_name}"
try:
storage.upload_file(bucket, storage_path, file_bytes, "application/pdf", upsert=True)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Storage upload failed: {exc}")
inserted = _first(
service.supabase.table("files").insert(
{
"id": file_id,
"cabinet_id": cabinet_id,
"name": safe_name,
"path": storage_path,
"bucket": bucket,
"mime_type": "application/pdf",
"uploaded_by": ctx.user_id,
"size_bytes": len(file_bytes),
"source": "classroomcopilot-web",
"is_directory": False,
"relative_path": safe_name,
"processing_status": "uploaded",
}
).execute()
)
if not inserted:
raise HTTPException(status_code=500, detail="Failed to create file record")
return file_id
# ─── templates ───────────────────────────────────────────────────────────────
@router.post("/templates")
async def create_template(
request: Request,
ctx: ExamContext = Depends(get_exam_context),
) -> Dict[str, Any]:
body, upload = await _parse_create_template_request(request)
institute_id = ctx.resolve_institute(body.institute_id)
if body.exam_id and body.source_file_id:
raise HTTPException(status_code=400, detail="Use either exam_id or source_file_id, not both")
exam_code = body.exam_code
if body.exam_id and not exam_code:
exam_code = lookup_exam_code(body.exam_id)
source_file_id = body.source_file_id
if upload is not None:
source_file_id = await _upload_template_source_file(ctx, institute_id, upload)
row = {
"title": body.title,
"subject": body.subject,
"exam_id": body.exam_id,
"exam_code": exam_code,
"source_file_id": 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("/catalogue")
async def list_catalogue_papers() -> Dict[str, Any]:
"""Lightweight exam-board paper catalogue for the create dialog."""
try:
sb = SupabaseServiceRoleClient().supabase
res = (
sb.table("eb_exams")
.select("id, exam_code, spec_code, paper_code, tier, session, type_code, storage_loc")
.eq("type_code", "QP")
.order("exam_code")
.execute()
)
return {"papers": _rows(res)}
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Could not load catalogue papers: {exc}")
@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.get("/templates/{template_id}/source-pdf")
async def get_template_source_pdf(
template_id: str,
ctx: ExamContext = Depends(get_exam_context),
) -> Response:
template = _fetch_template_or_404(ctx, template_id)
_require_source_visibility_or_404(ctx, template)
bucket: Optional[str] = None
path: Optional[str] = None
if template.get("exam_id"):
storage_loc = _lookup_exam_storage_loc(template["exam_id"])
if not storage_loc:
raise HTTPException(status_code=404, detail="Template source not found")
try:
bucket, path = _parse_storage_loc(storage_loc)
except ValueError:
raise HTTPException(status_code=404, detail="Template source not found")
elif template.get("source_file_id"):
# Resolve the file row via service role (authz already done above: the caller proved they
# can see this template, and source_file_id is the template's own file). Reading `files`
# as-the-user trips a pre-existing broken RLS policy on cabinet_memberships
# (42P17 infinite recursion) — documented service-role exception, like the catalogue lookup.
file_row = _first(
SupabaseServiceRoleClient().supabase.table("files")
.select("bucket, path, mime_type, name")
.eq("id", template["source_file_id"])
.limit(1)
.execute()
)
if not file_row or not file_row.get("bucket") or not file_row.get("path"):
raise HTTPException(status_code=404, detail="Template source not found")
bucket = file_row["bucket"]
path = file_row["path"]
else:
raise HTTPException(status_code=404, detail="Template source not found")
if not bucket or not path:
raise HTTPException(status_code=404, detail="Template source not found")
try:
pdf_bytes = StorageAdmin().download_file(bucket, path)
except Exception as exc:
logger.warning(f"Template source download failed for template {template_id}: {exc}")
raise HTTPException(status_code=404, detail="Template source not found")
return Response(content=pdf_bytes, media_type="application/pdf")
@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 = _template_meta_updates(body.meta)
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,
"bounds": q.bounds, # drawn Part box (73); null for derived main questions
"page": q.page,
}
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,
"context_type": ra.context_type, # 73: optional Context differentiation
"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.patch("/templates/{template_id}")
async def patch_template_meta(
template_id: str,
body: UpdateTemplateMetaRequest,
ctx: ExamContext = Depends(get_exam_context),
) -> Dict[str, Any]:
"""Metadata-only template update.
Unlike PUT /templates/{id}, this never deletes/re-inserts questions, response areas or
boundaries, so it is safe after marking has started. RLS scopes the initial read and
owner-only writes are enforced explicitly like the structural save path.
"""
updates = _template_meta_updates(body, include_explicit_nulls=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
template = _fetch_template_or_404(ctx, template_id)
_require_owner(ctx, template)
res = ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
updated = _first(res)
if not updated:
raise HTTPException(status_code=404, detail="Template not found")
return updated
@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 _model_dump(body).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
+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))
+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))
@@ -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))
+6
View File
@@ -40,6 +40,7 @@ from routers.transcribe.canvas_events import router as canvas_events_router
from routers.transcribe.keywords import router as keywords_router from routers.transcribe.keywords import router as keywords_router
from routers.me.bootstrap_router import router as me_bootstrap_router from routers.me.bootstrap_router import router as me_bootstrap_router
from routers import tlsync_token as tlsync_token_router from routers import tlsync_token as tlsync_token_router
from routers.exam import router as exam_router
def register_routes(app: FastAPI): def register_routes(app: FastAPI):
logger.info("Starting to register routes...") logger.info("Starting to register routes...")
@@ -58,7 +59,9 @@ def register_routes(app: FastAPI):
app.include_router(entity_init.router, prefix="/database/entity", tags=["Entity"]) app.include_router(entity_init.router, prefix="/database/entity", tags=["Entity"])
app.include_router(calendar.router, prefix="/database/calendar", tags=["Calendar"]) app.include_router(calendar.router, prefix="/database/calendar", tags=["Calendar"])
app.include_router(schools.router, prefix="/database/schools", tags=["Schools"]) 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(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"]) app.include_router(curriculum.router, prefix="/database/curriculum", tags=["Curriculum"])
# Navigation Routes # Navigation Routes
@@ -132,6 +135,9 @@ def register_routes(app: FastAPI):
# TLSync auth token route # TLSync auth token route
app.include_router(tlsync_token_router.router, prefix="/api/tlsync", tags=["TLSync"]) 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) # Transcription Routes (CIS Phase 1)
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"]) app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"]) app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
+9 -5
View File
@@ -55,15 +55,19 @@ def test_dev_api_health_endpoint_is_healthy():
assert payload['services']['redis']['database'] == 0 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(): def test_supabase_dev_seed_core_counts():
assert _rest_count('profiles') == 21 assert _rest_count('profiles') >= 21
assert _rest_count('institute_memberships') == 21 assert _rest_count('institute_memberships') >= 21
assert _rest_count('institutes') == 2 assert _rest_count('institutes') >= 2
def test_supabase_dev_seed_timetable_counts(): def test_supabase_dev_seed_timetable_counts():
assert _rest_count('classes') == 17 assert _rest_count('classes') >= 17
assert _rest_count('taught_lessons') == 1462 assert _rest_count('taught_lessons') >= 1462
def test_runtime_identity_does_not_expose_secret_values(): 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
+445
View File
@@ -0,0 +1,445 @@
"""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)
class _FakeStorageAdmin:
def upload_file(self, *args, **kwargs):
return None
def download_file(self, bucket_id, file_path):
return b"%PDF-1.7 fake"
class _FakeServiceRoleClient:
def __init__(self, store):
self.supabase = FakeSupabase(store)
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_accepts_uploaded_source_pdf(monkeypatch):
store = {}
client, store = make_client(store=store)
monkeypatch.setattr(templates_mod, "StorageAdmin", _FakeStorageAdmin)
monkeypatch.setattr(templates_mod, "SupabaseServiceRoleClient", lambda: _FakeServiceRoleClient(store))
resp = client.post(
"/api/exam/templates",
data={"title": "AQA Physics 1H", "subject": "Physics"},
files={"source_pdf": ("paper.pdf", b"%PDF-1.7 test", "application/pdf")},
)
assert resp.status_code == 200
row = resp.json()
assert row["source_file_id"] is not None
assert store["files"][0]["id"] == row["source_file_id"]
assert store["files"][0]["uploaded_by"] == TEACHER
def test_get_template_source_pdf_from_uploaded_file(monkeypatch):
store = {
"exam_templates": [{
"id": "t1",
"title": "p",
"status": "draft",
"institute_id": INST_A,
"teacher_id": TEACHER,
"source_file_id": "f1",
}],
"files": [{"id": "f1", "bucket": "cc.users", "path": "exam-marker/cab1/f1/paper.pdf", "name": "paper.pdf"}],
}
client, _ = make_client(store=store)
monkeypatch.setattr(templates_mod, "StorageAdmin", _FakeStorageAdmin)
# The download resolves the files row via service role (sidesteps the broken cabinet_memberships
# RLS recursion) — mock it to the same fake store, like the upload test does.
monkeypatch.setattr(templates_mod, "SupabaseServiceRoleClient", lambda: _FakeServiceRoleClient(store))
resp = client.get("/api/exam/templates/t1/source-pdf")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/pdf")
assert resp.content.startswith(b"%PDF-1.7")
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_persists_region_kinds_and_part_geometry():
# S4-9 taxonomy: Part box geometry on the question; new region kinds + context_type.
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
client, store = make_client(store=store)
resp = client.put("/api/exam/templates/t1", json={
"questions": [
{"id": "q1", "label": "01", "order": 0, "is_container": True},
{"id": "p1", "parent_id": "q1", "label": "01.1", "order": 0, "max_marks": 3,
"bounds": {"x": 1, "y": 2, "w": 3, "h": 4}, "page": 1},
],
"response_areas": [
{"id": "r1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "response", "response_form": "lines"},
{"id": "c1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "context", "context_type": "data_table"},
{"id": "qn1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "question_number"},
{"id": "m1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "mark_area"},
{"id": "f1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "furniture"},
],
})
assert resp.status_code == 200
part = next(q for q in store["exam_questions"] if q["id"] == "p1")
assert part["bounds"] == {"x": 1, "y": 2, "w": 3, "h": 4} and part["page"] == 1
ras = {r["id"]: r for r in store["exam_response_areas"]}
assert {ras["r1"]["kind"], ras["c1"]["kind"], ras["qn1"]["kind"], ras["m1"]["kind"], ras["f1"]["kind"]} == \
{"response", "context", "question_number", "mark_area", "furniture"}
assert ras["c1"]["context_type"] == "data_table"
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_patch_template_meta_does_not_replace_children_when_marks_recorded():
store = {
"exam_templates": [{"id": "t1", "title": "old", "subject": "Physics", "page_count": 12, "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
"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}],
}
client, store = make_client(store=store)
resp = client.patch("/api/exam/templates/t1", json={"title": "renamed", "subject": None, "page_count": 13})
assert resp.status_code == 200
body = resp.json()
assert body["title"] == "renamed"
assert body["subject"] is None
assert body["page_count"] == 13
assert {q["id"] for q in store["exam_questions"]} == {"q1"}
assert {m["id"] for m in store["mark_entries"]} == {"m1"}
def test_patch_template_meta_denied_for_non_owner():
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.patch("/api/exam/templates/t1", json={"title": "nope"}).status_code == 403
def test_patch_template_meta_empty_body_is_400():
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
client, _ = make_client(store=store)
assert client.patch("/api/exam/templates/t1", json={}).status_code == 400
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 -4
View File
@@ -122,11 +122,11 @@ def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
assert anon.access_token == "user-token" assert anon.access_token == "user-token"
assert captured["url"] == "http://supabase.test" assert captured["url"] == "http://supabase.test"
# apikey is supplied via the `key` positional arg (supabase-py sets the apikey header from it).
# options.headers must carry ONLY the per-user Authorization override — adding apikey here too
# produces a duplicate apikey header that Kong rejects ("Duplicate API key found").
assert captured["key"] == "anon-key" assert captured["key"] == "anon-key"
assert captured["options_kwargs"]["headers"] == { assert captured["options_kwargs"]["headers"] == {"Authorization": "Bearer user-token"}
"apikey": "anon-key",
"Authorization": "Bearer user-token",
}
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas(): def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
+3 -1
View File
@@ -26,8 +26,10 @@ def test_supabase_anon_for_user_sets_user_authorization_header(monkeypatch):
client_module.SupabaseAnonClient.for_user('Bearer user-jwt') client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
# apikey comes from the `key` arg (supabase-py sets the apikey header); options.headers must
# carry only the user Authorization override. A second apikey here → Kong "Duplicate API key".
assert captured['key'] == 'anon-key' assert captured['key'] == 'anon-key'
assert captured['options'].headers['apikey'] == 'anon-key' assert 'apikey' not in captured['options'].headers
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt' assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'