Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1aee28e2 | ||
|
|
93972a62f7 | ||
|
|
f3da9f3b59 | ||
|
|
49f84655f7 |
@@ -138,7 +138,12 @@ def project_template(template_id: str) -> Dict[str, Any]:
|
|||||||
counts["assesses"] += (r["n"] if r else 0)
|
counts["assesses"] += (r["n"] if r else 0)
|
||||||
|
|
||||||
# 6. Region nodes + HAS_REGION edges.
|
# 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:
|
for rg in regions:
|
||||||
|
if rg.get("kind") not in ("response", "context"):
|
||||||
|
continue
|
||||||
s.run(
|
s.run(
|
||||||
"MERGE (r:Region {uuid_string:$uid}) "
|
"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",
|
"SET r.exam_code=$ec, r.page=$page, r.kind=$kind, r.response_form=$rf, r.node_storage_path=$nsp",
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ def _create_base_client(url: str, key: str, access_token: Optional[str] = None,
|
|||||||
# Otherwise fall back to the API key
|
# Otherwise fall back to the API key
|
||||||
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
|
auth_header = f"Bearer {access_token}" if access_token else f"Bearer {key}"
|
||||||
|
|
||||||
|
# Only override Authorization here. apikey is supplied to create_client via the `key` arg and
|
||||||
|
# set by supabase-py itself; setting it again here sends a DUPLICATE apikey header that the
|
||||||
|
# Supabase gateway (Kong) rejects with 401 "Duplicate API key found". For a per-user client
|
||||||
|
# apikey stays the anon key (from `key`) while this Authorization carries the user JWT.
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": auth_header,
|
"Authorization": auth_header,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,17 +57,24 @@ class QuestionPayload(BaseModel):
|
|||||||
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
||||||
is_container: bool = False
|
is_container: bool = False
|
||||||
spec_ref: Optional[str] = None
|
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):
|
class ResponseAreaPayload(BaseModel):
|
||||||
id: Optional[str] = None # == Neo4j Region.uuid_string
|
id: Optional[str] = None # == Neo4j Region.uuid_string (only response/context project)
|
||||||
question_id: str
|
question_id: str
|
||||||
page: int
|
page: int
|
||||||
bounds: Dict[str, Any] # {x,y,w,h}
|
bounds: Dict[str, Any] # {x,y,w,h}
|
||||||
kind: Literal["response", "context"]
|
# 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[
|
response_form: Optional[
|
||||||
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
||||||
] = None
|
] = 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"
|
source: Literal["manual", "ai"] = "manual"
|
||||||
confirmed: bool = True
|
confirmed: bool = True
|
||||||
confidence: Optional[float] = None
|
confidence: Optional[float] = None
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ async def replace_template(
|
|||||||
"mark_scheme": q.mark_scheme,
|
"mark_scheme": q.mark_scheme,
|
||||||
"is_container": q.is_container,
|
"is_container": q.is_container,
|
||||||
"spec_ref": q.spec_ref,
|
"spec_ref": q.spec_ref,
|
||||||
|
"bounds": q.bounds, # drawn Part box (73); null for derived main questions
|
||||||
|
"page": q.page,
|
||||||
}
|
}
|
||||||
if q.id:
|
if q.id:
|
||||||
r["id"] = q.id
|
r["id"] = q.id
|
||||||
@@ -220,6 +222,7 @@ async def replace_template(
|
|||||||
"bounds": ra.bounds,
|
"bounds": ra.bounds,
|
||||||
"kind": ra.kind,
|
"kind": ra.kind,
|
||||||
"response_form": ra.response_form,
|
"response_form": ra.response_form,
|
||||||
|
"context_type": ra.context_type, # 73: optional Context differentiation
|
||||||
"source": ra.source,
|
"source": ra.source,
|
||||||
"confirmed": ra.confirmed,
|
"confirmed": ra.confirmed,
|
||||||
"confidence": ra.confidence,
|
"confidence": ra.confidence,
|
||||||
|
|||||||
@@ -55,15 +55,19 @@ def test_dev_api_health_endpoint_is_healthy():
|
|||||||
assert payload['services']['redis']['database'] == 0
|
assert payload['services']['redis']['database'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: these are >= baselines, not exact counts. The greenfield seed produces this floor;
|
||||||
|
# additive exam-marker fixtures (S4-4 cohort adds ~10 students/memberships; ad-hoc classes) push
|
||||||
|
# the live .94 counts above it. Exact == froze a snapshot that any new fixture breaks, while >=
|
||||||
|
# still catches a broken or missing seed.
|
||||||
def test_supabase_dev_seed_core_counts():
|
def test_supabase_dev_seed_core_counts():
|
||||||
assert _rest_count('profiles') == 21
|
assert _rest_count('profiles') >= 21
|
||||||
assert _rest_count('institute_memberships') == 21
|
assert _rest_count('institute_memberships') >= 21
|
||||||
assert _rest_count('institutes') == 2
|
assert _rest_count('institutes') >= 2
|
||||||
|
|
||||||
|
|
||||||
def test_supabase_dev_seed_timetable_counts():
|
def test_supabase_dev_seed_timetable_counts():
|
||||||
assert _rest_count('classes') == 17
|
assert _rest_count('classes') >= 17
|
||||||
assert _rest_count('taught_lessons') == 1462
|
assert _rest_count('taught_lessons') >= 1462
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_identity_does_not_expose_secret_values():
|
def test_runtime_identity_does_not_expose_secret_values():
|
||||||
|
|||||||
@@ -234,6 +234,33 @@ def test_put_replace_persists_children_with_client_ids():
|
|||||||
assert body["boundaries"][0]["id"] == "b-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():
|
def test_put_replace_clears_previous_children():
|
||||||
store = {
|
store = {
|
||||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ def test_supabase_client_for_user_uses_access_token_authorization(monkeypatch):
|
|||||||
|
|
||||||
assert anon.access_token == "user-token"
|
assert anon.access_token == "user-token"
|
||||||
assert captured["url"] == "http://supabase.test"
|
assert captured["url"] == "http://supabase.test"
|
||||||
|
# apikey is supplied via the `key` positional arg (supabase-py sets the apikey header from it).
|
||||||
|
# options.headers must carry ONLY the per-user Authorization override — adding apikey here too
|
||||||
|
# produces a duplicate apikey header that Kong rejects ("Duplicate API key found").
|
||||||
assert captured["key"] == "anon-key"
|
assert captured["key"] == "anon-key"
|
||||||
assert captured["options_kwargs"]["headers"] == {
|
assert captured["options_kwargs"]["headers"] == {"Authorization": "Bearer user-token"}
|
||||||
"apikey": "anon-key",
|
|
||||||
"Authorization": "Bearer user-token",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
|
def test_no_school_bootstrap_requires_school_membership_but_allows_canvas():
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ def test_supabase_anon_for_user_sets_user_authorization_header(monkeypatch):
|
|||||||
|
|
||||||
client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
|
client_module.SupabaseAnonClient.for_user('Bearer user-jwt')
|
||||||
|
|
||||||
|
# apikey comes from the `key` arg (supabase-py sets the apikey header); options.headers must
|
||||||
|
# carry only the user Authorization override. A second apikey here → Kong "Duplicate API key".
|
||||||
assert captured['key'] == 'anon-key'
|
assert captured['key'] == 'anon-key'
|
||||||
assert captured['options'].headers['apikey'] == 'anon-key'
|
assert 'apikey' not in captured['options'].headers
|
||||||
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'
|
assert captured['options'].headers['Authorization'] == 'Bearer user-jwt'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user