diff --git a/routers/markbook.py b/routers/markbook.py new file mode 100644 index 0000000..7724412 --- /dev/null +++ b/routers/markbook.py @@ -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}"'}) diff --git a/run/routers.py b/run/routers.py index 651dc33..834ac0e 100644 --- a/run/routers.py +++ b/run/routers.py @@ -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"]) diff --git a/tests/test_markbook.py b/tests/test_markbook.py new file mode 100644 index 0000000..2baa1db --- /dev/null +++ b/tests/test_markbook.py @@ -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,,,")