Compare commits

..
Author SHA1 Message Date
CC WorkerandClaude Sonnet 4.6 9cc986a3f1 fix(exam): allow any institute teacher to fetch template source PDF
api-ci-deploy / test-build-deploy (push) Has been cancelled
Removed the teacher_id ownership check from _require_source_visibility_or_404.
RLS already ensures a teacher can only see templates in their institute;
the ownership gate was blocking shared templates (e.g. board-uploaded AQA papers)
for any teacher who didn't personally create them.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 09:55:03 +00:00
CC Worker 6daa905ecd Merge remote-tracking branch 'origin/agent/s4-8-2-template-versioning-api'
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:12:05 +00:00
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
kcar e66c8ec291 t4: consolidate seed scripts, remove demo modes, standardize passwords
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-29 19:51:32 +01:00
kcar abc90fa1b6 test: align Supabase user client header expectation
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 19:19:05 +01:00
kcar 39ad1818ae Merge branch agent/p0-correctness-security
api-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	modules/database/supabase/utils/client.py
2026-05-28 19:17:22 +01:00
kcar 1738af0e3d Merge branch agent/tlsync-token-t_a69128a1 (TLSync JWT token endpoint)
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 18:04:25 +01:00
kcar 7808a0ae56 Add TLSync token endpoint 2026-05-28 17:55:37 +01:00
kcar 47409c499e Merge branch agent/me-bootstrap
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 17:52:27 +01:00
kcar 88a3193e01 Keep platform bootstrap permissions additive 2026-05-28 15:10:54 +01:00
kcar 4f6634e088 Implement Supabase-first me bootstrap 2026-05-28 14:14:35 +01:00
kcar 54760083b5 fix: tighten API P0 auth and route handling 2026-05-28 12:42:42 +01:00
kcar 550d405935 Merge branch agent/phase-a-dev-runtime
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 12:37:09 +01:00
kcar df40ddc286 fix: keep health errors non-sensitive 2026-05-28 11:43:59 +01:00
kcar 310e273aa5 feat: expose API runtime identity in health 2026-05-28 11:32:04 +01:00
kcar 7fede4d082 fix: run API dev stack in dev mode 2026-05-28 10:15:33 +01:00
kcar b452c9f593 test: add dev stack integration checks
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-27 23:24:28 +01:00
kcar 647f41e421 chore: consolidate api dev compose 2026-05-27 22:55:01 +01:00
kcar 9b49e92722 chore: add .gitignore for Python API project 2026-05-27 21:56:21 +01:00
kcar 3beb8069d3 docs: add .env.example with all API environment variables 2026-05-27 21:55:26 +01:00
kcar d5bda761d6 fix: enable per-user RLS via SupabaseAnonClient.for_user() and StorageUser(access_token=) 2026-05-27 21:51:58 +01:00
kcar ef75f08392 fix(redis): connect during health checks 2026-05-27 16:50:25 +01:00
kcarandClaude Sonnet 4.6 0d828315bb fix(graph-tree): class_code column, SubjectClass expansion handler
- _query_teacher_classes: fix code -> class_code (Supabase column name), add section_id param
- _build_timetable_section: tag class nodes with section_id=timetable
- _build_classes_section: tag class nodes with section_id=classes
- SubjectClass handler: section=timetable -> taught_lessons for class; section=classes -> enrolled students

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 13:30:53 +01:00
kcarandClaude Sonnet 4.6 b71995f4fb fix(graph-tree): switch class/timetable data source to Supabase
- _query_teacher_classes: now queries class_teachers + class_students tables instead of non-existent Neo4j TEACHER_HAS_CLASS relationship
- _build_classes_section: updated signature to (user_id, institute_id, institute_db)
- _build_timetable_section: updated signature; loads classes from Supabase, not Neo4j TIMETABLE_HAS_CLASS
- TeacherTimetable lazy handler: simplified (classes pre-loaded in section builder)
- AcademicWeek timetable-term: Supabase taught_lessons query by date range instead of Neo4j
- expose supabase_institute_id from _resolve_institute call

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 12:58:47 +01:00
kcarandClaude Sonnet 4.6 bf3df05632 fix(nav): fix AcademicWeek Cypher bug, add TeacherTimetable tree handlers, timetable-term view
- Fix uuid_string: $id Cypher bug in AcademicWeek handler (days were never loading)
- Pre-load SubjectClass children in _build_timetable_section (By Class view)
- Add TeacherTimetable handler: By Class (TIMETABLE_HAS_CLASS) + By Term (ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR chain)
- Add timetable-term context propagation through AcademicTerm -> AcademicWeek -> TaughtLesson
- AcademicWeek in timetable-term context returns TaughtLessons filtered by teacher email
- Pass user_email from credentials to _get_children_for_node
- Propagate section_id on AcademicWeek nodes so week expansion stays in timetable context

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 12:12:46 +01:00
kcarandClaude Sonnet 4.6 b42b409bb2 fix(nav): AcademicWeek children + days support in graph tree API
- AcademicWeek now returns has_children=true in both /calendar/academic
  endpoint and _get_children_for_node for AcademicTerm case
- Added AcademicWeek case to _get_children_for_node: queries
  ACADEMIC_WEEK_HAS_ACADEMIC_DAY relationships to return AcademicDay children
- Academic calendar can now expand weeks to show individual days

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 10:54:23 +01:00
kcarandClaude Sonnet 4.6 caeee6c9e4 fix: correct profiles.user_type constraint and admin_profiles column names in reset/seed
- reset_environment: profiles PATCH now sets only school_id=null (removing invalid
  user_type='platform_admin' that violated profiles_user_type_check constraint)
- seed_environment: same profiles PATCH fix; admin_profiles upsert now uses correct
  column names (admin_role, is_super_admin, display_name) matching 002_schema.sql
- Platform admin status is correctly tracked via admin_profiles.is_super_admin=true
  and JWT user_metadata.user_type='platform_admin', not profiles.user_type

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 06:27:23 +01:00
kcarandClaude Sonnet 4.6 0596ee5e2c feat(seed): Greenfield full timetable seed with classes and student enrollment
seed_greenfield_timetable.py creates the complete school data set:
- POST /timetable/setup: school_timetables, academic_years, terms, weeks, days
- POST /timetable/materialize-periods: 1624 academic_periods (203 days x 8 periods)
- 17 classes (Physics/Maths/English/History/Science, Yr7-12) with correct metadata
- class_teachers links (primary teacher per class)
- teacher timetable init + slot assignments (class_id FK patched onto slots)
- class_students enrollment: student1->Yr9 (5 classes), student2->Yr10 (4), student3->Yr11 (2)
- POST /timetable/materialize: 1462 taught_lessons all with class_id populated
- POST /admin/seed-timetable endpoint wired in platform_admin_router

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 05:59:06 +01:00
kcarandClaude Sonnet 4.6 9c32887407 fix: use PUT (not PATCH) for Supabase admin user_metadata update
Supabase Auth admin API requires PUT /auth/v1/admin/users/{id} to update
a user record — PATCH returns 405. Corrects the seed step that sets
kcar's user_type to 'platform_admin'.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 04:23:59 +01:00
kcarandClaude Sonnet 4.6 035ea17844 fix: prevent platform admin from being auto-enrolled in default school
Two root causes fixed:

1. seed_environment.py: KevlarAI website was 'https://kevlarai.com' (real
   domain) instead of 'https://kevlarai.test'. Also, seed step 8 now patches
   kcar's auth user_metadata to set user_type='platform_admin' on every
   reset+seed, so the fix is self-healing and doesn't require manual DB edits.

2. provisioning_service.py: user_type_map now maps 'platform_admin' to
   ('superadmin', 'superadmin'), so _ensure_membership() is never called for
   platform admin accounts and they are never silently enrolled in the
   default institute.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 04:16:22 +01:00
kcarandClaude Sonnet 4.6 52532ce00f feat(phase-c): lesson plans library backend — CRUD, delivery linking, AI suggest
Adds lesson_plans_router.py with 10 endpoints under /lessons/plans:
GET/POST /plans, GET/PATCH/DELETE /plans/{id}, POST /plans/{id}/deliver,
GET /plans/{id}/deliveries, POST/DELETE /plans/{id}/collaborators,
POST /plans/{id}/suggest (Ollama-backed per-field AI suggestions).

objectives and activities stored as JSONB arrays with Bloom taxonomy support.
Registers router in run/routers.py. Adds seed_test_environment.py for
platform-admin triggered reset + seed of demo users and Neo4j.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 03:59:26 +01:00
kcarandClaude Sonnet 4.6 abf8d05ca1 feat(phase-b): Supabase-first timetable, classes, enrollment, and student views
- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
  week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
  rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
  _sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
  GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 02:55:44 +01:00
kcar 7c75481245 feat(phase-b): rewrite demo_users with initialize_demo_users() for clean restart
Wraps all logic in initialize_demo_users() matching __init__.py import.
Idempotent: deletes stale .edu users, creates 3 @kevlarai.com demo accounts,
upserts Supabase profiles + institute_memberships, syncs Teacher nodes in Neo4j.
2026-05-26 02:19:44 +01:00
kcarandClaude Sonnet 4.6 e42cd09dea feat(phase-b): GAIS Supabase loader + school search/register endpoints
- gais_data.py: rewrite to load Edubase CSV into Supabase gais_schools +
  gais_local_authorities via two-pass batch upsert (LAs first for FK integrity)
- school_router.py: add GET /school/search (trigram ilike on name, URN exact),
  POST /school/register (create institute + Neo4j provision + membership link)
- Encoding: handles Windows-1252 (cp1252) Edubase CSV format

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:51:45 +01:00
kcarandClaude Sonnet 4.6 fe3d7a12c8 feat(phase-b): school/timetable API routers + graph nav tree
New routers (all previously untracked):
- graph_tree_router: /graph/tree, /graph/node/children, /graph/calendar/academic
  Supabase-driven tree builder; institute DB resolved by teacher email scan
- school_router: /school/status (role + calendar flags), /school/info PATCH
  Self-heals profiles.school_id from institute_memberships if null
- timetable_builder_router: /timetable/setup (AcademicYear/Term/Week + SchoolTimetable),
  /timetable/slots (read/write TimetableSlot nodes), /timetable/init (TeacherTimetable)
- user_init_router: /user/init (provision user node in institute DB)

routers.py: register all new routers with correct prefixes
users.py: add JournalNode and PlannerNode schema classes

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:24:44 +01:00
kcarandClaude Sonnet 4.6 84f7fa9de1 fix: cache Neo4j driver failure state to avoid 60s retry on every request
get_global_driver() now sets _driver_unavailable=True when the initial
connection fails, so subsequent calls fail immediately instead of
spending 60s retrying each time. Added reset_global_driver() to allow
manual reconnection after Neo4j comes back up.

Also fixes APP_BOLT_URL in .env: was bolt://bolt.classroomcopilot.ai
(public IP, port not exposed), now bolt://192.168.0.209:7687 (LAN).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:26:21 +00:00
kcar 7ca21ef538 feat: implement export endpoint for transcription sessions (Phase 3E)
- Add POST /transcribe/sessions/{id}/export endpoint
- Generate SRT (SubRip subtitle format) with timestamps
- Generate TXT (plain text with [HH:MM:SS,mmm] timestamps)
- Generate JSON (structured data: session, segments, summaries, canvas events)
- Return as FileResponse download with Content-Disposition headers
- Filenames include sanitized session title + date
- No API keys stored or logged during export
2026-05-20 22:25:36 +00:00
kcar 36ae76143f Phase 3B: Implement pluggable LLM client for summary generation
- Create llm_client.py with 5 provider implementations (Anthropic, OpenAI, Ollama, OpenRouter, Google)
- Add build_prompt() helper to construct system/user prompts from templates
- Wire up POST /transcribe/sessions/{id}/summaries endpoint to call LLM client
- Return generated content + token counts (input_tokens, output_tokens)
- API keys passed per-request, never stored or logged
- Uses prompt templates from prompts.py based on summary_type
2026-05-20 22:20:19 +00:00
kcar fd8d2a537d feat(cis): implement /database/timetables/current-period endpoint with Neo4j query
- Query Neo4j for Academic/Registration periods where now() is between start_time and end_time
- Return period_id, event_type, event_label, start_time, end_time
- Handles missing teacher or Neo4j connection gracefully
2026-05-20 22:06:46 +00:00
kcar b47c7c252d feat(transcription): add Supabase schema and API endpoints for CIS 2026-05-20 21:03:00 +00:00
Classroom Copilot Dev cf9bdca813 fix: Add filters parameter to BaseCRUD.get_multi() method
- Fixed signature mismatch where enrollment_requests router was passing
  filters parameter to get_multi() but method didn't accept it
- get_multi() now accepts optional filters dict and passes it to get_all()
2026-02-25 22:53:52 +00:00
Classroom Copilot Dev 24878ea3ca Merge remote master into local after gitignore updates 2026-02-23 21:15:30 +00:00
Classroom Copilot Dev 305124705f chore: add .env and *.bak to .gitignore, remove archive/ folder 2026-02-23 21:14:43 +00:00
kcar cf5077b009 Delete .env 2026-02-23 20:59:49 +00:00
kcar 6f110bd113 Cleanup 2026-02-23 20:58:35 +00:00
Classroom Copilot Dev 3b50ef1c59 chore: cleanup environment files
- Updated .env configuration
- Removed .env.local (274 lines)
- Removed .env.prod (260 lines)
- Cleanup of environment-specific configs
2026-02-23 17:48:35 +00:00
Classroom Copilot Dev 2d2c88706e fix: update Supabase env vars with new keys and local URL
- Updated ANON_KEY and SERVICE_ROLE_KEY from supabase container
- Changed SUPABASE_URL to local dev instance (192.168.0.155:8000)
- Synced .env.local with .env for consistency
2026-02-23 03:38:41 +00:00
kcar ccacad75fe chore: update env config with production Supabase credentials 2026-02-22 00:30:55 +00:00
kcar 2b7d52b5ae chore: update env config, docker-compose, requirements and bucket init 2026-02-21 16:29:26 +00:00
kcar 243cf7b657 env changes 2025-11-20 11:01:47 +00:00
kcar 14030170e7 timing 2025-11-19 20:13:35 +00:00
kcar 68d8f91199 setup setup 2025-11-19 20:02:34 +00:00
kcar 787fd86260 module updates 2025-11-19 19:38:09 +00:00
kcar 92dc4ff1ef docker redis checks 2025-11-19 19:34:13 +00:00
kcar 633e20e553 Made local and prod .env files 2025-11-19 18:18:33 +00:00
kcar 2b223bd2e9 update 2025-11-19 18:08:54 +00:00
kcar 3758c7572a latest 2025-11-14 14:47:19 +00:00
67 changed files with 13140 additions and 6313 deletions
+183
View File
@@ -0,0 +1,183 @@
# Classroom Copilot API - Environment Variables Template
# Copy this file to .env and fill in the values
# =============================================================================
# Server Configuration
# =============================================================================
PORT_OLLAMA=11434
UVICORN_PORT=8000
UVICORN_WORKERS=4
UVICORN_TIMEOUT=120
HOST_OLLAMA=http://localhost:11434
OLLAMA_MODEL=llama3.2
# =============================================================================
# Supabase Configuration
# =============================================================================
SUPABASE_URL=https://your-project.supabase.co
ANON_KEY=your-supabase-anon-key
SERVICE_ROLE_KEY=your-supabase-service-role-key
# =============================================================================
# Authentication
# =============================================================================
JWT_SECRET=your-jwt-secret-min-32-chars
# =============================================================================
# Admin Configuration (for initial setup)
# =============================================================================
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=change-me-immediately
ADMIN_USERNAME=admin
ADMIN_NAME=Admin User
ADMIN_DISPLAY_NAME=Administrator
ADMIN_WORKER_EMAIL=[email protected]
# =============================================================================
# Redis Configuration
# =============================================================================
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_SSL=false
REDIS_RECOVERY_ENABLED=true
REDIS_MAX_RETRY_ATTEMPTS=3
REDIS_HEALTH_CHECK_INTERVAL=30
# Dev/Prod/Test DB isolation
REDIS_DB_DEV=0
REDIS_DB_PROD=1
REDIS_DB_TEST=2
# Task TTL (seconds)
REDIS_TASK_TTL_DEV=3600
REDIS_TASK_TTL_PROD=7200
# Persistence
REDIS_PERSIST_DEV=false
REDIS_PERSIST_PROD=true
# =============================================================================
# OpenAI / LLM Configuration
# =============================================================================
OPENAI_API_KEY=your-openai-api-key
OPENAI_BASE_URL=https://api.openai.com/v1
# =============================================================================
# LangChain Configuration (optional)
# =============================================================================
LANGCHAIN_API_KEY=
LANGCHAIN_PROJECT=classroom-copilot
# =============================================================================
# Document Processing (Docling)
# =============================================================================
DOCLING_URL=http://localhost:8000
DOCLING_TIMEOUT=300
DOCLING_PDF_BACKEND=marker
DOCLING_INCLUDE_IMAGES=false
DOCLING_IMAGES_SCALE=1.0
DOCLING_VLM_MODEL=
DOCLING_VLM_TIMEOUT=300
DOCLING_NO_OCR_BY_PAGE=false
DOCLING_OCR_BY_PAGE=false
DOCLING_VLM_BY_PAGE=false
DOCLING_SPLIT_THRESHOLD=0.95
DOCLING_USE_SPLIT_MAP=false
DOCLING_PICTURE_DESCRIPTION_AREA_THRESHOLD=0.1
# Auto-processing flags
AUTO_DOCLING_OCR=false
AUTO_DOCLING_NO_OCR=false
AUTO_DOCLING_VLM=false
AUTO_DOCUMENT_ANALYSIS=false
AUTO_PAGE_IMAGES=false
AUTO_SPLIT_MAP_GENERATION=false
AUTO_TIKA_PROCESSING=false
# =============================================================================
# Document Processing (Tika)
# =============================================================================
TIKA_URL=http://localhost:9998
TIKA_TIMEOUT=120
# =============================================================================
# Queue Configuration
# =============================================================================
QUEUE_WORKERS=4
QUEUE_MAX_MEMORY_MB=512
QUEUE_MAX_USER_MEMORY_MB=128
QUEUE_DOCLING_LIMIT=5
QUEUE_DOCUMENT_ANALYSIS_LIMIT=3
QUEUE_LLM_LIMIT=2
QUEUE_PAGE_IMAGES_LIMIT=10
QUEUE_SPLIT_MAP_LIMIT=5
QUEUE_TIKA_LIMIT=5
# Upload processing
UPLOAD_QUEUE_ENABLED=true
UPLOAD_IMMEDIATE_PROCESSING=false
UPLOAD_STATUS_POLLING_INTERVAL=5
MAX_FILE_SIZE_MB=50
# =============================================================================
# OCR Configuration
# =============================================================================
OCR_ENGINE=tesseract
OCR_LANG=eng
# =============================================================================
# Memory / Warning Thresholds
# =============================================================================
MEMORY_WARNING_THRESHOLD=80
MEMORY_REJECT_THRESHOLD=95
# =============================================================================
# TLSync Configuration
# =============================================================================
# Server-side secret used to sign short-lived TLSync auth tokens. Do not expose with a VITE_ prefix.
TLSYNC_SECRET=change-me-server-side-only
TLSYNC_TOKEN_TTL_SECONDS=300
# =============================================================================
# CORS Configuration
# =============================================================================
CORS_SITE_URL=https://app.classroomcopilot.ai
CORS_API_URL=https://api.classroomcopilot.ai
CORS_GRAPH_URL=https://graph.classroomcopilot.ai
# =============================================================================
# Logging
# =============================================================================
LOG_LEVEL=info
LOG_PATH=/var/log/classroom-copilot
# =============================================================================
# Development Mode
# =============================================================================
DEV_MODE=false
BACKEND_DEV_MODE=false
BACKEND_INIT_PATH=/app/init
# =============================================================================
# App Metadata
# =============================================================================
APP_NAME=Classroom Copilot
APP_VERSION=1.0.0
APP_AUTHOR=KevlarAI
APP_AUTHOR_EMAIL=[email protected]
APP_DESCRIPTION=AI-powered classroom collaboration platform
APP_PROTOCOL=https
APP_BOLT_URL=bolt://neo4j:7687
APP_GRAPH_URL=http://localhost:7474
# =============================================================================
# External API Keys
# =============================================================================
YOUTUBE_API_KEY=
GOOGLE_CLIENT_SECRETS_FILE=
# =============================================================================
# Node Filesystem (for document storage)
# =============================================================================
NODE_FILESYSTEM_PATH=/tmp/cc-nodes
+35
View File
@@ -0,0 +1,35 @@
name: api-ci-deploy
on:
push:
branches: [master]
workflow_dispatch:
jobs:
test-build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build API image
run: docker build -t cc-api-ci:${{ github.sha }} .
- name: Configure SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy API
run: |
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.API_DEPLOY_HOST }}" '
set -euo pipefail
cd /home/kcar/api
git fetch origin master
git reset --hard origin/master
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
docker compose -p api -f docker-compose.yml up -d --build
docker compose -p api -f docker-compose.yml ps
curl -fsS http://127.0.0.1:8000/health >/dev/null
'
+62 -4
View File
@@ -1,11 +1,69 @@
__pycache__
.pytest_cache
# Python
__pycache__/
*.py[cod]
*.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment files (never commit secrets)
.env
.env.local
.env.*.local
# Logs
*.log
logs/
queue_workers.log
# Large files
*.csv
*.xlsx
*.sqlite
*.db
# OS files
.DS_Store
Thumbs.db
.archive/*
# Docker
docker-compose.override.yml
data/logs/*
# Node
node_modules/
# Local environment variants
.env.dev
.env.prod
.archive/
*.bak
*.bak.*
-36
View File
@@ -1,36 +0,0 @@
️ 2025-09-19 16:57:46,569 INFO : start_queue_workers.py:main :79 >>> Starting 1 queue workers
️ 2025-09-19 16:57:46,569 INFO : start_queue_workers.py:main :80 >>> Services: ['document_analysis']
️ 2025-09-19 16:57:46,569 INFO : start_queue_workers.py:main :81 >>> Redis URL: redis://localhost:6379
️ 2025-09-19 16:57:46,569 INFO : queue_system.py :__init__ :124 >>> Queue initialized with limits: {<ServiceType.TIKA: 'tika'>: 3, <ServiceType.DOCLING: 'docling'>: 2, <ServiceType.LLM: 'llm'>: 5, <ServiceType.SPLIT_MAP: 'split_map'>: 10, <ServiceType.DOCUMENT_ANALYSIS: 'document_analysis'>: 5, <ServiceType.PAGE_IMAGES: 'page_images'>: 3}
️ 2025-09-19 16:57:46,601 INFO : task_processors.py :__init__ :40 >>> Task processor initialized with service URLs
️ 2025-09-19 16:57:46,601 INFO : queue_system.py :worker_loop :414 >>> Starting worker cli-worker-1 for services: ['document_analysis']
️ 2025-09-19 16:57:46,601 INFO : start_queue_workers.py:main :100 >>> Started workers: ['cli-worker-1']
❌ 2025-09-19 16:57:58,883 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.DOCUMENT_ANALYSIS: 'document_analysis'>
️ 2025-09-19 16:58:16,607 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 0, Processing: 0, Dead: 0
️ 2025-09-19 16:58:46,613 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 0, Processing: 0, Dead: 0
❌ 2025-09-19 16:59:16,036 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.DOCUMENT_ANALYSIS: 'document_analysis'>
️ 2025-09-19 16:59:16,618 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 4, Processing: 2, Dead: 0
❌ 2025-09-19 16:59:17,041 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.PAGE_IMAGES: 'page_images'>
️ 2025-09-19 16:59:46,620 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 2, Processing: 3, Dead: 0
️ 2025-09-19 17:00:16,626 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 3, Processing: 3, Dead: 0
️ 2025-09-19 17:00:46,631 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 3, Processing: 3, Dead: 0
❌ 2025-09-19 17:00:57,797 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error while reading from localhost:6379 : (54, 'Connection reset by peer')
❌ 2025-09-19 17:00:58,802 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error 61 connecting to localhost:6379. Connection refused.
❌ 2025-09-19 17:00:59,806 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error 61 connecting to localhost:6379. Connection refused.
❌ 2025-09-19 17:01:00,813 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error 61 connecting to localhost:6379. Connection refused.
❌ 2025-09-19 17:01:01,816 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error 61 connecting to localhost:6379. Connection refused.
❌ 2025-09-19 17:01:02,822 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: Error 61 connecting to localhost:6379. Connection refused.
❌ 2025-09-19 17:01:11,739 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.DOCUMENT_ANALYSIS: 'document_analysis'>
❌ 2025-09-19 17:01:12,742 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.PAGE_IMAGES: 'page_images'>
️ 2025-09-19 17:01:16,637 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 3, Processing: 3, Dead: 0
️ 2025-09-19 17:01:46,639 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 2, Processing: 3, Dead: 0
️ 2025-09-19 17:02:16,644 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 3, Processing: 3, Dead: 0
❌ 2025-09-19 17:02:31,136 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.DOCUMENT_ANALYSIS: 'document_analysis'>
❌ 2025-09-19 17:02:32,140 ERROR : queue_system.py :worker_loop :432 >>> Worker cli-worker-1 error: <ServiceType.PAGE_IMAGES: 'page_images'>
️ 2025-09-19 17:02:46,647 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 6, Processing: 3, Dead: 0
️ 2025-09-19 17:03:16,653 INFO : start_queue_workers.py:main :124 >>> Queue status - Queued: 6, Processing: 3, Dead: 0
️ 2025-09-19 17:03:31,501 INFO : start_queue_workers.py:signal_handler :104 >>> Received signal 15, shutting down workers...
️ 2025-09-19 17:03:31,501 INFO : queue_system.py :shutdown :451 >>> Shutting down queue workers...
️ 2025-09-19 17:03:31,501 INFO : queue_system.py :worker_loop :435 >>> Worker cli-worker-1 shutting down
️ 2025-09-19 17:03:31,502 INFO : queue_system.py :shutdown :461 >>> Queue shutdown complete
️ 2025-09-19 17:03:31,502 INFO : start_queue_workers.py:signal_handler :106 >>> Workers shut down. Exiting.
+97
View File
@@ -0,0 +1,97 @@
services:
# ── Required environment variables (see .env.example) ───────────────────────
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
redis-dev:
image: redis:7-alpine
container_name: cc-redis-dev
ports:
- "16379:6379"
volumes:
- redis-dev-data:/data
command: redis-server --appendonly yes
networks:
- kevlarai-network
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
init:
image: cc-api-dev:latest
container_name: api-init-dev
env_file:
- .env.dev
environment:
- REDIS_HOST=redis-dev
- RUN_INIT=true
- INIT_MODE=${INIT_MODE:-infra}
- INIT_ONLY=true
command: ["./docker-entrypoint.sh", "init-only"]
depends_on:
redis-dev:
condition: service_healthy
networks:
- kevlarai-network
profiles:
- init
backend-dev:
container_name: cc-api-dev
image: cc-api-dev:latest
env_file:
- .env.dev
environment:
- REDIS_HOST=redis-dev
- REDIS_DB_DEV=0
- BACKEND_DEV_MODE=true
- APP_ENV=development
- ENVIRONMENT=development
- START_MODE=dev
- CC_COMPOSE_PROJECT=api-dev
- CC_COMPOSE_SERVICE=backend-dev
- RUN_INIT=false
- INIT_MODE=infra
ports:
- "18000:8000"
depends_on:
redis-dev:
condition: service_healthy
networks:
- kevlarai-network
restart: unless-stopped
backend-test:
image: cc-api-dev:latest
env_file:
- .env.dev
environment:
- REDIS_HOST=redis-dev
- REDIS_DB_DEV=0
- BACKEND_DEV_MODE=true
- APP_ENV=development
- ENVIRONMENT=development
- START_MODE=dev
- CC_COMPOSE_PROJECT=api-dev
- CC_COMPOSE_SERVICE=backend-test
- API_HEALTH_URL=http://192.168.0.64:18000/health
depends_on:
redis-dev:
condition: service_healthy
networks:
- kevlarai-network
entrypoint: ["python", "-m", "pytest"]
command: ["-q", "tests"]
profiles:
- test
volumes:
redis-dev-data:
networks:
kevlarai-network:
external: true
name: kevlarai-network
+10
View File
@@ -1,4 +1,9 @@
services:
# ── Required environment variables (see .env.example) ───────────────────────
# APP_BOLT_URL, USER_NEO4J, PASSWORD_NEO4J — Neo4j connection
# SUPABASE_URL, SERVICE_ROLE_KEY — Supabase project
# REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis (optional auth)
# FASTAPI_SECRET_KEY, ADMIN_EMAIL — API config
redis:
image: redis:7-alpine
container_name: classroomcopilot-redis
@@ -46,6 +51,11 @@ services:
- .env
environment:
- REDIS_HOST=redis
- START_MODE=prod
- APP_ENV=production
- ENVIRONMENT=production
- CC_COMPOSE_PROJECT=api
- CC_COMPOSE_SERVICE=backend
- RUN_INIT=${RUN_INIT:-false} # Set to 'true' to run init on startup
- INIT_MODE=${INIT_MODE:-infra} # Which init tasks to run
ports:
+18 -18
View File
@@ -29,7 +29,7 @@ print_error() {
# Check if we should run initialization
RUN_INIT="${RUN_INIT:-false}"
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'full', or comma-separated list
INIT_MODE="${INIT_MODE:-infra}" # Default to 'infra', can be 'infra', 'seed', 'seed-test', 'full', or comma-separated list
# If RUN_INIT is true, run initialization tasks
if [ "$RUN_INIT" = "true" ]; then
@@ -51,21 +51,21 @@ if [ "$RUN_INIT" = "true" ]; then
}
print_success "Infrastructure setup completed"
;;
"demo-school")
print_status "Creating demo school..."
python3 main.py --mode demo-school || {
print_error "Demo school creation failed!"
"seed")
print_status "Seeding canonical full environment..."
python3 main.py --mode seed || {
print_error "Seed failed!"
exit 1
}
print_success "Demo school creation completed"
print_success "Seed completed"
;;
"demo-users")
print_status "Creating demo users..."
python3 main.py --mode demo-users || {
print_error "Demo users creation failed!"
"seed-test")
print_status "Seeding lightweight test environment..."
python3 main.py --mode seed-test || {
print_error "Seed test failed!"
exit 1
}
print_success "Demo users creation completed"
print_success "Seed test completed"
;;
"gais-data")
print_status "Importing GAIS data..."
@@ -78,9 +78,7 @@ if [ "$RUN_INIT" = "true" ]; then
"full")
print_status "Running full initialization..."
python3 main.py --mode infra || exit 1
python3 main.py --mode demo-school || exit 1
python3 main.py --mode demo-users || exit 1
python3 main.py --mode gais-data || exit 1
python3 main.py --mode seed || exit 1
print_success "Full initialization completed"
;;
*)
@@ -98,11 +96,13 @@ if [ "$RUN_INIT" = "true" ]; then
fi
fi
# Start the production server (unless init-only mode)
# Start the server (unless init-only mode). Default remains production, but
# development Compose can set START_MODE=dev so cc-api-dev reports and uses
# development Redis/config instead of silently booting as prod.
START_MODE="${START_MODE:-prod}"
if [ "$1" != "init-only" ] && [ -z "$INIT_ONLY" ]; then
print_status "Starting production server..."
exec ./start.sh prod
print_status "Starting ${START_MODE} server..."
exec ./start.sh "$START_MODE"
else
print_status "Init-only mode - not starting server"
fi
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Helper script to run initialization tasks in production
# Usage: ./init-production.sh [mode]
# Modes: infra, demo-school, demo-users, gais-data, full
# Modes: infra, seed, seed-test, gais-data, full
set -e
+73 -40
View File
@@ -10,6 +10,7 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
from fastapi import FastAPI, HTTPException
import uvicorn
import requests
from urllib.parse import urlparse
from typing import Dict, Any, Optional
from modules.database.tools.neo4j_driver_tools import get_driver
@@ -22,15 +23,59 @@ from modules.queue_system import ServiceType
app = FastAPI()
setup_cors(app)
def _truthy_env(name: str) -> bool:
return os.getenv(name, "").lower() in {"1", "true", "yes", "on"}
def _runtime_environment() -> str:
"""Return the API runtime role used for dev/prod backing-service selection."""
start_mode = os.getenv("START_MODE", "prod").lower()
if start_mode == "dev" or _truthy_env("BACKEND_DEV_MODE"):
return "dev"
return "prod"
def _url_host(url: Optional[str]) -> Optional[str]:
if not url:
return None
parsed = urlparse(url)
return parsed.hostname
def _runtime_identity() -> Dict[str, Any]:
"""Non-secret runtime identity for agents and smoke tests.
This intentionally exposes only modes, labels, and URL hosts. It must not
include API keys, passwords, bearer tokens, or full URLs that may embed
credentials.
"""
return {
"api_runtime_role": _runtime_environment(),
"start_mode": os.getenv("START_MODE", "prod"),
"app_environment": os.getenv("APP_ENV"),
"environment": os.getenv("ENVIRONMENT"),
"backend_dev_mode": _truthy_env("BACKEND_DEV_MODE"),
"compose_project": os.getenv("COMPOSE_PROJECT_NAME") or os.getenv("CC_COMPOSE_PROJECT"),
"compose_service": os.getenv("COMPOSE_SERVICE") or os.getenv("CC_COMPOSE_SERVICE"),
"supabase_url_host": _url_host(os.getenv("SUPABASE_URL")),
}
# Health check endpoint
@app.get("/health")
async def health_check() -> Dict[str, Any]:
"""Health check endpoint that verifies all service dependencies"""
runtime_identity = _runtime_identity()
health_status = {
"status": "healthy",
"runtime": runtime_identity,
"services": {
"neo4j": {"status": "healthy", "message": "Connected"},
"supabase": {"status": "healthy", "message": "Connected"},
"supabase": {
"status": "healthy",
"message": "Connected",
"url_host": runtime_identity["supabase_url_host"],
},
"redis": {"status": "healthy", "message": "Connected"}
}
}
@@ -45,9 +90,10 @@ async def health_check() -> Dict[str, Any]:
}
health_status["status"] = "unhealthy"
except Exception as e:
logger.warning(f"Neo4j health check failed: {e}")
health_status["services"]["neo4j"] = {
"status": "unhealthy",
"message": f"Error checking Neo4j: {str(e)}"
"message": "Error checking Neo4j"
}
health_status["status"] = "unhealthy"
@@ -67,9 +113,10 @@ async def health_check() -> Dict[str, Any]:
}
health_status["status"] = "unhealthy"
except Exception as e:
logger.warning(f"Supabase health check failed: {e}")
health_status["services"]["supabase"] = {
"status": "unhealthy",
"message": f"Error checking Supabase Auth API: {str(e)}"
"message": "Error checking Supabase Auth API"
}
health_status["status"] = "unhealthy"
@@ -77,8 +124,8 @@ async def health_check() -> Dict[str, Any]:
# Check Redis using new Redis manager
from modules.redis_manager import get_redis_manager
# Determine environment
environment = 'dev' if os.getenv('BACKEND_DEV_MODE', 'true').lower() == 'true' else 'prod'
# Determine environment from explicit startup/runtime identity.
environment = runtime_identity["api_runtime_role"]
redis_manager = get_redis_manager(environment)
# Get comprehensive health check
@@ -96,9 +143,10 @@ async def health_check() -> Dict[str, Any]:
health_status["status"] = "unhealthy"
except Exception as e:
logger.warning(f"Redis health check failed: {e}")
health_status["services"]["redis"] = {
"status": "unhealthy",
"message": f"Error checking Redis: {str(e)}"
"message": "Error checking Redis"
}
health_status["status"] = "unhealthy"
@@ -244,33 +292,20 @@ def run_infrastructure_mode():
logger.error(f"Infrastructure setup failed: {str(e)}")
return False
def run_demo_school_mode():
"""Run demo school creation"""
logger.info("Running in demo school mode")
logger.info("Starting demo school creation...")
def run_seed_mode(test: bool = False):
"""Run canonical environment seed."""
mode = "test" if test else "full"
logger.info(f"Running canonical seed mode ({mode})")
try:
from run.initialization import initialize_demo_school_mode
initialize_demo_school_mode()
logger.info("Demo school creation completed successfully")
return True
from run.initialization.seed_environment import seed
import json
result = seed(test=test)
print(json.dumps(result, indent=2, default=str))
return bool(result.get('success'))
except Exception as e:
logger.error(f"Demo school creation failed: {str(e)}")
logger.error(f"Seed mode failed: {str(e)}")
return False
def run_demo_users_mode():
"""Run demo users creation"""
logger.info("Running in demo users mode")
logger.info("Starting demo users creation...")
try:
from run.initialization import initialize_demo_users_mode
initialize_demo_users_mode()
logger.info("Demo users creation completed successfully")
return True
except Exception as e:
logger.error(f"Demo users creation failed: {str(e)}")
return False
def run_gais_data_mode():
"""Run GAIS data import"""
@@ -366,8 +401,8 @@ def parse_arguments():
epilog="""
Startup modes:
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
demo-school - Create demo school (KevlarAI)
demo-users - Create demo users
seed - Seed canonical full environment (20 school users)
seed-test - Seed lightweight test environment (9 school users)
gais-data - Import GAIS data (Edubase, etc.)
dev - Run development server with auto-reload
prod - Run production server (for Docker/containerized deployment)
@@ -376,7 +411,7 @@ Startup modes:
parser.add_argument(
'--mode', '-m',
choices=['infra', 'demo-school', 'demo-users', 'gais-data', 'dev', 'prod'],
choices=['infra', 'seed', 'seed-test', 'gais-data', 'dev', 'prod'],
default='dev',
help='Startup mode (default: dev)'
)
@@ -399,16 +434,14 @@ if __name__ == "__main__":
success = run_infrastructure_mode()
sys.exit(0 if success else 1)
elif args.mode == 'demo-school':
# Run demo school creation
success = run_demo_school_mode()
elif args.mode == 'seed':
success = run_seed_mode(test=False)
sys.exit(0 if success else 1)
elif args.mode == 'demo-users':
# Run demo users creation
success = run_demo_users_mode()
elif args.mode == 'seed-test':
success = run_seed_mode(test=True)
sys.exit(0 if success else 1)
elif args.mode == 'gais-data':
# Run GAIS data import
success = run_gais_data_mode()
+57
View File
@@ -0,0 +1,57 @@
"""
FastAPI dependencies for platform-level admin access.
Two tiers:
require_platform_admin — user must be in admin_profiles
require_super_admin — user must have is_super_admin=True in admin_profiles
Usage:
@router.get("/admin/schools")
async def list_all_schools(admin=Depends(require_platform_admin)):
...
@router.post("/admin/provision")
async def provision(admin=Depends(require_super_admin)):
...
"""
from fastapi import Depends, HTTPException
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
async def require_platform_admin(
credentials: dict = Depends(SupabaseBearer()),
) -> dict:
"""Require the caller to be a registered platform admin (in admin_profiles)."""
user_id = credentials.get("sub")
if not user_id:
raise HTTPException(status_code=403, detail="Invalid token")
try:
sb = _sb()
result = (
sb.supabase.table("admin_profiles")
.select("id,admin_role,is_super_admin")
.eq("id", user_id)
.single()
.execute()
)
if not result.data:
raise HTTPException(status_code=403, detail="Platform admin access required")
return {**credentials, "admin_profile": result.data}
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=403, detail="Platform admin access required")
async def require_super_admin(
admin: dict = Depends(require_platform_admin),
) -> dict:
"""Require the caller to have is_super_admin=True."""
if not admin.get("admin_profile", {}).get("is_super_admin"):
raise HTTPException(status_code=403, detail="Super admin access required")
return admin
+4
View File
@@ -20,6 +20,10 @@ class SupabaseBearer(HTTPBearer):
token = credentials.credentials
# Decode using the string-based verifier to avoid async dependency conflicts
payload = verify_supabase_jwt_str(token)
# Keep the bearer token available to downstream dependencies that must
# call Supabase as the user (RLS/storage policies), without requiring
# each router to decode the Authorization header again.
payload["_access_token"] = token
return payload
except Exception as e:
logger.error(f"Token verification failed: {str(e)}")
@@ -0,0 +1,92 @@
"""
Neontology node schemas for the cc.public.exams knowledge graph.
cc.public.exams is a dedicated, shared, public Neo4j database — co-primary/authoritative for the
exam knowledge graph (specs, spec-points, paper→question→part→region structure, ASSESSES links).
Supabase remains source of truth for operational data (geometry, marks, submissions); the two
layers join on shared UUIDs:
exam_questions.id <-> Question|Part.uuid_string (container -> Question, leaf -> Part)
exam_response_areas.id <-> Region.uuid_string
eb_exams.exam_code <-> ExamPaper.exam_code
eb_specifications.spec_code <-> Specification.spec_code
Ownership: created by an infra-init step; read by all authenticated API calls; written by the API
service role only (no direct client writes).
"""
from typing import ClassVar, Optional
from ..base_nodes import CCBaseNode
class ExamBaseNode(CCBaseNode):
__primarylabel__: ClassVar[str] = ''
class ExamBoardNode(ExamBaseNode):
__primarylabel__: ClassVar[str] = 'ExamBoard'
code: str # 'AQA'
name: str
class SpecificationNode(ExamBaseNode):
__primarylabel__: ClassVar[str] = 'Specification'
spec_code: str # 'AQA-PHYS-8463' (== Supabase eb_specifications.spec_code)
exam_board_code: str
subject_code: Optional[str] = None
award_code: Optional[str] = None
title: Optional[str] = None
class SpecPointNode(ExamBaseNode):
__primarylabel__: ClassVar[str] = 'SpecPoint'
ref: str # '4.1', '4.2.1'
description: str
spec_code: str
exam_board_code: str
class ExamPaperNode(ExamBaseNode):
__primarylabel__: ClassVar[str] = 'ExamPaper'
exam_code: str # == Supabase eb_exams.exam_code
spec_code: str
paper_code: Optional[str] = None
tier: Optional[str] = None
session: Optional[str] = None
title: Optional[str] = None
page_count: Optional[int] = None
class QuestionNode(ExamBaseNode): # roll-up container; uuid_string == exam_questions.id
__primarylabel__: ClassVar[str] = 'Question'
exam_code: str
label: str # '01'
order: int
max_marks: float
class PartNode(ExamBaseNode): # leaf; uuid_string == exam_questions.id
__primarylabel__: ClassVar[str] = 'Part'
exam_code: str
label: str # '01.1'
order: int
max_marks: float
answer_type: str
mark_scheme_type: str
class RegionNode(ExamBaseNode): # uuid_string == exam_response_areas.id
__primarylabel__: ClassVar[str] = 'Region'
page: int
kind: str # 'response' | 'context'
response_form: str
# Relationship reference (written by the projection / linker, not modelled as classes here):
# (:ExamBoard)-[:PUBLISHES]->(:Specification)
# (:Specification)-[:HAS_SPEC_POINT]->(:SpecPoint)
# (:Specification)-[:HAS_PAPER]->(:ExamPaper)
# (:ExamPaper)-[:HAS_QUESTION]->(:Question)
# (:Question)-[:HAS_PART]->(:Part) # nested questions allowed
# (:Part)-[:HAS_REGION]->(:Region)
# (:Part)-[:ASSESSES]->(:SpecPoint) # from exam_questions.spec_ref
# (:SpecPoint)-[:TEACHES]->(:LearningStatement) # DEFERRED cross-db bridge
+9 -1
View File
@@ -1,5 +1,13 @@
from typing import ClassVar
from .base_nodes import UserBaseNode
from .base_nodes import UserBaseNode, CCBaseNode
class UserNode(UserBaseNode):
__primarylabel__: ClassVar[str] = 'User'
class JournalNode(CCBaseNode):
__primarylabel__: ClassVar[str] = 'Journal'
user_id: str
class PlannerNode(CCBaseNode):
__primarylabel__: ClassVar[str] = 'Planner'
user_id: str
@@ -0,0 +1,370 @@
import os
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
from modules.logger_tool import initialise_logger
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
import modules.database.tools.neo4j_driver_tools as driver_tools
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
MembershipRow = Dict[str, Any]
GraphProbe = Callable[..., Dict[str, Any]]
ROLE_PERMISSIONS: Dict[str, Dict[str, bool]] = {
"school_admin": {
"can_manage_school": True,
"can_manage_calendar": True,
"can_manage_timetable": True,
"can_invite_staff": True,
"can_manage_classes": True,
"can_view_student_data": True,
},
"department_head": {
"can_manage_school": False,
"can_manage_calendar": False,
"can_manage_timetable": True,
"can_invite_staff": False,
"can_manage_classes": True,
"can_view_student_data": True,
},
"teacher": {
"can_manage_school": False,
"can_manage_calendar": False,
"can_manage_timetable": False,
"can_invite_staff": False,
"can_manage_classes": True,
"can_view_student_data": False,
},
"student": {
"can_manage_school": False,
"can_manage_calendar": False,
"can_manage_timetable": False,
"can_invite_staff": False,
"can_manage_classes": False,
"can_view_student_data": False,
},
}
BASE_PERMISSIONS: Dict[str, bool] = {
"platform_admin": False,
"platform_super_admin": False,
"can_create_school": True,
"can_manage_school": False,
"can_manage_calendar": False,
"can_manage_timetable": False,
"can_invite_staff": False,
"can_manage_classes": False,
"can_view_student_data": False,
"can_use_canvas": True,
}
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _safe_list(result: Any) -> List[Dict[str, Any]]:
data = getattr(result, "data", None)
if not data:
return []
if isinstance(data, list):
return data
return [data]
def _safe_one(result: Any) -> Optional[Dict[str, Any]]:
data = getattr(result, "data", None)
return data if isinstance(data, dict) else None
def default_graph_probe(user_id: str, user_email: str, active_institute: Optional[Dict[str, Any]], institute: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Best-effort derived graph status; never authoritative for bootstrap state."""
user_db = f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else None
neo4j_uuid = (institute or {}).get("neo4j_uuid_string")
institute_db = f"cc.institutes.{neo4j_uuid}" if neo4j_uuid else None
status = {
"available": False,
"user_db": user_db,
"institute_db": institute_db,
"projection_state": "unknown",
"needs_rebuild": True,
"last_checked_at": utc_now_iso(),
"error_code": None,
}
try:
if not user_db and not institute_db:
status["projection_state"] = "missing"
return status
db_to_check = institute_db or user_db
with driver_tools.get_session(database=db_to_check) as session:
session.run("RETURN 1 AS ok").single()
status.update({"available": True, "projection_state": "ready", "needs_rebuild": False})
return status
except Exception:
status.update({"available": False, "projection_state": "error", "needs_rebuild": True, "error_code": "neo4j_unavailable"})
return status
class BootstrapService:
def __init__(self, supabase: Any, graph_probe: Optional[GraphProbe] = None):
self.supabase = supabase
self.graph_probe = graph_probe or default_graph_probe
def build(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
user_id = credentials.get("sub") or ""
user_email = credentials.get("email") or ""
if not user_id:
raise ValueError("Missing authenticated user id")
profile = self._get_profile(user_id, user_email)
admin_profile = self._get_admin_profile(user_id)
memberships = self._get_memberships(user_id)
active = self._select_active_institute(profile, memberships)
active_institute = self._membership_institute(active, memberships)
permissions = self._permissions(active, bool(admin_profile), bool((admin_profile or {}).get("is_super_admin")))
profile_user_type = "platform_admin" if admin_profile else (profile.get("user_type") or active.get("role") or "unknown")
school_status = self._school_status(active, memberships, admin_profile)
calendar_status = self._calendar_status(active.get("institute_id"))
timetable_status = self._timetable_status(user_id, active.get("institute_id"))
graph_status = self._graph_status(user_id, user_email, active, active_institute)
onboarding = self._onboarding(school_status, permissions, calendar_status, timetable_status, graph_status)
return {
"profile": {
"id": user_id,
"email": profile.get("email") or user_email,
"display_name": profile.get("display_name") or profile.get("full_name") or profile.get("username") or user_email,
"user_type": profile_user_type,
"school_id": profile.get("school_id"),
},
"memberships": [self._serialize_membership(row) for row in memberships],
"active_institute": {
"id": active.get("institute_id"),
"source": active.get("source", "none"),
"membership_role": active.get("role"),
},
"permissions": permissions,
"school_status": school_status,
"onboarding": onboarding,
"calendar_status": calendar_status,
"timetable_status": timetable_status,
"graph_status": graph_status,
}
def _get_profile(self, user_id: str, user_email: str) -> Dict[str, Any]:
try:
result = (
self.supabase.table("profiles")
.select("id,email,user_type,username,full_name,display_name,user_db_name,school_db_name,school_id")
.eq("id", user_id)
.single()
.execute()
)
profile = _safe_one(result) or {}
except Exception as exc:
logger.warning("Bootstrap profile lookup failed for authenticated user: %s", exc)
profile = {}
profile.setdefault("id", user_id)
profile.setdefault("email", user_email)
return profile
def _get_admin_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
try:
result = (
self.supabase.table("admin_profiles")
.select("id,admin_role,is_super_admin")
.eq("id", user_id)
.single()
.execute()
)
return _safe_one(result)
except Exception:
return None
def _get_memberships(self, user_id: str) -> List[MembershipRow]:
try:
member_rows = _safe_list(
self.supabase.table("institute_memberships")
.select("profile_id,institute_id,role")
.eq("profile_id", user_id)
.execute()
)
except Exception as exc:
logger.warning("Bootstrap membership lookup failed: %s", exc)
return []
institute_ids = [str(row.get("institute_id")) for row in member_rows if row.get("institute_id")]
institutes_by_id: Dict[str, Dict[str, Any]] = {}
if institute_ids:
try:
inst_rows = _safe_list(
self.supabase.table("institutes")
.select("id,name,urn,website,address,metadata,status,neo4j_uuid_string")
.in_("id", institute_ids)
.execute()
)
institutes_by_id = {str(row.get("id")): row for row in inst_rows}
except Exception as exc:
logger.warning("Bootstrap institute lookup failed: %s", exc)
rows: List[MembershipRow] = []
for member in member_rows:
inst = institutes_by_id.get(str(member.get("institute_id")), {})
if inst and inst.get("status") not in (None, "active"):
continue
rows.append({**member, "institute": inst, "status": member.get("status") or "active", "is_active": True})
return rows
def _select_active_institute(self, profile: Dict[str, Any], memberships: List[MembershipRow]) -> MembershipRow:
active_memberships = [m for m in memberships if m.get("is_active")]
profile_school_id = profile.get("school_id")
if profile_school_id:
for member in active_memberships:
if str(member.get("institute_id")) == str(profile_school_id):
return {**member, "source": "profile.school_id"}
if len(active_memberships) == 1:
return {**active_memberships[0], "source": "single_membership"}
return {"institute_id": None, "role": None, "source": "none"}
def _membership_institute(self, active: MembershipRow, memberships: List[MembershipRow]) -> Optional[Dict[str, Any]]:
active_id = active.get("institute_id")
if not active_id:
return None
for member in memberships:
if str(member.get("institute_id")) == str(active_id):
return member.get("institute") or None
return None
def _serialize_membership(self, row: MembershipRow) -> Dict[str, Any]:
inst = row.get("institute") or {}
return {
"institute_id": row.get("institute_id"),
"role": row.get("role") or "teacher",
"status": row.get("status") or "active",
"is_active": bool(row.get("is_active", True)),
"institute": {
"id": inst.get("id") or row.get("institute_id"),
"name": inst.get("name"),
"urn": inst.get("urn"),
"website": inst.get("website"),
"address": inst.get("address") or {},
"metadata": inst.get("metadata") or {},
},
}
def _permissions(self, active: MembershipRow, platform_admin: bool, super_admin: bool) -> Dict[str, bool]:
permissions = dict(BASE_PERMISSIONS)
permissions["platform_admin"] = platform_admin
permissions["platform_super_admin"] = super_admin
role_permissions = ROLE_PERMISSIONS.get(active.get("role") or "", {})
permissions.update(role_permissions)
if platform_admin:
# Platform authority is additive and must not be reduced by a user's
# school membership role (for example a platform admin who also has
# a teacher/student membership).
permissions.update({
"can_create_school": True,
"can_manage_school": True,
"can_manage_calendar": True,
"can_manage_timetable": True,
"can_invite_staff": True,
"can_manage_classes": True,
"can_view_student_data": True,
})
return permissions
def _school_status(self, active: MembershipRow, memberships: List[MembershipRow], admin_profile: Optional[Dict[str, Any]]) -> str:
if admin_profile:
return "platform_admin"
if active.get("institute_id"):
return "school_admin" if active.get("role") == "school_admin" else "member"
if len([m for m in memberships if m.get("is_active")]) > 1:
return "multi_school_needs_selection"
return "no_school"
def _calendar_status(self, institute_id: Optional[str]) -> Dict[str, Any]:
status = {"available": False, "academic_year_count": 0, "term_count": 0, "current_academic_year_id": None, "needs_setup": True}
if not institute_id:
return status
try:
years = _safe_list(self.supabase.table("academic_years").select("id,institute_id").eq("institute_id", institute_id).execute())
terms = _safe_list(self.supabase.table("academic_terms").select("id,institute_id").eq("institute_id", institute_id).execute())
status["academic_year_count"] = len(years)
status["term_count"] = len(terms)
status["current_academic_year_id"] = years[0].get("id") if years else None
status["available"] = bool(years and terms)
status["needs_setup"] = not status["available"]
except Exception as exc:
logger.warning("Bootstrap calendar status lookup failed: %s", exc)
return status
def _timetable_status(self, user_id: str, institute_id: Optional[str]) -> Dict[str, Any]:
status = {"available": False, "teacher_timetable_id": None, "slot_count": 0, "needs_setup": True}
if not institute_id:
return status
try:
timetables = _safe_list(
self.supabase.table("teacher_timetables")
.select("id,profile_id,institute_id")
.eq("institute_id", institute_id)
.eq("profile_id", user_id)
.limit(1)
.execute()
)
if not timetables:
# Test/future compatibility for teacher_profile_id naming.
timetables = _safe_list(
self.supabase.table("teacher_timetables")
.select("id,teacher_profile_id,institute_id")
.eq("institute_id", institute_id)
.eq("teacher_profile_id", user_id)
.limit(1)
.execute()
)
if timetables:
timetable_id = timetables[0].get("id")
slots = _safe_list(
self.supabase.table("teacher_timetable_slots")
.select("id,teacher_timetable_id")
.eq("teacher_timetable_id", timetable_id)
.execute()
)
status["teacher_timetable_id"] = timetable_id
status["slot_count"] = len(slots)
status["available"] = bool(slots)
status["needs_setup"] = not status["available"]
except Exception as exc:
logger.warning("Bootstrap timetable status lookup failed: %s", exc)
return status
def _graph_status(self, user_id: str, user_email: str, active: MembershipRow, institute: Optional[Dict[str, Any]]) -> Dict[str, Any]:
base = {"available": False, "user_db": None, "institute_db": None, "projection_state": "unknown", "needs_rebuild": True, "last_checked_at": utc_now_iso(), "error_code": None}
try:
result = self.graph_probe(user_id=user_id, user_email=user_email, active_institute=active, institute=institute)
return {**base, **(result or {}), "last_checked_at": (result or {}).get("last_checked_at") or base["last_checked_at"]}
except Exception:
return {**base, "projection_state": "error", "needs_rebuild": True, "error_code": "neo4j_unavailable"}
def _onboarding(self, school_status: str, permissions: Dict[str, bool], calendar: Dict[str, Any], timetable: Dict[str, Any], graph: Dict[str, Any]) -> Dict[str, Any]:
optional = [] if graph.get("available") else ["graph_rebuild"]
if school_status == "no_school":
return {"next_step": "create_or_join_school", "required": ["school_membership"], "optional": optional, "message": "Create or join a school to finish setup."}
if school_status == "multi_school_needs_selection":
return {"next_step": "select_school", "required": ["active_school_selection"], "optional": optional, "message": "Select which school to use for this session."}
if permissions.get("can_manage_calendar") and calendar.get("needs_setup"):
return {"next_step": "setup_calendar", "required": ["calendar"], "optional": optional, "message": "Set up the school calendar."}
if permissions.get("can_manage_timetable") and timetable.get("needs_setup"):
return {"next_step": "setup_timetable", "required": ["timetable"], "optional": optional, "message": "Set up the timetable."}
if school_status == "school_admin" and permissions.get("can_invite_staff"):
return {"next_step": "invite_staff", "required": [], "optional": optional, "message": "Invite staff or continue to your workspace."}
return {"next_step": "ready", "required": [], "optional": optional, "message": "Your workspace is ready."}
def build_bootstrap_response(credentials: Dict[str, Any]) -> Dict[str, Any]:
sb = SupabaseServiceRoleClient()
return BootstrapService(sb.supabase).build(credentials)
@@ -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}")
@@ -229,6 +229,7 @@ class ProvisioningService:
"neo4j_private_db_name": school_db,
"neo4j_private_sync_status": "ready",
"neo4j_private_sync_at": datetime.utcnow().isoformat(),
"neo4j_uuid_string": self._sanitize_component(institute_id),
}
try:
(
@@ -262,6 +263,7 @@ class ProvisioningService:
"admin": ("superadmin", "superadmin"),
"super_admin": ("superadmin", "superadmin"),
"superadmin": ("superadmin", "superadmin"),
"platform_admin": ("superadmin", "superadmin"),
}
neo_user_type, worker_type = user_type_map.get(user_type_raw, (user_type_raw or "standard", user_type_raw or "standard"))
+52 -12
View File
@@ -14,19 +14,39 @@ class CreateBucketOptions(TypedDict, total=False):
allowed_mime_types: List[str]
name: str
def _create_base_client(url: str, key: str, options: Optional[Dict[str, Any]] = None) -> Client:
"""Create a base Supabase client with given configuration."""
def _create_base_client(url: str, key: str, access_token: Optional[str] = None, options: Optional[Dict[str, Any]] = None) -> Client:
"""Create a base Supabase client with given configuration.
If access_token is provided, it is used as the Authorization header (for per-user RLS).
Otherwise, the API key is used (service role bypasses RLS, anon key does not).
"""
# If an access token is provided, use it for Authorization (enables per-user RLS)
# Otherwise fall back to the API key
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
# 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 = {
"Authorization": auth_header,
}
if options:
headers.update(options.get("headers", {}))
client_options = SyncClientOptions(
schema="public",
storage=SyncMemoryStorage(),
headers={
"Authorization": f"Bearer {key}"
}
headers=headers,
)
return create_client(url, key, options=client_options)
class SupabaseServiceRoleClient:
"""Supabase client for making authenticated requests using the service role key"""
"""Supabase client for making authenticated requests using the service role key.
NOTE: Service role bypasses all RLS policies. Use only for admin operations
where you need to read/write any row regardless of ownership.
"""
def __init__(self, url: Optional[str] = None, service_role_key: Optional[str] = None):
"""Initialize the Supabase client with URL and service role key"""
@@ -36,7 +56,7 @@ class SupabaseServiceRoleClient:
if not self.url or not self.service_role_key:
raise ValueError("SUPABASE_URL and SERVICE_ROLE_KEY must be provided")
# Initialize Supabase client with service role key and optional access token
# Initialize Supabase client with service role key (bypasses RLS)
self.supabase = _create_base_client(self.url, self.service_role_key)
def create_bucket(self, id: str, options: Optional[CreateBucketOptions] = None) -> Dict[str, Any]:
@@ -48,17 +68,29 @@ class SupabaseServiceRoleClient:
return self.supabase.storage.create_bucket(id, options=options)
class SupabaseAnonClient:
"""Supabase client for making authenticated requests using the anon key"""
"""Supabase client for making authenticated requests using the anon key.
When initialized with an access_token, per-user RLS policies are enforced
via auth.uid() in the JWT. Without an access_token, requests use the anon key
which does NOT enforce per-user RLS (only bucket-level storage rules apply).
"""
def __init__(self, url: Optional[str] = None, anon_key: Optional[str] = None, access_token: Optional[str] = None):
"""Initialize the Supabase client with URL and anon key"""
"""Initialize the Supabase client with URL and anon key.
Args:
url: Supabase URL
anon_key: Anon API key (fallback if no access_token)
access_token: User's JWT access token for per-user RLS enforcement
"""
self.url = url or os.environ.get("SUPABASE_URL", "http://localhost:8000")
self.anon_key = anon_key or os.environ.get("ANON_KEY")
self.access_token = access_token
if not self.url or not self.anon_key:
raise ValueError("SUPABASE_URL and ANON_KEY must be provided")
# Initialize Supabase client with anon key and optional access token
# Initialize Supabase client with anon key and optional access token for RLS
self.supabase = _create_base_client(self.url, self.anon_key, access_token=access_token)
def create_bucket(self, id: str, options: Optional[CreateBucketOptions] = None) -> Dict[str, Any]:
@@ -67,5 +99,13 @@ class SupabaseAnonClient:
@classmethod
def for_user(cls, access_token: str) -> 'SupabaseAnonClient':
"""Create a client instance for a specific user using their access token"""
return cls(access_token=access_token)
"""Create a client instance for a specific user using their access token.
This enables per-user RLS enforcement via auth.uid() in the JWT.
"""
if not access_token or not access_token.strip():
raise ValueError("access_token is required for per-user Supabase clients")
token = access_token.strip()
if token.lower().startswith("bearer "):
token = token.split(None, 1)[1]
return cls(access_token=token)
+25 -16
View File
@@ -110,14 +110,13 @@ class StorageAdmin(StorageManager):
public: bool = False,
file_size_limit: Optional[int] = None,
allowed_mime_types: Optional[List[str]] = None,
owner: Optional[str] = None, # Kept for backwards compatibility but not used
owner_id: Optional[str] = None # Kept for backwards compatibility but not used
owner: Optional[str] = None,
owner_id: Optional[str] = None
) -> Dict[str, Any]:
"""Create a new storage bucket with supported parameters."""
try:
self.logger.info(f"Creating bucket {id} with name {name}")
# Prepare bucket options with only supported parameters
options: Optional[CreateBucketOptions] = {}
if public:
options["public"] = public
@@ -126,7 +125,6 @@ class StorageAdmin(StorageManager):
if allowed_mime_types is not None:
options["allowed_mime_types"] = allowed_mime_types
# Create bucket with supported parameters only
bucket = self.client.supabase.storage.create_bucket(
str(id),
options=options if options else None
@@ -152,7 +150,7 @@ class StorageAdmin(StorageManager):
"public": False,
"owner": owner_id,
"owner_id": "superadmin",
"file_size_limit": 50 * 1024 * 1024, # 50MB
"file_size_limit": 50 * 1024 * 1024,
"allowed_mime_types": [
'image/*', 'video/*', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
@@ -165,7 +163,7 @@ class StorageAdmin(StorageManager):
"public": False,
"owner": owner_id,
"owner_id": "superadmin",
"file_size_limit": 50 * 1024 * 1024, # 50MB
"file_size_limit": 50 * 1024 * 1024,
"allowed_mime_types": [
'image/*', 'video/*', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
@@ -177,7 +175,7 @@ class StorageAdmin(StorageManager):
results = []
for bucket in core_buckets:
try:
bucket_name = bucket.pop("name") # Remove name from options
bucket_name = bucket.pop("name")
result = self.create_bucket(name=bucket_name, **bucket)
results.append({
"bucket": bucket["id"],
@@ -210,7 +208,7 @@ class StorageAdmin(StorageManager):
public=False,
owner=user_id,
owner_id=username,
file_size_limit=50 * 1024 * 1024, # 50MB
file_size_limit=50 * 1024 * 1024,
allowed_mime_types=[
'image/*', 'video/*', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
@@ -236,7 +234,7 @@ class StorageAdmin(StorageManager):
"public": True,
"owner": owner_id,
"owner_id": school_id,
"file_size_limit": 50 * 1024 * 1024, # 50MB
"file_size_limit": 50 * 1024 * 1024,
"allowed_mime_types": [
'image/*', 'video/*', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
@@ -249,7 +247,7 @@ class StorageAdmin(StorageManager):
"public": False,
"owner": owner_id,
"owner_id": school_id,
"file_size_limit": 50 * 1024 * 1024, # 50MB
"file_size_limit": 50 * 1024 * 1024,
"allowed_mime_types": [
'image/*', 'video/*', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.*',
@@ -261,7 +259,7 @@ class StorageAdmin(StorageManager):
results = {}
for bucket in school_buckets:
try:
bucket_name = bucket.pop("name") # Remove name from options
bucket_name = bucket.pop("name")
result = self.create_bucket(name=bucket_name, **bucket)
results[bucket["id"]] = {
"status": "success",
@@ -281,9 +279,20 @@ class StorageAdmin(StorageManager):
raise StorageError(str(e))
class StorageUser(StorageManager):
"""Storage user class for managing storage buckets with user role access."""
"""Storage user class for managing storage with per-user RLS enforcement.
def __init__(self, user_id: Optional[str] = None):
"""Initialize StorageUser with user role client."""
super().__init__(SupabaseAnonClient())
self.user_id = user_id
Requires a user access token to enforce Row Level Security policies.
Without a token, requests use the anon key which does NOT enforce per-user RLS.
"""
def __init__(self, user_id: Optional[str] = None, access_token: Optional[str] = None):
"""Initialize StorageUser with user role client.
Args:
user_id: The user's ID (for logging/context)
access_token: User's JWT access token for per-user RLS enforcement
"""
self.user_id = user_id
# Pass access_token to enable per-user RLS via auth.uid() in JWT
client = SupabaseAnonClient.for_user(access_token) if access_token else SupabaseAnonClient()
super().__init__(client)
+37 -14
View File
@@ -18,7 +18,7 @@ def _retry_with_backoff(
) -> any:
"""
Helper function to retry operations with exponential backoff.
Args:
func: Function to retry
max_attempts: Maximum number of retry attempts
@@ -29,26 +29,26 @@ def _retry_with_backoff(
attempt = 0
delay = initial_delay
start_time = time.time()
while attempt < max_attempts:
try:
return func()
except Exception as e:
attempt += 1
elapsed_time = time.time() - start_time
# Check if we've exceeded the maximum total wait time
if elapsed_time >= max_total_wait:
logger.error(f"Exceeded maximum total wait time of {max_total_wait} seconds")
raise
if attempt == max_attempts:
logger.error(f"Final attempt {attempt} failed: {e}")
raise
# Calculate next delay with exponential backoff, but cap it
delay = min(delay * 2, max_delay)
# If we're in a container initialization scenario, provide more context
if "Connection refused" in str(e):
logger.warning(
@@ -59,7 +59,7 @@ def _retry_with_backoff(
)
else:
logger.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay:.1f} seconds...")
time.sleep(delay)
def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: Optional[Tuple[str, str]] = None) -> Optional[Driver]:
@@ -71,7 +71,7 @@ def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: O
logger.error("Neo4j credentials not found in environment")
return None
auth = (username, password)
if auth is None:
logger.error("No authentication credentials provided")
return None
@@ -95,7 +95,7 @@ def get_driver(db_name: Optional[str] = None, url: Optional[str] = None, auth: O
except Exception as e:
logger.error(f"Failed to establish Neo4j connection after all retries: {e}")
return None
# Test the connection with the specific database
if db_name and driver:
def verify_database():
@@ -127,27 +127,50 @@ def close_driver(driver: Optional[Driver]) -> None:
logger.info("Closing driver")
driver.close()
# Global driver instance
# Global driver instance — None means not yet initialised, _driver_unavailable=True means connection failed
_driver: Optional[Driver] = None
_driver_unavailable: bool = False
def get_global_driver() -> Optional[Driver]:
"""Get or create the global Neo4j driver instance."""
global _driver
"""Get or create the global Neo4j driver instance.
Caches both success and failure so a broken Neo4j connection causes
a single 60-second retry at startup, then fast-fails on every
subsequent call instead of hanging for 60s each time.
"""
global _driver, _driver_unavailable
if _driver_unavailable:
return None
if _driver is None:
_driver = get_driver()
if _driver is None:
_driver_unavailable = True
logger.error("Neo4j driver unavailable — all subsequent Neo4j calls will fail fast until process restarts")
return _driver
def reset_global_driver() -> None:
"""Reset the cached driver, forcing a reconnection attempt on the next call.
Call this if Neo4j becomes available after the process started.
"""
global _driver, _driver_unavailable
if _driver:
close_driver(_driver)
_driver = None
_driver_unavailable = False
logger.info("Global Neo4j driver reset — will reconnect on next request")
@contextmanager
def get_session(database: Optional[str] = None) -> Generator[Session, None, None]:
"""Get a Neo4j session using the global driver."""
driver = get_global_driver()
if driver is None:
raise Exception("Failed to get Neo4j driver")
session = None
try:
session = driver.session(database=database)
yield session
finally:
if session:
session.close()
session.close()
+3 -1
View File
@@ -491,7 +491,9 @@ class RedisManager:
try:
if not self.client:
raise Exception("No Redis connection")
logger.info("Redis health check has no active client; connecting now")
if not self.connect():
raise Exception("No Redis connection")
# Test connection
self.client.ping()
+4
View File
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q
-4414
View File
File diff suppressed because one or more lines are too long
+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 []}
+626
View File
@@ -0,0 +1,626 @@
"""
Classes Router — Supabase-backed CRUD for the `classes` table.
Institute members can read their institute's classes.
School admins and teachers can create classes.
School admins manage teacher/student assignments; teachers can leave/enroll.
"""
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
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
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _resolve_institute_id(user_id: str) -> Optional[str]:
"""Return the Supabase institute UUID for this user via profiles.school_id."""
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 _require_institute(user_id: str) -> Optional[str]:
"""Return institute_id, or None if the user has no school membership."""
return _resolve_institute_id(user_id)
def _is_school_admin(user_id: str, institute_id: str) -> bool:
try:
sb = _sb()
r = (
sb.supabase.table("institute_memberships")
.select("role")
.eq("profile_id", user_id)
.eq("institute_id", institute_id)
.in_("role", ["school_admin", "department_head"])
.limit(1)
.execute()
)
return bool(r.data)
except Exception:
return False
# ─── Request models ───────────────────────────────────────────────────────────
class CreateClassRequest(BaseModel):
name: str
class_code: Optional[str] = None
subject: Optional[str] = None
key_stage: Optional[str] = None
year_group: Optional[str] = None
academic_year: Optional[str] = None
description: Optional[str] = None
class UpdateClassRequest(BaseModel):
name: Optional[str] = None
class_code: Optional[str] = None
subject: Optional[str] = None
key_stage: Optional[str] = None
year_group: Optional[str] = None
academic_year: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class AddTeacherRequest(BaseModel):
teacher_id: str
is_primary: bool = False
class AddStudentRequest(BaseModel):
student_id: str
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.get("")
async def list_classes(
subject: Optional[str] = None,
school_year: Optional[str] = None,
academic_year: Optional[str] = None,
key_stage: Optional[str] = None,
year_group: Optional[str] = None,
search: Optional[str] = None,
active_only: bool = True,
skip: int = 0,
limit: int = 50,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": [], "total": 0}
sb = _sb()
q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id)
if active_only:
q = q.eq("is_active", True)
if subject:
q = q.eq("subject", subject)
# support both param names
yr = academic_year or school_year
if yr:
q = q.eq("academic_year", yr)
if key_stage:
q = q.eq("key_stage", key_stage)
if year_group:
q = q.eq("year_group", year_group)
if search:
q = q.ilike("name", f"%{search}%")
q = q.order("name").range(skip, skip + limit - 1)
res = q.execute()
classes = res.data or []
total = res.count or 0
# Attach student/teacher counts
class_ids = [c["id"] for c in classes]
teacher_counts: Dict[str, int] = {}
student_counts: Dict[str, int] = {}
if class_ids:
try:
tc = (
sb.supabase.table("class_teachers")
.select("class_id", count="exact")
.in_("class_id", class_ids)
.execute()
)
for row in (tc.data or []):
teacher_counts[row["class_id"]] = teacher_counts.get(row["class_id"], 0) + 1
sc = (
sb.supabase.table("class_students")
.select("class_id")
.in_("class_id", class_ids)
.eq("status", "active")
.execute()
)
for row in (sc.data or []):
student_counts[row["class_id"]] = student_counts.get(row["class_id"], 0) + 1
except Exception:
pass
enriched = [
{**c, "teacher_count": teacher_counts.get(c["id"], 0), "student_count": student_counts.get(c["id"], 0)}
for c in classes
]
return {"classes": enriched, "total": total}
@router.get("/me/teacher")
async def my_teaching_classes(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": []}
sb = _sb()
assigned = (
sb.supabase.table("class_teachers")
.select("class_id, is_primary")
.eq("teacher_id", user_id)
.execute()
.data or []
)
if not assigned:
return {"classes": []}
class_ids = [a["class_id"] for a in assigned]
is_primary_map = {a["class_id"]: a["is_primary"] for a in assigned}
res = (
sb.supabase.table("classes")
.select("*")
.in_("id", class_ids)
.eq("institute_id", institute_id)
.eq("is_active", True)
.order("name")
.execute()
.data or []
)
enriched = [{**c, "is_primary_teacher": is_primary_map.get(c["id"], False)} for c in res]
return {"classes": enriched}
@router.get("/me/student")
async def my_student_classes(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not institute_id:
return {"classes": []}
sb = _sb()
enrolled = (
sb.supabase.table("class_students")
.select("class_id")
.eq("student_id", user_id)
.eq("status", "active")
.execute()
.data or []
)
if not enrolled:
return {"classes": []}
class_ids = [e["class_id"] for e in enrolled]
res = (
sb.supabase.table("classes")
.select("*")
.in_("id", class_ids)
.eq("institute_id", institute_id)
.eq("is_active", True)
.order("name")
.execute()
.data or []
)
return {"classes": res}
@router.get("/school/students")
async def list_school_students(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""List all students in the caller's school. Used by admin to add students to a class."""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not institute_id:
return {"students": []}
sb = _sb()
members = (
sb.supabase.table("institute_memberships")
.select("profile_id")
.eq("institute_id", institute_id)
.eq("role", "student")
.execute()
.data or []
)
student_ids = [m["profile_id"] for m in members]
if not student_ids:
return {"students": []}
profiles = (
sb.supabase.table("profiles")
.select("id, full_name, display_name, email, user_type")
.in_("id", student_ids)
.order("full_name")
.execute()
.data or []
)
return {"students": profiles}
@router.get("/{class_id}")
async def get_class(
class_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
cls_res = (
sb.supabase.table("classes")
.select("*")
.eq("id", class_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not cls_res.data:
raise HTTPException(status_code=404, detail="Class not found")
teachers = (
sb.supabase.table("class_teachers")
.select("teacher_id, is_primary, can_edit, assigned_at")
.eq("class_id", class_id)
.execute()
.data or []
)
students = (
sb.supabase.table("class_students")
.select("student_id, status, enrolled_at")
.eq("class_id", class_id)
.eq("status", "active")
.execute()
.data or []
)
# Enrich with profile data
all_ids = [t["teacher_id"] for t in teachers] + [s["student_id"] for s in students]
profile_map: Dict[str, Dict] = {}
if all_ids:
profiles = (
sb.supabase.table("profiles")
.select("id, full_name, display_name, email, user_type")
.in_("id", list(set(all_ids)))
.execute()
.data or []
)
profile_map = {p["id"]: p for p in profiles}
for t in teachers:
t["profile"] = profile_map.get(t["teacher_id"], {})
for s in students:
s["profile"] = profile_map.get(s["student_id"], {})
# Enrollment requests (pending)
reqs = (
sb.supabase.table("enrollment_requests")
.select("id, student_id, status, created_at")
.eq("class_id", class_id)
.eq("status", "pending")
.execute()
.data or []
)
req_student_ids = [r["student_id"] for r in reqs if r.get("student_id")]
req_profiles: Dict[str, Dict] = {}
if req_student_ids:
rp = (
sb.supabase.table("profiles")
.select("id, full_name, email")
.in_("id", req_student_ids)
.execute()
.data or []
)
req_profiles = {p["id"]: p for p in rp}
for r in reqs:
r["profile"] = req_profiles.get(r.get("student_id", ""), {})
return {
**cls_res.data,
"teachers": teachers,
"students": students,
"enrollment_requests": reqs,
"student_count": len(students),
}
@router.post("")
async def create_class(
body: CreateClassRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
row = {
"institute_id": institute_id,
"name": body.name,
"created_by": user_id,
}
for field in ("class_code", "subject", "key_stage", "year_group", "academic_year", "description"):
val = getattr(body, field)
if val is not None:
row[field] = val
res = sb.supabase.table("classes").insert(row).execute()
new_class = (res.data or [{}])[0]
class_id = new_class.get("id")
# Auto-assign creator as primary teacher if they have teacher role
if class_id:
try:
mem = (
sb.supabase.table("institute_memberships")
.select("role")
.eq("profile_id", user_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if (mem.data or {}).get("role") in ("teacher", "department_head"):
sb.supabase.table("class_teachers").insert({
"class_id": class_id,
"teacher_id": user_id,
"is_primary": True,
"assigned_by": user_id,
}).execute()
except Exception:
pass
logger.info(f"Class created: {class_id} '{body.name}' by {user_id}")
return new_class
@router.patch("/{class_id}")
async def update_class(
class_id: str,
body: UpdateClassRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
# Must be school admin OR primary teacher of this class
is_admin = _is_school_admin(user_id, institute_id)
if not is_admin:
ct = (
sb.supabase.table("class_teachers")
.select("is_primary")
.eq("class_id", class_id)
.eq("teacher_id", user_id)
.single()
.execute()
)
if not (ct.data or {}).get("is_primary"):
raise HTTPException(status_code=403, detail="Only school admins or the primary teacher can update this class")
updates = {k: v for k, v in body.dict().items() if v is not None}
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates["updated_at"] = datetime.utcnow().isoformat()
res = (
sb.supabase.table("classes")
.update(updates)
.eq("id", class_id)
.eq("institute_id", institute_id)
.execute()
)
return (res.data or [{}])[0]
@router.delete("/{class_id}")
async def delete_class(
class_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can delete classes")
sb = _sb()
sb.supabase.table("classes").update({"is_active": False}).eq("id", class_id).eq("institute_id", institute_id).execute()
return {"status": "ok"}
@router.post("/{class_id}/teachers")
async def add_teacher(
class_id: str,
body: AddTeacherRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can assign teachers")
sb = _sb()
res = sb.supabase.table("class_teachers").upsert({
"class_id": class_id,
"teacher_id": body.teacher_id,
"is_primary": body.is_primary,
"assigned_by": user_id,
}, on_conflict="class_id,teacher_id").execute()
return {"status": "ok", "row": (res.data or [{}])[0]}
@router.delete("/{class_id}/teachers/{teacher_id}")
async def remove_teacher(
class_id: str,
teacher_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can remove teachers")
sb = _sb()
sb.supabase.table("class_teachers").delete().eq("class_id", class_id).eq("teacher_id", teacher_id).execute()
return {"status": "ok"}
@router.post("/{class_id}/students")
async def add_student(
class_id: str,
body: AddStudentRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can enroll students")
sb = _sb()
res = sb.supabase.table("class_students").upsert({
"class_id": class_id,
"student_id": body.student_id,
"status": "active",
"enrolled_by": user_id,
}, on_conflict="class_id,student_id").execute()
return {"status": "ok", "row": (res.data or [{}])[0]}
@router.delete("/{class_id}/students/{student_id}")
async def remove_student(
class_id: str,
student_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can remove students")
sb = _sb()
sb.supabase.table("class_students").delete().eq("class_id", class_id).eq("student_id", student_id).execute()
return {"status": "ok"}
@router.post("/{class_id}/leave")
async def leave_class(
class_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
sb = _sb()
sb.supabase.table("class_students").update({"status": "inactive"}).eq("class_id", class_id).eq("student_id", user_id).execute()
return {"status": "ok"}
@router.get("/{class_id}/enrollment-requests")
async def list_enrollment_requests(
class_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
reqs = (
sb.supabase.table("enrollment_requests")
.select("*")
.eq("class_id", class_id)
.eq("status", "pending")
.execute()
.data or []
)
return {"requests": reqs}
@router.post("/{class_id}/enroll")
async def request_enrollment(
class_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
sb = _sb()
res = sb.supabase.table("enrollment_requests").insert({
"class_id": class_id,
"student_id": user_id,
"status": "pending",
}).execute()
return {"status": "ok", "request": (res.data or [{}])[0]}
@router.patch("/{class_id}/enrollment-requests/{request_id}")
async def respond_enrollment_request(
class_id: str,
request_id: str,
body: dict,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Approve or reject a pending enrollment request. School admin only."""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
if not _is_school_admin(user_id, institute_id):
raise HTTPException(status_code=403, detail="Only school admins can respond to enrollment requests")
action = body.get("action")
if action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="action must be 'approve' or 'reject'")
sb = _sb()
req = (
sb.supabase.table("enrollment_requests")
.select("student_id, status")
.eq("id", request_id)
.eq("class_id", class_id)
.single()
.execute()
)
if not req.data:
raise HTTPException(status_code=404, detail="Request not found")
if req.data["status"] != "pending":
raise HTTPException(status_code=400, detail="Request is not pending")
student_id = req.data["student_id"]
new_status = "accepted" if action == "approve" else "rejected"
sb.supabase.table("enrollment_requests").update({"status": new_status}).eq("id", request_id).execute()
if action == "approve":
sb.supabase.table("class_students").upsert({
"class_id": class_id,
"student_id": student_id,
"status": "active",
"enrolled_by": user_id,
}, on_conflict="class_id,student_id").execute()
return {"status": "ok", "action": action, "student_id": student_id}
+902
View File
@@ -0,0 +1,902 @@
import os
from typing import Dict, Any, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
import modules.database.tools.neo4j_driver_tools as driver_tools
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
# ─── DB helpers ────────────────────────────────────────────────────────────────
def _user_to_teacher_db(user_id: str) -> str:
return f"cc.users.teacher.{user_id.replace('-', '')}"
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]:
"""Query teacher UUID from a known Neo4j institute DB."""
try:
with driver_tools.get_session(database=db) as session:
rec = session.run(
'MATCH (t:Teacher) WHERE t.worker_email = $email '
'RETURN t.uuid_string AS uuid LIMIT 1',
email=user_email,
).single()
if rec:
return rec['uuid']
except Exception:
pass
return None
def _resolve_institute(
user_id: str, user_email: str
) -> tuple:
"""Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid).
Supabase-first lookup with Neo4j email-scan fallback."""
try:
sb = _sb()
p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute()
school_id = (p.data or {}).get('school_id')
if school_id:
i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute()
inst = i.data or {}
neo4j_uuid = inst.get('neo4j_uuid_string')
if neo4j_uuid:
db = f'cc.institutes.{neo4j_uuid}'
teacher_uuid = _find_teacher_uuid(db, user_email)
return str(school_id), db, teacher_uuid
except Exception as e:
logger.warning(f'Supabase-first institute resolve failed: {e}')
# Fallback: scan Neo4j
db, teacher_uuid = _find_teacher_institute(user_email)
return None, db, teacher_uuid
def _allowed_neo4j_dbs(user_id: str, user_email: str) -> set[str]:
"""Return Neo4j databases this user may request via lazy graph APIs."""
allowed = {f"cc.users.teacher.{user_id.replace('-', '')}"} if user_id else set()
if user_id or user_email:
_, institute_db, _ = _resolve_institute(user_id, user_email)
if institute_db:
allowed.add(institute_db)
allowed.add(f"{institute_db}.curriculum")
return allowed
def _require_allowed_neo4j_db(neo4j_db_name: str, node_type: str, section_id: str, user_id: str, user_email: str) -> None:
"""Reject arbitrary DB traversal from /graph/node/children query params."""
if not neo4j_db_name:
raise HTTPException(status_code=400, detail="neo4j_db_name is required")
if neo4j_db_name == "classroomcopilot":
if node_type.startswith("Calendar") or section_id == "calendar":
return
raise HTTPException(status_code=403, detail="Requested graph database is not allowed for this node")
if neo4j_db_name in _allowed_neo4j_dbs(user_id, user_email):
return
raise HTTPException(status_code=403, detail="Requested graph database is outside the authenticated user's scope")
def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]:
"""Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
if not user_email:
return None, None
try:
with driver_tools.get_session(database="system") as session:
result = session.run(
"SHOW DATABASES YIELD name "
"WHERE name STARTS WITH 'cc.institutes.' "
"AND NOT name ENDS WITH '.curriculum' "
"RETURN name"
)
dbs = [r["name"] for r in result]
for db in dbs:
try:
with driver_tools.get_session(database=db) as session:
rec = session.run(
"MATCH (t:Teacher) WHERE t.worker_email = $email "
"RETURN t.uuid_string AS uuid LIMIT 1",
email=user_email,
).single()
if rec and rec["uuid"]:
return db, rec["uuid"]
except Exception:
continue
except Exception as e:
logger.warning(f"Institute lookup failed: {e}")
return None, None
# ─── Node queries ───────────────────────────────────────────────────────────────
def _query_user_node(teacher_db: str) -> Optional[Dict]:
try:
with driver_tools.get_session(database=teacher_db) as session:
rec = session.run("MATCH (u:User) RETURN u LIMIT 1").single()
if not rec:
return None
u = rec["u"]
return {
"neo4j_node_id": u["uuid_string"],
"label": u.get("user_name") or u.get("cc_username") or "My Workspace",
"node_type": "User",
"neo4j_db_name": teacher_db,
"is_section": False,
"has_children": True,
}
except Exception as e:
logger.warning(f"Could not query User node in {teacher_db}: {e}")
return None
def _query_calendar_months(year: str) -> List[Dict]:
try:
with driver_tools.get_session(database="classroomcopilot") as session:
result = session.run(
"MATCH (y:CalendarYear {year: $year})-[:YEAR_INCLUDES_MONTH]->(m:CalendarMonth) "
"RETURN m ORDER BY toInteger(m.month)",
year=year,
)
return [
{
"neo4j_node_id": r["m"]["uuid_string"],
"label": r["m"]["month_name"],
"node_type": "CalendarMonth",
"neo4j_db_name": "classroomcopilot",
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.error(f"Error querying calendar months for {year}: {e}")
return []
def _query_month_days(month_uuid: str) -> List[Dict]:
try:
with driver_tools.get_session(database="classroomcopilot") as session:
result = session.run(
"MATCH (m:CalendarMonth {uuid_string: $mid})-[:MONTH_INCLUDES_DAY]->(d:CalendarDay) "
"RETURN d ORDER BY d.date",
mid=month_uuid,
)
return [
{
"neo4j_node_id": r["d"]["uuid_string"],
"label": f"{r['d']['day_of_week'][:3]} {r['d']['iso_day']}",
"node_type": "CalendarDay",
"neo4j_db_name": "classroomcopilot",
"is_section": False,
"has_children": False,
"neo4j_props": {
"date": str(r["d"].get("date", "")),
"day_of_week": r["d"].get("day_of_week", ""),
"iso_day": r["d"].get("iso_day", ""),
},
}
for r in result
]
except Exception as e:
logger.error(f"Error querying days for month {month_uuid}: {e}")
return []
def _query_teacher_classes(
user_id: str, institute_id: str, institute_db: str, section_id: str = ""
) -> List[Dict]:
"""Query classes for a teacher or student from Supabase (source of truth)."""
try:
sb = _sb()
teacher_rows = (
sb.supabase.table("class_teachers")
.select("class_id, is_primary")
.eq("teacher_id", user_id)
.execute()
.data or []
)
teacher_class_ids = {r["class_id"] for r in teacher_rows}
student_rows = (
sb.supabase.table("class_students")
.select("class_id")
.eq("student_id", user_id)
.eq("status", "active")
.execute()
.data or []
)
student_class_ids = {r["class_id"] for r in student_rows}
all_ids = list(teacher_class_ids | student_class_ids)
if not all_ids:
return []
classes = (
sb.supabase.table("classes")
.select("id, name, class_code, subject")
.in_("id", all_ids)
.eq("institute_id", institute_id)
.eq("is_active", True)
.order("name")
.execute()
.data or []
)
result = []
for c in classes:
role = "teacher" if c["id"] in teacher_class_ids else "student"
label = c.get("class_code") or c.get("name") or "Class"
node: Dict = {
"neo4j_node_id": c["id"],
"label": label,
"node_type": "SubjectClass",
"neo4j_db_name": institute_db,
"is_section": False,
"has_children": True,
"neo4j_props": {
"role": role,
"subject": c.get("subject") or "",
"name": c.get("name") or "",
"class_code": c.get("class_code") or "",
},
}
if section_id:
node["section_id"] = section_id
result.append(node)
return result
except Exception as e:
logger.warning(f"Could not query classes for user {user_id}: {e}")
return []
# ─── Section builders ───────────────────────────────────────────────────────────
def _section(section_id: str, label: str, db: str, status: str,
has_children: bool = False, children: Optional[List] = None) -> Dict:
return {
"neo4j_node_id": f"section_{section_id}",
"label": label,
"node_type": "Section",
"section_id": section_id,
"neo4j_db_name": db,
"is_section": True,
"has_children": has_children,
"status": status,
**({"children": children} if children is not None else {}),
}
def _build_calendar_section() -> Dict:
try:
with driver_tools.get_session(database="classroomcopilot") as session:
rows = session.run(
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
).data()
if not rows:
return _section("calendar", "Calendar", "classroomcopilot", "empty")
year_nodes = [
{
"neo4j_node_id": r["y"]["uuid_string"],
"label": r["y"].get("year") or r["y"]["uuid_string"],
"node_type": "CalendarYear",
"neo4j_db_name": "classroomcopilot",
"is_section": False,
"has_children": True,
}
for r in rows
]
return _section(
"calendar", "Calendar", "classroomcopilot", "populated",
has_children=True, children=year_nodes,
)
except Exception as e:
logger.warning(f"Calendar section build failed: {e}")
return _section("calendar", "Calendar", "classroomcopilot", "empty")
def _build_timetable_section(user_id: str, institute_id: Optional[str], institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict:
if not institute_db or not teacher_uuid or not institute_id:
return _section("timetable", "My Timetable", "", "no_school")
try:
with driver_tools.get_session(database=institute_db) as session:
rec = session.run(
"MATCH (t:Teacher {uuid_string: $uuid})-[:HAS_TIMETABLE]->(tt) "
"RETURN tt LIMIT 1",
uuid=teacher_uuid,
).single()
if rec:
tt = rec["tt"]
tt_uuid = tt["uuid_string"]
# Load classes from Supabase (source of truth)
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="timetable")
return {
**_section("timetable", "My Timetable", institute_db, "populated",
has_children=True, children=classes if classes else None),
"neo4j_node_id": tt_uuid,
"node_type": "TeacherTimetable",
"is_section": True,
}
except Exception as e:
logger.warning(f"Timetable query failed: {e}")
return _section("timetable", "My Timetable", institute_db, "empty")
def _build_classes_section(user_id: str, institute_id: Optional[str], institute_db: Optional[str]) -> Dict:
if not institute_db or not institute_id:
return _section("classes", "My Classes", "", "no_school")
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="classes")
if classes:
return _section("classes", "My Classes", institute_db, "populated",
has_children=True, children=classes)
return _section("classes", "My Classes", institute_db, "empty")
def _build_curriculum_section(institute_db: Optional[str]) -> Dict:
if not institute_db:
return _section("curriculum", "Curriculum", "", "no_school")
# Check for curriculum DB
curriculum_db = f"{institute_db}.curriculum"
try:
with driver_tools.get_session(database="system") as session:
rec = session.run(
"SHOW DATABASES YIELD name WHERE name = $db RETURN name",
db=curriculum_db,
).single()
if not rec:
return _section("curriculum", "Curriculum", institute_db, "empty")
with driver_tools.get_session(database=curriculum_db) as session:
rec = session.run("MATCH (n) RETURN count(n) AS cnt").single()
cnt = rec["cnt"] if rec else 0
if cnt > 0:
return _section("curriculum", "Curriculum", curriculum_db, "populated",
has_children=True)
except Exception as e:
logger.warning(f"Curriculum check failed: {e}")
return _section("curriculum", "Curriculum", institute_db, "empty")
def _build_journal_section(teacher_db: str) -> Dict:
try:
with driver_tools.get_session(database=teacher_db) as session:
rec = session.run("MATCH (j:Journal) RETURN j LIMIT 1").single()
if rec:
j = rec["j"]
return {
**_section("journal", "Journal", teacher_db, "populated", has_children=True),
"neo4j_node_id": j["uuid_string"],
"node_type": "Journal",
"is_section": True,
}
except Exception as e:
logger.warning(f"Journal query failed: {e}")
return _section("journal", "Journal", teacher_db, "not_initialized")
def _build_planner_section(teacher_db: str) -> Dict:
try:
with driver_tools.get_session(database=teacher_db) as session:
rec = session.run("MATCH (p:Planner) RETURN p LIMIT 1").single()
if rec:
p = rec["p"]
return {
**_section("planner", "Planner", teacher_db, "populated", has_children=True),
"neo4j_node_id": p["uuid_string"],
"node_type": "Planner",
"is_section": True,
}
except Exception as e:
logger.warning(f"Planner query failed: {e}")
return _section("planner", "Planner", teacher_db, "not_initialized")
def _build_school_section(institute_db: str) -> Dict:
try:
with driver_tools.get_session(database=institute_db) as session:
rec = session.run("MATCH (s:School) RETURN s LIMIT 1").single()
if not rec:
return _section("school", "My School", institute_db, "empty")
s = rec["s"]
name = s.get("name") or "My School"
# Academic years under this school
ay_rows = session.run(
"MATCH (ay:AcademicYear) RETURN ay ORDER BY ay.year"
).data()
children = [
{
"neo4j_node_id": row["ay"]["uuid_string"],
"label": row["ay"].get("year") or "Academic Year",
"node_type": "AcademicYear",
"neo4j_db_name": institute_db,
"is_section": False,
"has_children": True,
}
for row in ay_rows
]
return {
**_section("school", name, institute_db, "populated",
has_children=len(children) > 0,
children=children if children else None),
"neo4j_node_id": s["uuid_string"],
"node_type": "School",
"is_section": True,
}
except Exception as e:
logger.warning(f"School query failed: {e}")
return _section("school", "My School", institute_db, "empty")
# ─── Lazy children for expanded nodes ──────────────────────────────────────────
def _get_children_for_node(
neo4j_node_id: str,
neo4j_db_name: str,
node_type: str,
section_id: str = "",
user_email: str = "",
) -> List[Dict]:
# Calendar
if node_type == "CalendarYear":
return _query_calendar_months(neo4j_node_id)
if node_type == "CalendarMonth":
return _query_month_days(neo4j_node_id)
# TeacherTimetable lazy-load (fallback if not pre-loaded, or for By-Term view)
if node_type == "TeacherTimetable" and neo4j_db_name:
if section_id in ("", "timetable"):
# Classes are pre-loaded in _build_timetable_section via Supabase.
# Lazy expansion here is not needed in normal flow.
return []
if section_id == "timetable-term":
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (tt:TeacherTimetable {uuid_string: $id}) "
"-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay:AcademicYear) "
"-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
"RETURN t ORDER BY toInteger(t.term_number)",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["t"]["uuid_string"],
"label": r["t"].get("term_name") or "Term {}".format(r["t"].get("term_number", "")),
"node_type": "AcademicTerm",
"neo4j_db_name": neo4j_db_name,
"section_id": "timetable-term",
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.warning(f"TeacherTimetable term children failed: {e}")
return []
# SubjectClass — expand to taught lessons (timetable context) or members (classes context)
if node_type == "SubjectClass":
class_id = neo4j_node_id # Supabase class UUID
try:
sb = _sb()
if section_id == "timetable" and user_email:
# Resolve teacher profile_id from email
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
teacher_id = (prof.data or {}).get("id")
if teacher_id:
lessons = (
sb.supabase.table("taught_lessons")
.select("id, date, period_code, class_name, subject")
.eq("class_id", class_id)
.eq("teacher_profile_id", teacher_id)
.order("date")
.order("period_code")
.execute()
.data or []
)
return [
{
"neo4j_node_id": tl["id"],
"label": "{} {}".format(
tl.get("period_code") or "",
tl.get("date") or ""
).strip(),
"node_type": "TaughtLesson",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": False,
"section_id": "timetable",
}
for tl in lessons
]
return []
if section_id == "classes":
# Class members: students enrolled in this class
members = (
sb.supabase.table("class_students")
.select("student_id, status, enrolled_at")
.eq("class_id", class_id)
.order("enrolled_at")
.execute()
.data or []
)
if not members:
return []
student_ids = [m["student_id"] for m in members]
status_map = {m["student_id"]: m["status"] for m in members}
profiles = (
sb.supabase.table("profiles")
.select("id, email, first_name, last_name, user_type")
.in_("id", student_ids)
.order("last_name")
.execute()
.data or []
)
return [
{
"neo4j_node_id": p["id"],
"label": "{} {} ({})".format(
p.get("first_name") or "",
p.get("last_name") or "",
status_map.get(p["id"], ""),
).strip(),
"node_type": "Student",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": False,
"section_id": "classes",
}
for p in profiles
]
except Exception as e:
logger.warning(f"SubjectClass children failed: {e}")
return []
# Section containers that need lazy loading
if node_type == "Section":
if section_id == "timetable" and neo4j_db_name:
# Children of timetable section = classes
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (tt)-[:TIMETABLE_HAS_CLASS]->(c:SubjectClass) "
"WHERE tt.uuid_string = $id "
"RETURN c ORDER BY c.name",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["c"]["uuid_string"],
"label": r["c"].get("name") or "Class",
"node_type": "SubjectClass",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.warning(f"Timetable children query failed: {e}")
return []
if section_id == "curriculum" and neo4j_db_name:
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (s:KeyStageSyllabus) RETURN s ORDER BY s.key_stage"
)
rows = list(result)
if not rows:
result = session.run(
"MATCH (s:YearGroupSyllabus) RETURN s ORDER BY s.year_group"
)
rows = list(result)
return [
{
"neo4j_node_id": r[list(r.keys())[0]]["uuid_string"],
"label": r[list(r.keys())[0]].get("name") or r[list(r.keys())[0]].get("key_stage") or "Syllabus",
"node_type": list(r.keys())[0],
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": True,
}
for r in rows
]
except Exception as e:
logger.warning(f"Curriculum children query failed: {e}")
return []
if section_id == "school" and neo4j_db_name:
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (s:School {uuid_string: $id})-[:HAS_DEPARTMENT]->(d) "
"RETURN d ORDER BY d.name",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["d"]["uuid_string"],
"label": r["d"].get("name") or "Department",
"node_type": "Department",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.warning(f"School children query failed: {e}")
return []
# AcademicYear → terms
if node_type == "AcademicYear" and neo4j_db_name:
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (ay:AcademicYear {uuid_string: $id})"
"-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
"RETURN t ORDER BY toInteger(t.term_number)",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["t"]["uuid_string"],
"label": r["t"]["term_name"],
"node_type": "AcademicTerm",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.warning(f"AcademicYear children failed: {e}")
return []
# AcademicWeek → days (or TaughtLessons in timetable-term context)
if node_type == "AcademicWeek" and neo4j_db_name:
if section_id == "timetable-term" and user_email:
# Supabase: get week date range from Neo4j, then query taught_lessons
try:
week_start = None
with driver_tools.get_session(database=neo4j_db_name) as session:
rec = session.run(
"MATCH (w:AcademicWeek {uuid_string: $id}) "
"RETURN w.start_date AS start_date",
id=neo4j_node_id,
).single()
if rec:
week_start = rec["start_date"]
if week_start:
from datetime import datetime, timedelta
start_dt = datetime.strptime(str(week_start)[:10], "%Y-%m-%d")
end_dt = start_dt + timedelta(days=6)
sb = _sb()
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
teacher_id = (prof.data or {}).get("id")
if teacher_id:
lessons = (
sb.supabase.table("taught_lessons")
.select("id, date, period_code, class_name, subject")
.eq("teacher_profile_id", teacher_id)
.gte("date", start_dt.strftime("%Y-%m-%d"))
.lte("date", end_dt.strftime("%Y-%m-%d"))
.order("date")
.order("period_code")
.execute()
.data or []
)
return [
{
"neo4j_node_id": tl["id"],
"label": "{}{}".format(
tl.get("period_code") or "",
tl.get("class_name") or tl.get("subject") or "Lesson"
),
"node_type": "TaughtLesson",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": False,
}
for tl in lessons
]
except Exception as e:
logger.warning(f"AcademicWeek timetable-term Supabase lessons failed: {e}")
return []
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (w:AcademicWeek {uuid_string: $id}) "
"-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d:AcademicDay) "
"RETURN d ORDER BY d.date",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["d"]["uuid_string"],
"label": r["d"].get("date", ""),
"node_type": "AcademicDay",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": False,
}
for r in result
]
except Exception as e:
logger.warning(f"AcademicWeek children failed: {e}")
return []
# AcademicTerm → weeks
if node_type == "AcademicTerm" and neo4j_db_name:
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (t:AcademicTerm {uuid_string: $id})"
"-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
"RETURN w ORDER BY toInteger(w.academic_week_number)",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["w"]["uuid_string"],
"label": "Week {}".format(r["w"].get("academic_week_number", r["w"].get("week_number", "?"))),
"node_type": "AcademicWeek",
"neo4j_db_name": neo4j_db_name,
"section_id": section_id if section_id == "timetable-term" else "",
"is_section": False,
"has_children": True,
}
for r in result
]
except Exception as e:
logger.warning(f"AcademicTerm children failed: {e}")
return []
# SubjectClass → lessons
if node_type == "SubjectClass" and neo4j_db_name:
try:
with driver_tools.get_session(database=neo4j_db_name) as session:
result = session.run(
"MATCH (c:SubjectClass {uuid_string: $id})-[:CLASS_HAS_LESSON]->(l) "
"RETURN l ORDER BY l.date, l.start_time LIMIT 50",
id=neo4j_node_id,
)
return [
{
"neo4j_node_id": r["l"]["uuid_string"],
"label": r["l"].get("title") or r["l"].get("period_code") or "Lesson",
"node_type": "TimetableLesson",
"neo4j_db_name": neo4j_db_name,
"is_section": False,
"has_children": False,
}
for r in result
]
except Exception as e:
logger.warning(f"Class lessons query failed: {e}")
return []
# ─── Endpoints ──────────────────────────────────────────────────────────────────
@router.get("/tree")
async def get_teacher_graph_tree(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
if not user_id:
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
teacher_db = _user_to_teacher_db(user_id)
user_node = _query_user_node(teacher_db) or {
"neo4j_node_id": user_id,
"label": "My Workspace",
"node_type": "User",
"neo4j_db_name": teacher_db,
"is_section": False,
"has_children": True,
}
supabase_institute_id, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
sections = [
_build_calendar_section(),
_build_timetable_section(user_id, supabase_institute_id, institute_db, teacher_node_uuid),
_build_classes_section(user_id, supabase_institute_id, institute_db),
_build_curriculum_section(institute_db),
_build_journal_section(teacher_db),
_build_planner_section(teacher_db),
]
if institute_db:
sections.append(_build_school_section(institute_db))
return {
"status": "success",
"tree": {**user_node, "children": sections},
"meta": {
"institute_db": institute_db,
"teacher_linked": institute_db is not None,
},
}
@router.get("/node/children")
async def get_node_children(
neo4j_node_id: str,
neo4j_db_name: str,
node_type: str,
section_id: str = "",
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
if not user_id:
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
_require_allowed_neo4j_db(neo4j_db_name, node_type, section_id, user_id, user_email)
children = _get_children_for_node(neo4j_node_id, neo4j_db_name, node_type, section_id, user_email)
return {"status": "success", "children": children}
@router.get("/calendar/academic")
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
_, institute_db, _ = _resolve_institute(user_id, user_email)
if not institute_db:
return {"status": "no_school", "terms": []}
try:
with driver_tools.get_session(database=institute_db) as s:
if not s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single():
return {"status": "empty", "terms": [], "institute_db": institute_db}
terms = []
for tr in s.run(
"MATCH (ay:AcademicYear)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
"RETURN t ORDER BY toInteger(t.term_number)"
):
t = tr["t"]
with driver_tools.get_session(database=institute_db) as s2:
weeks = [{
"neo4j_node_id": w["w"]["uuid_string"],
"label": f"Week {w['w']['academic_week_number']}",
"node_type": "AcademicWeek",
"neo4j_db_name": institute_db,
"is_section": False,
"has_children": True,
} for w in s2.run(
"MATCH (t:AcademicTerm {uuid_string: $tid})-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
"RETURN w ORDER BY toInteger(w.academic_week_number)",
tid=t["uuid_string"]
)]
terms.append({
"neo4j_node_id": t["uuid_string"],
"label": t["term_name"],
"node_type": "AcademicTerm",
"neo4j_db_name": institute_db,
"is_section": False,
"has_children": len(weeks) > 0,
"children": weeks,
})
return {"status": "populated", "terms": terms, "institute_db": institute_db}
except Exception as e:
logger.error(f"Academic calendar query failed: {e}")
return {"status": "error", "terms": []}
@@ -0,0 +1,372 @@
"""
Invitations & People Router.
POST /users/invite — school admin sends magic-link invitation
GET /users/invitations — list invitations for the school
DELETE /users/invitations/{id} — cancel a pending invitation
POST /users/invitations/{id}/resend — re-send magic link
GET /users/staff — list current staff members
GET /users/students — list current student members
"""
import os
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
VALID_ROLES = {"teacher", "student", "school_admin", "department_head"}
STAFF_ROLES = {"teacher", "school_admin", "department_head"}
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _resolve_institute_id(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 "") or None
except Exception:
return None
def _require_institute(user_id: str) -> str:
iid = _resolve_institute_id(user_id)
if not iid:
raise HTTPException(status_code=400, detail="User is not linked to a school")
return iid
def _require_school_admin(user_id: str, institute_id: str) -> None:
try:
sb = _sb()
m = (
sb.supabase.table("institute_memberships")
.select("role")
.eq("profile_id", user_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
role = (m.data or {}).get("role", "")
if role not in ("school_admin", "department_head"):
raise HTTPException(status_code=403, detail="School admin access required")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=403, detail="Could not verify school admin status")
# ─── Request models ───────────────────────────────────────────────────────────
class InviteRequest(BaseModel):
email: str
role: str # teacher | student | school_admin | department_head
metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc.
# ─── Invite ───────────────────────────────────────────────────────────────────
@router.post("/invite")
async def invite_user(
body: InviteRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Create an invitation row and send a Supabase magic-link invite email.
Idempotent: if a pending invitation already exists for the same email/school,
it is returned (use /resend to refresh the magic link).
"""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
if body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}")
email = body.email.strip().lower()
sb = _sb()
# Check for existing pending invitation
existing = (
sb.supabase.table("invitations")
.select("id,status,email,role,created_at,expires_at")
.eq("institute_id", institute_id)
.eq("email", email)
.eq("status", "pending")
.execute()
.data or []
)
if existing:
return {
"status": "already_pending",
"invitation": existing[0],
"message": "A pending invitation already exists. Use /resend to refresh the magic link.",
}
# Insert invitation row
expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
inv_data = {
"institute_id": institute_id,
"email": email,
"role": body.role,
"invited_by": user_id,
"expires_at": expires_at,
"status": "pending",
"metadata": body.metadata or {},
}
inv_res = sb.supabase.table("invitations").insert(inv_data).execute()
if not inv_res.data:
raise HTTPException(status_code=500, detail="Failed to create invitation record")
invitation = inv_res.data[0]
# Send magic link via Supabase Auth admin
try:
sb.supabase.auth.admin.invite_user_by_email(
email,
options={
"data": {
"invitation_id": invitation["id"],
"institute_id": institute_id,
"role": body.role,
**(body.metadata or {}),
}
},
)
magic_link_sent = True
except Exception as e:
logger.warning(f"Magic link send failed for {email}: {e}")
magic_link_sent = False
logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}")
return {
"status": "ok",
"invitation": invitation,
"magic_link_sent": magic_link_sent,
}
# ─── List invitations ─────────────────────────────────────────────────────────
@router.get("/invitations")
async def list_invitations(
role: Optional[str] = Query(None),
status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"),
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
sb = _sb()
q = (
sb.supabase.table("invitations")
.select("id,email,role,status,invited_by,expires_at,created_at,metadata")
.eq("institute_id", institute_id)
.order("created_at", desc=True)
)
if role:
q = q.eq("role", role)
if status:
q = q.eq("status", status)
rows = q.execute().data or []
# Mark expired rows (status still 'pending' but past expires_at)
now = datetime.now(timezone.utc)
for row in rows:
if row["status"] == "pending":
try:
exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
if exp < now:
row["status"] = "expired"
except Exception:
pass
return {"status": "ok", "invitations": rows, "total": len(rows)}
# ─── Cancel invitation ────────────────────────────────────────────────────────
@router.delete("/invitations/{invitation_id}")
async def cancel_invitation(
invitation_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
sb = _sb()
res = (
sb.supabase.table("invitations")
.update({"status": "cancelled"})
.eq("id", invitation_id)
.eq("institute_id", institute_id)
.eq("status", "pending")
.execute()
)
if not res.data:
raise HTTPException(status_code=404, detail="Pending invitation not found")
return {"status": "ok", "invitation": res.data[0]}
# ─── Resend invitation ────────────────────────────────────────────────────────
@router.post("/invitations/{invitation_id}/resend")
async def resend_invitation(
invitation_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Re-trigger the magic link for a pending (or expired) invitation."""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
sb = _sb()
inv = (
sb.supabase.table("invitations")
.select("*")
.eq("id", invitation_id)
.eq("institute_id", institute_id)
.single()
.execute()
).data
if not inv:
raise HTTPException(status_code=404, detail="Invitation not found")
if inv["status"] == "accepted":
raise HTTPException(status_code=400, detail="Invitation already accepted")
if inv["status"] == "cancelled":
raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one")
# Refresh expiry + status
new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
sb.supabase.table("invitations").update({
"expires_at": new_expires,
"status": "pending",
}).eq("id", invitation_id).execute()
# Re-send magic link
try:
sb.supabase.auth.admin.invite_user_by_email(
inv["email"],
options={
"data": {
"invitation_id": invitation_id,
"institute_id": institute_id,
"role": inv["role"],
**inv.get("metadata", {}),
}
},
)
magic_link_sent = True
except Exception as e:
logger.warning(f"Resend magic link failed for {inv['email']}: {e}")
magic_link_sent = False
return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires}
# ─── Staff list ───────────────────────────────────────────────────────────────
@router.get("/staff")
async def list_staff(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""List all staff members (teachers, admins, department heads) in the school."""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
sb = _sb()
members = (
sb.supabase.table("institute_memberships")
.select("profile_id,role,joined_at")
.eq("institute_id", institute_id)
.in_("role", list(STAFF_ROLES))
.execute()
.data or []
)
if not members:
return {"status": "ok", "staff": [], "total": 0}
profile_ids = [m["profile_id"] for m in members]
profiles = (
sb.supabase.table("profiles")
.select("id,email,username,display_name")
.in_("id", profile_ids)
.execute()
.data or []
)
profile_map = {p["id"]: p for p in profiles}
staff = []
for m in members:
p = profile_map.get(m["profile_id"], {})
staff.append({
"profile_id": m["profile_id"],
"email": p.get("email"),
"username": p.get("username"),
"display_name": p.get("display_name"),
"role": m["role"],
"joined_at": m["joined_at"],
})
return {"status": "ok", "staff": staff, "total": len(staff)}
# ─── Student list ─────────────────────────────────────────────────────────────
@router.get("/students")
async def list_students(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""List all student members in the school."""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
_require_school_admin(user_id, institute_id)
sb = _sb()
members = (
sb.supabase.table("institute_memberships")
.select("profile_id,role,joined_at")
.eq("institute_id", institute_id)
.eq("role", "student")
.execute()
.data or []
)
if not members:
return {"status": "ok", "students": [], "total": 0}
profile_ids = [m["profile_id"] for m in members]
profiles = (
sb.supabase.table("profiles")
.select("id,email,username,display_name")
.in_("id", profile_ids)
.execute()
.data or []
)
profile_map = {p["id"]: p for p in profiles}
students = []
for m in members:
p = profile_map.get(m["profile_id"], {})
students.append({
"profile_id": m["profile_id"],
"email": p.get("email"),
"username": p.get("username"),
"display_name": p.get("display_name"),
"role": m["role"],
"joined_at": m["joined_at"],
})
return {"status": "ok", "students": students, "total": len(students)}
@@ -0,0 +1,650 @@
import os
import uuid
import aiohttp
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
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
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
_LLM_TIMEOUT = 60
VALID_STATUS = {"draft", "ready", "archived"}
VALID_FIELDS = {"objectives", "activity_description", "title"}
BLOOM_LEVELS = {"remember", "understand", "apply", "analyse", "evaluate", "create"}
# ─── Supabase helpers ─────────────────────────────────────────────────────────
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _resolve_institute_id(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 "") or None
except Exception:
return None
def _require_institute(user_id: str) -> str:
iid = _resolve_institute_id(user_id)
if not iid:
raise HTTPException(status_code=400, detail="User is not linked to a school")
return iid
def _is_creator(plan: Dict[str, Any], user_id: str) -> bool:
return str(plan.get("created_by", "")) == str(user_id)
def _can_edit_plan(sb: SupabaseServiceRoleClient, plan_id: str, user_id: str) -> bool:
try:
plan_res = (
sb.supabase.table("planned_lessons")
.select("created_by")
.eq("id", plan_id)
.single()
.execute()
)
if not plan_res.data:
return False
if str(plan_res.data.get("created_by", "")) == str(user_id):
return True
collab = (
sb.supabase.table("lesson_collaborators")
.select("can_edit")
.eq("planned_lesson_id", plan_id)
.eq("profile_id", user_id)
.single()
.execute()
)
return bool((collab.data or {}).get("can_edit", False))
except Exception:
return False
# ─── Request models ───────────────────────────────────────────────────────────
class CreatePlanRequest(BaseModel):
title: str
class_id: Optional[str] = None
subject: Optional[str] = None
year_group: Optional[str] = None
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
status: Optional[str] = "draft"
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
whiteboard_room_id: Optional[str] = None
course_id: Optional[str] = None
sequence_number: Optional[int] = None
class UpdatePlanRequest(BaseModel):
title: Optional[str] = None
class_id: Optional[str] = None
subject: Optional[str] = None
year_group: Optional[str] = None
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
whiteboard_room_id: Optional[str] = None
course_id: Optional[str] = None
sequence_number: Optional[int] = None
class DeliverPlanRequest(BaseModel):
taught_lesson_id: Optional[str] = None
class_id: Optional[str] = None
notes: Optional[str] = None
class AddCollaboratorRequest(BaseModel):
profile_id: str
can_edit: bool = True
class SuggestRequest(BaseModel):
field: str
context: Optional[str] = None
activity_section: Optional[str] = None
objective_texts: Optional[List[str]] = None
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/plans")
async def list_plans(
class_id: Optional[str] = None,
status: Optional[str] = None,
subject: Optional[str] = None,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
q = (
sb.supabase.table("planned_lessons")
.select("*")
.eq("institute_id", institute_id)
)
if class_id:
q = q.eq("class_id", class_id)
if status:
q = q.eq("status", status)
if subject:
q = q.eq("subject", subject)
owned = q.eq("created_by", user_id).execute().data or []
collab_rows = (
sb.supabase.table("lesson_collaborators")
.select("planned_lesson_id")
.eq("profile_id", user_id)
.execute()
.data or []
)
collab_plan_ids = [r["planned_lesson_id"] for r in collab_rows]
collab_plans: List[Dict] = []
if collab_plan_ids:
q2 = (
sb.supabase.table("planned_lessons")
.select("*")
.eq("institute_id", institute_id)
.in_("id", collab_plan_ids)
)
if class_id:
q2 = q2.eq("class_id", class_id)
if status:
q2 = q2.eq("status", status)
if subject:
q2 = q2.eq("subject", subject)
collab_plans = q2.execute().data or []
seen_ids: set = set()
all_plans: List[Dict] = []
for p in owned + collab_plans:
if p["id"] not in seen_ids:
seen_ids.add(p["id"])
all_plans.append(p)
if not all_plans:
return {"plans": []}
plan_ids = [p["id"] for p in all_plans]
collab_counts: Dict[str, int] = {}
delivery_counts: Dict[str, int] = {}
try:
cc = (
sb.supabase.table("lesson_collaborators")
.select("planned_lesson_id")
.in_("planned_lesson_id", plan_ids)
.execute()
.data or []
)
for r in cc:
collab_counts[r["planned_lesson_id"]] = collab_counts.get(r["planned_lesson_id"], 0) + 1
except Exception:
pass
try:
dc = (
sb.supabase.table("lesson_deliveries")
.select("planned_lesson_id")
.in_("planned_lesson_id", plan_ids)
.execute()
.data or []
)
for r in dc:
delivery_counts[r["planned_lesson_id"]] = delivery_counts.get(r["planned_lesson_id"], 0) + 1
except Exception:
pass
enriched = [
{
**p,
"collaborator_count": collab_counts.get(p["id"], 0),
"delivery_count": delivery_counts.get(p["id"], 0),
"is_owner": str(p.get("created_by", "")) == str(user_id),
}
for p in all_plans
]
return {"plans": enriched}
@router.post("/plans")
async def create_plan(
body: CreatePlanRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
if body.status and body.status not in VALID_STATUS:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
row: Dict[str, Any] = {
"created_by": user_id,
"institute_id": institute_id,
"title": body.title,
"status": body.status or "draft",
"objectives": body.objectives or [],
"activities": body.activities or [],
"tags": body.tags or [],
}
for field in (
"class_id", "subject", "year_group", "estimated_duration_minutes",
"topic_code", "whiteboard_room_id", "course_id", "sequence_number",
):
val = getattr(body, field)
if val is not None:
row[field] = val
res = sb.supabase.table("planned_lessons").insert(row).execute()
plan = (res.data or [{}])[0]
logger.info(f"Lesson plan created: {plan.get('id')} '{body.title}' by {user_id}")
return plan
@router.get("/plans/{plan_id}")
async def get_plan(
plan_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("*")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Lesson plan not found")
plan = plan_res.data
if not _is_creator(plan, user_id):
collab_check = (
sb.supabase.table("lesson_collaborators")
.select("can_edit")
.eq("planned_lesson_id", plan_id)
.eq("profile_id", user_id)
.execute()
.data
)
if collab_check is None:
raise HTTPException(status_code=403, detail="Access denied")
collab_rows = (
sb.supabase.table("lesson_collaborators")
.select("profile_id, can_edit, added_at")
.eq("planned_lesson_id", plan_id)
.execute()
.data or []
)
profile_ids = [r["profile_id"] for r in collab_rows]
profile_map: Dict[str, Dict] = {}
if profile_ids:
try:
profiles = (
sb.supabase.table("profiles")
.select("id, full_name, email")
.in_("id", profile_ids)
.execute()
.data or []
)
profile_map = {p["id"]: p for p in profiles}
except Exception:
pass
collaborators = [
{
**r,
"full_name": profile_map.get(r["profile_id"], {}).get("full_name"),
"email": profile_map.get(r["profile_id"], {}).get("email"),
}
for r in collab_rows
]
deliveries = (
sb.supabase.table("lesson_deliveries")
.select("*")
.eq("planned_lesson_id", plan_id)
.order("started_at", desc=True)
.limit(10)
.execute()
.data or []
)
return {**plan, "collaborators": collaborators, "recent_deliveries": deliveries}
@router.patch("/plans/{plan_id}")
async def update_plan(
plan_id: str,
body: UpdatePlanRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
if not _can_edit_plan(sb, plan_id, user_id):
raise HTTPException(status_code=403, detail="Access denied — not creator or editor collaborator")
if body.status and body.status not in VALID_STATUS:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
updates: Dict[str, Any] = {}
for field in (
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
"objectives", "activities", "status", "tags", "topic_code",
"whiteboard_room_id", "course_id", "sequence_number",
):
val = getattr(body, field)
if val is not None:
updates[field] = val
if not updates:
raise HTTPException(status_code=400, detail="Nothing to update")
updates["updated_at"] = datetime.utcnow().isoformat()
res = (
sb.supabase.table("planned_lessons")
.update(updates)
.eq("id", plan_id)
.eq("institute_id", institute_id)
.execute()
)
if not res.data:
raise HTTPException(status_code=404, detail="Plan not found")
return res.data[0]
@router.delete("/plans/{plan_id}")
async def delete_plan(
plan_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("created_by")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
if not _is_creator(plan_res.data, user_id):
raise HTTPException(status_code=403, detail="Only the creator can delete a plan")
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).execute()
sb.supabase.table("planned_lessons").delete().eq("id", plan_id).execute()
logger.info(f"Lesson plan deleted: {plan_id} by {user_id}")
return {"status": "ok"}
@router.post("/plans/{plan_id}/deliver")
async def deliver_plan(
plan_id: str,
body: DeliverPlanRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("id, whiteboard_room_id")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
if not _can_edit_plan(sb, plan_id, user_id):
raise HTTPException(status_code=403, detail="Access denied")
row: Dict[str, Any] = {
"planned_lesson_id": plan_id,
"delivered_by": user_id,
"institute_id": institute_id,
"started_at": datetime.utcnow().isoformat(),
}
if body.taught_lesson_id:
row["taught_lesson_id"] = body.taught_lesson_id
if body.class_id:
row["class_id"] = body.class_id
if body.notes:
row["notes"] = body.notes
whiteboard_room_id = plan_res.data.get("whiteboard_room_id")
if whiteboard_room_id:
row["whiteboard_room_id"] = whiteboard_room_id
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
delivery = (res.data or [{}])[0]
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
return delivery
@router.get("/plans/{plan_id}/deliveries")
async def list_deliveries(
plan_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("created_by")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
deliveries = (
sb.supabase.table("lesson_deliveries")
.select("*")
.eq("planned_lesson_id", plan_id)
.order("started_at", desc=True)
.execute()
.data or []
)
return {"deliveries": deliveries}
@router.post("/plans/{plan_id}/collaborators")
async def add_collaborator(
plan_id: str,
body: AddCollaboratorRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("created_by")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
if not _is_creator(plan_res.data, user_id):
raise HTTPException(status_code=403, detail="Only the creator can add collaborators")
res = (
sb.supabase.table("lesson_collaborators")
.upsert(
{
"planned_lesson_id": plan_id,
"profile_id": body.profile_id,
"can_edit": body.can_edit,
"added_at": datetime.utcnow().isoformat(),
},
on_conflict="planned_lesson_id,profile_id",
)
.execute()
)
return {"status": "ok", "row": (res.data or [{}])[0]}
@router.delete("/plans/{plan_id}/collaborators/{profile_id}")
async def remove_collaborator(
plan_id: str,
profile_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
plan_res = (
sb.supabase.table("planned_lessons")
.select("created_by")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
if not _is_creator(plan_res.data, user_id):
raise HTTPException(status_code=403, detail="Only the creator can remove collaborators")
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).eq("profile_id", profile_id).execute()
return {"status": "ok"}
@router.post("/plans/{plan_id}/suggest")
async def suggest_field(
plan_id: str,
body: SuggestRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
if body.field not in VALID_FIELDS:
raise HTTPException(status_code=400, detail=f"field must be one of {VALID_FIELDS}")
plan_res = (
sb.supabase.table("planned_lessons")
.select("*")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
.execute()
)
if not plan_res.data:
raise HTTPException(status_code=404, detail="Plan not found")
if not _can_edit_plan(sb, plan_id, user_id):
raise HTTPException(status_code=403, detail="Access denied")
plan = plan_res.data
subject = plan.get("subject") or "a UK secondary school subject"
year_group = plan.get("year_group") or "unspecified year group"
title = plan.get("title", "")
context = body.context or ""
context_part = ("Teacher notes: " + context + "\n") if context else ""
if body.field == "objectives":
existing = ""
if body.objective_texts:
existing = "\n".join("- " + t for t in body.objective_texts)
objectives_part = ("Existing objectives:\n" + existing + "\n") if existing else ""
prompt = (
"You are an expert UK secondary school teacher.\n"
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
+ objectives_part + context_part
+ "Suggest ONE clear, measurable learning objective for this lesson using Bloom's taxonomy. "
"Write only the objective text, no bullet point or prefix."
)
elif body.field == "activity_description":
section = body.activity_section or "Main"
prompt = (
"You are an expert UK secondary school teacher.\n"
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
"Activity section: " + section + ".\n"
+ context_part
+ "Write a concise, practical description (2-4 sentences) for a " + section + " activity "
"suitable for UK secondary pupils. Include what the teacher does and what pupils do."
)
else: # title
prompt = (
"You are an expert UK secondary school teacher.\n"
"Subject: " + subject + ", Year: " + year_group + ".\n"
+ context_part
+ "Suggest ONE engaging, clear lesson title for a UK secondary school lesson. "
"Write only the title, no explanation."
)
try:
payload = {
"model": _OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{_OLLAMA_URL}/api/generate",
json=payload,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=_LLM_TIMEOUT),
) as resp:
if resp.status != 200:
body_text = await resp.text()
logger.error(f"Ollama suggest error ({resp.status}): {body_text}")
raise HTTPException(status_code=502, detail=f"LLM request failed: {resp.status}")
data = await resp.json()
suggestion = (data.get("response") or "").strip()
except HTTPException:
raise
except Exception as e:
logger.error(f"suggest_field LLM call failed for plan {plan_id}: {e}")
raise HTTPException(status_code=502, detail="LLM service unavailable")
return {"suggestion": suggestion}
@@ -0,0 +1,160 @@
"""
Platform Admin Router — super_admin / platform_admin operations.
GET /admin/schools — list all institutes with member + calendar counts
GET /admin/stats — platform-level summary
"""
import os
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
from modules.auth.platform_admin import require_platform_admin
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
@router.get("/schools")
async def list_all_schools(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""List every institute with basic counts. Platform admin only."""
sb = _sb()
institutes = (
sb.supabase.table("institutes")
.select("id,name,urn,website,status,created_at,neo4j_uuid_string")
.order("name")
.execute()
.data or []
)
if not institutes:
return {"status": "ok", "schools": [], "total": 0}
inst_ids = [i["id"] for i in institutes]
# Member counts per institute
all_members = (
sb.supabase.table("institute_memberships")
.select("institute_id,role")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
from collections import defaultdict
member_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"staff": 0, "students": 0})
staff_roles = {"teacher", "school_admin", "department_head"}
for m in all_members:
iid = m["institute_id"]
if m["role"] in staff_roles:
member_counts[iid]["staff"] += 1
elif m["role"] == "student":
member_counts[iid]["students"] += 1
# Calendar presence per institute
term_rows = (
sb.supabase.table("academic_terms")
.select("institute_id")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
has_calendar = {r["institute_id"] for r in term_rows}
# Pending invitations count
inv_rows = (
sb.supabase.table("invitations")
.select("institute_id")
.eq("status", "pending")
.in_("institute_id", inst_ids)
.execute()
.data or []
)
from collections import Counter
inv_counts = Counter(r["institute_id"] for r in inv_rows)
schools = []
for inst in institutes:
iid = inst["id"]
mc = member_counts.get(iid, {})
schools.append({
**inst,
"staff_count": mc.get("staff", 0),
"student_count": mc.get("students", 0),
"has_calendar": iid in has_calendar,
"pending_invitations": inv_counts.get(iid, 0),
})
return {"status": "ok", "schools": schools, "total": len(schools)}
@router.get("/stats")
async def platform_stats(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""High-level platform counts. Platform admin only."""
sb = _sb()
inst_count = len(
sb.supabase.table("institutes").select("id").execute().data or []
)
profile_count = len(
sb.supabase.table("profiles").select("id").execute().data or []
)
lesson_count = len(
sb.supabase.table("taught_lessons").select("id").execute().data or []
)
inv_count = len(
sb.supabase.table("invitations").select("id").eq("status", "pending").execute().data or []
)
return {
"status": "ok",
"schools": inst_count,
"profiles": profile_count,
"taught_lessons": lesson_count,
"pending_invitations": inv_count,
}
@router.post("/reset")
async def reset_environment(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""DESTRUCTIVE: wipe all test data. Neo4j + Supabase. Platform admin only."""
import asyncio
from run.initialization.reset_environment import reset as _reset
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _reset)
return {"status": "ok", **result}
@router.post("/seed")
async def seed_environment(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""Idempotent rebuild: both schools, global calendar, 20 test accounts. Platform admin only."""
import asyncio
from run.initialization.seed_environment import seed as _seed
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}
@router.post("/seed-timetable")
async def seed_greenfield_timetable(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""Seed full timetable + taught lessons for Greenfield Academy. Platform admin only."""
import asyncio
from run.initialization.seed_greenfield_timetable import seed as _seed
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}
+603
View File
@@ -0,0 +1,603 @@
"""
School Router — school status, search, register, and admin-editable info.
"""
import os
import json
import uuid as uuid_lib
from typing import Dict, Any, Optional, List
from fastapi import APIRouter, Depends, 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
import modules.database.tools.neo4j_driver_tools as driver_tools
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _get_sb():
return SupabaseServiceRoleClient()
def _institute_db(neo4j_uuid: str) -> str:
return f"cc.institutes.{neo4j_uuid}"
def _find_institute_db_by_email(user_email: str) -> Optional[str]:
"""Fallback: scan all institute DBs for the teacher's email."""
try:
with driver_tools.get_session(database="system") as s:
dbs = [r["name"] for r in s.run(
"SHOW DATABASES YIELD name "
"WHERE name STARTS WITH 'cc.institutes.' "
"AND NOT name ENDS WITH '.curriculum' RETURN name"
)]
for db in dbs:
try:
with driver_tools.get_session(database=db) as s:
rec = s.run(
"MATCH (t:Teacher) WHERE t.worker_email = $e "
"RETURN t.uuid_string AS uuid LIMIT 1",
e=user_email
).single()
if rec:
return db
except Exception:
continue
except Exception as e:
logger.warning(f"Institute DB scan failed: {e}")
return None
# ─── Status endpoint ─────────────────────────────────────────────────────────
@router.get("/status")
async def get_school_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
"""Return the current user's school role, school info, and calendar/timetable setup status."""
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
try:
sb = _get_sb()
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = (p.data or {}).get("school_id")
# Fallback: if profiles.school_id not set, check institute_memberships directly
if not school_id:
m_fb = sb.supabase.table("institute_memberships").select("institute_id,role").eq("profile_id", user_id).single().execute()
if m_fb.data and m_fb.data.get("institute_id"):
school_id = m_fb.data["institute_id"]
user_role = m_fb.data.get("role") or "teacher"
# Self-heal: write school_id back to profile
try:
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
except Exception:
pass
else:
return {"status": "no_school"}
else:
m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute()
user_role = ((m.data or {}).get("role") or "teacher")
i = sb.supabase.table("institutes").select("id,name,urn,website,address,metadata,neo4j_uuid_string").eq("id", school_id).single().execute()
inst = i.data or {}
neo4j_uuid = inst.get("neo4j_uuid_string")
meta = inst.get("metadata") or {}
except Exception as e:
logger.warning(f"Supabase school status query failed: {e}")
return {"status": "error", "message": str(e)}
if neo4j_uuid:
inst_db = _institute_db(neo4j_uuid)
else:
inst_db = _find_institute_db_by_email(user_email)
if not inst_db:
return {"status": "no_school"}
school_has_calendar = False
teacher_has_timetable = False
timetable_id = None
periods_template = None
try:
with driver_tools.get_session(database=inst_db) as s:
ay = s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single()
school_has_calendar = ay is not None
st = s.run(
"MATCH (st:SchoolTimetable) WHERE st.periods_template IS NOT NULL "
"RETURN st.periods_template AS p LIMIT 1"
).single()
if st:
periods_template = json.loads(st["p"])
t = s.run(
"MATCH (t:Teacher) WHERE t.worker_email = $e "
"RETURN t.uuid_string AS uuid LIMIT 1",
e=user_email
).single()
if t:
teacher_uuid = t["uuid"]
tt = s.run(
"MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) "
"RETURN tt.uuid_string AS id LIMIT 1",
u=teacher_uuid
).single()
if tt:
teacher_has_timetable = True
timetable_id = tt["id"]
except Exception as e:
logger.warning(f"Neo4j school status check failed for {inst_db}: {e}")
return {
"status": "ok",
"user_role": user_role,
"school_id": str(school_id),
"institute_db": inst_db,
"school_has_calendar": school_has_calendar,
"teacher_has_timetable": teacher_has_timetable,
"timetable_id": timetable_id,
"periods_template": periods_template,
"school_info": {
"name": inst.get("name", ""),
"urn": inst.get("urn", ""),
"website": inst.get("website", ""),
"address": inst.get("address") or {},
"headteacher": meta.get("headteacher", ""),
"term_dates_url": meta.get("term_dates_url", ""),
"staff_list_url": meta.get("staff_list_url", ""),
},
}
# ─── School info update ───────────────────────────────────────────────────────
class SchoolInfoUpdate(BaseModel):
headteacher: Optional[str] = None
term_dates_url: Optional[str] = None
staff_list_url: Optional[str] = None
@router.patch("/info")
async def update_school_info(
body: SchoolInfoUpdate,
credentials: dict = Depends(SupabaseBearer())
) -> Dict[str, Any]:
"""Admin: update school metadata fields stored in institutes.metadata jsonb."""
user_id = credentials.get("sub", "")
try:
sb = _get_sb()
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = (p.data or {}).get("school_id")
if not school_id:
return {"status": "error", "message": "No school linked"}
m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute()
role = ((m.data or {}).get("role") or "teacher")
if role != "school_admin":
return {"status": "error", "message": "school_admin role required"}
i = sb.supabase.table("institutes").select("metadata").eq("id", school_id).single().execute()
meta = dict((i.data or {}).get("metadata") or {})
if body.headteacher is not None:
meta["headteacher"] = body.headteacher
if body.term_dates_url is not None:
meta["term_dates_url"] = body.term_dates_url
if body.staff_list_url is not None:
meta["staff_list_url"] = body.staff_list_url
sb.supabase.table("institutes").update({"metadata": meta}).eq("id", school_id).execute()
return {"status": "ok"}
except Exception as e:
logger.error(f"School info update failed: {e}")
return {"status": "error", "message": str(e)}
# ─── GAIS school search ───────────────────────────────────────────────────────
@router.get("/search")
async def search_schools(
q: str = Query(..., min_length=2, description="School name, URN, or postcode"),
limit: int = Query(20, ge=1, le=100),
status: str = Query("Open", description="Filter by establishment status"),
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Search GAIS school reference data by name, URN, or postcode."""
try:
sb = _get_sb()
query = sb.supabase.table("gais_schools").select(
"urn,name,status,phase,type,street,locality,town,county,postcode,"
"website,telephone,head_title,head_first_name,head_last_name,"
"la_code,la_name,number_of_pupils,gender,religious_character,region"
)
if status:
query = query.eq("status", status)
q_stripped = q.strip()
if q_stripped.isdigit():
# URN exact match
query = query.eq("urn", q_stripped)
else:
# Name / postcode trigram search — use ilike on name first, fallback includes postcode
query = query.ilike("name", f"%{q_stripped}%")
result = query.limit(limit).execute()
return {"status": "ok", "schools": result.data or [], "count": len(result.data or [])}
except Exception as e:
logger.error(f"School search failed: {e}")
return {"status": "error", "message": str(e)}
# ─── School registration (onboarding) ────────────────────────────────────────
class SchoolRegisterBody(BaseModel):
urn: str
name: str
address: Optional[Dict[str, Any]] = None
website: Optional[str] = None
headteacher: Optional[str] = None
@router.post("/register")
async def register_school(
body: SchoolRegisterBody,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Onboarding: create an institute record from a GAIS-selected school,
provision the Neo4j database, and link the calling user as school_admin.
"""
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "")
try:
sb = _get_sb()
# Prevent duplicate registration
existing = sb.supabase.table("institutes").select("id").eq("urn", body.urn).execute()
if existing.data:
school_id = existing.data[0]["id"]
# Still link user if not already linked
_ensure_membership(sb, user_id, school_id, "school_admin")
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
return {"status": "already_exists", "school_id": str(school_id)}
# Build metadata
meta: Dict[str, Any] = {}
if body.headteacher:
meta["headteacher"] = body.headteacher
institute_data: Dict[str, Any] = {
"name": body.name,
"urn": body.urn,
"status": "active",
"address": body.address or {},
"website": body.website or "",
"metadata": meta,
}
ins_result = sb.supabase.table("institutes").insert(institute_data).execute()
if not ins_result.data:
return {"status": "error", "message": "Failed to create institute record"}
school_id = ins_result.data[0]["id"]
# Provision Neo4j institute database
try:
from modules.database.services.provisioning_service import ProvisioningService
ps = ProvisioningService()
ps.ensure_school(school_id)
# Reload institute to get the neo4j_uuid_string set by provisioning
inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute()
neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string")
except Exception as prov_err:
logger.warning(f"Neo4j provisioning failed for {school_id}: {prov_err}")
neo4j_uuid = None
# Link user as school_admin
_ensure_membership(sb, user_id, school_id, "school_admin")
sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute()
return {
"status": "ok",
"school_id": str(school_id),
"neo4j_uuid": neo4j_uuid,
}
except Exception as e:
logger.error(f"School registration failed: {e}")
return {"status": "error", "message": str(e)}
def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: str, role: str) -> None:
existing = sb.supabase.table("institute_memberships").select("id").eq("profile_id", user_id).eq("institute_id", school_id).execute()
if not existing.data:
sb.supabase.table("institute_memberships").insert({
"profile_id": user_id,
"institute_id": school_id,
"role": role,
}).execute()
# ─── School Overview ──────────────────────────────────────────────────────────
@router.get("/overview")
async def get_school_overview(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Summary dashboard for school admins: staff/student/class counts,
calendar snapshot (terms, total academic days, current/next term).
"""
user_id = credentials.get("sub", "")
sb = _get_sb()
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = str((p.data or {}).get("school_id") or "")
if not school_id:
raise HTTPException(status_code=400, detail="User is not linked to a school")
# Role check
mem = (
sb.supabase.table("institute_memberships")
.select("role")
.eq("profile_id", user_id)
.eq("institute_id", school_id)
.single()
.execute()
)
user_role = (mem.data or {}).get("role", "teacher")
# Counts
staff_roles = ["teacher", "school_admin", "department_head"]
staff_rows = (
sb.supabase.table("institute_memberships")
.select("profile_id", count="exact")
.eq("institute_id", school_id)
.in_("role", staff_roles)
.execute()
)
student_rows = (
sb.supabase.table("institute_memberships")
.select("profile_id", count="exact")
.eq("institute_id", school_id)
.eq("role", "student")
.execute()
)
class_rows = (
sb.supabase.table("classes")
.select("id", count="exact")
.eq("institute_id", school_id)
.eq("is_active", True)
.execute()
)
# Calendar snapshot from academic_terms
terms = (
sb.supabase.table("academic_terms")
.select("id,term_name,term_number,start_date,end_date")
.eq("institute_id", school_id)
.order("term_number")
.execute()
.data or []
)
# Academic day counts per term
if terms:
term_ids = [t["id"] for t in terms]
day_counts_res = (
sb.supabase.table("academic_days")
.select("academic_term_id", count="exact")
.eq("institute_id", school_id)
.eq("day_type", "Academic")
.in_("academic_term_id", term_ids)
.execute()
)
# Supabase doesn't group-by server-side; count manually per term
all_days = (
sb.supabase.table("academic_days")
.select("academic_term_id,day_type")
.eq("institute_id", school_id)
.in_("academic_term_id", term_ids)
.execute()
.data or []
)
from collections import defaultdict
academic_day_count: Dict[str, int] = defaultdict(int)
total_day_count: Dict[str, int] = defaultdict(int)
for d in all_days:
total_day_count[d["academic_term_id"]] += 1
if d["day_type"] == "Academic":
academic_day_count[d["academic_term_id"]] += 1
from datetime import date
today_str = str(date.today())
for t in terms:
t["academic_days"] = academic_day_count.get(t["id"], 0)
t["total_days"] = total_day_count.get(t["id"], 0)
if t["start_date"] <= today_str <= t["end_date"]:
t["is_current"] = True
else:
t["is_current"] = False
pending_invites = (
sb.supabase.table("invitations")
.select("id", count="exact")
.eq("institute_id", school_id)
.eq("status", "pending")
.execute()
)
return {
"status": "ok",
"user_role": user_role,
"counts": {
"staff": staff_rows.count or 0,
"students": student_rows.count or 0,
"classes": class_rows.count or 0,
"pending_invitations": pending_invites.count or 0,
},
"terms": terms,
"has_calendar": len(terms) > 0,
}
# ─── Calendar days (admin view) ───────────────────────────────────────────────
@router.get("/calendar/days")
async def list_calendar_days(
term_id: Optional[str] = None,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Return academic_days for the school, optionally filtered by term.
Includes week_cycle from the parent academic_week.
"""
user_id = credentials.get("sub", "")
sb = _get_sb()
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = str((p.data or {}).get("school_id") or "")
if not school_id:
raise HTTPException(status_code=400, detail="User is not linked to a school")
q = (
sb.supabase.table("academic_days")
.select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes")
.eq("institute_id", school_id)
.order("date")
)
if term_id:
q = q.eq("academic_term_id", term_id)
days = q.execute().data or []
# Enrich with week_cycle
if days:
week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")})
weeks = (
sb.supabase.table("academic_weeks")
.select("id,week_number,week_cycle")
.in_("id", week_ids)
.execute()
.data or []
)
wk_map = {w["id"]: w for w in weeks}
for d in days:
wk = wk_map.get(d.get("academic_week_id", ""), {})
d["week_cycle"] = wk.get("week_cycle", "")
d["week_number"] = wk.get("week_number")
return {"status": "ok", "days": days, "total": len(days)}
@router.patch("/calendar/days/{day_id}")
async def update_calendar_day(
day_id: str,
body: Dict[str, Any],
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Override day_type for a single academic day (school admin only).
Syncs academic_periods: removes periods for non-Academic days,
creates periods from periods_template for newly-Academic days.
"""
user_id = credentials.get("sub", "")
sb = _get_sb()
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = str((p.data or {}).get("school_id") or "")
if not school_id:
raise HTTPException(status_code=400, detail="User is not linked to a school")
# Verify admin role
mem = (
sb.supabase.table("institute_memberships")
.select("role")
.eq("profile_id", user_id)
.eq("institute_id", school_id)
.single()
.execute()
)
if (mem.data or {}).get("role") not in ("school_admin", "department_head"):
raise HTTPException(status_code=403, detail="School admin access required")
# Verify day belongs to school
day = (
sb.supabase.table("academic_days")
.select("*")
.eq("id", day_id)
.eq("institute_id", school_id)
.single()
.execute()
).data
if not day:
raise HTTPException(status_code=404, detail="Day not found")
new_day_type = body.get("day_type", day["day_type"])
excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or [])
valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"}
if new_day_type not in valid_types:
raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}")
# Update the day
sb.supabase.table("academic_days").update({
"day_type": new_day_type,
"excluded_period_codes": excluded,
}).eq("id", day_id).execute()
old_type = day["day_type"]
periods_changed = 0
if old_type == "Academic" and new_day_type != "Academic":
# Remove periods for this day
del_res = (
sb.supabase.table("academic_periods")
.delete()
.eq("academic_day_id", day_id)
.execute()
)
periods_changed = -(len(del_res.data or []))
elif old_type != "Academic" and new_day_type == "Academic":
# Create periods from template
stt = (
sb.supabase.table("school_timetables")
.select("periods_template")
.eq("institute_id", school_id)
.order("created_at", desc=True)
.limit(1)
.execute()
.data or []
)
template = (stt[0].get("periods_template") or []) if stt else []
skip = set(excluded)
new_periods = []
for period in template:
if period.get("code") in skip:
continue
new_periods.append({
"academic_day_id": day_id,
"institute_id": school_id,
"period_code": period["code"],
"period_name": period.get("name", period["code"]),
"start_time": period.get("start_time"),
"end_time": period.get("end_time"),
"period_type": period.get("period_type", "lesson"),
})
if new_periods:
ins_res = (
sb.supabase.table("academic_periods")
.upsert(new_periods, on_conflict="academic_day_id,period_code")
.execute()
)
periods_changed = len(ins_res.data or [])
return {
"status": "ok",
"day_id": day_id,
"new_day_type": new_day_type,
"periods_changed": periods_changed,
}
@@ -0,0 +1,601 @@
"""
Taught Lessons Router — materialization and lesson CRUD.
POST /materialize — slot template × academic_periods → taught_lessons rows
GET /lessons — teacher's lessons for a date range
GET /lessons/{id} — single lesson detail
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
"""
import os
from collections import defaultdict
from datetime import datetime, date, timedelta
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
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
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _resolve_institute_id(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 "") or None
except Exception:
return None
def _require_institute(user_id: str) -> str:
iid = _resolve_institute_id(user_id)
if not iid:
raise HTTPException(status_code=400, detail="User is not linked to a school")
return iid
# ─── Request models ───────────────────────────────────────────────────────────
class UpdateLessonRequest(BaseModel):
lesson_plan: Optional[Dict[str, Any]] = None
notes: Optional[str] = None
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
# ─── Materialize ─────────────────────────────────────────────────────────────
@router.post("/materialize")
async def materialize_taught_lessons(
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Materialize taught_lessons from teacher_timetable_slots × academic_periods.
For each slot (day_of_week + period_code + week_cycle), find every
academic_period that falls on a matching day and week cycle, then
UPSERT a taught_lesson row and a whiteboard_room for it.
Safe to re-run; uses ON CONFLICT DO UPDATE.
"""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
# ── 1. Get teacher timetable ──────────────────────────────────────────────
tt_rows = (
sb.supabase.table("teacher_timetables")
.select("id")
.eq("profile_id", user_id)
.eq("institute_id", institute_id)
.limit(1)
.execute()
.data or []
)
if not tt_rows:
return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"}
tt_id = tt_rows[0]["id"]
# ── 2. Get teacher's timetable slots ──────────────────────────────────────
slots = (
sb.supabase.table("teacher_timetable_slots")
.select("id,day_of_week,period_code,subject_class,start_time,end_time,week_cycle,class_id")
.eq("teacher_timetable_id", tt_id)
.execute()
.data or []
)
if not slots:
return {"status": "ok", "created": 0, "updated": 0, "message": "No timetable slots found"}
# ── 3. Resolve class_ids for slots (match by subject_class name) ──────────
class_name_to_id: Dict[str, str] = {}
subject_names = list({s["subject_class"] for s in slots if s.get("subject_class")})
if subject_names:
try:
classes_res = (
sb.supabase.table("classes")
.select("id,name,class_code")
.eq("institute_id", institute_id)
.eq("is_active", True)
.execute()
.data or []
)
for c in classes_res:
if c.get("name"):
class_name_to_id[c["name"]] = c["id"]
if c.get("class_code"):
class_name_to_id[c["class_code"]] = c["id"]
except Exception as e:
logger.warning(f"Could not resolve class names: {e}")
# slot lookup keyed by (day_of_week, period_code, week_cycle)
# week_cycle='' means applies to both A and B weeks
slot_map: Dict[Tuple[str, str, str], Dict] = {}
for slot in slots:
key = (slot["day_of_week"], slot["period_code"], slot.get("week_cycle", ""))
slot_map[key] = slot
# ── 4. Get all academic_periods with day + week info ──────────────────────
# Fetch academic_days and academic_weeks separately, then join in Python
days_res = (
sb.supabase.table("academic_days")
.select("id,date,day_of_week,academic_week_id,day_type")
.eq("institute_id", institute_id)
.execute()
.data or []
)
weeks_res = (
sb.supabase.table("academic_weeks")
.select("id,week_cycle")
.eq("institute_id", institute_id)
.execute()
.data or []
)
week_cycle_map = {w["id"]: w["week_cycle"] for w in weeks_res}
# Only academic days get lesson periods
academic_day_map = {
d["id"]: {**d, "week_cycle": week_cycle_map.get(d["academic_week_id"], "A")}
for d in days_res
if d["day_type"] == "Academic"
}
periods_res = (
sb.supabase.table("academic_periods")
.select("id,academic_day_id,period_code,period_name,start_time,end_time,period_type")
.eq("institute_id", institute_id)
.execute()
.data or []
)
# ── 5. Match slots to periods ─────────────────────────────────────────────
to_upsert: List[Dict] = []
whiteboard_rows: List[Dict] = []
for period in periods_res:
day_info = academic_day_map.get(period["academic_day_id"])
if not day_info:
continue
dow = day_info["day_of_week"]
week_cycle = day_info["week_cycle"]
pcode = period["period_code"]
d = str(day_info["date"])[:10]
# Check all matching slot patterns: exact week_cycle match or '' (both weeks)
matched_slot = (
slot_map.get((dow, pcode, week_cycle))
or slot_map.get((dow, pcode, ""))
)
if not matched_slot:
continue
subj_class = matched_slot.get("subject_class", "")
# Prefer the slot's own class_id, fall back to name-lookup
class_id = (
matched_slot.get("class_id")
or class_name_to_id.get(subj_class)
)
tl_id_hint = f"tl_{period['id']}" # deterministic for neo4j_node_id
to_upsert.append({
"academic_period_id": period["id"],
"teacher_timetable_slot_id": matched_slot["id"],
"class_id": class_id, # may be None
"teacher_id": user_id,
"institute_id": institute_id,
"date": d,
"period_code": pcode,
"week_cycle": week_cycle,
"day_of_week": dow,
"status": "planned",
"neo4j_node_id": tl_id_hint,
})
if not to_upsert:
return {"status": "ok", "created": 0, "updated": 0, "message": "No slot-period matches found"}
# ── 6. Batch-upsert taught_lessons ────────────────────────────────────────
BATCH = 100
created = 0
for i in range(0, len(to_upsert), BATCH):
chunk = to_upsert[i : i + BATCH]
try:
res = (
sb.supabase.table("taught_lessons")
.upsert(chunk, on_conflict="academic_period_id,teacher_id")
.execute()
)
created += len(res.data or [])
except Exception as e:
logger.error(f"taught_lessons upsert chunk {i}: {e}")
# ── 7. Fetch newly-created taught_lesson ids, create whiteboard_rooms ─────
try:
tl_rows = (
sb.supabase.table("taught_lessons")
.select("id,date,period_code,class_id")
.eq("teacher_id", user_id)
.eq("institute_id", institute_id)
.is_("whiteboard_room_id", "null")
.execute()
.data or []
)
# Build whiteboard_room rows
wr_rows = []
for tl in tl_rows:
class_name = class_name_to_id and next(
(name for name, cid in class_name_to_id.items() if cid == tl.get("class_id")),
tl.get("period_code", "")
)
wr_rows.append({
"user_id": user_id,
"institute_id": institute_id,
"name": f"{tl['date']} {tl['period_code']}",
"context_type": "taught_lesson",
"context_id": tl["id"],
"storage_path": f"taught_lessons/{tl['id']}",
})
# Batch insert whiteboard_rooms
wr_ids: Dict[str, str] = {} # context_id → room_id
for i in range(0, len(wr_rows), BATCH):
chunk = wr_rows[i : i + BATCH]
try:
res = sb.supabase.table("whiteboard_rooms").insert(chunk).execute()
for row in (res.data or []):
if row.get("context_id"):
wr_ids[row["context_id"]] = row["id"]
except Exception as e:
logger.warning(f"whiteboard_rooms batch insert: {e}")
# Update taught_lessons with their whiteboard_room_id
for context_id, room_id in wr_ids.items():
try:
sb.supabase.table("taught_lessons").update(
{"whiteboard_room_id": room_id}
).eq("id", context_id).execute()
except Exception as e:
logger.warning(f"whiteboard_room_id update for {context_id}: {e}")
rooms_created = len(wr_ids)
except Exception as e:
logger.warning(f"Whiteboard room creation failed (non-fatal): {e}")
rooms_created = 0
logger.info(f"Materialized {created} taught_lessons, {rooms_created} whiteboard_rooms for teacher {user_id}")
return {
"status": "ok",
"lessons_upserted": created,
"whiteboard_rooms_created": rooms_created,
"total_matches": len(to_upsert),
}
# ─── Lesson timeline ─────────────────────────────────────────────────────────
@router.get("/lessons")
async def get_lessons(
week_start: Optional[str] = None, # ISO date "YYYY-MM-DD" (Monday); defaults to current week
weeks: int = 1, # how many weeks to return (max 4)
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Return taught_lessons for the teacher within a date range, grouped by date.
Defaults to the current week.
"""
user_id = credentials.get("sub", "")
institute_id = _require_institute(user_id)
sb = _sb()
# Resolve week window
today = date.today()
if week_start:
try:
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
except ValueError:
monday = today - timedelta(days=today.weekday())
else:
monday = today - timedelta(days=today.weekday())
weeks = min(max(weeks, 1), 4)
friday = monday + timedelta(weeks=weeks, days=4)
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id"
)
.eq("teacher_id", user_id)
.eq("institute_id", institute_id)
.gte("date", str(monday))
.lte("date", str(friday))
.order("date")
.order("period_code")
.execute()
.data or []
)
# Enrich with period time and class name
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
period_map: Dict[str, Dict] = {}
class_map: Dict[str, Dict] = {}
if period_ids:
prows = (
sb.supabase.table("academic_periods")
.select("id,period_name,start_time,end_time")
.in_("id", period_ids)
.execute()
.data or []
)
period_map = {r["id"]: r for r in prows}
if class_ids:
crows = (
sb.supabase.table("classes")
.select("id,name,class_code,subject,year_group")
.in_("id", class_ids)
.execute()
.data or []
)
class_map = {r["id"]: r for r in crows}
# Group by date
days_map: Dict[str, List[Dict]] = defaultdict(list)
for lesson in lessons:
p = period_map.get(lesson.get("academic_period_id", ""), {})
c = class_map.get(lesson.get("class_id", ""), {})
enriched = {
**lesson,
"period_name": p.get("period_name", lesson["period_code"]),
"start_time": p.get("start_time"),
"end_time": p.get("end_time"),
"class_name": c.get("name") or c.get("class_code"),
"subject": c.get("subject"),
"year_group": c.get("year_group"),
}
days_map[lesson["date"]].append(enriched)
# Build ordered list of days
days_list = []
current = monday
while current <= friday:
d_str = str(current)
days_list.append({
"date": d_str,
"day_of_week": current.strftime("%A"),
"is_today": current == today,
"lessons": days_map.get(d_str, []),
})
current += timedelta(days=1)
# Skip weekends
if current.weekday() >= 5:
current += timedelta(days=7 - current.weekday())
return {
"status": "ok",
"week_start": str(monday),
"week_end": str(friday),
"days": days_list,
"total_lessons": len(lessons),
}
@router.get("/lessons/{lesson_id}")
async def get_lesson(
lesson_id: str,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
sb = _sb()
res = (
sb.supabase.table("taught_lessons")
.select("*")
.eq("id", lesson_id)
.eq("teacher_id", user_id)
.single()
.execute()
)
if not res.data:
raise HTTPException(status_code=404, detail="Lesson not found")
return res.data
@router.patch("/lessons/{lesson_id}")
async def update_lesson(
lesson_id: str,
body: UpdateLessonRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Teacher updates their own lesson content: plan, notes, status."""
user_id = credentials.get("sub", "")
sb = _sb()
updates: Dict[str, Any] = {}
if body.lesson_plan is not None:
updates["lesson_plan"] = body.lesson_plan
if body.notes is not None:
updates["notes"] = body.notes
if body.status is not None:
valid = {"planned", "in_progress", "completed", "cancelled", "substituted"}
if body.status not in valid:
raise HTTPException(status_code=400, detail=f"status must be one of {valid}")
updates["status"] = body.status
if not updates:
raise HTTPException(status_code=400, detail="Nothing to update")
updates["updated_at"] = datetime.utcnow().isoformat()
res = (
sb.supabase.table("taught_lessons")
.update(updates)
.eq("id", lesson_id)
.eq("teacher_id", user_id)
.execute()
)
if not res.data:
raise HTTPException(status_code=404, detail="Lesson not found or access denied")
return res.data[0]
# ─── Student lesson view ──────────────────────────────────────────────────────
@router.get("/student/lessons")
async def get_student_lessons(
week_start: Optional[str] = None,
weeks: int = 1,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""
Return taught_lessons for a student's enrolled classes for a date range.
Grouped by date, Mon-Fri only.
"""
user_id = credentials.get("sub", "")
institute_id = _resolve_institute_id(user_id)
if not institute_id:
return {"status": "error", "message": "Not linked to a school"}
sb = _sb()
today = date.today()
if week_start:
try:
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
except ValueError:
monday = today - timedelta(days=today.weekday())
else:
monday = today - timedelta(days=today.weekday())
weeks = min(max(weeks, 1), 4)
friday = monday + timedelta(weeks=weeks, days=4)
# Get student's active class memberships
memberships = (
sb.supabase.table("class_students")
.select("class_id")
.eq("student_id", user_id)
.eq("status", "active")
.execute()
.data or []
)
class_ids = [m["class_id"] for m in memberships]
if not class_ids:
days_list = []
current = monday
while current <= friday:
days_list.append({
"date": str(current),
"day_of_week": current.strftime("%A"),
"is_today": current == today,
"lessons": [],
})
current += timedelta(days=1)
if current.weekday() >= 5:
current += timedelta(days=7 - current.weekday())
return {"status": "ok", "week_start": str(monday), "days": days_list, "total_lessons": 0}
# Query taught_lessons for those classes
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id,teacher_id"
)
.in_("class_id", class_ids)
.eq("institute_id", institute_id)
.gte("date", str(monday))
.lte("date", str(friday))
.order("date")
.order("period_code")
.execute()
.data or []
)
# Enrich with period times, class name, teacher name
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
lesson_class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")})
period_map: Dict[str, Dict] = {}
class_map: Dict[str, Dict] = {}
teacher_map: Dict[str, Dict] = {}
if period_ids:
prows = (
sb.supabase.table("academic_periods")
.select("id,period_name,start_time,end_time")
.in_("id", period_ids)
.execute()
.data or []
)
period_map = {r["id"]: r for r in prows}
if lesson_class_ids:
crows = (
sb.supabase.table("classes")
.select("id,name,class_code,subject,year_group")
.in_("id", lesson_class_ids)
.execute()
.data or []
)
class_map = {r["id"]: r for r in crows}
if teacher_ids:
trows = (
sb.supabase.table("profiles")
.select("id,full_name,display_name")
.in_("id", teacher_ids)
.execute()
.data or []
)
teacher_map = {r["id"]: r for r in trows}
from collections import defaultdict
days_map: Dict[str, List[Dict]] = defaultdict(list)
for lesson in lessons:
p = period_map.get(lesson.get("academic_period_id", ""), {})
c = class_map.get(lesson.get("class_id", ""), {})
t = teacher_map.get(lesson.get("teacher_id", ""), {})
enriched = {
**lesson,
"period_name": p.get("period_name", lesson["period_code"]),
"start_time": p.get("start_time"),
"end_time": p.get("end_time"),
"class_name": c.get("name") or c.get("class_code"),
"subject": c.get("subject"),
"year_group": c.get("year_group"),
"teacher_name": t.get("display_name") or t.get("full_name"),
}
days_map[lesson["date"]].append(enriched)
days_list = []
current = monday
while current <= friday:
d_str = str(current)
days_list.append({
"date": d_str,
"day_of_week": current.strftime("%A"),
"is_today": current == today,
"lessons": days_map.get(d_str, []),
})
current += timedelta(days=1)
if current.weekday() >= 5:
current += timedelta(days=7 - current.weekday())
return {
"status": "ok",
"week_start": str(monday),
"week_end": str(friday),
"days": days_list,
"total_lessons": len(lessons),
}
File diff suppressed because it is too large Load Diff
+123 -63
View File
@@ -11,15 +11,117 @@ load_dotenv(find_dotenv())
import os
import json
import logging
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, Tuple
from modules.database.supabase.utils.storage import StorageAdmin
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
from modules.database.supabase.utils.storage import StorageError, StorageUser
from modules.logger_tool import initialise_logger
router = APIRouter()
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
ALLOWED_SNAPSHOT_BUCKETS = {"cc.public.snapshots"}
PERSONAL_NODE_TYPES = {"User", "Teacher", "Developer", "SuperAdmin", "UserTeacherTimetable"}
GLOBAL_READONLY_NODE_TYPES = {"CalendarYear", "CalendarMonth", "CalendarWeek", "CalendarDay", "CalendarTimeChunk"}
def _sb() -> SupabaseServiceRoleClient:
return SupabaseServiceRoleClient()
def _parse_snapshot_path(path: str) -> Tuple[str, str, str, str]:
"""Parse and validate bucket/node_type/node_id into a storage object path."""
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
path_parts = [part for part in path.split('/') if part]
if len(path_parts) != 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket, node_type, node_id = path_parts
if bucket not in ALLOWED_SNAPSHOT_BUCKETS:
raise HTTPException(status_code=403, detail="Snapshot bucket is not allowed")
if any(part in {".", ".."} or ".." in part for part in path_parts):
raise HTTPException(status_code=400, detail="Invalid path component")
if not node_type.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Invalid node type")
if not node_id.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Invalid node id")
return bucket, node_type, node_id, f"{node_type}/{node_id}/tldraw_file.json"
def _user_scope(user_id: str) -> Dict[str, Any]:
"""Resolve Supabase/Neo4j scope for the authenticated user."""
scope: Dict[str, Any] = {
"user_id": user_id,
"teacher_db": f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else "",
"institute_id": "",
"institute_db": "",
"curriculum_db": "",
}
if not user_id:
return scope
try:
sb = _sb()
prof = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
school_id = str((prof.data or {}).get("school_id") or "")
scope["institute_id"] = school_id
if school_id:
inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute()
neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string")
if neo4j_uuid:
scope["institute_db"] = f"cc.institutes.{neo4j_uuid}"
scope["curriculum_db"] = f"cc.institutes.{neo4j_uuid}.curriculum"
except Exception as exc:
logger.warning(f"Could not resolve TLDraw storage scope for user {user_id}: {exc}")
return scope
def _authorize_snapshot_path(path: str, db_name: str, credentials: Dict[str, Any], write: bool) -> Tuple[str, str, str, str]:
"""Authorize TLDraw snapshot access before touching Supabase Storage."""
user_id = credentials.get("sub", "")
if not user_id:
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
bucket, node_type, node_id, file_path = _parse_snapshot_path(path)
scope = _user_scope(user_id)
allowed_dbs = {db for db in (scope["teacher_db"], scope["institute_db"], scope["curriculum_db"]) if db}
if node_type in PERSONAL_NODE_TYPES:
if node_id == user_id or (db_name and db_name == scope["teacher_db"]):
return bucket, node_type, node_id, file_path
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's workspace")
if node_type in GLOBAL_READONLY_NODE_TYPES and db_name == "classroomcopilot":
if write:
raise HTTPException(status_code=403, detail="Global calendar snapshots are read-only")
return bucket, node_type, node_id, file_path
# Institute/curriculum snapshots must be accessed through the caller's institute DB.
if db_name and db_name in allowed_dbs:
return bucket, node_type, node_id, file_path
raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's tenant")
def _storage_for_user(credentials: Dict[str, Any]) -> StorageUser:
access_token = credentials.get("_access_token")
if not access_token:
raise HTTPException(status_code=403, detail="User access token is required for storage access")
return StorageUser(user_id=credentials.get("sub"), access_token=access_token)
def _is_valid_tldraw_snapshot(snapshot_data: Any) -> bool:
if not isinstance(snapshot_data, dict):
return False
if not ("document" in snapshot_data and "session" in snapshot_data):
return False
document = snapshot_data.get("document")
if isinstance(document, dict) and "schema" in document:
return True
return "schemaVersion" in snapshot_data
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
@@ -101,7 +203,8 @@ def create_default_tldraw_content():
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file_from_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context")
db_name: str = Query(..., description="Database name for context"),
credentials: dict = Depends(SupabaseBearer()),
):
"""
Load TLDraw snapshot from Supabase Storage.
@@ -116,26 +219,9 @@ async def read_tldraw_node_file_from_supabase(
logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
# Expected format: "cc.public.snapshots/User/user_id" or "cc.public.snapshots/Teacher/teacher_id"
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
# Format: nodetype/node_id/tldraw_file.json
file_path = f"{node_type}/{node_id}/tldraw_file.json"
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=False)
storage = _storage_for_user(credentials)
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
@@ -147,29 +233,17 @@ async def read_tldraw_node_file_from_supabase(
# Parse JSON data
try:
snapshot_data = json.loads(file_data.decode('utf-8'))
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
except (UnicodeDecodeError, json.JSONDecodeError) as e:
logger.warning(f"Malformed TLDraw snapshot {file_path}; returning default content: {e}")
return create_default_tldraw_content()
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
if _is_valid_tldraw_snapshot(snapshot_data):
return snapshot_data
logger.warning(f"Snapshot data from {file_path} is missing required TLDraw structure. Using default structure.")
return create_default_tldraw_content()
# Ensure the snapshot has the correct structure for TLDraw
if isinstance(snapshot_data, dict) and 'document' in snapshot_data and 'session' in snapshot_data:
# Check if it has the new format (schemaVersion in document.schema)
if 'document' in snapshot_data and isinstance(snapshot_data['document'], dict) and 'schema' in snapshot_data['document']:
return snapshot_data
# Check if it has the old format (schemaVersion at root level)
elif 'schemaVersion' in snapshot_data:
return snapshot_data
else:
# Use default structure if schema is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing schemaVersion. Using default structure.")
return create_default_tldraw_content()
else:
# Use default structure if basic structure is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing top-level TLDraw keys. Using default structure.")
return create_default_tldraw_content()
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON from Supabase Storage file: {e}")
raise HTTPException(status_code=500, detail="Invalid JSON in file")
except Exception as e:
except StorageError as e:
# File doesn't exist, create default content
logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}")
@@ -199,7 +273,8 @@ async def read_tldraw_node_file_from_supabase(
async def set_tldraw_node_file_in_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context"),
data: Dict[str, Any] = None
data: Dict[str, Any] = None,
credentials: dict = Depends(SupabaseBearer()),
):
"""
Save TLDraw snapshot to Supabase Storage.
@@ -215,27 +290,12 @@ async def set_tldraw_node_file_in_supabase(
logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
if not data:
raise HTTPException(status_code=400, detail="Data not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
file_path = f"{node_type}/{node_id}/tldraw_file.json"
bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=True)
storage = _storage_for_user(credentials)
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
@@ -0,0 +1,81 @@
import os
from typing import Dict, Any
from fastapi import APIRouter, Depends
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.services.provisioning_service import ProvisioningService
import modules.database.tools.neo4j_driver_tools as driver_tools
import modules.database.schemas.nodes.users as user_nodes
import modules.database.tools.neontology_tools as neon
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
def _teacher_db(user_id: str) -> str:
return f"cc.users.teacher.{user_id.replace('-', '')}"
def _ensure_journal_planner(user_id: str, teacher_db: str) -> None:
neon.init_neontology_connection()
try:
with driver_tools.get_session(database=teacher_db) as session:
has_journal = session.run("MATCH (j:Journal) RETURN count(j) AS n").single()["n"] > 0
has_planner = session.run("MATCH (p:Planner) RETURN count(p) AS n").single()["n"] > 0
if not has_journal:
journal = user_nodes.JournalNode(
uuid_string=f"{user_id}_journal",
node_storage_path=f"users/{user_id}/nodes/journal",
user_id=user_id,
)
neon.create_or_merge_neontology_node(journal, database=teacher_db, operation='merge')
logger.info(f"Created Journal node for {user_id}")
if not has_planner:
planner = user_nodes.PlannerNode(
uuid_string=f"{user_id}_planner",
node_storage_path=f"users/{user_id}/nodes/planner",
user_id=user_id,
)
neon.create_or_merge_neontology_node(planner, database=teacher_db, operation='merge')
logger.info(f"Created Planner node for {user_id}")
finally:
neon.close_neontology_connection()
@router.post("/init")
async def init_user(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
if not user_id:
return {"status": "error", "message": "No user ID in token"}
db = _teacher_db(user_id)
# Fast path: check if already fully provisioned
try:
with driver_tools.get_session(database=db) as session:
r = session.run(
"MATCH (u:User) "
"OPTIONAL MATCH (j:Journal) "
"OPTIONAL MATCH (p:Planner) "
"RETURN count(u) AS u, count(j) AS j, count(p) AS p"
).single()
if r and r["u"] > 0 and r["j"] > 0 and r["p"] > 0:
logger.debug(f"User {user_id} already initialized — fast path")
return {"status": "ok", "initialized": True, "teacher_db": db}
except Exception:
pass # DB doesn't exist yet — fall through to full provisioning
# Full provisioning
try:
logger.info(f"Provisioning user {user_id}...")
service = ProvisioningService()
result = service.ensure_user(user_id)
user_db = result.get("user_db_name") or db
_ensure_journal_planner(user_id, user_db)
logger.info(f"User {user_id} provisioned successfully: {user_db}")
return {"status": "ok", "initialized": True, "teacher_db": user_db}
except Exception as e:
logger.error(f"User init failed for {user_id}: {e}")
return {"status": "error", "message": str(e)}
+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
+564
View File
@@ -0,0 +1,564 @@
"""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:
"""Institute boundary check — RLS already gates template visibility; this prevents cross-institute PDF leakage."""
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
+1 -1
View File
@@ -16,7 +16,7 @@ logging = logger.get_logger(
from fastapi import APIRouter, HTTPException
from langchain_classic.chains import GraphCypherQAChain
from langchain_community.graphs import Neo4jGraph
from langchain_community.chat_models import ChatOpenAI
from langchain_openai import ChatOpenAI
from langchain_classic.prompts.prompt import PromptTemplate
from routers.llm.private.ollama.ollama_wrapper import OllamaWrapper
from modules.database.tools.neontology.utils import get_node_types, get_rels_by_type
View File
+18
View File
@@ -0,0 +1,18 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.services.bootstrap_service import build_bootstrap_response
router = APIRouter()
auth_scheme = SupabaseBearer()
@router.get("/bootstrap")
async def get_me_bootstrap(credentials: Dict[str, Any] = Depends(auth_scheme)) -> Dict[str, Any]:
"""Authenticated Supabase-first session/onboarding bootstrap contract."""
try:
return build_bootstrap_response(credentials)
except ValueError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
+65
View File
@@ -0,0 +1,65 @@
import os
import time
from typing import Any, Dict
from uuid import uuid4
import jwt
from fastapi import APIRouter, Depends, HTTPException
from modules.auth.supabase_bearer import verify_supabase_token_dep
router = APIRouter()
TLSYNC_TOKEN_AUDIENCE = "tlsync"
DEFAULT_TLSYNC_TOKEN_TTL_SECONDS = 300
def _tlsync_token_ttl_seconds() -> int:
raw_value = os.getenv("TLSYNC_TOKEN_TTL_SECONDS")
if not raw_value:
return DEFAULT_TLSYNC_TOKEN_TTL_SECONDS
try:
ttl = int(raw_value)
except ValueError as exc:
raise HTTPException(status_code=500, detail="TLSync token TTL is misconfigured") from exc
if ttl <= 0 or ttl > 3600:
raise HTTPException(status_code=500, detail="TLSync token TTL is out of range")
return ttl
def create_tlsync_token(user_claims: Dict[str, Any]) -> Dict[str, Any]:
secret = os.getenv("TLSYNC_SECRET")
if not secret:
raise HTTPException(status_code=503, detail="TLSync authentication is not configured")
user_id = user_claims.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing")
ttl_seconds = _tlsync_token_ttl_seconds()
issued_at = int(time.time())
expires_at = issued_at + ttl_seconds
payload = {
"sub": user_id,
"aud": TLSYNC_TOKEN_AUDIENCE,
"iat": issued_at,
"exp": expires_at,
"jti": uuid4().hex,
}
token = jwt.encode(payload, secret, algorithm="HS256")
return {
"token": token,
"token_type": "Bearer",
"expires_in": ttl_seconds,
"expires_at": expires_at,
}
@router.get("/token")
async def get_tlsync_token(user_claims: Dict[str, Any] = Depends(verify_supabase_token_dep)) -> Dict[str, Any]:
"""Issue a short-lived TLSync token for an authenticated Supabase user."""
return create_tlsync_token(user_claims)
+7 -35
View File
@@ -1,6 +1,4 @@
from .infrastructure import initialize_infrastructure
from .demo_school import initialize_demo_school
from .demo_users import initialize_demo_users
from .gais_data import import_gais_data
from modules.logger_tool import initialise_logger
import os
@@ -10,54 +8,32 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
def initialize_infrastructure_mode() -> None:
"""Initialize infrastructure: Neo4j schema, calendar, and Supabase buckets"""
logger.info("Starting infrastructure initialization...")
# 1. Initialize Neo4j database, schema, and calendar structure
logger.info("Step 1: Initializing Neo4j infrastructure...")
from .neo4j import initialize_neo4j
neo4j_result = initialize_neo4j()
if not neo4j_result["success"]:
logger.error(f"Neo4j infrastructure initialization failed: {neo4j_result['message']}")
return
# 2. Initialize Supabase storage buckets
logger.info("Step 2: Initializing Supabase storage buckets...")
from .buckets import initialize_buckets
buckets_result = initialize_buckets()
if not buckets_result["success"]:
logger.error(f"Storage buckets initialization failed: {buckets_result['message']}")
return
logger.info("Infrastructure initialization completed successfully!")
logger.info(f"Neo4j: {neo4j_result['message']}")
logger.info(f"Buckets: {buckets_result['message']}")
def initialize_demo_school_mode() -> None:
"""Initialize demo school (KevlarAI)"""
logger.info("Starting demo school initialization...")
result = initialize_demo_school()
if result["success"]:
logger.info("Demo school initialization completed successfully")
else:
logger.error(f"Demo school initialization failed: {result['message']}")
def initialize_demo_users_mode() -> None:
"""Initialize demo users"""
logger.info("Starting demo users initialization...")
result = initialize_demo_users()
if result["success"]:
logger.info("Demo users initialization completed successfully")
else:
logger.error(f"Demo users initialization failed: {result['message']}")
def initialize_gais_data_mode() -> None:
"""Initialize GAIS data import (Edubase, etc.)"""
logger.info("Starting GAIS data import...")
result = import_gais_data()
if result["success"]:
logger.info("GAIS data import completed successfully")
else:
@@ -65,11 +41,7 @@ def initialize_gais_data_mode() -> None:
__all__ = [
'initialize_infrastructure_mode',
'initialize_demo_school_mode',
'initialize_demo_users_mode',
'initialize_gais_data_mode',
'initialize_infrastructure',
'initialize_demo_school',
'initialize_demo_users',
'import_gais_data'
]
]
-181
View File
@@ -1,181 +0,0 @@
"""
Demo school initialization module for ClassroomCopilot
Creates the KevlarAI demo school
"""
import os
import json
import requests
from typing import Dict, Any
from modules.logger_tool import initialise_logger
from modules.database.services.provisioning_service import ProvisioningService
import time
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
class DemoSchoolInitializer:
"""Handles demo school creation"""
def __init__(self, supabase_url: str, service_role_key: str):
self.supabase_url = supabase_url
self.service_role_key = service_role_key
self.supabase_headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json"
}
self.provisioning_service = ProvisioningService()
def create_kevlarai_school(self) -> Dict[str, Any]:
"""Create the KevlarAI demo school"""
logger.info("Creating KevlarAI demo school...")
try:
# Check if KevlarAI school already exists
response = self._supabase_request_with_retry(
'get',
f"{self.supabase_url}/rest/v1/institutes",
headers=self.supabase_headers,
params={
"select": "*",
"name": "eq.KevlarAI"
}
)
if response.status_code == 200:
existing_schools = response.json()
if existing_schools and len(existing_schools) > 0:
logger.info("KevlarAI school already exists")
school = existing_schools[0]
try:
self.provisioning_service.ensure_school(school["id"])
except Exception as provisioning_error:
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
return {
"success": True,
"message": "KevlarAI school already exists",
"school": school
}
# Create KevlarAI school
school_data = {
"name": "KevlarAI",
"urn": "KEVLARAI001",
"status": "active",
"address": {
"street": "123 Innovation Drive",
"town": "Tech City",
"county": "Digital County",
"postcode": "TC1 2AI",
"country": "United Kingdom"
},
"website": "https://kevlar.ai",
"metadata": {
"school_type": "AI and Technology",
"phase_of_education": "Secondary and Further Education",
"establishment_status": "Open",
"specialization": "Artificial Intelligence, Machine Learning, Robotics"
}
}
# Insert the school
response = self._supabase_request_with_retry('post', f"{self.supabase_url}/rest/v1/institutes", headers={**self.supabase_headers, "Prefer": "return=representation"}, json=school_data, params={"select": "*"})
logger.info(f"Supabase response status: {response.status_code}")
logger.info(f"Supabase response headers: {dict(response.headers)}")
logger.info(f"Supabase response text: {response.text}")
if response.status_code in (200, 201):
try:
data = response.json()
school = data[0] if isinstance(data, list) and data else data
logger.info("Successfully created KevlarAI school")
# Ensure Neo4j provisioning is in place
try:
self.provisioning_service.ensure_school(school["id"])
except Exception as provisioning_error:
logger.warning(f"Provisioning KevlarAI school failed: {provisioning_error}")
return {
"success": True,
"message": "Successfully created KevlarAI school",
"school": school
}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON response: {str(e)}")
logger.error(f"Response text: {response.text}")
# If the status code is successful but we can't parse JSON,
# the school was likely created successfully
return {
"success": True,
"message": "Successfully created KevlarAI school (response not JSON)",
"school": None
}
else:
logger.error(f"Failed to create KevlarAI school: {response.text}")
return {
"success": False,
"message": f"Failed to create KevlarAI school: {response.text}"
}
except Exception as e:
logger.error(f"Error creating KevlarAI school: {str(e)}")
return {
"success": False,
"message": f"Error creating KevlarAI school: {str(e)}"
}
def _supabase_request_with_retry(self, method, url, **kwargs):
"""Make a request to Supabase with retry logic"""
max_retries = 3
retry_delay = 2 # seconds
for attempt in range(max_retries):
try:
if method.lower() == 'get':
response = requests.get(url, **kwargs)
elif method.lower() == 'post':
response = requests.post(url, **kwargs)
elif method.lower() == 'put':
response = requests.put(url, **kwargs)
elif method.lower() == 'delete':
response = requests.delete(url, **kwargs)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
# If successful or client error (4xx), don't retry
if response.status_code < 500:
return response
# Server error (5xx), retry after delay
logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}")
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
except requests.RequestException as e:
logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}")
if attempt == max_retries - 1:
raise
time.sleep(retry_delay * (attempt + 1))
# If we get here, all retries failed with server errors
raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}")
def initialize_demo_school() -> Dict[str, Any]:
"""Initialize demo school (KevlarAI)"""
logger.info("Starting demo school initialization...")
supabase_url = os.getenv("SUPABASE_URL")
service_role_key = os.getenv("SERVICE_ROLE_KEY")
if not supabase_url or not service_role_key:
return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"}
initializer = DemoSchoolInitializer(supabase_url, service_role_key)
# Create KevlarAI school
result = initializer.create_kevlarai_school()
if result["success"]:
logger.info("Demo school initialization completed successfully")
else:
logger.error(f"Demo school initialization failed: {result['message']}")
return result
-395
View File
@@ -1,395 +0,0 @@
"""
Demo users initialization module for ClassroomCopilot
Creates demo teachers and students
"""
import os
import json
import requests
import time
from typing import Dict, Any
from modules.logger_tool import initialise_logger
from modules.database.services.provisioning_service import ProvisioningService
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
class DemoUsersInitializer:
"""Handles demo users creation"""
def __init__(self, supabase_url: str, service_role_key: str):
self.supabase_url = supabase_url
self.service_role_key = service_role_key
self.supabase_headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json"
}
self.provisioning_service = ProvisioningService()
def create_demo_users(self) -> Dict[str, Any]:
"""Create demo teachers and students"""
logger.info("Creating demo users...")
try:
# Define demo users
demo_users = [
# Demo Teachers
{
"email": "[email protected]",
"password": "DemoTeacher123!",
"email_confirm": True,
"user_metadata": {
"name": "Dr. Sarah Chen",
"username": "sarah.chen",
"full_name": "Dr. Sarah Chen",
"display_name": "Dr. Chen",
"user_type": "teacher"
},
"app_metadata": {
"provider": "email",
"providers": ["email"]
}
},
{
"email": "[email protected]",
"password": "DemoTeacher123!",
"email_confirm": True,
"user_metadata": {
"name": "Prof. Marcus Rodriguez",
"username": "marcus.rodriguez",
"full_name": "Professor Marcus Rodriguez",
"display_name": "Prof. Rodriguez",
"user_type": "teacher"
},
"app_metadata": {
"provider": "email",
"providers": ["email"]
}
},
# Demo Students
{
"email": "[email protected]",
"password": "DemoStudent123!",
"email_confirm": True,
"user_metadata": {
"name": "Alex Thompson",
"username": "alex.thompson",
"full_name": "Alex Thompson",
"display_name": "Alex",
"user_type": "student"
},
"app_metadata": {
"provider": "email",
"providers": ["email"]
}
},
{
"email": "[email protected]",
"password": "DemoStudent123!",
"email_confirm": True,
"user_metadata": {
"name": "Jordan Lee",
"username": "jordan.lee",
"full_name": "Jordan Lee",
"display_name": "Jordan",
"user_type": "student"
},
"app_metadata": {
"provider": "email",
"providers": ["email"]
}
}
]
created_users = []
failed_users = []
for user_data in demo_users:
try:
# Create user via Auth API
response = self._supabase_request_with_retry(
'post',
f"{self.supabase_url}/auth/v1/admin/users",
headers=self.supabase_headers,
json=user_data
)
if response.status_code in (200, 201):
user = response.json()
user_id = user.get("id")
# Wait a moment for user to be created
time.sleep(1)
# Create profile
profile_data = {
"id": user_id,
"email": user_data["email"],
"user_type": user_data["user_metadata"]["user_type"],
"username": user_data["user_metadata"]["username"],
"full_name": user_data["user_metadata"]["full_name"],
"display_name": user_data["user_metadata"]["display_name"]
}
profile_response = self._supabase_request_with_retry(
'post',
f"{self.supabase_url}/rest/v1/profiles",
headers=self.supabase_headers,
json=profile_data
)
if profile_response.status_code in (200, 201):
created_users.append({
"id": user_id,
"email": user_data["email"],
"user_type": user_data["user_metadata"]["user_type"],
"username": user_data["user_metadata"]["username"]
})
logger.info(f"Successfully created user: {user_data['email']}")
else:
logger.warning(f"Failed to create profile for {user_data['email']}: {profile_response.text}")
failed_users.append({
"email": user_data["email"],
"error": f"Profile creation failed: {profile_response.text}"
})
else:
logger.warning(f"Failed to create user {user_data['email']}: {response.text}")
failed_users.append({
"email": user_data["email"],
"error": f"User creation failed: {response.text}"
})
except Exception as e:
logger.error(f"Error creating user {user_data['email']}: {str(e)}")
failed_users.append({
"email": user_data["email"],
"error": str(e)
})
# Create institute memberships for KevlarAI and provision users
all_users_to_provision = []
# Add newly created users
if created_users:
all_users_to_provision.extend(created_users)
self._create_institute_memberships(created_users)
# Also provision existing users that failed due to email_exists
existing_users = []
for failed_user in failed_users:
if "email_exists" in failed_user.get("error", ""):
# Get the existing user ID from Supabase
existing_user_id = self._get_existing_user_id(failed_user["email"])
if existing_user_id:
existing_users.append({
"id": existing_user_id,
"email": failed_user["email"],
"user_type": self._get_user_type_from_email(failed_user["email"]),
"username": self._get_username_from_email(failed_user["email"])
})
if existing_users:
logger.info(f"Found {len(existing_users)} existing users to provision")
all_users_to_provision.extend(existing_users)
self._create_institute_memberships(existing_users)
# Provision all users (new and existing)
if all_users_to_provision:
self._provision_users(all_users_to_provision)
logger.info(f"Demo users creation completed: {len(created_users)} created, {len(failed_users)} failed")
return {
"success": True,
"message": f"Successfully created {len(created_users)} demo users",
"created_users": created_users,
"failed_users": failed_users
}
except Exception as e:
logger.error(f"Error creating demo users: {str(e)}")
return {
"success": False,
"message": f"Error creating demo users: {str(e)}"
}
def _create_institute_memberships(self, users: list) -> None:
"""Create institute memberships for users in KevlarAI"""
logger.info("Creating institute memberships for demo users...")
try:
# Get KevlarAI institute ID
response = self._supabase_request_with_retry(
'get',
f"{self.supabase_url}/rest/v1/institutes",
headers=self.supabase_headers,
params={
"select": "id",
"name": "eq.KevlarAI"
}
)
if response.status_code != 200:
logger.warning("Could not get KevlarAI institute ID for memberships")
return
institutes = response.json()
if not institutes:
logger.warning("KevlarAI institute not found for memberships")
return
institute_id = institutes[0]["id"]
# Get user profile IDs
for user in users:
try:
profile_response = self._supabase_request_with_retry(
'get',
f"{self.supabase_url}/rest/v1/profiles",
headers=self.supabase_headers,
params={
"select": "id",
"email": f"eq.{user['email']}"
}
)
if profile_response.status_code == 200:
profiles = profile_response.json()
if profiles:
profile_id = profiles[0]["id"]
# Create membership
membership_data = {
"profile_id": profile_id,
"institute_id": institute_id,
"role": user["user_type"]
}
membership_response = self._supabase_request_with_retry(
'post',
f"{self.supabase_url}/rest/v1/institute_memberships",
headers=self.supabase_headers,
json=membership_data
)
if membership_response.status_code in (200, 201):
logger.info(f"Created membership for {user['email']} in KevlarAI")
else:
logger.warning(f"Failed to create membership for {user['email']}: {membership_response.text}")
except Exception as e:
logger.warning(f"Error creating membership for {user['email']}: {str(e)}")
except Exception as e:
logger.warning(f"Error creating institute memberships: {str(e)}")
def _get_existing_user_id(self, email: str) -> str:
"""Get the user ID for an existing user by email"""
try:
response = self._supabase_request_with_retry(
'get',
f"{self.supabase_url}/rest/v1/profiles",
headers=self.supabase_headers,
params={
"select": "id",
"email": f"eq.{email}"
}
)
if response.status_code == 200:
profiles = response.json()
if profiles and len(profiles) > 0:
return profiles[0].get("id")
logger.warning(f"Could not find existing user ID for {email}")
return None
except Exception as e:
logger.warning(f"Error getting existing user ID for {email}: {str(e)}")
return None
def _get_user_type_from_email(self, email: str) -> str:
"""Get user type from email based on demo user definitions"""
if "teacher" in email:
return "teacher"
elif "student" in email:
return "student"
return "teacher" # default
def _get_username_from_email(self, email: str) -> str:
"""Get username from email based on demo user definitions"""
username_map = {
"[email protected]": "sarah.chen",
"[email protected]": "marcus.rodriguez",
"[email protected]": "alex.thompson",
"[email protected]": "jordan.lee"
}
return username_map.get(email, email.split("@")[0])
def _provision_users(self, users: list) -> None:
"""Provision Neo4j databases for the created demo users."""
for user in users:
user_id = user.get("id")
if not user_id:
continue
try:
self.provisioning_service.ensure_user(user_id)
logger.info(f"Provisioned Neo4j resources for {user.get('email')}")
except Exception as exc:
logger.warning(f"Failed to provision Neo4j resources for {user.get('email')}: {exc}")
def _supabase_request_with_retry(self, method, url, **kwargs):
"""Make a request to Supabase with retry logic"""
max_retries = 3
retry_delay = 2 # seconds
for attempt in range(max_retries):
try:
if method.lower() == 'get':
response = requests.get(url, **kwargs)
elif method.lower() == 'post':
response = requests.post(url, **kwargs)
elif method.lower() == 'put':
response = requests.put(url, **kwargs)
elif method.lower() == 'delete':
response = requests.delete(url, **kwargs)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
# If successful or client error (4xx), don't retry
if response.status_code < 500:
return response
# Server error (5xx), retry after delay
logger.warning(f"Supabase server error (attempt {attempt+1}/{max_retries}): {response.status_code} - {response.text}")
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
except requests.RequestException as e:
logger.warning(f"Supabase request exception (attempt {attempt+1}/{max_retries}): {str(e)}")
if attempt == max_retries - 1:
raise
time.sleep(retry_delay * (attempt + 1))
# If we get here, all retries failed with server errors
raise requests.RequestException(f"Failed after {max_retries} attempts to {method} {url}")
def initialize_demo_users() -> Dict[str, Any]:
"""Initialize demo users"""
logger.info("Starting demo users initialization...")
supabase_url = os.getenv("SUPABASE_URL")
service_role_key = os.getenv("SERVICE_ROLE_KEY")
if not supabase_url or not service_role_key:
return {"success": False, "message": "Missing SUPABASE_URL or SERVICE_ROLE_KEY environment variables"}
initializer = DemoUsersInitializer(supabase_url, service_role_key)
# Create demo users
result = initializer.create_demo_users()
if result["success"]:
logger.info("Demo users initialization completed successfully")
else:
logger.error(f"Demo users initialization failed: {result['message']}")
return result
File diff suppressed because it is too large Load Diff
+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))
+230
View File
@@ -0,0 +1,230 @@
"""
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
Clears:
- Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*)
- Supabase: deletes ALL data tables except gais_local_authorities and gais_schools
- Supabase: deletes all auth users except kcar, then re-seeds kcar profile state
Safe invariants (never touched):
- kcar auth account
- gais_local_authorities and gais_schools Supabase tables
- system / neo4j Neo4j system databases
Run from inside the ccapi container:
python3 -c "from run.initialization.reset_environment import reset; reset()"
"""
import os
import time
import requests
from typing import List, Dict, Any
from modules.logger_tool import initialise_logger
import modules.database.tools.neo4j_driver_tools as dt
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
KCAR_EMAIL = "[email protected]"
# Neo4j system databases — never drop these
NEO4J_SYSTEM_DBS = {"system", "neo4j"}
# Supabase tables to clear, in FK child-first order.
# gais_local_authorities and gais_schools are intentionally absent.
SUPABASE_TABLES_TO_CLEAR = [
# ── Transcription (deepest children first) ───────────────────────────────
"canvas_events",
"keyword_events",
"transcription_summaries",
"transcription_segments",
"keyword_watches",
"transcription_sessions",
# ── Lesson delivery chain ────────────────────────────────────────────────
"lesson_deliveries",
"lesson_collaborators",
# ── Timetable materialization ────────────────────────────────────────────
"taught_lessons",
# ── Academic calendar (children → parents) ───────────────────────────────
"academic_periods",
"academic_days",
"academic_weeks",
"academic_term_breaks",
"academic_terms",
"academic_years",
# ── Teacher timetables ───────────────────────────────────────────────────
"teacher_timetable_slots",
"teacher_timetables",
"school_timetables",
# ── Lesson plans ─────────────────────────────────────────────────────────
"planned_lessons",
# ── Whiteboard rooms ─────────────────────────────────────────────────────
"whiteboard_rooms",
# ── Classes & enrollment ─────────────────────────────────────────────────
"enrollment_requests",
"class_students",
"class_teachers",
"classes",
# ── Files & brains ───────────────────────────────────────────────────────
"document_artefacts",
"brain_files",
"cabinet_memberships",
"files",
"file_cabinets",
"brains",
# ── Invitations & memberships ────────────────────────────────────────────
"invitations",
"institute_memberships",
"institute_membership_requests",
# ── Institutes ───────────────────────────────────────────────────────────
"institutes",
# ── Profiles (non-kcar cleared separately via auth deletion cascade) ─────
"admin_profiles",
]
def _sb_headers():
url = os.environ["SUPABASE_URL"]
key = os.environ["SERVICE_ROLE_KEY"]
return url, {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Prefer": "return=minimal",
}
# ─── Neo4j helpers ────────────────────────────────────────────────────────────
def _neo4j_drop_all_non_system() -> Dict[str, List[str]]:
"""Drop every Neo4j DB except the system-reserved ones."""
with dt.get_session(database="system") as s:
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
to_drop = [db for db in all_dbs if db not in NEO4J_SYSTEM_DBS]
dropped = []
for db in to_drop:
logger.info(f" DROP DATABASE `{db}`")
try:
with dt.get_session(database="system") as s:
s.run(f"DROP DATABASE `{db}` IF EXISTS")
dropped.append(db)
except Exception as e:
logger.warning(f" Could not drop `{db}`: {e}")
return dropped
# ─── Supabase helpers ─────────────────────────────────────────────────────────
# Tables without an uid=1000(kcar) gid=1000(kcar) groups=1000(kcar),27(sudo),119(docker) column — map to the column to use as the delete filter.
TABLE_FILTER_COLUMN = {
"brain_files": "brain_id",
}
def _sb_clear_table(url: str, headers: dict, table: str) -> int:
"""Delete all rows from a Supabase table. Returns HTTP status."""
col = TABLE_FILTER_COLUMN.get(table, "id")
r = requests.delete(
f"{url}/rest/v1/{table}",
headers=headers,
params={col: "not.is.null"},
)
if r.status_code not in (200, 204):
logger.warning(f" Clear {table}: {r.status_code} {r.text[:120]}")
return r.status_code
def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
r = requests.get(f"{url}/auth/v1/admin/users", headers=headers, params={"per_page": 200})
r.raise_for_status()
return r.json().get("users", [])
def _supabase_delete_auth_user(url: str, headers: dict, uid: str):
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
if r.status_code not in (200, 204):
logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:80]}")
# ─── Main reset ───────────────────────────────────────────────────────────────
def reset() -> Dict[str, Any]:
logger.info("=" * 60)
logger.info("RESET ENVIRONMENT — full destructive wipe starting")
logger.info("=" * 60)
results: Dict[str, Any] = {}
# ── 1. Neo4j: drop everything except system + neo4j ──────────────────────
logger.info("\n[Neo4j] Dropping all non-system databases...")
dropped = _neo4j_drop_all_non_system()
logger.info(f" Dropped {len(dropped)}: {dropped}")
results["neo4j"] = {"dropped": dropped}
# ── 2. Supabase: clear all data tables (GAIS preserved) ──────────────────
logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...")
url, headers = _sb_headers()
cleared, failed = [], []
for table in SUPABASE_TABLES_TO_CLEAR:
status = _sb_clear_table(url, headers, table)
if status in (200, 204):
cleared.append(table)
logger.info(f"{table}")
else:
failed.append(table)
logger.info(f" Cleared {len(cleared)} tables, {len(failed)} failed")
# ── 3. Supabase: delete all auth users except kcar ────────────────────────
logger.info("\n[Supabase] Deleting test auth users...")
all_users = _supabase_list_auth_users(url, headers)
deleted_emails = []
for u in all_users:
if u["email"] == KCAR_EMAIL:
continue
_supabase_delete_auth_user(url, headers, u["id"])
deleted_emails.append(u["email"])
time.sleep(0.05)
logger.info(f" Deleted {len(deleted_emails)} auth users")
# Explicit cleanup in case cascade didn't fire
requests.delete(f"{url}/rest/v1/profiles", headers=headers,
params={"id": f"neq.{KCAR_ID}"})
# ── 4. Reset kcar profile to known-good platform_admin state ──────────────
logger.info("\n[Supabase] Resetting kcar profile...")
requests.patch(
f"{url}/rest/v1/profiles",
headers=headers,
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
)
logger.info(" kcar → school_id: null ✓")
# Restore admin_profiles row (wiped with other tables above)
requests.post(
f"{url}/rest/v1/admin_profiles",
headers={**headers, "Prefer": "resolution=merge-duplicates"},
json={
"id": KCAR_ID,
"email": KCAR_EMAIL,
"display_name": "Kevin Carroll",
"admin_role": "super_admin",
"is_super_admin": True,
},
)
logger.info(" kcar → admin_profiles restored ✓")
results["supabase"] = {
"tables_cleared": cleared,
"tables_failed": failed,
"deleted_users": deleted_emails,
}
logger.info("\n" + "=" * 60)
logger.info("RESET COMPLETE")
logger.info("=" * 60)
return results
if __name__ == "__main__":
import json
print(json.dumps(reset(), 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))
+531
View File
@@ -0,0 +1,531 @@
"""
seed_environment.py — idempotent full-environment rebuild.
Assumes reset_environment.py has already been run (or it's the first boot).
Safe to re-run: all writes use UPSERT / MERGE.
Schools
-------
KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test
Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test
Uniform accounts per school (10 × 2 = 20 total)
------------------------------------------------
admin@{domain} school_admin
head@{domain} school_admin
physics@{domain} teacher
maths@{domain} teacher
teacher1@{domain} teacher
teacher2@{domain} teacher
teacher3@{domain} teacher
student1@{domain} student
student2@{domain} student
student3@{domain} student
Run from inside the ccapi container:
python3 main.py --mode seed
python3 main.py --mode seed-test
Or directly:
python3 -c "from run.initialization.seed_environment import seed; seed()"
python3 -c "from run.initialization.seed_environment import seed; seed(test=True)"
"""
import os
import time
import requests
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from modules.logger_tool import initialise_logger
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
# ─── School constants ─────────────────────────────────────────────────────────
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
KEVLARAI_NAME = "KevlarAI"
KEVLARAI_URN = "KEVLARAI-001"
KEVLARAI_DOMAIN = "kevlarai.test"
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
GREENFIELD_NAME = "Greenfield Academy"
GREENFIELD_URN = "TEST-GFA-001"
GREENFIELD_DOMAIN = "greenfieldacademy.test"
# ─── Passwords ────────────────────────────────────────────────────────────────
DEFAULT_PLATFORM_ADMIN_PASSWORD = "KevlarAI2025!"
DEFAULT_SCHOOL_ADMIN_PASSWORD = "Admin@Cc2025!"
DEFAULT_TEACHER_PASSWORD = "Teacher@Cc2025!"
DEFAULT_STUDENT_PASSWORD = "Student@Cc2025!"
def get_seed_password(role: str) -> str:
"""Return the seed password for a role, allowing env overrides."""
normalized = role.lower().strip()
env_by_role = {
"platform_admin": ("SEED_PLATFORM_ADMIN_PASSWORD", DEFAULT_PLATFORM_ADMIN_PASSWORD),
"school_admin": ("SEED_SCHOOL_ADMIN_PASSWORD", DEFAULT_SCHOOL_ADMIN_PASSWORD),
"teacher": ("SEED_TEACHER_PASSWORD", DEFAULT_TEACHER_PASSWORD),
"student": ("SEED_STUDENT_PASSWORD", DEFAULT_STUDENT_PASSWORD),
}
if normalized not in env_by_role:
raise ValueError(f"Unknown seed password role: {role}")
env_name, default = env_by_role[normalized]
return os.getenv(env_name, default)
def get_seed_passwords() -> Dict[str, str]:
return {
"platform_admin": get_seed_password("platform_admin"),
"school_admin": get_seed_password("school_admin"),
"teacher": get_seed_password("teacher"),
"student": get_seed_password("student"),
}
# ─── Account template ────────────────────────────────────────────────────────
def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
passwords = get_seed_passwords()
return [
# school_admin accounts
{
"prefix": "admin", "email": f"admin@{domain}",
"full_name": "Alex Admin", "display_name": "Alex",
"username": f"admin.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
"institute_id": institute_id,
},
{
"prefix": "head", "email": f"head@{domain}",
"full_name": "Helen Head", "display_name": "Helen",
"username": f"head.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "school_admin", "password": passwords["school_admin"],
"institute_id": institute_id,
},
# teacher accounts
{
"prefix": "physics", "email": f"physics@{domain}",
"full_name": "Phil Physics", "display_name": "Phil",
"username": f"physics.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "maths", "email": f"maths@{domain}",
"full_name": "Mary Maths", "display_name": "Mary",
"username": f"maths.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher1", "email": f"teacher1@{domain}",
"full_name": "Tom Teacher", "display_name": "Tom",
"username": f"teacher1.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher2", "email": f"teacher2@{domain}",
"full_name": "Tara Teach", "display_name": "Tara",
"username": f"teacher2.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
{
"prefix": "teacher3", "email": f"teacher3@{domain}",
"full_name": "Tim Teachwell", "display_name": "Tim",
"username": f"teacher3.{domain.replace('.', '_')}",
"user_type": "teacher", "role": "teacher", "password": passwords["teacher"],
"institute_id": institute_id,
},
# student accounts
{
"prefix": "student1", "email": f"student1@{domain}",
"full_name": "Sam Student", "display_name": "Sam",
"username": f"student1.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
{
"prefix": "student2", "email": f"student2@{domain}",
"full_name": "Sophie Study", "display_name": "Sophie",
"username": f"student2.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
{
"prefix": "student3", "email": f"student3@{domain}",
"full_name": "Steve Scholar", "display_name": "Steve",
"username": f"student3.{domain.replace('.', '_')}",
"user_type": "student", "role": "student", "password": passwords["student"],
"institute_id": institute_id,
},
]
FULL_ACCOUNTS = (
_school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) +
_school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID)
)
def get_accounts(test: bool = False) -> List[Dict]:
"""Return full (20-user) or lightweight test (9-user) seed fixtures."""
if not test:
return list(FULL_ACCOUNTS)
wanted = {
f"student1@{KEVLARAI_DOMAIN}",
f"student2@{KEVLARAI_DOMAIN}",
f"student3@{KEVLARAI_DOMAIN}",
f"admin@{GREENFIELD_DOMAIN}",
f"physics@{GREENFIELD_DOMAIN}",
f"maths@{GREENFIELD_DOMAIN}",
f"teacher1@{GREENFIELD_DOMAIN}",
f"student1@{GREENFIELD_DOMAIN}",
f"student2@{GREENFIELD_DOMAIN}",
}
return [account for account in FULL_ACCOUNTS if account["email"] in wanted]
# ─── Supabase helpers ─────────────────────────────────────────────────────────
def _sb_ctx():
url = os.environ["SUPABASE_URL"]
key = os.environ["SERVICE_ROLE_KEY"]
headers = {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
return url, headers
def _auth_post(url, headers, path, data):
return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data)
def _auth_get(url, headers, path, params=None):
r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params)
r.raise_for_status()
return r.json()
def _rest_upsert(url, headers, table, data, on_conflict):
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
r = requests.post(
f"{url}/rest/v1/{table}",
headers=h,
json=data,
params={"on_conflict": on_conflict},
)
return r
def _rest_patch(url, headers, table, match_col, match_val, data):
r = requests.patch(
f"{url}/rest/v1/{table}",
headers={**headers, "Prefer": "return=minimal"},
params={match_col: f"eq.{match_val}"},
json=data,
)
return r
# ─── Main seed function ───────────────────────────────────────────────────────
def seed(test: bool = False) -> Dict[str, Any]:
from modules.database.services.provisioning_service import ProvisioningService
from modules.database.services.neo4j_service import Neo4jService
from modules.database.init.init_calendar import create_calendar
accounts = get_accounts(test=test)
url, headers = _sb_ctx()
errors: List[str] = []
results: Dict[str, Any] = {"mode": "test" if test else "full", "account_count": len(accounts)}
# ── Step 1: Fix KevlarAI institute record ─────────────────────────────────
logger.info("=" * 60)
logger.info(f"SEED ENVIRONMENT ({'test' if test else 'full'} mode)")
logger.info("=" * 60)
logger.info("\n[1] KevlarAI institute record...")
try:
r = _rest_upsert(url, headers, "institutes", {
"id": KEVLARAI_ID,
"name": KEVLARAI_NAME,
"urn": KEVLARAI_URN,
"status": "active",
"website": "https://kevlarai.test",
"address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"},
"metadata": {"headteacher": "Alex Admin", "seeded": True},
}, on_conflict="id")
if r.status_code in (200, 201):
logger.info(" KevlarAI upserted ✓")
else:
raise Exception(r.text[:200])
except Exception as e:
errors.append(f"kevlarai_institute: {e}")
logger.error(f" {e}")
# ── Step 2: Create Greenfield Academy if needed ───────────────────────────
logger.info("[2] Greenfield Academy institute record...")
try:
neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "")
r = _rest_upsert(url, headers, "institutes", {
"id": GREENFIELD_ID,
"name": GREENFIELD_NAME,
"urn": GREENFIELD_URN,
"status": "active",
"website": "https://greenfieldacademy.test",
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
"metadata": {"headteacher": "Alex Admin", "seeded": True},
"neo4j_uuid_string": neo4j_uuid_greenfield,
}, on_conflict="id")
if r.status_code in (200, 201):
logger.info(" Greenfield Academy upserted ✓")
else:
raise Exception(r.text[:200])
except Exception as e:
errors.append(f"greenfield_institute: {e}")
logger.error(f" {e}")
# ── Step 3: Provision Neo4j for both schools ──────────────────────────────
logger.info("[3] Neo4j school provisioning...")
provisioner = ProvisioningService()
school_dbs: Dict[str, str] = {}
for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
try:
result = provisioner.ensure_school(iid)
db = result["db_name"]
school_dbs[iid] = db
logger.info(f" {name}: {db}")
except Exception as e:
errors.append(f"ensure_school {name}: {e}")
logger.error(f" {name}: {e}")
# derive fallback db name
school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}"
# ── Step 4: Rebuild classroomcopilot global calendar ─────────────────────
logger.info("[4] classroomcopilot global calendar (20242028)...")
try:
neo4j_svc = Neo4jService()
neo4j_svc.create_database("classroomcopilot")
logger.info(" DB created, waiting 5s for availability...")
time.sleep(5)
start_dt = datetime(2024, 1, 1)
end_dt = datetime(2028, 12, 31)
create_calendar("classroomcopilot", start_dt, end_dt)
logger.info(" Calendar built ✓")
results["global_calendar"] = "ok"
except Exception as e:
errors.append(f"global_calendar: {e}")
logger.error(f" {e}")
results["global_calendar"] = "error"
# ── Step 5: Create / verify auth users ────────────────────────────────────
logger.info(f"[5] Creating auth users ({len(accounts)} accounts)...")
try:
existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", [])
existing_by_email = {u["email"]: u for u in existing}
except Exception as e:
errors.append(f"list_auth_users: {e}")
existing_by_email = {}
created_users: Dict[str, str] = {} # email → uid
for spec in accounts:
email = spec["email"]
if email in existing_by_email:
created_users[email] = existing_by_email[email]["id"]
logger.info(f" {email}: exists [{created_users[email][:8]}]")
continue
r = _auth_post(url, headers, "/users", {
"email": email,
"password": spec["password"],
"email_confirm": True,
"user_metadata": {
"username": spec["username"],
"full_name": spec["full_name"],
"display_name": spec["display_name"],
"user_type": spec["user_type"],
},
})
if r.status_code in (200, 201):
uid = r.json()["id"]
created_users[email] = uid
logger.info(f" {email}: created [{uid[:8]}]")
else:
errors.append(f"create {email}: {r.text[:150]}")
logger.error(f" {email}: {r.text[:150]}")
time.sleep(0.2)
results["users_created"] = len(created_users)
# ── Step 6: Upsert profiles and memberships ───────────────────────────────
logger.info("[6] Upserting profiles and memberships...")
for spec in accounts:
uid = created_users.get(spec["email"])
if not uid:
continue
try:
_rest_upsert(url, headers, "profiles", {
"id": uid,
"email": spec["email"],
"user_type": spec["user_type"],
"username": spec["username"],
"full_name": spec["full_name"],
"display_name": spec["display_name"],
"school_id": spec["institute_id"],
"neo4j_sync_status": "pending",
}, on_conflict="id")
_rest_upsert(url, headers, "institute_memberships", {
"profile_id": uid,
"institute_id": spec["institute_id"],
"role": spec["role"],
"metadata": {},
}, on_conflict="profile_id,institute_id")
except Exception as e:
errors.append(f"profile/membership {spec['email']}: {e}")
logger.error(f" {spec['email']}: {e}")
logger.info(" Profiles and memberships upserted ✓")
# ── Step 7: Merge Neo4j Teacher/Student nodes ─────────────────────────────
logger.info("[7] Merging Neo4j worker nodes...")
try:
from modules.database.tools.neo4j_driver_tools import close_driver, get_driver
bolt_url = os.getenv("NEO4J_BOLT_URL") or os.getenv("APP_BOLT_URL")
neo4j_user = os.getenv("NEO4J_USER") or os.getenv("USER_NEO4J")
neo4j_password = os.getenv("NEO4J_PASSWORD") or os.getenv("PASSWORD_NEO4J")
auth = (neo4j_user, neo4j_password) if neo4j_user and neo4j_password else None
driver = get_driver(url=bolt_url, auth=auth) if bolt_url else get_driver()
if driver is None:
raise RuntimeError("Neo4j driver unavailable; check NEO4J_BOLT_URL/APP_BOLT_URL and NEO4J_PASSWORD/PASSWORD_NEO4J")
# Group by institute DB
by_db: Dict[str, List[Dict]] = {}
for spec in accounts:
uid = created_users.get(spec["email"])
if not uid:
continue
db = school_dbs.get(spec["institute_id"])
if not db:
continue
by_db.setdefault(db, []).append({**spec, "uid": uid})
for db, users in by_db.items():
with driver.session(database=db) as s:
for u in users:
label = "Teacher" if u["user_type"] == "teacher" else "Student"
s.run(
f"MERGE (n:{label} {{uuid_string: $uid}}) "
"SET n.worker_email = $email, "
" n.worker_name = $name, "
" n.worker_type = $utype",
uid=u["uid"], email=u["email"],
name=u["full_name"], utype=u["user_type"],
)
logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓")
close_driver(driver)
results["neo4j_nodes"] = "ok"
except Exception as e:
errors.append(f"neo4j_nodes: {e}")
logger.error(f" {e}")
results["neo4j_nodes"] = "error"
# ── Ensure kcar is a platform super-admin ─────────────────────────────────
logger.info("[8] Ensuring kcar platform admin record...")
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
try:
_rest_upsert(url, headers, "admin_profiles", {
"id": KCAR_ID,
"email": "[email protected]",
"display_name": "Kevin Carroll",
"admin_role": "super_admin",
"is_super_admin": True,
"metadata": {"seeded": True},
}, on_conflict="id")
logger.info(" kcar → admin_profiles ✓")
except Exception as e:
errors.append(f"kcar_admin: {e}")
logger.error(f" {e}")
# Fix kcar's auth user_metadata and profiles.user_type to "platform_admin".
# Without this, POST /user/init assigns kcar to the default school on first login.
try:
r = requests.put(
f"{url}/auth/v1/admin/users/{KCAR_ID}",
headers=headers,
json={"user_metadata": {"user_type": "platform_admin"}},
)
if r.status_code in (200, 201):
logger.info(" kcar → auth user_metadata: platform_admin ✓")
else:
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_user_type: {e}")
logger.error(f" {e}")
try:
r = requests.patch(
f"{url}/rest/v1/profiles",
headers={**headers, "Prefer": "return=minimal"},
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
)
if r.status_code in (200, 204):
logger.info(" kcar → profiles.school_id: null ✓")
else:
logger.warning(f" kcar profiles patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_profile: {e}")
logger.error(f" {e}")
# ── Summary ───────────────────────────────────────────────────────────────
results["success"] = len(errors) == 0
results["errors"] = errors
_print_credential_sheet(created_users, accounts)
logger.info("\n" + "=" * 60)
if errors:
logger.info(f"SEED COMPLETE with {len(errors)} error(s)")
for e in errors:
logger.info(f"{e}")
else:
logger.info("SEED COMPLETE — all steps succeeded")
logger.info("=" * 60)
return results
def _print_credential_sheet(created_users: Dict[str, str], accounts: List[Dict]):
PAD = 36
include_passwords = os.getenv("PRINT_SEED_CREDENTIALS", "").lower() in {"1", "true", "yes", "on"}
logger.info("\n" + "=" * 70)
logger.info("CREDENTIAL SHEET" + ("" if include_passwords else " (passwords redacted; set PRINT_SEED_CREDENTIALS=true to print)"))
logger.info("=" * 70)
logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD")
logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------")
platform_password = get_seed_password("platform_admin") if include_passwords else "<redacted>"
logger.info(f" {'[platform admin]':<16} {'[email protected]':<{PAD}} {platform_password}")
logger.info("")
for school_id, domain, label in [
(KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"),
(GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"),
]:
logger.info(f" [{label}]")
for spec in accounts:
if spec["institute_id"] != school_id:
continue
uid = created_users.get(spec["email"], "")
status = f"[{uid[:8]}]" if uid != "" else "[MISSING]"
password = spec["password"] if include_passwords else "<redacted>"
logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {password} {status}")
logger.info("")
logger.info("=" * 70)
if __name__ == "__main__":
import json
print(json.dumps(seed(test="--test" in os.sys.argv), indent=2, default=str))
+423
View File
@@ -0,0 +1,423 @@
"""
seed_file_cabinets.py — Create one file cabinet per class with sample document references.
Creates file_cabinets, files, and cabinet_memberships rows via Supabase REST API
using the service role key. Also creates document_artefacts entries for sample files.
Each cabinet is owned by the class's primary teacher and shared with students
in that class via cabinet_memberships.
Tables: file_cabinets, files, cabinet_memberships, document_artefacts
Run inside ccapi container:
python3 -c "from run.initialization.seed_file_cabinets import seed; seed()"
"""
import os
import time
import uuid
import requests
from typing import Dict, Any, List, Optional
SUPA_URL = os.environ["SUPABASE_URL"]
SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"]
API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000")
# ─── Passwords (standardized from T4) ────────────────────────────────────────
PWD_ADMIN = "Admin@Cc2025!"
PWD_TEACHER = "Teacher@Cc2025!"
PWD_STUDENT = "Student@Cc2025!"
# ─── Sample file data ────────────────────────────────────────────────────────
# Each cabinet gets 2-3 sample files with realistic paths.
SAMPLE_FILES = {
# Physics cabinets
"lesson_plans": [
{"name": "forces_motion_plan.pdf", "path": "cc.public.snapshots/lesson_plans/forces_motion.pdf", "mime_type": "application/pdf", "size": "245KB"},
{"name": "electric_circuits_plan.pdf", "path": "cc.public.snapshots/lesson_plans/electric_circuits.pdf", "mime_type": "application/pdf", "size": "312KB"},
],
"worksheets": [
{"name": "worksheet_fma.pdf", "path": "cc.public.snapshots/worksheets/fma_practice.pdf", "mime_type": "application/pdf", "size": "128KB"},
{"name": "worksheet_resistance.pdf", "path": "cc.public.snapshots/worksheets/resistance_calc.pdf", "mime_type": "application/pdf", "size": "95KB"},
],
"presentations": [
{"name": "intro_forces.pptx", "path": "cc.public.snapshots/presentations/forces_intro.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "size": "2.1MB"},
],
# Maths cabinets
"lesson_plans": [
{"name": "quadratic_factorisation_plan.pdf", "path": "cc.public.snapshots/lesson_plans/quadratics.pdf", "mime_type": "application/pdf", "size": "278KB"},
],
"worksheets": [
{"name": "worksheet_quadratics.pdf", "path": "cc.public.snapshots/worksheets/quadratic_practice.pdf", "mime_type": "application/pdf", "size": "156KB"},
{"name": "worksheet_tree_diagrams.pdf", "path": "cc.public.snapshots/worksheets/tree_diagrams.pdf", "mime_type": "application/pdf", "size": "134KB"},
],
# CS cabinets
"lesson_plans": [
{"name": "intro_python_plan.pdf", "path": "cc.public.snapshots/lesson_plans/intro_python.pdf", "mime_type": "application/pdf", "size": "198KB"},
],
"code_samples": [
{"name": "hello_world.py", "path": "cc.public.snapshots/code_samples/hello_world.py", "mime_type": "text/x-python", "size": "0.5KB"},
{"name": "variables.py", "path": "cc.public.snapshots/code_samples/variables.py", "mime_type": "text/x-python", "size": "1.2KB"},
],
}
# ─── Helpers ───────────────────────────────────────────────────────────────────
def _sb_headers() -> Dict:
return {
"apikey": SERVICE_KEY,
"Authorization": f"Bearer {SERVICE_KEY}",
"Content-Type": "application/json",
}
def _sign_in(email: str, password: str) -> str:
r = requests.post(
f"{SUPA_URL}/auth/v1/token?grant_type=password",
headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"},
json={"email": email, "password": password},
)
r.raise_for_status()
return r.json()["access_token"]
def _get_profile_id(email: str) -> Optional[str]:
"""Look up a profile's UUID by email via Supabase service role."""
r = requests.get(
f"{SUPA_URL}/rest/v1/profiles",
headers=_sb_headers(),
params={"email": f"eq.{email}", "select": "id", "limit": "1"},
)
data = r.json() if r.ok else []
return data[0]["id"] if data else None
def _get_class_info(admin_token: str, class_code: str) -> Optional[Dict]:
"""Get class info including teacher and students."""
r = requests.get(
f"{API_BASE}/database/timetable/classes",
headers={"Authorization": f"Bearer {admin_token}"},
params={"class_code": class_code},
)
if not r.ok:
return None
data = r.json()
if isinstance(data, list) and data:
return data[0]
if isinstance(data, dict):
return data
return None
def _get_class_students(admin_token: str, class_id: str) -> List[str]:
"""Get student profile IDs enrolled in a class."""
r = requests.get(
f"{API_BASE}/database/timetable/classes/{class_id}/students",
headers={"Authorization": f"Bearer {admin_token}"},
)
if r.ok:
data = r.json()
if isinstance(data, list):
return [s.get("student_id") or s.get("id") for s in data if s.get("student_id") or s.get("id")]
return []
# ─── Main seed ─────────────────────────────────────────────────────────────────
def seed() -> Dict[str, Any]:
print("=" * 60)
print("File cabinets seed — both schools")
print("=" * 60)
results: Dict[str, Any] = {}
errors: List[str] = []
# ── Sign in as both school admins ───────────────────────────────────────
print("\n[1] Signing in as school admins...")
admin_tokens = {}
for school, email, pwd in [
("KevlarAI", "[email protected]", PWD_ADMIN),
("Greenfield", "[email protected]", PWD_ADMIN),
]:
try:
token = _sign_in(email, pwd)
admin_tokens[school] = token
print(f"{school} admin signed in")
except Exception as e:
print(f"{school} admin login failed: {e}")
errors.append(f"{school}_admin_login: {e}")
if not admin_tokens:
return {"success": False, "error": "No admin tokens obtained"}
# ── Resolve class codes per school ──────────────────────────────────────
print("\n[2] Resolving classes per school...")
# KevlarAI classes
kevlarai_classes = [
("10K/Ph1", "[email protected]"),
("11K/Ph1", "[email protected]"),
("10K/Ma1", "[email protected]"),
("11K/Ma1", "[email protected]"),
("10K/CS1", "[email protected]"),
("11K/CS1", "[email protected]"),
("9K/Ph1", "[email protected]"),
("9K/Ma1", "[email protected]"),
]
# Greenfield classes (subset — just a few for cabinet seeding)
greenfield_classes = [
("9P/Ph1", "[email protected]"),
("10P/Ph2", "[email protected]"),
("9M/Ma1", "[email protected]"),
("10M/Ma1", "[email protected]"),
("9En/1", "[email protected]"),
("10Hs/1", "[email protected]"),
]
# ── Seed KevlarAI cabinets ──────────────────────────────────────────────
print("\n[3] Seeding KevlarAI file cabinets...")
results["kevlarai"] = {"cabinets": 0, "files": 0, "memberships": 0}
for class_code, teacher_email in kevlarai_classes:
try:
# Get class info
class_info = _get_class_info(admin_tokens["KevlarAI"], class_code)
if not class_info:
print(f" ✗ class not found: {class_code}")
errors.append(f"class_not_found: {class_code}")
continue
class_id = class_info.get("id") or class_info
teacher_pid = _get_profile_id(teacher_email)
if not teacher_pid:
print(f" ✗ teacher profile not found: {teacher_email}")
errors.append(f"teacher_profile_not_found: {teacher_email}")
continue
# Get students in this class
student_ids = _get_class_students(admin_tokens["KevlarAI"], str(class_id))
# Determine file category based on subject
subject = (class_info.get("subject") or "").lower()
if "physics" in subject:
file_category = "lesson_plans"
elif "math" in subject:
file_category = "worksheets"
elif "cs" in subject or "computer" in subject:
file_category = "code_samples"
else:
file_category = "lesson_plans"
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
# Create cabinet
cabinet_id = str(uuid.uuid4())
cabinet_name = f"{class_code}{class_info.get('name', class_code)}"
r = requests.post(
f"{SUPA_URL}/rest/v1/file_cabinets",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
params={"on_conflict": "id"},
)
if r.status_code in (200, 201):
print(f" ✓ Cabinet: {cabinet_name}")
results["kevlarai"]["cabinets"] += 1
else:
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
errors.append(f"cabinet_create: {class_code}")
continue
# Create files in cabinet
for fi in files_list:
file_id = str(uuid.uuid4())
r = requests.post(
f"{SUPA_URL}/rest/v1/files",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={
"id": file_id,
"cabinet_id": cabinet_id,
"name": fi["name"],
"path": fi["path"],
"bucket": "file-cabinets",
"mime_type": fi.get("mime_type"),
"size": fi.get("size"),
"metadata": {},
},
params={"on_conflict": "id"},
)
if r.status_code in (200, 201):
results["kevlarai"]["files"] += 1
# Create document_artefact for this file
artefact_id = str(uuid.uuid4())
requests.post(
f"{SUPA_URL}/rest/v1/document_artefacts",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={
"id": artefact_id,
"file_id": file_id,
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
"rel_path": fi["path"],
"status": "processed",
"extra": {"seeded": True, "source": "seed_file_cabinets"},
},
params={"on_conflict": "id"},
)
time.sleep(0.05)
# Create cabinet memberships for students
for sid in student_ids:
r = requests.post(
f"{SUPA_URL}/rest/v1/cabinet_memberships",
headers={**_sb_headers(), "Prefer": "return=minimal"},
json={
"cabinet_id": cabinet_id,
"profile_id": sid,
"role": "viewer",
},
params={"on_conflict": "cabinet_id,profile_id"},
)
if r.status_code in (200, 201, 409):
results["kevlarai"]["memberships"] += 1
time.sleep(0.1)
except Exception as e:
err = f"cabinet seed {class_code}: {e}"
print(f"{err}")
errors.append(err)
# ── Seed Greenfield cabinets ────────────────────────────────────────────
print("\n[4] Seeding Greenfield file cabinets...")
results["greenfield"] = {"cabinets": 0, "files": 0, "memberships": 0}
for class_code, teacher_email in greenfield_classes:
try:
class_info = _get_class_info(admin_tokens["Greenfield"], class_code)
if not class_info:
print(f" ✗ class not found: {class_code}")
errors.append(f"class_not_found: {class_code}")
continue
class_id = class_info.get("id") or class_info
teacher_pid = _get_profile_id(teacher_email)
if not teacher_pid:
print(f" ✗ teacher profile not found: {teacher_email}")
errors.append(f"teacher_profile_not_found: {teacher_email}")
continue
student_ids = _get_class_students(admin_tokens["Greenfield"], str(class_id))
subject = (class_info.get("subject") or "").lower()
if "physics" in subject:
file_category = "lesson_plans"
elif "math" in subject:
file_category = "worksheets"
elif "english" in subject:
file_category = "presentations"
elif "history" in subject:
file_category = "lesson_plans"
else:
file_category = "lesson_plans"
files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"])
cabinet_id = str(uuid.uuid4())
cabinet_name = f"{class_code}{class_info.get('name', class_code)}"
r = requests.post(
f"{SUPA_URL}/rest/v1/file_cabinets",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name},
params={"on_conflict": "id"},
)
if r.status_code in (200, 201):
print(f" ✓ Cabinet: {cabinet_name}")
results["greenfield"]["cabinets"] += 1
else:
print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}")
errors.append(f"cabinet_create: {class_code}")
continue
for fi in files_list:
file_id = str(uuid.uuid4())
r = requests.post(
f"{SUPA_URL}/rest/v1/files",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={
"id": file_id,
"cabinet_id": cabinet_id,
"name": fi["name"],
"path": fi["path"],
"bucket": "file-cabinets",
"mime_type": fi.get("mime_type"),
"size": fi.get("size"),
"metadata": {},
},
params={"on_conflict": "id"},
)
if r.status_code in (200, 201):
results["greenfield"]["files"] += 1
artefact_id = str(uuid.uuid4())
requests.post(
f"{SUPA_URL}/rest/v1/document_artefacts",
headers={**_sb_headers(), "Prefer": "return=representation"},
json={
"id": artefact_id,
"file_id": file_id,
"type": fi.get("mime_type", "application/octet-stream").split("/")[-1],
"rel_path": fi["path"],
"status": "processed",
"extra": {"seeded": True, "source": "seed_file_cabinets"},
},
params={"on_conflict": "id"},
)
time.sleep(0.05)
for sid in student_ids:
r = requests.post(
f"{SUPA_URL}/rest/v1/cabinet_memberships",
headers={**_sb_headers(), "Prefer": "return=minimal"},
json={
"cabinet_id": cabinet_id,
"profile_id": sid,
"role": "viewer",
},
params={"on_conflict": "cabinet_id,profile_id"},
)
if r.status_code in (200, 201, 409):
results["greenfield"]["memberships"] += 1
time.sleep(0.1)
except Exception as e:
err = f"cabinet seed {class_code}: {e}"
print(f"{err}")
errors.append(err)
# ── Summary ─────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
results["success"] = len(errors) == 0
results["errors"] = errors
total_cabinets = results["kevlarai"]["cabinets"] + results["greenfield"]["cabinets"]
total_files = results["kevlarai"]["files"] + results["greenfield"]["files"]
total_memberships = results["kevlarai"]["memberships"] + results["greenfield"]["memberships"]
print(f"COMPLETE — {total_cabinets} cabinets, {total_files} files, {total_memberships} memberships")
if errors:
print(f"Errors ({len(errors)}):")
for e in errors:
print(f"{e}")
print("=" * 60)
return results
if __name__ == "__main__":
import json
print(json.dumps(seed(), indent=2, default=str))
@@ -0,0 +1,493 @@
"""
seed_greenfield_timetable.py Full timetable + class + student seed for Greenfield Academy.
Flow:
1. POST /timetable/setup academic year, 3 terms, periods Supabase
2. POST /timetable/materialize-periods academic_periods rows (days × template)
3. Create classes 17 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 student1Yr9, student2Yr10, student3Yr11
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_greenfield_timetable import seed; seed()"
"""
import os
import time
import requests
from typing import Dict, Any, Optional, List
from run.initialization.seed_environment import get_seed_password
GREENFIELD_ADMIN_EMAIL = "[email protected]"
def _runtime_context() -> Dict[str, str]:
return {
"supa_url": os.environ["SUPABASE_URL"],
"service_key": os.environ["SERVICE_ROLE_KEY"],
"api_base": os.environ.get("API_BASE_URL", "http://localhost:8000"),
}
# ─── Period templates ──────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────
# Covers every unique subject_class code in TEACHER_SLOTS.
# key_stage: KS3 = years 7-9, KS4 = years 10-11, KS5 = years 12-13
CLASSES = [
# Physics
{"class_code": "9P/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "10P/Ph2", "name": "Year 10 Physics Group 2", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
{"class_code": "11P/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
{"class_code": "12P/Ph1", "name": "Year 12 Physics Group 1", "subject": "Physics", "year_group": "12", "key_stage": "5", "teacher": "[email protected]"},
# Maths
{"class_code": "9M/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "10M/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
{"class_code": "11M/Ma2", "name": "Year 11 Maths Group 2", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "[email protected]"},
# English
{"class_code": "7En/1", "name": "Year 7 English Group 1", "subject": "English", "year_group": "7", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "8En/1", "name": "Year 8 English Group 1", "subject": "English", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "9En/1", "name": "Year 9 English Group 1", "subject": "English", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
# History
{"class_code": "8Hs/1", "name": "Year 8 History Group 1", "subject": "History", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "9Hs/1", "name": "Year 9 History Group 1", "subject": "History", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "10Hs/1", "name": "Year 10 History Group 1", "subject": "History", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
# Science
{"class_code": "7Sc/1", "name": "Year 7 Science Group 1", "subject": "Science", "year_group": "7", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "8Sc/1", "name": "Year 8 Science Group 1", "subject": "Science", "year_group": "8", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "9Sc/1", "name": "Year 9 Science Group 1", "subject": "Science", "year_group": "9", "key_stage": "3", "teacher": "[email protected]"},
{"class_code": "10Sc/1", "name": "Year 10 Science Group 1", "subject": "Science", "year_group": "10", "key_stage": "4", "teacher": "[email protected]"},
]
# ─── Teacher slot assignments ──────────────────────────────────────────────────
TEACHER_SLOTS = {
"[email protected]": [
("Monday", "P1", "11P/Ph1"),
("Monday", "P3", "12P/Ph1"),
("Tuesday", "P2", "10P/Ph2"),
("Tuesday", "P4", "9P/Ph1"),
("Wednesday", "P1", "11P/Ph1"),
("Wednesday", "P5", "12P/Ph1"),
("Thursday", "P3", "10P/Ph2"),
("Thursday", "P5", "9P/Ph1"),
("Friday", "P2", "11P/Ph1"),
("Friday", "P4", "12P/Ph1"),
],
"[email protected]": [
("Monday", "P2", "10M/Ma1"),
("Monday", "P4", "11M/Ma2"),
("Tuesday", "P1", "9M/Ma1"),
("Tuesday", "P3", "10M/Ma1"),
("Wednesday", "P2", "11M/Ma2"),
("Wednesday", "P4", "9M/Ma1"),
("Thursday", "P1", "10M/Ma1"),
("Thursday", "P4", "11M/Ma2"),
("Friday", "P1", "9M/Ma1"),
("Friday", "P3", "10M/Ma1"),
],
"[email protected]": [
("Monday", "P1", "7En/1"),
("Monday", "P5", "8En/1"),
("Tuesday", "P2", "9En/1"),
("Wednesday", "P3", "7En/1"),
("Thursday", "P2", "8En/1"),
("Friday", "P5", "9En/1"),
],
"[email protected]": [
("Monday", "P3", "8Hs/1"),
("Tuesday", "P5", "9Hs/1"),
("Wednesday", "P1", "10Hs/1"),
("Thursday", "P2", "8Hs/1"),
("Friday", "P3", "9Hs/1"),
],
"[email protected]": [
("Monday", "P4", "7Sc/1"),
("Tuesday", "P3", "8Sc/1"),
("Wednesday", "P5", "9Sc/1"),
("Thursday", "P4", "10Sc/1"),
("Friday", "P2", "7Sc/1"),
],
}
# ─── Student enrollments ───────────────────────────────────────────────────────
# One student per year-group band — enrolled in all subjects for that year.
STUDENT_ENROLLMENTS = {
"[email protected]": ["9P/Ph1", "9M/Ma1", "9En/1", "9Hs/1", "9Sc/1"],
"[email protected]": ["10P/Ph2", "10M/Ma1", "10Hs/1", "10Sc/1"],
"[email protected]": ["11P/Ph1", "11M/Ma2"],
}
# ─── Helpers ───────────────────────────────────────────────────────────────────
def _sb_headers() -> Dict:
service_key = _runtime_context()["service_key"]
return {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
def _sign_in(email: str, password: str) -> str:
ctx = _runtime_context()
r = requests.post(
f"{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 _api(token: str, method: str, path: str, body: Dict = None) -> Dict:
api_base = _runtime_context()["api_base"]
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
r = getattr(requests, method)(f"{api_base}{path}", headers=h, json=body)
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."""
supa_url = _runtime_context()["supa_url"]
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."""
supa_url = _runtime_context()["supa_url"]
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."""
supa_url = _runtime_context()["supa_url"]
patched = 0
for code, class_uuid in class_code_to_id.items():
r = requests.patch(
f"{supa_url}/rest/v1/teacher_timetable_slots",
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("Greenfield Academy — full timetable + class + student seed")
print("=" * 60)
results: Dict[str, Any] = {}
errors: List[str] = []
# ── [1] Sign in as Greenfield admin ───────────────────────────────────────
print("\n[1] Signing in as [email protected]...")
try:
admin_token = _sign_in(GREENFIELD_ADMIN_EMAIL, get_seed_password("school_admin"))
print(" ✓ signed in")
except Exception as e:
return {"success": False, "error": str(e)}
# ── [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 × 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, get_seed_password("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, get_seed_password("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
@@ -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 student1Yr10, student2Yr11
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))
@@ -0,0 +1,15 @@
"""Compatibility wrapper for the canonical seed environment test mode."""
from typing import Any, Dict
from run.initialization.seed_environment import seed
def seed_test_environment() -> Dict[str, Any]:
"""Seed the lightweight 9-user test environment."""
return seed(test=True)
if __name__ == "__main__":
import json
print(json.dumps(seed_test_environment(), indent=2, default=str))
+238
View File
@@ -0,0 +1,238 @@
"""Sync Supabase profile users into the central Neo4j cc.users database.
This script is intentionally idempotent. It defaults to --dry-run so it can be
used safely during diagnostics. Use --apply to write/update Neo4j nodes.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from dataclasses import dataclass
from typing import Any
SCRIPT_DIR = str(Path(__file__).resolve().parent)
if SCRIPT_DIR in sys.path:
sys.path.remove(SCRIPT_DIR)
import requests
from dotenv import load_dotenv
from neo4j import GraphDatabase
@dataclass(frozen=True)
class UserRecord:
uuid_string: str
user_email: str
cc_username: str
user_type: str
user_name: str
display_name: str | None
institute_id: str | None
institute_name: str | None
institute_role: str | None
institute_neo4j_uuid_string: str | None
user_db_name: str
institute_db_name: str | None
node_storage_path: str
def load_environment(env_file: str | None) -> None:
if env_file:
load_dotenv(env_file, override=True)
else:
load_dotenv(override=False)
def supabase_headers() -> dict[str, str]:
key = os.getenv('SERVICE_ROLE_KEY') or os.getenv('ANON_KEY')
if not key:
raise RuntimeError('SERVICE_ROLE_KEY or ANON_KEY is required')
return {'apikey': key, 'Authorization': f'Bearer {key}'}
def fetch_table(table: str, select: str) -> list[dict[str, Any]]:
base = os.getenv('SUPABASE_URL')
if not base:
raise RuntimeError('SUPABASE_URL is required')
response = requests.get(
f'{base.rstrip("/")}/rest/v1/{table}',
headers=supabase_headers(),
params={'select': select},
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f'Supabase {table} query failed: {response.status_code} {response.text[:500]}')
return response.json()
def normalize_uuid(value: str | None) -> str | None:
return value.replace('-', '') if value else None
def build_records() -> list[UserRecord]:
profiles = fetch_table('profiles', 'id,email,username,full_name,display_name,user_type,metadata,user_db_name,school_db_name')
memberships = fetch_table('institute_memberships', 'profile_id,institute_id,role')
institutes = fetch_table('institutes', 'id,name,urn,neo4j_uuid_string')
membership_by_profile = {m['profile_id']: m for m in memberships}
institute_by_id = {i['id']: i for i in institutes}
records: list[UserRecord] = []
for profile in profiles:
profile_id = profile['id']
user_type = profile.get('user_type') or 'unknown'
uuid_no_dash = normalize_uuid(profile_id)
email = profile.get('email') or ''
username = profile.get('username') or (email.split('@', 1)[0] if email else uuid_no_dash)
user_name = profile.get('full_name') or profile.get('display_name') or username or email or profile_id
membership = membership_by_profile.get(profile_id, {})
institute = institute_by_id.get(membership.get('institute_id'), {}) if membership else {}
institute_uuid = institute.get('neo4j_uuid_string') or normalize_uuid(institute.get('id'))
user_db_name = profile.get('user_db_name') or f'cc.users.{user_type}.{uuid_no_dash}'
institute_db_name = profile.get('school_db_name') or (f'cc.institutes.{institute_uuid}' if institute_uuid else None)
records.append(UserRecord(
uuid_string=profile_id,
user_email=email,
cc_username=username or profile_id,
user_type=user_type,
user_name=user_name,
display_name=profile.get('display_name'),
institute_id=membership.get('institute_id') if membership else None,
institute_name=institute.get('name') if institute else None,
institute_role=membership.get('role') if membership else None,
institute_neo4j_uuid_string=institute_uuid,
user_db_name=user_db_name,
institute_db_name=institute_db_name,
node_storage_path=f'neo4j://{user_db_name}/User/{profile_id}',
))
return records
def neo4j_driver():
url = os.getenv('APP_BOLT_URL')
username = os.getenv('USER_NEO4J')
password = os.getenv('PASSWORD_NEO4J')
if not url or not username or not password:
raise RuntimeError('APP_BOLT_URL, USER_NEO4J, and PASSWORD_NEO4J are required')
return GraphDatabase.driver(url, auth=(username, password))
def ensure_schema(session) -> None:
statements = [
'CREATE CONSTRAINT user_uuid_unique IF NOT EXISTS FOR (u:User) REQUIRE u.uuid_string IS UNIQUE',
'CREATE INDEX user_email_index IF NOT EXISTS FOR (u:User) ON (u.user_email)',
'CREATE INDEX user_username_index IF NOT EXISTS FOR (u:User) ON (u.cc_username)',
'CREATE INDEX user_type_index IF NOT EXISTS FOR (u:User) ON (u.user_type)',
'CREATE INDEX user_institute_index IF NOT EXISTS FOR (u:User) ON (u.institute_id)',
'CREATE CONSTRAINT institute_uuid_unique IF NOT EXISTS FOR (i:Institute) REQUIRE i.uuid_string IS UNIQUE',
]
for statement in statements:
session.run(statement).consume()
def merge_user(session, record: UserRecord) -> None:
labels = ':User'
if record.user_type == 'teacher':
labels = ':User:Teacher'
elif record.user_type == 'student':
labels = ':User:Student'
session.run(
f"""
MERGE (u{labels} {{uuid_string: $uuid_string}})
SET u.user_email = $user_email,
u.cc_username = $cc_username,
u.user_type = $user_type,
u.user_name = $user_name,
u.display_name = $display_name,
u.institute_id = $institute_id,
u.institute_name = $institute_name,
u.institute_role = $institute_role,
u.institute_neo4j_uuid_string = $institute_neo4j_uuid_string,
u.user_db_name = $user_db_name,
u.institute_db_name = $institute_db_name,
u.node_storage_path = $node_storage_path,
u.merged = true,
u.source = 'supabase.profiles',
u.synced_at = datetime()
""",
**record.__dict__,
).consume()
if record.institute_neo4j_uuid_string:
session.run(
"""
MATCH (u:User {uuid_string: $uuid_string})
MERGE (i:Institute {uuid_string: $institute_neo4j_uuid_string})
SET i.supabase_id = $institute_id,
i.name = $institute_name
MERGE (u)-[r:MEMBER_OF]->(i)
SET r.role = $institute_role,
r.synced_at = datetime()
""",
**record.__dict__,
).consume()
def verify(session) -> dict[str, Any]:
result: dict[str, Any] = {}
result['users'] = session.run('MATCH (u:User) RETURN count(u) AS c').single()['c']
result['teachers'] = session.run("MATCH (u:User {user_type: 'teacher'}) RETURN count(u) AS c").single()['c']
result['students'] = session.run("MATCH (u:User {user_type: 'student'}) RETURN count(u) AS c").single()['c']
result['bad_users'] = session.run("""
MATCH (u:User)
WHERE u.uuid_string IS NULL OR u.user_email IS NULL OR u.cc_username IS NULL
OR u.user_type IS NULL OR u.user_name IS NULL OR u.user_db_name IS NULL
RETURN count(u) AS c
""").single()['c']
result['duplicate_uuids'] = session.run("""
MATCH (u:User)
WITH u.uuid_string AS uuid, count(*) AS c
WHERE c > 1
RETURN count(*) AS c
""").single()['c']
result['memberships'] = session.run('MATCH (:User)-[r:MEMBER_OF]->(:Institute) RETURN count(r) AS c').single()['c']
result['institutes'] = session.run('MATCH (i:Institute) RETURN count(i) AS c').single()['c']
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('--env-file', default=None)
parser.add_argument('--database', default='cc.users')
parser.add_argument('--apply', action='store_true', help='Write to Neo4j. Defaults to dry-run.')
args = parser.parse_args()
load_environment(args.env_file)
records = build_records()
by_type: dict[str, int] = {}
for record in records:
by_type[record.user_type] = by_type.get(record.user_type, 0) + 1
print(f'Supabase source records: {len(records)} {by_type}')
if not args.apply:
for record in records[:5]:
print(f'DRY RUN {record.uuid_string} {record.user_email} -> {record.user_db_name}')
print('Dry run only. Re-run with --apply to write Neo4j cc.users.')
return 0
with neo4j_driver() as driver:
with driver.session(database=args.database) as session:
ensure_schema(session)
for record in records:
merge_user(session, record)
result = verify(session)
print(f'Neo4j verification: {result}')
expected = {
'users': len(records),
'teachers': by_type.get('teacher', 0),
'students': by_type.get('student', 0),
'bad_users': 0,
'duplicate_uuids': 0,
'memberships': len(records),
'institutes': 2,
}
for key, value in expected.items():
if result.get(key) != value:
raise RuntimeError(f'Verification failed for {key}: expected {value}, got {result.get(key)}')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+34
View File
@@ -9,6 +9,15 @@ from routers.msgraph import router_onenote
from routers.dev.tests import timetable_test
from routers.database.init import entity_init, calendar, timetables, curriculum, get_data, schools
from routers.database.tools import get_nodes, get_nodes_and_edges, tldraw_filesystem, tldraw_supabase_storage, get_events, calendar_structure_router, default_nodes_router, worker_structure_router
from routers.database.tools.graph_tree_router import router as graph_tree_router
from routers.database.tools.user_init_router import router as user_init_router
from routers.database.tools.timetable_builder_router import router as timetable_builder_router
from routers.database.tools.school_router import router as school_router
from routers.database.tools.classes_router import router as classes_router
from routers.database.tools.taught_lessons_router import router as taught_lessons_router
from routers.database.tools.invitations_router import router as invitations_router
from routers.database.tools.platform_admin_router import router as platform_admin_router
from routers.database.tools.lesson_plans_router import router as lesson_plans_router
from routers.database.files import cabinets as cabinets_router
from routers.database.files import files as files_router
from routers.simple_upload import router as simple_upload_router
@@ -29,6 +38,9 @@ from routers import provisioning as provisioning_router
from routers.transcribe.sessions import router as sessions_router
from routers.transcribe.canvas_events import router as canvas_events_router
from routers.transcribe.keywords import router as keywords_router
from routers.me.bootstrap_router import router as me_bootstrap_router
from routers import tlsync_token as tlsync_token_router
from routers.exam import router as exam_router
def register_routes(app: FastAPI):
logger.info("Starting to register routes...")
@@ -47,12 +59,28 @@ def register_routes(app: FastAPI):
app.include_router(entity_init.router, prefix="/database/entity", tags=["Entity"])
app.include_router(calendar.router, prefix="/database/calendar", tags=["Calendar"])
app.include_router(schools.router, prefix="/database/schools", tags=["Schools"])
from routers.database.timetable.timetables import router as timetable_router
app.include_router(timetables.router, prefix="/database/timetables", tags=["Timetables"])
app.include_router(timetable_router, prefix="/database/timetable/timetables", tags=["Timetables"])
app.include_router(curriculum.router, prefix="/database/curriculum", tags=["Curriculum"])
# Navigation Routes
app.include_router(calendar_structure_router.router, prefix="/database/calendar-structure", tags=["Calendar"])
app.include_router(worker_structure_router.router, prefix="/database/worker-structure", tags=["Worker"])
# Session/bootstrap routes
app.include_router(me_bootstrap_router, prefix="/me", tags=["Bootstrap"])
# Graph navigation
app.include_router(graph_tree_router, prefix="/graph", tags=["Graph Navigation"])
app.include_router(user_init_router, prefix="/user", tags=["User"])
app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"])
app.include_router(school_router, prefix="/school", tags=["School"])
app.include_router(classes_router, prefix="/database/timetable/classes", tags=["Classes"])
app.include_router(taught_lessons_router, prefix="/timetable", tags=["Taught Lessons"])
app.include_router(invitations_router, prefix="/users", tags=["People"])
app.include_router(platform_admin_router, prefix="/admin", tags=["Platform Admin"])
app.include_router(lesson_plans_router, prefix="/lessons", tags=["Lesson Plans"])
app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"])
# Database Filesystem Routes
@@ -104,6 +132,12 @@ def register_routes(app: FastAPI):
# Provisioning Routes
app.include_router(provisioning_router.router)
# TLSync auth token route
app.include_router(tlsync_token_router.router, prefix="/api/tlsync", tags=["TLSync"])
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
app.include_router(exam_router, prefix="/api/exam", tags=["Exam"])
# Transcription Routes (CIS Phase 1)
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
+28 -56
View File
@@ -2,7 +2,7 @@
# ClassroomCopilot Startup Script
# Usage: ./start.sh [start_mode]
# start_mode options: infra, demo-school, demo-users, gais-data, full, dev, prod
# start_mode options: infra, seed, seed-test, gais-data, full, dev, prod
set -e
@@ -14,10 +14,10 @@ show_help() {
echo ""
echo "Start modes:"
echo " infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)"
echo " demo-school - Create demo school (KevlarAI)"
echo " demo-users - Create demo users"
echo " seed - Seed canonical full environment (20 school users)"
echo " seed-test - Seed lightweight test environment (9 school users)"
echo " gais-data - Import GAIS data (Edubase, etc.)"
echo " full - Run full initialization (infra → demo-school → demo-users → gais-data)"
echo " full - Run full initialization (infra → seed)"
echo " nuke - 💥 NUKE Redis - Clear all queue data for fresh start"
echo " dev - Run development server with auto-reload"
echo " prod - Run production server (for Docker/containerized deployment)"
@@ -25,8 +25,8 @@ show_help() {
echo "Examples:"
echo " ./start.sh # Run in dev mode (default)"
echo " ./start.sh infra # Setup infrastructure"
echo " ./start.sh demo-school # Create demo school"
echo " ./start.sh demo-users # Create demo users"
echo " ./start.sh seed # Seed canonical full environment"
echo " ./start.sh seed-test # Seed lightweight test environment"
echo " ./start.sh gais-data # Import GAIS data"
echo " ./start.sh full # Run full initialization"
echo " ./start.sh full --yes # Run full initialization without prompts"
@@ -133,54 +133,32 @@ run_infra() {
fi
}
# Function to run demo school creation
run_demo_school() {
print_status "Running demo school creation mode..."
print_status "This will create the KevlarAI demo school."
# Function to run canonical environment seed
run_seed() {
local mode=${1:-seed}
if [[ "$mode" == "seed-test" ]]; then
print_status "Running lightweight seed-test mode (9 school users)..."
else
print_status "Running canonical seed mode (20 school users)..."
fi
# Check if we should proceed
if [[ "$AUTO_YES" != true ]]; then
read -p "Do you want to continue with demo school creation? (y/N): " -n 1 -r
read -p "Do you want to continue with $mode? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_status "Demo school creation cancelled."
print_status "$mode cancelled."
exit 0
fi
fi
print_status "Starting demo school creation process..."
$PYTHON_CMD main.py --mode demo-school
print_status "Starting $mode process..."
$PYTHON_CMD main.py --mode "$mode"
if [ $? -eq 0 ]; then
print_success "Demo school creation completed successfully!"
print_success "$mode completed successfully!"
else
print_error "Demo school creation failed!"
exit 1
fi
}
# Function to run demo users creation
run_demo_users() {
print_status "Running demo users creation mode..."
print_status "This will create demo users for testing."
# Check if we should proceed
if [[ "$AUTO_YES" != true ]]; then
read -p "Do you want to continue with demo users creation? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_status "Demo users creation cancelled."
exit 0
fi
fi
print_status "Starting demo users creation process..."
$PYTHON_CMD main.py --mode demo-users
if [ $? -eq 0 ]; then
print_success "Demo users creation completed successfully!"
else
print_error "Demo users creation failed!"
print_error "$mode failed!"
exit 1
fi
}
@@ -274,7 +252,7 @@ except Exception as e:
# Function to run full initialization (all steps in order)
run_full() {
print_status "Running full initialization (infra → demo-school → demo-users → gais-data)..."
print_status "Running full initialization (infra → seed)..."
# Single confirmation for the whole flow
if [[ "$AUTO_YES" != true ]]; then
@@ -289,14 +267,8 @@ run_full() {
# Run infra
run_infra || { print_error "Full init aborted during infra."; exit 1; }
# Run demo school
run_demo_school || { print_error "Full init aborted during demo-school."; exit 1; }
# Run demo users
run_demo_users || { print_error "Full init aborted during demo-users."; exit 1; }
# Run GAIS data import
run_gais_data || { print_error "Full init aborted during gais-data."; exit 1; }
# Run canonical full seed
run_seed seed || { print_error "Full init aborted during seed."; exit 1; }
print_success "Full initialization completed successfully!"
}
@@ -383,11 +355,11 @@ main() {
"infra")
run_infra
;;
"demo-school")
run_demo_school
"seed")
run_seed seed
;;
"demo-users")
run_demo_users
"seed-test")
run_seed seed-test
;;
"gais-data")
run_gais_data
@@ -406,7 +378,7 @@ main() {
;;
*)
print_error "Invalid start mode: $START_MODE"
print_status "Valid modes: infra, demo-school, demo-users, gais-data, nuke, dev, prod"
print_status "Valid modes: infra, seed, seed-test, gais-data, full, nuke, dev, prod"
print_status "Usage: ./start.sh [start_mode]"
print_status "Use './start.sh --help' for more information"
exit 1
+86
View File
@@ -0,0 +1,86 @@
import os
import requests
def _supabase_headers():
key = os.getenv('SERVICE_ROLE_KEY') or os.getenv('ANON_KEY')
assert key, 'SERVICE_ROLE_KEY or ANON_KEY must be set for Supabase integration tests'
return {
'apikey': key,
'Authorization': f'Bearer {key}',
'Prefer': 'count=exact',
}
def _rest_count(table: str) -> int:
supabase_url = os.getenv('SUPABASE_URL')
assert supabase_url, 'SUPABASE_URL must be set'
response = requests.get(
f'{supabase_url.rstrip("/")}/rest/v1/{table}',
headers=_supabase_headers(),
params={'select': 'id'},
timeout=15,
)
assert response.status_code in (200, 206), response.text[:500]
content_range = response.headers.get('content-range', '')
assert '/' in content_range, f'missing exact content-range count for {table}: {content_range!r}'
return int(content_range.rsplit('/', 1)[1])
def test_dev_environment_points_at_dev_supabase():
assert os.getenv('SUPABASE_URL') == 'http://192.168.0.94:8000'
def test_dev_api_health_endpoint_is_healthy():
health_url = os.getenv('API_HEALTH_URL', 'http://192.168.0.64:18000/health')
response = requests.get(health_url, timeout=15)
assert response.status_code == 200
payload = response.json()
assert payload['status'] == 'healthy'
runtime = payload['runtime']
assert runtime['api_runtime_role'] == 'dev'
assert runtime['start_mode'] == 'dev'
assert runtime['app_environment'] == 'development'
assert runtime['environment'] == 'development'
assert runtime['backend_dev_mode'] is True
assert runtime['compose_project'] == 'api-dev'
assert runtime['supabase_url_host'] == '192.168.0.94'
assert payload['services']['supabase']['status'] == 'healthy'
assert payload['services']['supabase']['url_host'] == '192.168.0.94'
assert payload['services']['redis']['status'] == 'healthy'
assert payload['services']['redis']['environment'] == 'dev'
assert payload['services']['redis']['database'] == 0
# NOTE: these are >= baselines, not exact counts. The greenfield seed produces this floor;
# additive exam-marker fixtures (S4-4 cohort adds ~10 students/memberships; ad-hoc classes) push
# the live .94 counts above it. Exact == froze a snapshot that any new fixture breaks, while >=
# still catches a broken or missing seed.
def test_supabase_dev_seed_core_counts():
assert _rest_count('profiles') >= 21
assert _rest_count('institute_memberships') >= 21
assert _rest_count('institutes') >= 2
def test_supabase_dev_seed_timetable_counts():
assert _rest_count('classes') >= 17
assert _rest_count('taught_lessons') >= 1462
def test_runtime_identity_does_not_expose_secret_values():
health_url = os.getenv('API_HEALTH_URL', 'http://192.168.0.64:18000/health')
response = requests.get(health_url, timeout=15)
assert response.status_code == 200
payload_text = response.text
for secret_name in ('SERVICE_ROLE_KEY', 'ANON_KEY', 'SUPABASE_JWT_SECRET', 'REDIS_PASSWORD'):
secret_value = os.getenv(secret_name)
if secret_value:
assert secret_value not in payload_text
runtime = response.json()['runtime']
assert 'supabase_url' not in runtime
assert 'service_role_key' not in runtime
assert 'anon_key' not in runtime
+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
+281
View File
@@ -0,0 +1,281 @@
import os
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers.me.bootstrap_router import router, auth_scheme
from modules.database.services.bootstrap_service import BootstrapService
USER_ID = "00000000-0000-0000-0000-000000000001"
INST_A = "10000000-0000-0000-0000-000000000001"
INST_B = "10000000-0000-0000-0000-000000000002"
class FakeResult:
def __init__(self, data):
self.data = data
class FakeQuery:
def __init__(self, rows):
self.rows = list(rows)
self._single = False
self._limit = None
def select(self, *_args, **_kwargs):
return self
def eq(self, key, value):
self.rows = [row for row in self.rows if row.get(key) == value]
return self
def in_(self, key, values):
values = set(values)
self.rows = [row for row in self.rows if row.get(key) in values]
return self
def order(self, *_args, **_kwargs):
return self
def limit(self, count):
self._limit = count
return self
def single(self):
self._single = True
return self
def execute(self):
rows = self.rows[: self._limit] if self._limit is not None else self.rows
if self._single:
return FakeResult(rows[0] if rows else None)
return FakeResult(rows)
class FakeSupabase:
def __init__(self, tables):
self.tables = tables
def table(self, name):
return FakeQuery(self.tables.get(name, []))
def profile(user_type="teacher", school_id=None):
return {
"id": USER_ID,
"email": "[email protected]",
"full_name": "Example Teacher",
"display_name": None,
"user_type": user_type,
"school_id": school_id,
}
def institute(id_=INST_A, name="Example School", status="active"):
return {
"id": id_,
"name": name,
"urn": "123456",
"website": "https://school.example",
"address": {"town": "Testville"},
"metadata": {"internal_note": "should remain institute metadata only"},
"status": status,
"neo4j_uuid_string": "neo-a" if id_ == INST_A else "neo-b",
}
def membership(institute_id=INST_A, role="teacher"):
return {"profile_id": USER_ID, "institute_id": institute_id, "role": role}
def service(tables, graph_probe=None):
return BootstrapService(FakeSupabase(tables), graph_probe=graph_probe or (lambda **_: {"available": True, "projection_state": "ready"}))
def build(credentials=None, tables=None, graph_probe=None):
credentials = credentials or {"sub": USER_ID, "email": "[email protected]"}
tables = tables or {"profiles": [profile()]}
return service(tables, graph_probe).build(credentials)
def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
from modules.database.supabase.utils import client as client_module
captured = {}
class FakeOptions:
def __init__(self, **kwargs):
captured["options_kwargs"] = kwargs
def fake_create_client(url, key, options=None):
captured["url"] = url
captured["key"] = key
captured["options"] = options
return {"ok": True}
monkeypatch.setenv("SUPABASE_URL", "http://supabase.test")
monkeypatch.setenv("ANON_KEY", "anon-key")
monkeypatch.setattr(client_module, "SyncClientOptions", FakeOptions)
monkeypatch.setattr(client_module, "create_client", fake_create_client)
anon = client_module.SupabaseAnonClient.for_user("user-token")
assert anon.access_token == "user-token"
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["options_kwargs"]["headers"] == {"Authorization": "Bearer user-token"}
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
payload = build(tables={"profiles": [profile()]})
assert payload["school_status"] == "no_school"
assert payload["active_institute"]["source"] == "none"
assert payload["memberships"] == []
assert payload["permissions"]["can_create_school"] is True
assert payload["permissions"]["can_use_canvas"] is True
assert payload["onboarding"]["next_step"] == "create_or_join_school"
assert "school_membership" in payload["onboarding"]["required"]
def test_single_membership_becomes_active_and_uses_supabase_calendar_timetable_status():
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership()],
"institutes": [institute()],
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
"school_timetables": [{"id": "school-tt-1", "institute_id": INST_A}],
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
"teacher_timetable_slots": [
{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"},
{"id": "slot-2", "teacher_timetable_id": "teacher-tt-1"},
],
})
assert payload["school_status"] == "member"
assert payload["active_institute"] == {"id": INST_A, "source": "single_membership", "membership_role": "teacher"}
assert payload["calendar_status"] == {
"available": True,
"academic_year_count": 1,
"term_count": 1,
"current_academic_year_id": "ay-1",
"needs_setup": False,
}
assert payload["timetable_status"] == {
"available": True,
"teacher_timetable_id": "teacher-tt-1",
"slot_count": 2,
"needs_setup": False,
}
assert payload["onboarding"]["next_step"] == "ready"
def test_multiple_memberships_require_selection_when_no_profile_school_id_matches():
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership(INST_A, "teacher"), membership(INST_B, "department_head")],
"institutes": [institute(INST_A, "Alpha"), institute(INST_B, "Beta")],
})
assert payload["school_status"] == "multi_school_needs_selection"
assert payload["active_institute"] == {"id": None, "source": "none", "membership_role": None}
assert payload["onboarding"]["next_step"] == "select_school"
assert "active_school_selection" in payload["onboarding"]["required"]
def test_school_admin_permissions_and_onboarding_invite_staff_after_calendar_timetable_ready():
payload = build(tables={
"profiles": [profile(user_type="school_admin", school_id=INST_A)],
"institute_memberships": [membership(INST_A, "school_admin")],
"institutes": [institute()],
"academic_years": [{"id": "ay-1", "institute_id": INST_A, "is_current": True}],
"academic_terms": [{"id": "term-1", "institute_id": INST_A}],
"teacher_timetables": [{"id": "teacher-tt-1", "institute_id": INST_A, "teacher_profile_id": USER_ID}],
"teacher_timetable_slots": [{"id": "slot-1", "teacher_timetable_id": "teacher-tt-1"}],
})
assert payload["school_status"] == "school_admin"
perms = payload["permissions"]
assert perms["can_manage_school"] is True
assert perms["can_manage_calendar"] is True
assert perms["can_manage_timetable"] is True
assert perms["can_invite_staff"] is True
assert perms["can_view_student_data"] is True
assert payload["onboarding"]["next_step"] == "invite_staff"
def test_platform_admin_uses_admin_profiles_without_relying_on_profile_super_admin_type():
payload = build(credentials={"sub": USER_ID, "email": "[email protected]"}, tables={
"profiles": [profile(user_type="teacher")],
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
})
assert payload["profile"]["user_type"] == "platform_admin"
assert payload["school_status"] == "platform_admin"
assert payload["permissions"]["platform_admin"] is True
assert payload["permissions"]["platform_super_admin"] is True
assert payload["permissions"]["can_create_school"] is True
def test_platform_admin_membership_role_does_not_reduce_platform_permissions():
payload = build(credentials={"sub": USER_ID, "email": "[email protected]"}, tables={
"profiles": [profile(user_type="student")],
"admin_profiles": [{"id": USER_ID, "admin_role": "owner", "is_super_admin": True}],
"institute_memberships": [membership(INST_A, "student")],
"institutes": [institute()],
})
assert payload["school_status"] == "platform_admin"
assert payload["permissions"]["platform_admin"] is True
assert payload["permissions"]["can_manage_school"] is True
assert payload["permissions"]["can_manage_calendar"] is True
assert payload["permissions"]["can_view_student_data"] is True
def test_neo4j_probe_failure_does_not_block_supabase_bootstrap_state():
def failing_probe(**_kwargs):
raise RuntimeError("connection refused with host details that must not leak")
payload = build(tables={
"profiles": [profile()],
"institute_memberships": [membership()],
"institutes": [institute()],
}, graph_probe=failing_probe)
assert payload["school_status"] == "member"
assert payload["active_institute"]["id"] == INST_A
assert payload["graph_status"]["available"] is False
assert payload["graph_status"]["projection_state"] == "error"
assert payload["graph_status"]["needs_rebuild"] is True
assert payload["graph_status"]["error_code"] == "neo4j_unavailable"
assert "connection refused" not in str(payload)
def test_route_is_authenticated_and_does_not_return_raw_auth_metadata(monkeypatch):
app = FastAPI()
app.include_router(router, prefix="/me")
app.dependency_overrides[auth_scheme] = lambda: {"sub": USER_ID, "email": "[email protected]", "app_metadata": {"secret": "nope"}}
monkeypatch.setattr(
"routers.me.bootstrap_router.build_bootstrap_response",
lambda credentials: {
"profile": {"id": USER_ID, "email": credentials["email"], "display_name": "Example", "user_type": "teacher", "school_id": None},
"memberships": [],
"active_institute": {"id": None, "source": "none", "membership_role": None},
"permissions": {"platform_admin": False},
"school_status": "no_school",
"onboarding": {"next_step": "create_or_join_school", "required": [], "optional": [], "message": "Create or join a school."},
"calendar_status": {"available": False, "academic_year_count": 0, "term_count": 0, "current_academic_year_id": None, "needs_setup": True},
"timetable_status": {"available": False, "teacher_timetable_id": None, "slot_count": 0, "needs_setup": True},
"graph_status": {"available": False, "user_db": None, "institute_db": None, "projection_state": "unknown", "needs_rebuild": True, "last_checked_at": "2026-05-28T00:00:00Z", "error_code": None},
},
)
response = TestClient(app).get("/me/bootstrap")
assert response.status_code == 200
assert "app_metadata" not in response.text
assert "secret" not in response.text
+34
View File
@@ -0,0 +1,34 @@
import os
from neo4j import GraphDatabase
def test_cc_users_database_is_populated_from_seed_profiles():
url = os.getenv('APP_BOLT_URL')
user = os.getenv('USER_NEO4J')
password = os.getenv('PASSWORD_NEO4J')
assert url and user and password
with GraphDatabase.driver(url, auth=(user, password)) as driver:
with driver.session(database='cc.users') as session:
result = session.run('''
CALL { MATCH (u:User) RETURN count(u) AS users }
CALL { MATCH (u:User {user_type: 'teacher'}) RETURN count(u) AS teachers }
CALL { MATCH (u:User {user_type: 'student'}) RETURN count(u) AS students }
CALL { MATCH (:User)-[r:MEMBER_OF]->(:Institute) RETURN count(r) AS memberships }
CALL { MATCH (i:Institute) RETURN count(i) AS institutes }
CALL {
MATCH (bad:User)
WHERE bad.uuid_string IS NULL OR bad.user_email IS NULL OR bad.cc_username IS NULL
OR bad.user_type IS NULL OR bad.user_name IS NULL OR bad.user_db_name IS NULL
RETURN count(bad) AS bad_users
}
RETURN users, teachers, students, memberships, institutes, bad_users
''').single()
assert dict(result) == {
'users': 21,
'teachers': 15,
'students': 6,
'memberships': 21,
'institutes': 2,
'bad_users': 0,
}
+109
View File
@@ -0,0 +1,109 @@
import pytest
from fastapi import HTTPException
def test_classes_school_students_route_registered_before_dynamic_class_id():
from routers.database.tools.classes_router import router
paths = [route.path for route in router.routes]
assert paths.index('/school/students') < paths.index('/{class_id}')
def test_supabase_anon_for_user_sets_user_authorization_header(monkeypatch):
from modules.database.supabase.utils import client as client_module
captured = {}
def fake_create_client(url, key, options=None):
captured['url'] = url
captured['key'] = key
captured['options'] = options
return object()
monkeypatch.setenv('SUPABASE_URL', 'http://supabase.test')
monkeypatch.setenv('ANON_KEY', 'anon-key')
monkeypatch.setattr(client_module, 'create_client', fake_create_client)
client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
# 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 'apikey' not in captured['options'].headers
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'
@pytest.mark.parametrize('token', ['', ' '])
def test_supabase_anon_for_user_requires_token(token):
from modules.database.supabase.utils.client import SupabaseAnonClient
with pytest.raises(ValueError):
SupabaseAnonClient.for_user(token)
def test_tldraw_malformed_snapshot_falls_back_to_default():
from routers.database.tools import tldraw_supabase_storage as storage
assert not storage._is_valid_tldraw_snapshot({'document': {}, 'session': {}})
default = storage.create_default_tldraw_content()
assert storage._is_valid_tldraw_snapshot(default)
assert default['document']['schema']['schemaVersion'] == 2
def test_tldraw_rejects_cross_tenant_snapshot_db(monkeypatch):
from routers.database.tools import tldraw_supabase_storage as storage
monkeypatch.setattr(storage, '_user_scope', lambda user_id: {
'user_id': user_id,
'teacher_db': f"cc.users.teacher.{user_id.replace('-', '')}",
'institute_id': 'school-1',
'institute_db': 'cc.institutes.allowed',
'curriculum_db': 'cc.institutes.allowed.curriculum',
})
with pytest.raises(HTTPException) as exc:
storage._authorize_snapshot_path(
'cc.public.snapshots/School/other-school',
'cc.institutes.other',
{'sub': 'user-1'},
write=False,
)
assert exc.value.status_code == 403
def test_graph_node_children_rejects_unscoped_db(monkeypatch):
from routers.database.tools import graph_tree_router
monkeypatch.setattr(graph_tree_router, '_allowed_neo4j_dbs', lambda user_id, email: {'cc.users.teacher.user1'})
with pytest.raises(HTTPException) as exc:
graph_tree_router._require_allowed_neo4j_db(
'cc.institutes.not-mine',
'SubjectClass',
'classes',
'user-1',
'[email protected]',
)
assert exc.value.status_code == 403
def test_graph_node_children_allows_global_calendar_only():
from routers.database.tools import graph_tree_router
graph_tree_router._require_allowed_neo4j_db(
'classroomcopilot',
'CalendarYear',
'',
'user-1',
'[email protected]',
)
with pytest.raises(HTTPException) as exc:
graph_tree_router._require_allowed_neo4j_db(
'classroomcopilot',
'School',
'school',
'user-1',
'[email protected]',
)
assert exc.value.status_code == 403
+69
View File
@@ -0,0 +1,69 @@
import jwt
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from modules.auth.supabase_bearer import verify_supabase_token_dep
from routers.tlsync_token import TLSYNC_TOKEN_AUDIENCE, create_tlsync_token, router
def test_create_tlsync_token_is_short_lived_and_signed(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "120")
response = create_tlsync_token({"sub": "user-123"})
assert response["token_type"] == "Bearer"
assert response["expires_in"] == 120
assert response["token"]
payload = jwt.decode(
response["token"],
"test-tlsync-secret-with-at-least-32-bytes",
algorithms=["HS256"],
audience=TLSYNC_TOKEN_AUDIENCE,
)
assert payload["sub"] == "user-123"
assert payload["exp"] == response["expires_at"]
assert payload["exp"] - payload["iat"] == 120
assert payload["jti"]
def test_create_tlsync_token_requires_tlsync_secret(monkeypatch):
monkeypatch.delenv("TLSYNC_SECRET", raising=False)
with pytest.raises(HTTPException) as excinfo:
create_tlsync_token({"sub": "user-123"})
assert excinfo.value.status_code == 503
def test_create_tlsync_token_requires_authenticated_subject(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
with pytest.raises(HTTPException) as excinfo:
create_tlsync_token({})
assert excinfo.value.status_code == 401
def test_tlsync_token_route_uses_authenticated_user_claims(monkeypatch):
monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes")
monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "60")
app = FastAPI()
app.include_router(router, prefix="/api/tlsync")
app.dependency_overrides[verify_supabase_token_dep] = lambda: {"sub": "route-user-123"}
response = TestClient(app).get("/api/tlsync/token", headers={"Authorization": "Bearer supabase-jwt"})
assert response.status_code == 200
body = response.json()
payload = jwt.decode(
body["token"],
"test-tlsync-secret-with-at-least-32-bytes",
algorithms=["HS256"],
audience=TLSYNC_TOKEN_AUDIENCE,
)
assert payload["sub"] == "route-user-123"
assert body["expires_in"] == 60