Compare commits
No commits in common. "agent/g2-markbook-t_7ecba14b" and "master" have entirely different histories.
agent/g2-m
...
master
@ -1,256 +0,0 @@
|
|||||||
"""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}"'})
|
|
||||||
@ -41,7 +41,6 @@ from routers.transcribe.keywords import router as keywords_router
|
|||||||
from routers.me.bootstrap_router import router as me_bootstrap_router
|
from routers.me.bootstrap_router import router as me_bootstrap_router
|
||||||
from routers import tlsync_token as tlsync_token_router
|
from routers import tlsync_token as tlsync_token_router
|
||||||
from routers.exam import router as exam_router
|
from routers.exam import router as exam_router
|
||||||
from routers.markbook import router as markbook_router
|
|
||||||
|
|
||||||
def register_routes(app: FastAPI):
|
def register_routes(app: FastAPI):
|
||||||
logger.info("Starting to register routes...")
|
logger.info("Starting to register routes...")
|
||||||
@ -139,9 +138,6 @@ def register_routes(app: FastAPI):
|
|||||||
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
|
# Exam-marker Routes (as-user Supabase, RLS-enforced; spec §4)
|
||||||
app.include_router(exam_router, prefix="/api/exam", tags=["Exam"])
|
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)
|
# Transcription Routes (CIS Phase 1)
|
||||||
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
|
app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"])
|
||||||
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
|
app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"])
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
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,,,")
|
|
||||||
Loading…
x
Reference in New Issue
Block a user