Download tests/test_worksheets.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 38.1 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_worksheets.py
- Command line
-
hf download hf://spaces/wzender/mathly/tests/test_worksheets.py
-
curl -L -o test_worksheets.py https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_worksheets.py
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 | |