Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e45f59e8 |
@@ -245,36 +245,6 @@ async def batch_csv(
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _advance_completion(ctx: ExamContext, batch_id: str, submission_id: str) -> None:
|
||||
"""After a mark upsert, advance statuses: a submission with a mark for every markable (leaf)
|
||||
question → complete; a batch whose every non-absent submission is complete → complete. Nothing
|
||||
here regresses a status (only promotes to complete), so it is safe to run on every upsert."""
|
||||
batch = _first(
|
||||
ctx.supabase.table("marking_batches").select("id, template_id, status").eq("id", batch_id).limit(1).execute()
|
||||
)
|
||||
if not batch:
|
||||
return
|
||||
markable = {
|
||||
q["id"] for q in _rows(
|
||||
ctx.supabase.table("exam_questions").select("id, is_container").eq("template_id", batch["template_id"]).execute()
|
||||
) if not q.get("is_container")
|
||||
}
|
||||
if not markable:
|
||||
return
|
||||
marked = {
|
||||
m["question_id"] for m in _rows(
|
||||
ctx.supabase.table("mark_entries").select("question_id").eq("submission_id", submission_id).execute()
|
||||
)
|
||||
}
|
||||
if not markable.issubset(marked):
|
||||
return
|
||||
ctx.supabase.table("student_submissions").update({"status": "complete"}).eq("id", submission_id).execute()
|
||||
subs = _rows(ctx.supabase.table("student_submissions").select("status").eq("batch_id", batch_id).execute())
|
||||
active = [s for s in subs if s.get("status") != "absent"]
|
||||
if active and all(s.get("status") == "complete" for s in active) and batch.get("status") != "complete":
|
||||
ctx.supabase.table("marking_batches").update({"status": "complete"}).eq("id", batch_id).execute()
|
||||
|
||||
|
||||
@router.put("/marks/{mark_id}")
|
||||
async def upsert_mark(
|
||||
mark_id: str,
|
||||
@@ -289,15 +259,6 @@ async def upsert_mark(
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Reject an award that exceeds the question's max (only when a max is actually set; 0/None means
|
||||
# "not scored yet" for AI/unmapped questions, so we can't validate those).
|
||||
question = _first(
|
||||
ctx.supabase.table("exam_questions").select("id, max_marks").eq("id", body.question_id).limit(1).execute()
|
||||
)
|
||||
max_marks = (question or {}).get("max_marks")
|
||||
if isinstance(max_marks, (int, float)) and max_marks > 0 and body.awarded_marks is not None and body.awarded_marks > max_marks:
|
||||
raise HTTPException(status_code=422, detail=f"awarded_marks {body.awarded_marks} exceeds max_marks {max_marks} for this question")
|
||||
|
||||
row = {
|
||||
"id": mark_id,
|
||||
"submission_id": body.submission_id,
|
||||
@@ -324,9 +285,6 @@ async def upsert_mark(
|
||||
if submission.get("status") in ("absent", "unmatched"):
|
||||
ctx.supabase.table("student_submissions").update({"status": "marking"}).eq("id", body.submission_id).execute()
|
||||
|
||||
# Promote the submission/batch to complete once every markable question has a mark.
|
||||
_advance_completion(ctx, submission["batch_id"], body.submission_id)
|
||||
|
||||
return upserted
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""
|
||||
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).
|
||||
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam board
|
||||
+ the 6 current test specifications (GCSE & A-level Physics/Chemistry/Biology) with their top-level
|
||||
topic SpecPoints (44 in total). 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.
|
||||
NOTE: only *top-level* topics are seeded (the granularity a teacher plans against). 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). Seeding all 6 specs means a template's spec_ref finds a matching SpecPoint so
|
||||
(:Part)-[:ASSESSES]->(:SpecPoint) fires beyond GCSE Physics; spec_code (e.g. AQA-PHYS-8463) must match
|
||||
the eb_exams/eb_specifications seed (card S4-3) and the app's deriveSpecCode.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
@@ -41,6 +43,37 @@ SPEC_POINTS = [
|
||||
("4.8", "Space physics"),
|
||||
]
|
||||
|
||||
# Full AQA catalogue for the current test specs (top-level topics; ref = topic number). Seeding all of
|
||||
# them means a template's spec_ref finds a matching SpecPoint so (:Part)-[:ASSESSES]->(:SpecPoint) fires
|
||||
# beyond AQA GCSE Physics. Sub-point granularity (e.g. 4.1.1.1) remains a later data-population task.
|
||||
SPECIFICATIONS = [
|
||||
{**SPEC, "topics": SPEC_POINTS},
|
||||
{"spec_code": "AQA-CHEM-8462", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "GCSE",
|
||||
"title": "AQA GCSE Chemistry (8462)", "topics": [
|
||||
("4.1", "Atomic structure and the periodic table"), ("4.2", "Bonding, structure, and the properties of matter"),
|
||||
("4.3", "Quantitative chemistry"), ("4.4", "Chemical changes"), ("4.5", "Energy changes"),
|
||||
("4.6", "The rate and extent of chemical change"), ("4.7", "Organic chemistry"), ("4.8", "Chemical analysis"),
|
||||
("4.9", "Chemistry of the atmosphere"), ("4.10", "Using resources")]},
|
||||
{"spec_code": "AQA-BIOL-8461", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "GCSE",
|
||||
"title": "AQA GCSE Biology (8461)", "topics": [
|
||||
("4.1", "Cell biology"), ("4.2", "Organisation"), ("4.3", "Infection and response"), ("4.4", "Bioenergetics"),
|
||||
("4.5", "Homeostasis and response"), ("4.6", "Inheritance, variation and evolution"), ("4.7", "Ecology")]},
|
||||
{"spec_code": "AQA-PHYS-7408", "exam_board_code": "AQA", "subject_code": "PHYS", "award_code": "A-level",
|
||||
"title": "AQA A-level Physics (7408)", "topics": [
|
||||
("3.1", "Measurements and their errors"), ("3.2", "Particles and radiation"), ("3.3", "Waves"),
|
||||
("3.4", "Mechanics and materials"), ("3.5", "Electricity"), ("3.6", "Further mechanics and thermal physics"),
|
||||
("3.7", "Fields and their consequences"), ("3.8", "Nuclear physics")]},
|
||||
{"spec_code": "AQA-CHEM-7405", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "A-level",
|
||||
"title": "AQA A-level Chemistry (7405)", "topics": [
|
||||
("3.1", "Physical chemistry"), ("3.2", "Inorganic chemistry"), ("3.3", "Organic chemistry")]},
|
||||
{"spec_code": "AQA-BIOL-7402", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "A-level",
|
||||
"title": "AQA A-level Biology (7402)", "topics": [
|
||||
("3.1", "Biological molecules"), ("3.2", "Cells"), ("3.3", "Organisms exchange substances with their environment"),
|
||||
("3.4", "Genetic information, variation and relationships between organisms"),
|
||||
("3.5", "Energy transfers in and between organisms"), ("3.6", "Organisms respond to changes"),
|
||||
("3.7", "Genetics, populations, evolution and ecosystems"), ("3.8", "The control of gene expression")]},
|
||||
]
|
||||
|
||||
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",
|
||||
@@ -81,37 +114,38 @@ def init() -> Dict[str, Any]:
|
||||
s.run(c).consume()
|
||||
result["constraints"] += 1
|
||||
|
||||
# 3. board + spec
|
||||
# 3. board (once)
|
||||
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)
|
||||
# 4. each specification + its top-level spec points (idempotent MERGE)
|
||||
for spec in SPECIFICATIONS:
|
||||
spec_uid = _uid("Specification", spec["spec_code"])
|
||||
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}",
|
||||
"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()
|
||||
result["spec_points"] += 1
|
||||
for ref, desc in spec["topics"]:
|
||||
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 "
|
||||
|
||||
@@ -249,30 +249,6 @@ def test_upsert_mark_submission_404():
|
||||
assert c.put("/api/exam/marks/mk-x", json={"submission_id": "nope", "question_id": "q1", "awarded_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_upsert_mark_rejects_over_max():
|
||||
c = make_client(_batch_with_cohort()) # q1 max_marks = 3
|
||||
r = c.put("/api/exam/marks/mk-over", json={"submission_id": "sub2", "question_id": "q1", "awarded_marks": 4})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_upsert_mark_completes_submission_and_batch():
|
||||
store = base_store(
|
||||
marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "marking"}],
|
||||
exam_questions=[
|
||||
{"id": "q0", "template_id": TPL, "label": "Q1", "max_marks": 0, "order": 0, "is_container": True},
|
||||
{"id": "q1", "template_id": TPL, "label": "01", "max_marks": 3, "order": 1, "is_container": False},
|
||||
{"id": "q2", "template_id": TPL, "label": "02", "max_marks": 5, "order": 2, "is_container": False},
|
||||
],
|
||||
student_submissions=[{"id": "sub1", "batch_id": "b1", "student_id": "s1", "status": "marking"}],
|
||||
mark_entries=[{"id": "m1", "batch_id": "b1", "submission_id": "sub1", "question_id": "q1", "awarded_marks": 2}],
|
||||
)
|
||||
c = make_client(store)
|
||||
# marking the last leaf question completes the submission (container q0 doesn't block) and the batch
|
||||
assert c.put("/api/exam/marks/m2", json={"submission_id": "sub1", "question_id": "q2", "awarded_marks": 4}).status_code == 200
|
||||
assert next(s for s in store["student_submissions"] if s["id"] == "sub1")["status"] == "complete"
|
||||
assert next(b for b in store["marking_batches"] if b["id"] == "b1")["status"] == "complete"
|
||||
|
||||
|
||||
# ─── scans (E3 guards) ───────────────────────────────────────────────────────
|
||||
|
||||
def _batch_store():
|
||||
|
||||
Reference in New Issue
Block a user