Compare commits
6 Commits
4b296cff74
...
6ce6272a1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce6272a1e | ||
|
|
b8cb9083ec | ||
|
|
8427063bd1 | ||
|
|
5f822eaf87 | ||
|
|
c690caa26d | ||
|
|
0ce654c6c6 |
0
modules/database/schemas/nodes/exams/__init__.py
Normal file
0
modules/database/schemas/nodes/exams/__init__.py
Normal file
92
modules/database/schemas/nodes/exams/exam_nodes.py
Normal file
92
modules/database/schemas/nodes/exams/exam_nodes.py
Normal file
@ -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
|
||||||
128
run/initialization/init_exam_graph.py
Normal file
128
run/initialization/init_exam_graph.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
init_exam_graph.py — Initialise the cc.public.exams Neo4j knowledge graph.
|
||||||
|
|
||||||
|
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam
|
||||||
|
board + AQA GCSE Physics (8463) specification with its 8 top-level topic SpecPoints. Idempotent
|
||||||
|
(CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT EXISTS / MERGE).
|
||||||
|
|
||||||
|
Run inside the ccapi container:
|
||||||
|
python3 -c "from run.initialization.init_exam_graph import init; import json; print(json.dumps(init()))"
|
||||||
|
|
||||||
|
NOTE: the 8 SpecPoints seeded here are the real AQA GCSE Physics *top-level* topics. The full
|
||||||
|
sub-point breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA
|
||||||
|
spec PDF via Docling). spec_code AQA-PHYS-8463 is the standalone GCSE Physics code that matches
|
||||||
|
"AQA Physics Paper 1H"; the eb_exams/eb_specifications seed (card S4-3) must use the same code.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from modules.database.tools.neo4j_driver_tools import get_driver
|
||||||
|
|
||||||
|
EXAM_DB = "cc.public.exams"
|
||||||
|
NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1") # stable namespace for deterministic uuids
|
||||||
|
|
||||||
|
BOARD = {"code": "AQA", "name": "AQA"}
|
||||||
|
SPEC = {
|
||||||
|
"spec_code": "AQA-PHYS-8463",
|
||||||
|
"exam_board_code": "AQA",
|
||||||
|
"subject_code": "PHYS",
|
||||||
|
"award_code": "GCSE",
|
||||||
|
"title": "AQA GCSE Physics (8463)",
|
||||||
|
}
|
||||||
|
# Real AQA GCSE Physics (8463) top-level topics (ref = topic number).
|
||||||
|
SPEC_POINTS = [
|
||||||
|
("4.1", "Energy"),
|
||||||
|
("4.2", "Electricity"),
|
||||||
|
("4.3", "Particle model of matter"),
|
||||||
|
("4.4", "Atomic structure"),
|
||||||
|
("4.5", "Forces"),
|
||||||
|
("4.6", "Waves"),
|
||||||
|
("4.7", "Magnetism and electromagnetism"),
|
||||||
|
("4.8", "Space physics"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONSTRAINTS = [
|
||||||
|
"CREATE CONSTRAINT exam_board_uid IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT spec_uid IF NOT EXISTS FOR (n:Specification) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT specpoint_uid IF NOT EXISTS FOR (n:SpecPoint) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT exampaper_uid IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT question_uid IF NOT EXISTS FOR (n:Question) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT part_uid IF NOT EXISTS FOR (n:Part) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT region_uid IF NOT EXISTS FOR (n:Region) REQUIRE n.uuid_string IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT spec_code_unique IF NOT EXISTS FOR (n:Specification) REQUIRE n.spec_code IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT exam_code_unique IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.exam_code IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT board_code_unique IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.code IS UNIQUE",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _uid(*parts: str) -> str:
|
||||||
|
return str(uuid.uuid5(NS, ":".join(parts)))
|
||||||
|
|
||||||
|
|
||||||
|
def init() -> Dict[str, Any]:
|
||||||
|
driver = get_driver()
|
||||||
|
result: Dict[str, Any] = {"db": EXAM_DB, "constraints": 0, "spec_points": 0}
|
||||||
|
|
||||||
|
# 1. database
|
||||||
|
with driver.session(database="system") as s:
|
||||||
|
s.run(f"CREATE DATABASE `{EXAM_DB}` IF NOT EXISTS").consume()
|
||||||
|
# wait for availability
|
||||||
|
import time
|
||||||
|
for _ in range(30):
|
||||||
|
with driver.session(database="system") as s:
|
||||||
|
st = s.run("SHOW DATABASE $n YIELD currentStatus RETURN currentStatus", n=EXAM_DB).single()
|
||||||
|
if st and st["currentStatus"] == "online":
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
with driver.session(database=EXAM_DB) as s:
|
||||||
|
# 2. constraints
|
||||||
|
for c in CONSTRAINTS:
|
||||||
|
s.run(c).consume()
|
||||||
|
result["constraints"] += 1
|
||||||
|
|
||||||
|
# 3. board + spec
|
||||||
|
board_uid = _uid("ExamBoard", BOARD["code"])
|
||||||
|
spec_uid = _uid("Specification", SPEC["spec_code"])
|
||||||
|
s.run(
|
||||||
|
"MERGE (b:ExamBoard {uuid_string:$uid}) "
|
||||||
|
"SET b.code=$code, b.name=$name, b.node_storage_path=$nsp",
|
||||||
|
uid=board_uid, code=BOARD["code"], name=BOARD["name"],
|
||||||
|
nsp=f"{EXAM_DB}/ExamBoard/{BOARD['code']}",
|
||||||
|
).consume()
|
||||||
|
s.run(
|
||||||
|
"MERGE (sp:Specification {uuid_string:$uid}) "
|
||||||
|
"SET sp.spec_code=$sc, sp.exam_board_code=$ebc, sp.subject_code=$subj, "
|
||||||
|
" sp.award_code=$award, sp.title=$title, sp.node_storage_path=$nsp "
|
||||||
|
"WITH sp MATCH (b:ExamBoard {code:$ebc}) MERGE (b)-[:PUBLISHES]->(sp)",
|
||||||
|
uid=spec_uid, sc=SPEC["spec_code"], ebc=SPEC["exam_board_code"],
|
||||||
|
subj=SPEC["subject_code"], award=SPEC["award_code"], title=SPEC["title"],
|
||||||
|
nsp=f"{EXAM_DB}/Specification/{SPEC['spec_code']}",
|
||||||
|
).consume()
|
||||||
|
|
||||||
|
# 4. spec points
|
||||||
|
for ref, desc in SPEC_POINTS:
|
||||||
|
sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref)
|
||||||
|
s.run(
|
||||||
|
"MERGE (p:SpecPoint {uuid_string:$uid}) "
|
||||||
|
"SET p.ref=$ref, p.description=$desc, p.spec_code=$sc, "
|
||||||
|
" p.exam_board_code=$ebc, p.node_storage_path=$nsp "
|
||||||
|
"WITH p MATCH (s:Specification {spec_code:$sc}) MERGE (s)-[:HAS_SPEC_POINT]->(p)",
|
||||||
|
uid=sp_uid, ref=ref, desc=desc, sc=SPEC["spec_code"],
|
||||||
|
ebc=SPEC["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{SPEC['spec_code']}/{ref}",
|
||||||
|
).consume()
|
||||||
|
result["spec_points"] += 1
|
||||||
|
|
||||||
|
counts = s.run(
|
||||||
|
"MATCH (b:ExamBoard) WITH count(b) AS boards "
|
||||||
|
"MATCH (sp:Specification) WITH boards, count(sp) AS specs "
|
||||||
|
"MATCH (p:SpecPoint) RETURN boards, specs, count(p) AS spec_points"
|
||||||
|
).single()
|
||||||
|
result["verify"] = dict(counts) if counts else {}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print(json.dumps(init(), indent=2, default=str))
|
||||||
218
run/initialization/seed_cohort_9p_ph1.py
Normal file
218
run/initialization/seed_cohort_9p_ph1.py
Normal 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))
|
||||||
@ -52,6 +52,18 @@ SPECIFICATIONS = [
|
|||||||
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8203_spec.pdf",
|
"storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8203_spec.pdf",
|
||||||
"doc_type": "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
|
# Edexcel Maths
|
||||||
{
|
{
|
||||||
"spec_code": "EDX-MATH-1MA1",
|
"spec_code": "EDX-MATH-1MA1",
|
||||||
@ -102,6 +114,12 @@ SPECIFICATIONS = [
|
|||||||
# Realistic exam paper references linked to specifications.
|
# Realistic exam paper references linked to specifications.
|
||||||
|
|
||||||
EXAMS = [
|
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)
|
# AQA Physics 8201/1 (Foundation)
|
||||||
{"exam_code": "AQA-PHYS-8201-1-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
{"exam_code": "AQA-PHYS-8201-1-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1",
|
||||||
"tier": "foundation", "session": "June", "type_code": "QP"},
|
"tier": "foundation", "session": "June", "type_code": "QP"},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user