Compare commits

..
Author SHA1 Message Date
kcar 42347f73bf Add running class markbook API 2026-07-24 18:18:49 +00:00
8 changed files with 434 additions and 43 deletions
@@ -1,14 +0,0 @@
-- G1: Homework as a first-class lesson/planner field.
-- Apply to DEV Supabase only before promoting app/api changes.
alter table if exists public.taught_lessons
add column if not exists homework text not null default '';
alter table if exists public.planned_lessons
add column if not exists homework text not null default '';
comment on column public.taught_lessons.homework is
'Teacher-authored homework for this taught lesson; surfaced in lesson/timetable views.';
comment on column public.planned_lessons.homework is
'Default homework carried by a lesson plan and copied to a taught lesson when delivered.';
@@ -53,7 +53,6 @@ class PlannedLessonNode(CCBaseNode):
subject: str
teacher_code: str
planning_status: str
homework: Optional[str] = None
topic_code: Optional[str] = None
topic_name: Optional[str] = None
lesson_code: Optional[str] = None
+2 -18
View File
@@ -83,7 +83,6 @@ class CreatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = "draft"
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -100,7 +99,6 @@ class UpdatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -251,7 +249,6 @@ async def create_plan(
"status": body.status or "draft",
"objectives": body.objectives or [],
"activities": body.activities or [],
"homework": body.homework or "",
"tags": body.tags or [],
}
for field in (
@@ -365,7 +362,7 @@ async def update_plan(
updates: Dict[str, Any] = {}
for field in (
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
"objectives", "activities", "homework", "status", "tags", "topic_code",
"objectives", "activities", "status", "tags", "topic_code",
"whiteboard_room_id", "course_id", "sequence_number",
):
val = getattr(body, field)
@@ -428,7 +425,7 @@ async def deliver_plan(
plan_res = (
sb.supabase.table("planned_lessons")
.select("id, whiteboard_room_id, homework")
.select("id, whiteboard_room_id")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
@@ -459,19 +456,6 @@ async def deliver_plan(
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
delivery = (res.data or [{}])[0]
# If the plan has default homework and was delivered into a taught lesson,
# copy it onto the lesson so homework appears in daily/timetable views.
plan_homework = plan_res.data.get("homework")
if body.taught_lesson_id and plan_homework is not None:
try:
sb.supabase.table("taught_lessons").update({
"homework": plan_homework,
"updated_at": datetime.utcnow().isoformat(),
}).eq("id", body.taught_lesson_id).eq("teacher_id", user_id).execute()
except Exception as e:
logger.warning(f"Could not copy homework to taught_lesson {body.taught_lesson_id}: {e}")
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
return delivery
@@ -4,7 +4,7 @@ Taught Lessons Router — materialization and lesson CRUD.
POST /materialize — slot template × academic_periods → taught_lessons rows
GET /lessons — teacher's lessons for a date range
GET /lessons/{id} — single lesson detail
PATCH /lessons/{id} — update lesson_plan, homework, notes, status (teacher-owned)
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
"""
import os
from collections import defaultdict
@@ -44,7 +44,6 @@ def _require_institute(user_id: str) -> str:
class UpdateLessonRequest(BaseModel):
lesson_plan: Optional[Dict[str, Any]] = None
homework: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
@@ -311,7 +310,7 @@ async def get_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id"
)
.eq("teacher_id", user_id)
@@ -419,15 +418,13 @@ async def update_lesson(
body: UpdateLessonRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Teacher updates their own lesson content: plan, homework, notes, status."""
"""Teacher updates their own lesson content: plan, notes, status."""
user_id = credentials.get("sub", "")
sb = _sb()
updates: Dict[str, Any] = {}
if body.lesson_plan is not None:
updates["lesson_plan"] = body.lesson_plan
if body.homework is not None:
updates["homework"] = body.homework
if body.notes is not None:
updates["notes"] = body.notes
if body.status is not None:
@@ -511,7 +508,7 @@ async def get_student_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id,teacher_id"
)
.in_("class_id", class_ids)
@@ -675,7 +675,7 @@ def _sync_taught_lessons_to_neo4j(
"""
lessons = (
sb.supabase.table("taught_lessons")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status,homework")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status")
.eq("institute_id", institute_id)
.execute()
.data or []
@@ -728,7 +728,6 @@ def _sync_taught_lessons_to_neo4j(
SET tl.date = date($date), tl.period_code = $pcode,
tl.week_cycle = $wc, tl.day_of_week = $dow,
tl.status = $status,
tl.homework = $homework,
tl.node_storage_path = $path
""",
id=tl_id,
@@ -737,7 +736,6 @@ def _sync_taught_lessons_to_neo4j(
wc=lesson.get("week_cycle", ""),
dow=lesson.get("day_of_week", ""),
status=lesson.get("status", "planned"),
homework=lesson.get("homework") or "",
path=f"taught_lessons/{tl_id}",
)
count += 1
+256
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}"'})
+4
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
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,,,")