Add running class markbook API

This commit is contained in:
Kevin Carter (via Claude) 2026-07-24 18:18:49 +00:00
parent e48dd73fdf
commit 42347f73bf
3 changed files with 427 additions and 0 deletions

256
routers/markbook.py Normal file
View File

@ -0,0 +1,256 @@
"""Running class markbook / gradebook API (/api/markbook).
A markbook is a term-long teacher-editable ledger: roster rows × arbitrary assessment
columns. It deliberately does not depend on exam-marker batches. All user-facing reads and
writes use the as-user Supabase client so class/markbook RLS gates are enforced.
"""
from __future__ import annotations
import csv
import io
import os
from datetime import date as Date
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from modules.logger_tool import initialise_logger
from routers.exam.dependencies import ExamContext, get_exam_context, resolve_student_names
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
router = APIRouter()
class CreateAssessmentRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=160)
date: Optional[Date] = None
max_marks: float = Field(default=100, gt=0)
class MarkUpsertRequest(BaseModel):
mark: Optional[float] = Field(default=None, ge=0)
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 _round(value: Optional[float]) -> Optional[float]:
return None if value is None else round(float(value), 1)
def _fetch_class_or_404(ctx: ExamContext, class_id: str) -> Dict[str, Any]:
row = _first(ctx.supabase.table("classes").select("id, name, institute_id").eq("id", class_id).limit(1).execute())
if not row:
raise HTTPException(status_code=404, detail="Class not found")
return row
def _active_roster(ctx: ExamContext, class_id: str) -> List[Dict[str, Any]]:
roster = _rows(
ctx.supabase.table("class_students")
.select("student_id, status, enrolled_at")
.eq("class_id", class_id)
.eq("status", "active")
.execute()
)
names = resolve_student_names([r["student_id"] for r in roster if r.get("student_id")])
return [
{
"student_id": r["student_id"],
"student_name": names.get(r["student_id"]) or r["student_id"],
"status": r.get("status"),
"enrolled_at": r.get("enrolled_at"),
}
for r in roster
if r.get("student_id")
]
def _assessments(ctx: ExamContext, class_id: str) -> List[Dict[str, Any]]:
return _rows(
ctx.supabase.table("class_assessments")
.select("id, class_id, tenant_id, title, date, max_marks, created_at, updated_at")
.eq("class_id", class_id)
.order("date")
.order("created_at")
.execute()
)
def _marks(ctx: ExamContext, assessment_ids: List[str]) -> List[Dict[str, Any]]:
if not assessment_ids:
return []
return _rows(
ctx.supabase.table("assessment_marks")
.select("assessment_id, student_id, mark, updated_at, updated_by")
.in_("assessment_id", assessment_ids)
.execute()
)
def _assemble_grid(ctx: ExamContext, class_id: str) -> Dict[str, Any]:
cls = _fetch_class_or_404(ctx, class_id)
roster = _active_roster(ctx, class_id)
assessments = _assessments(ctx, class_id)
assessment_ids = [a["id"] for a in assessments]
marks = _marks(ctx, assessment_ids)
marks_by_student: Dict[str, Dict[str, Optional[float]]] = {r["student_id"]: {} for r in roster}
for m in marks:
sid = m.get("student_id")
aid = m.get("assessment_id")
if isinstance(sid, str) and isinstance(aid, str) and sid in marks_by_student and aid in assessment_ids:
marks_by_student[sid][aid] = m.get("mark")
max_total = sum(float(a.get("max_marks") or 0) for a in assessments)
students = []
all_entered: List[float] = []
for idx, student in enumerate(roster, start=1):
entered = [
float(v)
for v in (marks_by_student.get(student["student_id"], {}).get(aid) for aid in assessment_ids)
if v is not None
]
total = sum(entered) if entered else None
students.append(
{
**student,
"row_number": idx,
"marks": {aid: marks_by_student.get(student["student_id"], {}).get(aid) for aid in assessment_ids},
"total": total,
"percentage": _round((total / max_total) * 100) if total is not None and max_total > 0 else None,
}
)
all_entered.extend(entered)
assessment_summaries = []
for a in assessments:
vals = []
for s in roster:
maybe_mark = marks_by_student.get(s["student_id"], {}).get(a["id"])
if maybe_mark is not None:
vals.append(float(maybe_mark))
max_marks = float(a.get("max_marks") or 0)
assessment_summaries.append(
{
"assessment_id": a["id"],
"entered_count": len(vals),
"average_mark": _round(sum(vals) / len(vals)) if vals else None,
"average_percentage": _round((sum(vals) / len(vals) / max_marks) * 100) if vals and max_marks > 0 else None,
}
)
return {
"class": cls,
"students": students,
"assessments": assessments,
"assessment_summaries": assessment_summaries,
"summary": {
"student_count": len(roster),
"assessment_count": len(assessments),
"entered_mark_count": len(all_entered),
"class_average_mark": _round(sum(all_entered) / len(all_entered)) if all_entered else None,
},
}
@router.get("/classes/{class_id}/assessments")
async def list_assessments(class_id: str, ctx: ExamContext = Depends(get_exam_context)) -> Dict[str, Any]:
_fetch_class_or_404(ctx, class_id)
return {"assessments": _assessments(ctx, class_id)}
@router.post("/classes/{class_id}/assessments")
async def create_assessment(class_id: str, body: CreateAssessmentRequest, ctx: ExamContext = Depends(get_exam_context)) -> Dict[str, Any]:
cls = _fetch_class_or_404(ctx, class_id)
row = {
"class_id": class_id,
"tenant_id": cls["institute_id"],
"title": body.title.strip(),
"date": body.date.isoformat() if body.date else None,
"max_marks": body.max_marks,
}
created = _first(ctx.supabase.table("class_assessments").insert(row).execute())
if not created:
raise HTTPException(status_code=500, detail="Failed to create assessment")
logger.info(f"Markbook assessment {created.get('id')} created for class {class_id} by {ctx.user_id}")
return created
@router.get("/classes/{class_id}/grid")
async def get_grid(class_id: str, ctx: ExamContext = Depends(get_exam_context)) -> Dict[str, Any]:
return _assemble_grid(ctx, class_id)
@router.put("/classes/{class_id}/assessments/{assessment_id}/marks/{student_id}")
async def upsert_mark(
class_id: str,
assessment_id: str,
student_id: str,
body: MarkUpsertRequest,
ctx: ExamContext = Depends(get_exam_context),
) -> Dict[str, Any]:
cls = _fetch_class_or_404(ctx, class_id)
assessment = _first(
ctx.supabase.table("class_assessments")
.select("id, class_id, tenant_id, max_marks")
.eq("id", assessment_id)
.eq("class_id", class_id)
.limit(1)
.execute()
)
if not assessment:
raise HTTPException(status_code=404, detail="Assessment not found")
roster_row = _first(
ctx.supabase.table("class_students")
.select("student_id")
.eq("class_id", class_id)
.eq("student_id", student_id)
.eq("status", "active")
.limit(1)
.execute()
)
if not roster_row:
raise HTTPException(status_code=404, detail="Student is not active in this class")
if body.mark is not None and body.mark > float(assessment.get("max_marks") or 0):
raise HTTPException(status_code=422, detail="mark exceeds assessment max_marks")
row = {
"assessment_id": assessment_id,
"student_id": student_id,
"tenant_id": assessment.get("tenant_id") or cls["institute_id"],
"mark": body.mark,
"updated_by": ctx.user_id,
}
upserted = _first(ctx.supabase.table("assessment_marks").upsert(row, on_conflict="assessment_id,student_id").execute())
if not upserted:
raise HTTPException(status_code=500, detail="Failed to upsert mark")
return {"status": "ok", "mark": upserted}
@router.get("/classes/{class_id}/csv")
async def export_csv(class_id: str, ctx: ExamContext = Depends(get_exam_context)) -> Response:
data = _assemble_grid(ctx, class_id)
assessments = data["assessments"]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["row", "student_name", "student_id"] + [a["title"] for a in assessments] + ["total", "percentage"])
for student in data["students"]:
writer.writerow(
[student["row_number"], student.get("student_name") or "", student.get("student_id") or ""]
+ ["" if student["marks"].get(a["id"]) is None else student["marks"].get(a["id"]) for a in assessments]
+ ["" if student["total"] is None else student["total"], "" if student["percentage"] is None else student["percentage"]]
)
filename = f"markbook-{class_id}.csv"
return Response(content=buf.getvalue(), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{filename}"'})

View File

@ -41,6 +41,7 @@ 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
from routers.markbook import router as markbook_router
def register_routes(app: FastAPI):
logger.info("Starting to register routes...")
@ -138,6 +139,9 @@ def register_routes(app: FastAPI):
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
app.include_router(exam_router, prefix="/api/exam", tags=["Exam"])
# Running markbook / gradebook Routes (as-user Supabase, RLS-enforced)
app.include_router(markbook_router, prefix="/api/markbook", tags=["Markbook"])
# 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"])

167
tests/test_markbook.py Normal file
View File

@ -0,0 +1,167 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routers.markbook as markbook_mod
from routers.exam.dependencies import ExamContext
from routers.markbook import router
TEACHER = "00000000-0000-0000-0000-000000000001"
INST = "10000000-0000-0000-0000-000000000001"
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 upsert(self, payload, **_kwargs):
self._op = "upsert"; self._payload = payload; 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 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 == "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 self.table == "assessment_marks":
existing = next((r for r in backing if r.get("assessment_id") == row.get("assessment_id") and r.get("student_id") == row.get("student_id")), None)
if existing:
existing.update(row); out.append(existing); continue
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)
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):
app = FastAPI()
app.include_router(router, prefix="/api/markbook")
from routers.exam.dependencies import get_exam_context
app.dependency_overrides[get_exam_context] = lambda: ExamContext(TEACHER, "tok", FakeSupabase(store), [INST])
return TestClient(app)
def base_store(**extra):
store = {
"classes": [{"id": CLASS, "name": "10A Maths", "institute_id": INST}],
"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"},
],
}
store.update(extra)
return store
@pytest.fixture(autouse=True)
def names(monkeypatch):
monkeypatch.setattr(markbook_mod, "resolve_student_names", lambda ids: {sid: {"s1": "Alice", "s2": "Bob"}.get(sid, sid) for sid in ids})
def test_create_assessment_sets_class_and_tenant():
store = base_store()
c = make_client(store)
r = c.post(f"/api/markbook/classes/{CLASS}/assessments", json={"title": "Homework 1", "date": "2026-09-10", "max_marks": 20})
assert r.status_code == 200
body = r.json()
assert body["class_id"] == CLASS
assert body["tenant_id"] == INST
assert body["title"] == "Homework 1"
assert body["max_marks"] == 20
def test_grid_reuses_active_roster_and_computes_summaries():
store = base_store(
class_assessments=[
{"id": "a1", "class_id": CLASS, "tenant_id": INST, "title": "HW", "date": "2026-09-01", "max_marks": 10},
{"id": "a2", "class_id": CLASS, "tenant_id": INST, "title": "Quiz", "date": "2026-09-08", "max_marks": 20},
],
assessment_marks=[
{"assessment_id": "a1", "student_id": "s1", "mark": 8},
{"assessment_id": "a2", "student_id": "s1", "mark": 18},
{"assessment_id": "a1", "student_id": "s2", "mark": 6},
],
)
body = make_client(store).get(f"/api/markbook/classes/{CLASS}/grid").json()
assert [s["student_name"] for s in body["students"]] == ["Alice", "Bob"]
alice = body["students"][0]
assert alice["marks"] == {"a1": 8, "a2": 18}
assert alice["total"] == 26
assert alice["percentage"] == 86.7
assert body["assessment_summaries"][0]["average_mark"] == 7.0
assert body["summary"]["entered_mark_count"] == 3
def test_mark_upsert_rejects_over_max_and_inactive_students():
store = base_store(class_assessments=[{"id": "a1", "class_id": CLASS, "tenant_id": INST, "title": "HW", "max_marks": 10}])
c = make_client(store)
assert c.put(f"/api/markbook/classes/{CLASS}/assessments/a1/marks/s1", json={"mark": 11}).status_code == 422
assert c.put(f"/api/markbook/classes/{CLASS}/assessments/a1/marks/s3", json={"mark": 5}).status_code == 404
ok = c.put(f"/api/markbook/classes/{CLASS}/assessments/a1/marks/s1", json={"mark": 9})
assert ok.status_code == 200
assert ok.json()["mark"]["mark"] == 9
def test_csv_export_has_roster_rows_and_assessment_columns():
store = base_store(
class_assessments=[{"id": "a1", "class_id": CLASS, "tenant_id": INST, "title": "HW", "max_marks": 10}],
assessment_marks=[{"assessment_id": "a1", "student_id": "s1", "mark": 8}],
)
text = make_client(store).get(f"/api/markbook/classes/{CLASS}/csv").text
lines = text.strip().splitlines()
assert lines[0] == "row,student_name,student_id,HW,total,percentage"
assert lines[1].startswith("1,Alice,s1,8,8")
assert lines[2].startswith("2,Bob,s2,,,")