mathly / tests /test_worksheets.py
3v324v23's picture
Add tests for student task list and quiz modes functionality
0ba5932
Raw History Blame Contribute Delete
38.1 kB
"""Worksheets on assignments: the teacher uploads, the class reads, students hand in, the teacher checks.
Storage is in memory; the migration runs on real PostgreSQL."""
import asyncio
import io
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
import pytest
from PIL import Image
from app.worksheets import Worksheets, WorksheetsUnavailable, cleanup_unused, render_pdf
from scan_support import BOOTSTRAP, postgres_server # noqa: F401 (a fixture)
from app.answer_scans import ScanError
from app.main import create_app
from test_assignments import (EMAILS, GOOD, MANAGER, OTHER_TEACHER, OUTSIDER, PUPIL4, STUDENT, TEACHER, ClassAuth,
MemoryAssignments, client)
MIGRATION = Path("supabase/migrations/20261105090000_math_worksheets.sql")
class ReportAuth(ClassAuth):
"""Enough of Supabase for the class report: the accounts of the class, and no saved progress."""
def user_accounts(self, emails):
return {email: {"id": uid, "name": ""} for uid, email in EMAILS.items() if email in emails}
def _admin(self, path, method="GET", body=None, prefer=""):
return []
def pdf(pages=2, extra=b""):
import pypdfium2 as pdfium
doc = pdfium.PdfDocument.new()
for _ in range(pages):
doc.new_page(595, 842)
out = io.BytesIO()
doc.save(out)
return out.getvalue() + extra
def photo(color="white", size=(300, 400), fmt="PNG"):
out = io.BytesIO()
Image.new("RGB", size, color).save(out, fmt)
return out.getvalue()
class MemoryWorksheets(Worksheets):
configured = True
def __init__(self):
self.rows, self.subs, self.files, self.fail = {}, {}, {}, False
def _check(self):
if self.fail:
raise WorksheetsUnavailable("worksheets_unavailable")
def add(self, row):
self._check()
row = {"created_at": datetime.now(timezone.utc).isoformat(), **row}
self.rows[row["id"]] = row
return row
def get(self, wid):
self._check()
return self.rows.get(wid)
def of_class(self, class_id):
self._check()
return [r for r in self.rows.values() if r["class_id"] == class_id]
def by_ids(self, ids):
self._check()
return [self.rows[i] for i in sorted(set(ids)) if i in self.rows]
def older_than(self, cutoff):
self._check()
return [r for r in self.rows.values() if datetime.fromisoformat(r["created_at"]) < cutoff]
def remove(self, wid):
self._check()
self.rows.pop(wid, None)
self.subs = {k: s for k, s in self.subs.items() if s["worksheet_id"] != wid} # cascade
def submissions(self, wids, student_id=None):
self._check()
return [s for s in self.subs.values() if s["worksheet_id"] in wids and (student_id is None or s["student_id"] == student_id)]
def submission(self, sid):
self._check()
return self.subs.get(sid)
def add_submission(self, row):
self._check()
if any(s["worksheet_id"] == row["worksheet_id"] and s["student_id"] == row["student_id"]
and (s.get("version") or 1) == (row.get("version") or 1) for s in self.subs.values()):
raise WorksheetsUnavailable("worksheets_unavailable") # the unique key
saved = {"checked_at": None, "score": None, "comment": "", "version": 1, "state": "submitted", "note": "",
"returned_at": None, "return_comment": "", "return_due_on": None, "id": str(uuid.uuid4()), **row}
self.subs[saved["id"]] = saved
return saved
def check(self, sid, fields):
self._check()
if sid not in self.subs:
return None
self.subs[sid] = {**self.subs[sid], **fields}
return self.subs[sid]
def put(self, key, data, content_type="image/jpeg"):
self._check()
self.files[key] = (data, content_type)
def read(self, key):
self._check()
return self.files[key][0]
def delete(self, keys):
self._check()
for k in keys:
self.files.pop(k, None)
class NoAnswers:
"""No quiz sittings or review requests here: the report shows them as not read."""
configured = True
def quiz(self, *args, **kwargs):
raise ScanError(503, "not_here")
review = quiz
def setup(store=None):
app = create_app(auth=ReportAuth())
app.state.assignments, app.state.answers = MemoryAssignments(), NoAnswers()
app.state.worksheets = store or MemoryWorksheets()
return app
async def upload(c, class_id=3, title="דף עבודה – פיתגורס", files=None):
files = files if files is not None else [("files", ("sheet.pdf", pdf(3), "application/pdf"))]
return await c.post(f"/api/classes/{class_id}/worksheets", data={"title": title}, files=files)
async def assign(c, wid, class_id=3, **extra):
body = {**GOOD, "items": [{"kind": "worksheet", "id": wid}], **extra}
return await c.post(f"/api/classes/{class_id}/assignments", json=body)
def photos(n, name="p"):
return [("files", (f"{name}{i}.jpg", photo(fmt="JPEG"), "image/jpeg")) for i in range(n)]
# ---- files in
def test_a_pdf_is_rendered_to_pages_and_bad_files_are_refused():
pages = render_pdf(pdf(2))
assert len(pages) == 2 and all(p.startswith(b"\xff\xd8") for p in pages)
with Image.open(io.BytesIO(pages[0])) as first:
assert first.width <= 1600 and first.height <= 2400 and first.format == "JPEG"
for data, code in ((pdf(11), "too_many_pages"), (b"%PDF-1.7 not really", "unreadable_pdf"),
(photo(), "not_a_pdf"), (b"%PDF-" + b"0" * 10_000_001, "pdf_too_large")):
with pytest.raises(Exception) as info:
render_pdf(data)
assert info.value.detail == code, code
def test_the_class_teacher_uploads_a_pdf_or_photos():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher:
r = await upload(teacher)
assert r.status_code == 201, r.text
got = r.json()
assert (got["title"], got["pages"], got["source"], got["class_id"]) == ("דף עבודה – פיתגורס", 3, "pdf", 3)
assert "key" not in r.text and "w/" not in r.text # no storage paths
row = store.rows[got["id"]]
assert row["original_key"].endswith("original.pdf") and store.files[row["original_key"]][1] == "application/pdf"
assert len(row["page_keys"]) == 3 and all(k in store.files for k in row["page_keys"])
# photos, in the order chosen
colors = [("a.png", photo("red"), "image/png"), ("b.webp", photo("blue", fmt="WEBP"), "image/webp")]
r = await upload(teacher, title="שני", files=[("files", f) for f in colors])
assert r.status_code == 201 and (r.json()["pages"], r.json()["source"]) == (2, "images")
first = store.files[store.rows[r.json()["id"]]["page_keys"][0]][0]
with Image.open(io.BytesIO(first)) as im:
assert im.getpixel((5, 5))[0] > 200 and im.getpixel((5, 5))[2] < 60 # red first
count = len(store.rows)
for files, title, status in (
([("files", ("x.pdf", pdf(11), "application/pdf"))], "ok", 400),
([("files", ("x.pdf", b"hello", "application/pdf"))], "ok", 415), # not a PDF, PDF name
([("files", ("x.txt", b"hello", "text/plain"))], "ok", 415),
(photos(11), "ok", 400),
([("files", ("x.pdf", pdf(1), "application/pdf"))] + photos(1), "ok", 400), # a PDF and photos
(photos(1), "", 400), (photos(1), "א" * 81, 400)):
r = await upload(teacher, title=title, files=files)
assert r.status_code == status, (title, r.text)
assert len(store.rows) == count and len(store.files) == 3 + 1 + 2 # nothing more was stored
asyncio.run(check())
assert [a["kind"] for a in app.state.actions.recent()].count("worksheet_added") == 2
def test_only_the_class_teacher_or_a_manager_uploads():
app = setup()
async def check():
for who in (STUDENT, OTHER_TEACHER, OUTSIDER):
async with client(app, who) as c:
assert (await upload(c)).status_code == 403, who
async with client(app, MANAGER) as manager:
assert (await upload(manager)).status_code == 201
assert (await upload(manager, class_id=99)).status_code == 404
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as guest:
assert (await upload(guest)).status_code == 401
asyncio.run(check())
# ---- worksheets on assignments
def test_assignments_take_their_classs_worksheets_and_drop_the_ones_they_lose():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher, client(app, OTHER_TEACHER) as other:
mine = [(await upload(teacher, title=f"דף {i}")).json()["id"] for i in range(6)]
theirs = (await upload(other, class_id=4)).json()["id"]
assert (await assign(teacher, theirs)).status_code == 400 # another class's worksheet
assert (await assign(teacher, str(uuid.uuid4()))).status_code == 400
many = [(await upload(teacher, title=f"עוד {i}")).json()["id"] for i in range(6)]
six = {**GOOD, "items": [{"kind": "worksheet", "id": w} for w in many]}
assert (await teacher.post("/api/classes/3/assignments", json=six)).status_code == 201 # no cap of 5 any more
made = await assign(teacher, mine[0]) # a worksheet alone is fine
assert made.status_code == 201 and made.json()["items"] == [{"kind": "worksheet", "id": mine[0]}]
aid = made.json()["id"]
both = {**GOOD, "items": [{"kind": "worksheet", "id": mine[0]}, {"kind": "worksheet", "id": mine[1]}]}
assert (await teacher.put(f"/api/classes/3/assignments/{aid}", json=both)).status_code == 200
# a student hands in the first; then the teacher removes it from the assignment
async with client(app, STUDENT) as student:
assert (await student.put(f"/api/math/worksheets/{mine[0]}/submission", files=photos(2))).status_code == 200
keys = list(store.rows[mine[0]]["page_keys"]) + [store.rows[mine[0]]["original_key"]]
sub_keys = [k for s in store.subs.values() for k in s["page_keys"]]
only_second = {**GOOD, "items": [{"kind": "worksheet", "id": mine[1]}]}
assert (await teacher.put(f"/api/classes/3/assignments/{aid}", json=only_second)).status_code == 200
assert mine[0] not in store.rows and not store.subs
assert not any(k in store.files for k in keys + sub_keys)
# deleting the assignment takes its worksheets too
assert (await teacher.delete(f"/api/classes/3/assignments/{aid}")).status_code == 200
assert mine[1] not in store.rows
# an upload not saved into an assignment can be taken back; one in use cannot
assert (await teacher.delete(f"/api/classes/3/worksheets/{mine[2]}")).status_code == 200
assert mine[2] not in store.rows
await assign(teacher, mine[3])
assert (await teacher.delete(f"/api/classes/3/worksheets/{mine[3]}")).status_code == 409
assert (await other.delete(f"/api/classes/4/worksheets/{mine[4]}")).status_code == 404
asyncio.run(check())
def test_without_the_worksheet_tables_assignments_still_work():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
await assign(teacher, wid)
store.fail = True
assert (await upload(teacher)).status_code == 503
assert (await assign(teacher, wid)).status_code == 503
async with client(app, MANAGER) as manager: # skills and quizzes: a manager's assignment
assert (await manager.post("/api/classes/3/assignments", json=GOOD)).status_code == 201
async with client(app, TEACHER) as teacher:
report = (await teacher.get("/api/classes/3/report")).json()
assert report["worksheets"] is None and report["students"][0]["worksheet_submissions"] is None
async with client(app, STUDENT) as student:
data = (await student.get("/api/math/assignments")).json()
assert len(data["assignments"]) == 2 and data["worksheets"] is None and data["submissions"] is None
assert (await student.get(f"/api/math/worksheets/{wid}/pages/1")).status_code == 503
asyncio.run(check())
# ---- the class reads it
def test_the_class_reads_the_worksheet_and_nobody_else_does():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
pics = (await upload(teacher, title="תמונות", files=photos(1))).json()["id"]
for who in (STUDENT, TEACHER, MANAGER):
async with client(app, who) as c:
page = await c.get(f"/api/math/worksheets/{wid}/pages/3")
assert page.status_code == 200 and page.headers["content-type"] == "image/jpeg", who
assert page.headers["cache-control"].startswith("private")
original = await c.get(f"/api/math/worksheets/{wid}/original")
assert original.status_code == 200 and original.content.startswith(b"%PDF-")
assert original.headers["content-type"] == "application/pdf"
assert "attachment" in original.headers["content-disposition"]
async with client(app, STUDENT) as student:
assert (await student.get(f"/api/math/worksheets/{wid}/pages/4")).status_code == 404
assert (await student.get(f"/api/math/worksheets/{wid}/pages/0")).status_code == 404
assert (await student.get(f"/api/math/worksheets/{pics}/original")).status_code == 404 # photos: no PDF
assert (await student.get("/api/math/worksheets/not-a-uuid/pages/1")).status_code == 404
for who in (PUPIL4, OTHER_TEACHER, OUTSIDER):
async with client(app, who) as c:
assert (await c.get(f"/api/math/worksheets/{wid}/pages/1")).status_code in (403, 404), who
assert (await c.get(f"/api/math/worksheets/{wid}/original")).status_code in (403, 404), who
asyncio.run(check())
# ---- students hand in, the teacher checks
def test_a_student_hands_in_replaces_and_is_checked():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
loose = (await upload(teacher, title="לא במשימה")).json()["id"]
await assign(teacher, wid)
async with client(app, STUDENT) as student:
first = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(2))
assert first.status_code == 200, first.text
got = first.json()
assert (got["pages"], got["checked_at"], got["score"]) == (2, None, None) and "page_keys" not in got
old_keys = list(store.subs[got["id"]]["page_keys"])
again = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(3, "q"))
assert again.status_code == 200 and again.json()["id"] == got["id"] and again.json()["pages"] == 3
assert again.json()["submitted_at"] >= got["submitted_at"]
assert len(store.subs) == 1 and not any(k in store.files for k in old_keys) # only the new set is kept
assert (await student.get(f"/api/math/worksheets/{wid}/submission/pages/3")).status_code == 200
assert (await student.get(f"/api/math/worksheets/{wid}/submission/pages/4")).status_code == 404
for files in ([], photos(11)):
assert (await student.put(f"/api/math/worksheets/{wid}/submission", files=files or {"x": "y"})).status_code in (400, 422)
assert (await student.put(f"/api/math/worksheets/{loose}/submission", files=photos(1))).status_code == 404
mine = (await student.get("/api/math/assignments")).json()
assert [w["id"] for w in mine["worksheets"]] == [wid] and mine["submissions"][0]["pages"] == 3
assert "key" not in str(mine) and "teacher@x.test" not in str(mine)
sid = got["id"]
async with client(app, PUPIL4) as other, client(app, OUTSIDER) as outsider:
assert (await other.put(f"/api/math/worksheets/{wid}/submission", files=photos(1))).status_code == 404
assert (await other.get(f"/api/math/worksheets/{wid}/submission/pages/1")).status_code == 404
assert (await outsider.put(f"/api/math/worksheets/{wid}/submission", files=photos(1))).status_code in (403, 404)
async with client(app, TEACHER) as teacher:
report = (await teacher.get("/api/classes/3/report")).json()
assert [w["id"] for w in report["worksheets"]] == [wid, loose]
(subs,) = [s["worksheet_submissions"] for s in report["students"] if s["email"] == EMAILS[STUDENT]]
assert [(s["id"], s["pages"], s["checked_at"]) for s in subs] == [(sid, 3, None)]
page = await teacher.get(f"/api/classes/3/worksheets/{wid}/submissions/{sid}/pages/1")
assert page.status_code == 200 and page.headers["content-type"] == "image/jpeg"
url = f"/api/classes/3/worksheets/{wid}/submissions/{sid}"
for bad in ({"score": 120}, {"score": -1}, {"score": 8.5}, {"score": "85"}, {"score": True}, {"comment": "א" * 1001}):
assert (await teacher.put(url, json=bad)).status_code == 400, bad
checked = await teacher.put(url, json={"score": 85, "comment": " יפה, שימי לב ליחידות "})
assert checked.status_code == 200
first_check = checked.json()["checked_at"]
assert (checked.json()["score"], checked.json()["comment"]) == (85, "יפה, שימי לב ליחידות") and first_check
later = await teacher.put(url, json={"score": 90, "comment": ""})
assert (later.json()["score"], later.json()["checked_at"]) == (90, first_check) # first check time kept
assert (await teacher.put(url, json={"score": None, "comment": ""})).json()["score"] is None # no score
await teacher.put(url, json={"score": 90, "comment": "יפה"})
for who in (OTHER_TEACHER, STUDENT):
async with client(app, who) as c:
assert (await c.put(f"/api/classes/3/worksheets/{wid}/submissions/{sid}", json={"score": 1})).status_code == 403
assert (await c.get(f"/api/classes/3/worksheets/{wid}/submissions/{sid}/pages/1")).status_code == 403
async with client(app, STUDENT) as student:
refused = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(1))
assert (refused.status_code, refused.json()["detail"]) == (409, "already_checked")
assert store.subs[sid]["score"] == 90 and len(store.subs[sid]["page_keys"]) == 3 # as it was
seen = (await student.get("/api/math/assignments")).json()["submissions"][0]
assert (seen["score"], seen["comment"]) == (90, "יפה") and seen["checked_at"]
asyncio.run(check())
kinds = [a["kind"] for a in app.state.actions.recent(50)]
assert kinds.count("worksheet_submitted") == 2 and kinds.count("worksheet_checked") == 4
def test_unused_worksheets_are_deleted_after_a_day():
store, assignments = MemoryWorksheets(), MemoryAssignments()
now = datetime.now(timezone.utc)
old, fresh, used = (str(uuid.uuid4()) for _ in range(3))
for wid, age in ((old, 25), (fresh, 1), (used, 25)):
store.add({"id": wid, "class_id": 3, "title": "x", "source": "images", "pages": 1, "original_key": None,
"page_keys": [f"w/{wid}/1.jpg"], "byte_size": 1, "created_at": (now - timedelta(hours=age)).isoformat()})
store.put(f"w/{wid}/1.jpg", b"x")
assignments.add({**GOOD, "class_id": 3, "items": [{"kind": "worksheet", "id": used}]})
assert cleanup_unused(store, assignments, now) == 1
assert set(store.rows) == {fresh, used} and f"w/{old}/1.jpg" not in store.files
def test_the_migration_is_additive_service_only_and_follows_its_class(postgres_server): # noqa: F811
psycopg = pytest.importorskip("psycopg")
database = "ws_" + uuid.uuid4().hex
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("create database " + database)
try:
with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
c.execute(BOOTSTRAP)
c.execute("insert into classes values (2, 'other class')")
c.execute(MIGRATION.read_text())
assert c.execute("select allowed_mime_types, public from storage.buckets where id='mathly-worksheets'").fetchone() == (
["image/jpeg", "application/pdf"], False)
c.execute("set role service_role")
sheet = lambda cls, source="images", pages=1, keys=None, original=None, title="דף": c.execute(
"insert into math_worksheets(class_id,title,source,pages,original_key,page_keys,byte_size) "
"values(%s,%s,%s,%s,%s,%s,1) returning id", (cls, title, source, pages, original, keys or ["a"])).fetchone()[0]
w1 = sheet(1, "pdf", 2, ["a", "b"], "o.pdf")
w2 = sheet(2)
for bad in (dict(cls=99), dict(cls=1, title=""), dict(cls=1, pages=2), dict(cls=1, source="pdf"),
dict(cls=1, original="o.pdf"), dict(cls=1, pages=11, keys=["k"] * 11), dict(cls=1, source="doc")):
with pytest.raises(psycopg.errors.Error):
sheet(**bad)
student = str(uuid.uuid4())
hand = lambda w, keys=("p",), score=None, comment="": c.execute(
"insert into math_worksheet_submissions(worksheet_id,student_id,page_keys,score,comment) values(%s,%s,%s,%s,%s)",
(w, student, list(keys), score, comment))
hand(w1)
hand(w2, score=100)
# a second one of the same student, an unknown worksheet, no pages, a score over 100
for bad in (dict(w=w1), dict(w=str(uuid.uuid4())), dict(w=w1, keys=()), dict(w=w1, score=101)):
with pytest.raises(psycopg.errors.Error):
hand(**bad)
with pytest.raises(psycopg.errors.Error):
c.execute("update math_worksheet_submissions set comment=%s", ("א" * 1001,))
c.execute("reset role")
c.execute("delete from classes where id = 1") # the class takes its worksheets and their submissions with it
assert c.execute("select count(*) from math_worksheets").fetchone()[0] == 1
assert c.execute("select count(*) from math_worksheet_submissions").fetchone()[0] == 1
c.execute("set role authenticated")
for table in ("math_worksheets", "math_worksheet_submissions"):
with pytest.raises(psycopg.errors.InsufficientPrivilege):
c.execute("select * from " + table)
finally:
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("drop database " + database + " with (force)")
# ---- the AI's check of a submission: a suggestion for the teacher, one of their 3 requests a day
class Grader:
def __init__(self, result=None, error=None):
self.calls, self.result, self.error = [], result, error
def grade(self, title, sheet_pages, solution_pages):
self.calls.append((title, len(sheet_pages), len(solution_pages)))
if self.error:
raise self.error
return self.result or {"readable": True, "score": 80, "comment": "יפה מאוד. תרגיל 3 דורש תיקון.", "usage": None}
def handed_in(app):
"""A worksheet in an assignment of class 3 with a 2-page submission by STUDENT; (worksheet id, submission id)."""
async def go():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
await assign(teacher, wid)
async with client(app, STUDENT) as student:
return wid, (await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(2))).json()["id"]
return asyncio.run(go())
def test_the_ai_check_suggests_a_score_and_comment_and_counts_toward_the_three(monkeypatch):
monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
app = setup()
grader = app.state.homework_grader = Grader()
wid, sid = handed_in(app)
url = f"/api/classes/3/worksheets/{wid}/submissions/{sid}/ai-check"
async def check():
async with client(app, TEACHER) as teacher:
r = await teacher.post(url)
assert r.status_code == 200, r.text
assert r.json() == {"score": 80, "comment": "יפה מאוד. תרגיל 3 דורש תיקון.", "readable": True, "left": 2,
"class_budget": False} # no class budget here: the teacher's own allowance
assert grader.calls == [("דף עבודה – פיתגורס", 3, 2)] # 3 sheet pages, 2 solution pages
assert app.state.worksheets.subs[sid]["checked_at"] is None # a suggestion: nothing is saved
for _ in range(2):
assert (await teacher.post(url)).status_code == 200
refused = await teacher.post(url)
assert (refused.status_code, refused.json()["detail"]) == (429, "teacher_ai_limit")
assert len(grader.calls) == 3
for who in (STUDENT, OTHER_TEACHER):
async with client(app, who) as c:
assert (await c.post(url)).status_code == 403
async with client(app, OTHER_TEACHER) as other:
assert (await other.post(f"/api/classes/4/worksheets/{wid}/submissions/{sid}/ai-check")).status_code == 404
asyncio.run(check())
assert [a["kind"] for a in app.state.actions.recent(50)].count("worksheet_ai_checked") == 3
def test_a_failed_or_unreadable_ai_check_is_given_back(monkeypatch):
from app.math_photo import PhotoUnavailable
monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
app = setup()
wid, sid = handed_in(app)
url = f"/api/classes/3/worksheets/{wid}/submissions/{sid}/ai-check"
async def check():
async with client(app, TEACHER) as teacher:
app.state.homework_grader = Grader(error=PhotoUnavailable("down", "provider_error", ""))
r = await teacher.post(url)
assert (r.status_code, r.json()["detail"]) == (503, "ai_check_failed|provider_error")
app.state.homework_grader = Grader({"readable": False, "score": None, "comment": "צלמו שוב", "usage": None})
r = await teacher.post(url)
assert r.json() == {"score": None, "comment": "צלמו שוב", "readable": False, "left": 3, "class_budget": False}
assert (await teacher.get("/api/me")).json()["ai_left"] == 3 # none of the two counted
asyncio.run(check())
def test_the_ai_check_stays_locked_with_open_ended_questions_and_needs_a_key(monkeypatch):
monkeypatch.delenv("MATHLY_OPEN_ENDED_LOCKED", raising=False)
app = setup()
wid, sid = handed_in(app)
url = f"/api/classes/3/worksheets/{wid}/submissions/{sid}/ai-check"
async def check():
async with client(app, TEACHER) as teacher:
r = await teacher.post(url)
assert (r.status_code, r.json()["detail"]) == (403, "open_ended_locked")
monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
r = await teacher.post(url)
assert (r.status_code, r.json()["detail"]) == (503, "grading_unavailable")
assert (await teacher.get("/api/me")).json()["ai_left"] == 3
asyncio.run(check())
def test_the_homework_check_validates_what_the_ai_returns():
from app.homework_grading import validate_check
ok = validate_check({"readable": True, "score": 90, "comment": " יפה "})
assert ok == {"readable": True, "score": 90, "comment": "יפה"}
assert validate_check({"readable": False, "score": 0, "comment": "צלמו שוב"})["score"] is None # unreadable: no score
assert len(validate_check({"readable": True, "score": 1, "comment": "א" * 3000})["comment"]) == 1000
for bad in ({"readable": True, "score": 101, "comment": "x"}, {"readable": True, "score": -1, "comment": "x"},
{"readable": True, "score": 5.5, "comment": "x"}, {"readable": True, "score": 5, "comment": " "},
{"readable": "yes", "score": 5, "comment": "x"}, {"readable": True, "score": 5}, "text", None):
with pytest.raises(Exception):
validate_check(bad)
# ---- a page photographed by the phone (or this device) arrives like an exercise's answer
class AttemptAnswers(NoAnswers):
def __init__(self):
self.seen = []
def action(self, user, action, **data):
self.seen.append((user, action, data))
return {"attempt": {"id": str(uuid.uuid4()), "question_id": data["question_id"], "snapshot": data["snapshot"]}}
def test_a_worksheet_page_can_be_photographed_like_an_answer():
store, answers = MemoryWorksheets(), AttemptAnswers()
app = setup(store)
app.state.answers = answers
async def check():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
loose = (await upload(teacher, title="לא במשימה")).json()["id"]
await assign(teacher, wid)
async with client(app, STUDENT) as student:
got = await student.post("/api/math/worksheet-attempts", json={"worksheet_id": wid, "new": True})
assert got.status_code == 200, got.text
assert got.json()["attempt"]["question_id"] == f"worksheet:{wid}"
assert "דף עבודה – פיתגורס" in got.json()["attempt"]["snapshot"]["question"]
assert answers.seen[-1][2]["new"] is True
# a worksheet no assignment of the student's class uses is not theirs to answer
assert (await student.post("/api/math/worksheet-attempts", json={"worksheet_id": loose})).status_code == 404
assert (await student.post("/api/math/worksheet-attempts", json={"worksheet_id": "nope"})).status_code == 404
async with client(app, OUTSIDER) as outsider:
assert (await outsider.post("/api/math/worksheet-attempts", json={"worksheet_id": wid})).status_code in (403, 404)
asyncio.run(check())
def test_a_note_comes_with_the_hand_in_and_a_returned_submission_is_handed_in_again_as_a_new_version():
store = MemoryWorksheets()
app = setup(store)
async def check():
async with client(app, TEACHER) as teacher:
wid = (await upload(teacher)).json()["id"]
await assign(teacher, wid)
async with client(app, STUDENT) as student:
long = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(1), data={"note": "א" * 301})
assert (long.status_code, long.json()["detail"]) == (400, "note_too_long") and not store.subs
first = (await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(2),
data={"note": "לא הבנתי את שאלה 3"})).json()
assert (first["note"], first["version"], first["state"]) == ("לא הבנתי את שאלה 3", 1, "submitted")
sid = first["id"]
url = f"/api/classes/3/worksheets/{wid}/submissions/{sid}"
async with client(app, TEACHER) as teacher, client(app, STUDENT) as student:
subs = (await teacher.get("/api/classes/3/report")).json()["students"]
(mine,) = [s["worksheet_submissions"] for s in subs if s["email"] == EMAILS[STUDENT]]
assert mine[0]["note"] == "לא הבנתי את שאלה 3" # the teacher sees the note
assert (await teacher.post(url + "/return", json={"comment": " "})).status_code == 400
assert (await student.post(url + "/return", json={"comment": "x"})).status_code == 403
back = await teacher.post(url + "/return", json={"comment": "חסרה דרך פתרון בשאלה 2", "due_on": "2026-11-16"})
assert back.status_code == 200
assert (back.json()["state"], back.json()["return_comment"], back.json()["return_due_on"]) == (
"returned", "חסרה דרך פתרון בשאלה 2", "2026-11-16")
seen = (await student.get("/api/math/assignments")).json()["submissions"][0]
assert seen["state"] == "returned" and seen["return_comment"] == "חסרה דרך פתרון בשאלה 2"
fixed = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(3), data={"note": "תיקנתי"})
assert fixed.status_code == 200
got = fixed.json()
assert (got["version"], got["state"], got["pages"], got["note"]) == (2, "submitted", 3, "תיקנתי")
assert [(e["version"], e["state"], e["return_comment"]) for e in got["earlier"]] == [(1, "returned", "חסרה דרך פתרון בשאלה 2")]
assert len(store.subs) == 2
# the earlier version's pages stay to look back at
assert (await student.get(f"/api/math/worksheets/{wid}/submission/pages/2?version=1")).status_code == 200
assert (await student.get(f"/api/math/worksheets/{wid}/submission/pages/3?version=1")).status_code == 404
# only the current version can be returned
stale = await teacher.post(url + "/return", json={"comment": "שוב"})
assert (stale.status_code, stale.json()["detail"]) == (409, "not_the_current_version")
report = (await teacher.get("/api/classes/3/report")).json()["students"]
(mine,) = [s["worksheet_submissions"] for s in report if s["email"] == EMAILS[STUDENT]]
assert [(x["version"], x["state"]) for x in mine] == [(2, "submitted")] and mine[0]["earlier"][0]["version"] == 1
# checked: no more hand-ins
await teacher.put(f"/api/classes/3/worksheets/{wid}/submissions/{got['id']}", json={"score": 90})
refused = await student.put(f"/api/math/worksheets/{wid}/submission", files=photos(1))
assert (refused.status_code, refused.json()["detail"]) == (409, "already_checked")
asyncio.run(check())
kinds = [a["kind"] for a in app.state.actions.recent(50)]
assert kinds.count("worksheet_returned") == 1
def test_the_versions_migration_keeps_old_submissions_as_version_one(postgres_server): # noqa: F811
psycopg = pytest.importorskip("psycopg")
database = "wsv_" + uuid.uuid4().hex
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("create database " + database)
try:
with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
c.execute(BOOTSTRAP)
c.execute(MIGRATION.read_text())
wid = c.execute("insert into math_worksheets(class_id,title,source,pages,page_keys,byte_size) "
"values(1,'דף','images',1,array['a'],1) returning id").fetchone()[0]
sid = str(uuid.uuid4())
c.execute("insert into math_worksheet_submissions(worksheet_id,student_id,page_keys,checked_at) values(%s,%s,array['p'],now())", (wid, sid))
c.execute(Path("supabase/migrations/20261106110000_math_worksheet_versions.sql").read_text())
assert c.execute("select version, state from math_worksheet_submissions").fetchone() == (1, "checked")
c.execute("insert into math_worksheet_submissions(worksheet_id,student_id,page_keys,version) values(%s,%s,array['q'],2)", (wid, sid))
with pytest.raises(psycopg.errors.UniqueViolation):
c.execute("insert into math_worksheet_submissions(worksheet_id,student_id,page_keys,version) values(%s,%s,array['r'],2)", (wid, sid))
with pytest.raises(psycopg.errors.CheckViolation):
c.execute("insert into math_worksheet_submissions(worksheet_id,student_id,page_keys,version,note) values(%s,%s,array['r'],3,%s)", (wid, sid, "א" * 301))
finally:
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("drop database " + database + " with (force)")
def test_before_the_versions_migration_submissions_are_read_and_written_as_before():
from app.auth import AuthUnavailable
asked = []
class OldTable:
secret, url = "server-test", "https://test.invalid"
def _admin(self, path, method="GET", body=None, prefer=""):
asked.append((path, body))
if "version" in path or (isinstance(body, dict) and "note" in body):
raise AuthUnavailable("supabase answered 400: column math_worksheet_submissions.version does not exist")
return [{"id": "s1"}]
store = Worksheets(OldTable())
assert store.submissions(["11111111-1111-4111-8111-111111111111"]) == [{"id": "s1"}]
assert store.legacy and "version" not in asked[-1][0]
store.check("s1", {"page_keys": ["k"], "note": "x", "state": "submitted"})
assert asked[-1][1] == {"page_keys": ["k"]} # the new columns are left out