Download tests/test_assignments.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 29.8 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_assignments.py
- Command line
-
hf download hf://spaces/wzender/mathly/tests/test_assignments.py
-
curl -L -o test_assignments.py https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_assignments.py
29.8 kB
| """Assignments: what a class teacher asks their class to do. Storage is in memory; the migration runs on real PostgreSQL.""" | |
| import asyncio | |
| import uuid | |
| from pathlib import Path | |
| import httpx | |
| import pytest | |
| from app.answer_scans import ScanError | |
| from app.assignments import Assignments, AssignmentsUnavailable | |
| from app.main import create_app | |
| from app.math_skills import algebra9 | |
| from scan_support import BOOTSTRAP, answer_store, postgres_server | |
| TEACHER, STUDENT, OTHER_TEACHER, OUTSIDER, MANAGER, PUPIL4 = "t-id", "s-id", "o-id", "x-id", "m-id", "p-id" | |
| EMAILS = {TEACHER: "teacher@x.test", STUDENT: "student@x.test", OTHER_TEACHER: "other@x.test", | |
| OUTSIDER: "out@x.test", MANAGER: "boss@x.test", PUPIL4: "pupil4@x.test"} | |
| UNITS = algebra9()["units"] | |
| NO_QUIZ = next(u["id"] for u in UNITS if "quiz" not in u) | |
| class ClassAuth: | |
| url, key, secret = "https://test.invalid", "public-test", "server-test" | |
| developers = frozenset({"boss@x.test"}) # a manager (management is for developers) | |
| teachers = frozenset() | |
| def __init__(self): | |
| self.rows = [ | |
| {"id": 3, "name": "ืืณ1", "lessons": ["t", "k", NO_QUIZ], "teachers": ["teacher@x.test"], | |
| "emails": ["student@x.test", "teacher@x.test"], "nicknames": {}, "exams": []}, | |
| {"id": 4, "name": "ืืณ2", "lessons": ["t"], "teachers": ["other@x.test"], | |
| "emails": ["pupil4@x.test", "other@x.test"], "nicknames": {}, "exams": []}] | |
| def user(self, token): | |
| return {"id": token, "email": EMAILS[token]} if token in EMAILS else None | |
| def classes(self, fresh=False): | |
| return self.rows | |
| def access_for(self, _id, **kwargs): | |
| return None | |
| class MemoryAssignments(Assignments): | |
| configured = True | |
| def __init__(self, fail=False): | |
| self.rows, self.fail = {}, fail | |
| def _check(self): | |
| if self.fail: | |
| raise AssignmentsUnavailable("assignments_unavailable") | |
| def for_classes(self, class_ids): | |
| self._check() | |
| return sorted((r for r in self.rows.values() if r["class_id"] in class_ids), key=lambda r: r["due_on"], reverse=True) | |
| def get(self, assignment_id): | |
| self._check() | |
| return self.rows.get(assignment_id) | |
| def add(self, row): | |
| self._check() | |
| row = {**row, "id": str(uuid.uuid4()), "created_at": "2026-11-04T08:00:00+00:00", "updated_at": "2026-11-04T08:00:00+00:00"} | |
| self.rows[row["id"]] = row | |
| return row | |
| def update(self, assignment_id, fields): | |
| self._check() | |
| self.rows[assignment_id] = {**self.rows[assignment_id], **fields} | |
| return self.rows[assignment_id] | |
| def remove(self, assignment_id): | |
| self._check() | |
| self.rows.pop(assignment_id, None) | |
| class Sittings: | |
| """The server's unit-quiz sittings (app/quiz_grades.py): quiz(user, "list", unit_id=...).""" | |
| configured = True | |
| def __init__(self, by_user=None, fail=False): | |
| self.by_user, self.fail = by_user or {}, fail | |
| def quiz(self, user_id, action, **data): | |
| if self.fail: | |
| raise ScanError(503, "quiz_sittings_unavailable") | |
| return [s for s in self.by_user.get(user_id, []) if s["unit_id"] == data["unit_id"]] | |
| def make_app(store=None, sittings=None): | |
| app = create_app(auth=ClassAuth()) | |
| app.state.assignments = store or MemoryAssignments() | |
| app.state.answers = sittings or Sittings() | |
| return app | |
| def client(app, user): | |
| return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test", | |
| headers={"Authorization": f"Bearer {user}"}) | |
| GOOD = {"title": "ืืืจื ืืคื ื ืืืืื", "note": "ืืืฆืืื", "due_on": "2026-11-12", | |
| "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "unit_quiz", "unit": "k"}, | |
| {"kind": "exam", "units": ["k", "t"]}]} # exam units are kept sorted | |
| def test_a_class_teacher_gives_worksheets_only(): | |
| """Skills, unit quizzes and practice exams are for managers (developers); a class teacher's assignment holds worksheets. | |
| Items already in an assignment stay when the teacher edits it.""" | |
| from test_worksheets import MemoryWorksheets | |
| store = MemoryAssignments() | |
| app = make_app(store) | |
| app.state.worksheets = sheets = MemoryWorksheets() | |
| sheets.add({"id": "11111111-1111-4111-8111-111111111111", "class_id": 3, "title": "ืืฃ", "source": "images", "pages": 1, | |
| "original_key": None, "page_keys": ["k"], "byte_size": 1}) | |
| sheet = {"kind": "worksheet", "id": "11111111-1111-4111-8111-111111111111"} | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, MANAGER) as manager: | |
| for item in GOOD["items"]: | |
| r = await teacher.post("/api/classes/3/assignments", json={**GOOD, "items": [item, sheet]}) | |
| assert (r.status_code, r.json()["detail"]) == (400, "teachers_give_worksheets_only"), item | |
| made = await teacher.post("/api/classes/3/assignments", json={**GOOD, "items": [sheet]}) | |
| assert made.status_code == 201 | |
| old = (await manager.post("/api/classes/3/assignments", json=GOOD)).json() # a manager's, with skills | |
| kept = await teacher.put(f"/api/classes/3/assignments/{old['id']}", json={**GOOD, "title": "ืฉืื ื"}) | |
| assert kept.status_code == 200 # the same items: fine | |
| more = {**GOOD, "items": GOOD["items"] + [{"kind": "skill", "id": "t_simplify"}]} | |
| assert (await teacher.put(f"/api/classes/3/assignments/{old['id']}", json=more)).status_code == 400 | |
| asyncio.run(check()) | |
| def test_the_class_teacher_creates_lists_edits_and_deletes_assignments(): | |
| store = MemoryAssignments() | |
| app = make_app(store) | |
| async def check(): | |
| async with client(app, MANAGER) as teacher: | |
| made = await teacher.post("/api/classes/3/assignments", json=GOOD) | |
| assert made.status_code == 201 | |
| row = made.json() | |
| assert (row["title"], row["note"], row["due_on"], row["class_id"]) == ("ืืืจื ืืคื ื ืืืืื", "ืืืฆืืื", "2026-11-12", 3) | |
| assert row["items"] == GOOD["items"] and row["created_by_email"] == "boss@x.test" | |
| assert [r["id"] for r in store.rows.values()] == [row["id"]] and store.rows[row["id"]]["class_id"] == 3 | |
| listed = (await teacher.get("/api/classes/3/assignments")).json()["assignments"] | |
| assert [r["id"] for r in listed] == [row["id"]] | |
| edited = await teacher.put(f"/api/classes/3/assignments/{row['id']}", | |
| json={**GOOD, "title": " ืืืจื ืืืจืืืช ", "due_on": "2026-11-14", | |
| "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_ext"}]}) | |
| assert edited.status_code == 200 | |
| assert (edited.json()["title"], edited.json()["due_on"], edited.json()["items"]) == ( | |
| "ืืืจื ืืืจืืืช", "2026-11-14", [{"kind": "skill", "id": "t_ext"}]) # spacing tidied, a repeated item once | |
| gone = await teacher.delete(f"/api/classes/3/assignments/{row['id']}") | |
| assert gone.json() == {"ok": True} and not store.rows | |
| assert (await teacher.delete(f"/api/classes/3/assignments/{row['id']}")).status_code == 404 | |
| asyncio.run(check()) | |
| assert [a["kind"] for a in app.state.actions.recent()].count("assignment_saved") == 2 | |
| def test_an_assignment_is_checked_against_the_classs_units(): | |
| app = make_app() | |
| skills = algebra9()["skills"] | |
| class_skills = [s["id"] for s in skills if s["unit"] in ("t", "t2", "k", "i", NO_QUIZ)] | |
| with_items = lambda *items: {**GOOD, "items": list(items)} | |
| bad = [ | |
| with_items({"kind": "skill", "id": "no_such_skill"}), | |
| with_items({"kind": "unit_quiz", "unit": NO_QUIZ}), # a unit without a quiz | |
| with_items({"kind": "exam", "units": []}), | |
| with_items({"kind": "exam", "units": ["t", "t"]}), | |
| with_items({"kind": "homework"}), with_items("t_ext"), with_items(), | |
| {**GOOD, "items": [{"kind": "skill", "id": sid} for sid in class_skills[:21]]}, # one too many | |
| {**GOOD, "title": ""}, {**GOOD, "title": " "}, {**GOOD, "title": "ื" * 81}, {**GOOD, "note": "ื" * 501}, | |
| {**GOOD, "due_on": "2026-13-45"}, {**GOOD, "due_on": "12/11/2026"}, {**GOOD, "due_on": "2026-11-12T10:00:00"}, | |
| {**GOOD, "due_on": ""}, {**GOOD, "due_on": 20261112}, {k: v for k, v in GOOD.items() if k != "due_on"}, | |
| {**GOOD, "items": "t_ext"}] | |
| async def check(): | |
| async with client(app, MANAGER) as teacher: | |
| for body in bad: | |
| assert (await teacher.post("/api/classes/3/assignments", json=body)).status_code == 400, body | |
| assert (await teacher.post("/api/classes/3/assignments", json=GOOD)).status_code == 201 | |
| twenty = [{"kind": "skill", "id": sid} for sid in class_skills[:20]] | |
| assert (await teacher.post("/api/classes/3/assignments", json={**GOOD, "items": twenty})).status_code == 201 | |
| assert len(app.state.assignments.rows) == 2 # nothing else was stored | |
| async with client(app, MANAGER) as other: # a manager on class 4 has unit t (and t2, split off from it), not k | |
| ok = {"kind": "skill", "id": "t_ext"} | |
| for foreign in ({"kind": "skill", "id": next(s["id"] for s in skills if s["unit"] == "k")}, | |
| {"kind": "unit_quiz", "unit": "k"}, {"kind": "exam", "units": ["t", "k"]}): | |
| assert (await other.post("/api/classes/4/assignments", json=with_items(ok, foreign))).status_code == 400, foreign | |
| assert (await other.post("/api/classes/4/assignments", json=with_items(ok, {"kind": "unit_quiz", "unit": "t"}))).status_code == 201 | |
| asyncio.run(check()) | |
| def test_only_the_classs_teacher_or_a_manager_may_manage_its_assignments(): | |
| store = MemoryAssignments() | |
| app = make_app(store) | |
| async def check(): | |
| async with client(app, MANAGER) as teacher: | |
| row = (await teacher.post("/api/classes/3/assignments", json=GOOD)).json() | |
| for who in (STUDENT, OTHER_TEACHER, OUTSIDER): | |
| async with client(app, who) as c: | |
| assert (await c.get("/api/classes/3/assignments")).status_code == 403, who | |
| assert (await c.post("/api/classes/3/assignments", json=GOOD)).status_code == 403, who | |
| assert (await c.put(f"/api/classes/3/assignments/{row['id']}", json=GOOD)).status_code == 403 | |
| assert (await c.delete(f"/api/classes/3/assignments/{row['id']}")).status_code == 403 | |
| assert len(store.rows) == 1 | |
| async with client(app, MANAGER) as manager: | |
| assert (await manager.post("/api/classes/3/assignments", json=GOOD)).status_code == 201 | |
| assert (await manager.post("/api/classes/99/assignments", json=GOOD)).status_code == 404 | |
| async with client(app, OTHER_TEACHER) as other: # their own class, but not another class's assignment | |
| assert (await other.delete(f"/api/classes/4/assignments/{row['id']}")).status_code == 404 | |
| assert (await other.put(f"/api/classes/4/assignments/{row['id']}", json=GOOD)).status_code == 404 | |
| assert (await other.get("/api/classes/4/assignments")).json() == {"assignments": []} | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as guest: | |
| assert (await guest.get("/api/classes/3/assignments")).status_code == 401 | |
| asyncio.run(check()) | |
| def test_students_get_only_their_classs_assignments_and_when_they_handed_in_each_unit_quiz(): | |
| store = MemoryAssignments() | |
| app = make_app(store, Sittings({STUDENT: [ | |
| {"id": "s1", "unit_id": "k", "submitted_at": "2026-11-05T10:00:00Z", "grade": 80}, | |
| {"id": "s2", "unit_id": "t", "submitted_at": "2026-11-01T10:00:00Z", "grade": 90}]})) | |
| async def check(): | |
| async with client(app, MANAGER) as teacher, client(app, MANAGER) as other: | |
| mine = (await teacher.post("/api/classes/3/assignments", json=GOOD)).json() | |
| await other.post("/api/classes/4/assignments", json={**GOOD, "title": "ืฉื ืืืชื ืืืจืช", "items": [{"kind": "skill", "id": "t_ext"}]}) | |
| async with client(app, STUDENT) as student: | |
| r = await student.get("/api/math/assignments") | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert [a["id"] for a in data["assignments"]] == [mine["id"]] and data["assignments"][0]["class_name"] == "ืืณ1" | |
| assert "created_by_email" not in data["assignments"][0] and "teacher@x.test" not in r.text # no teacher email | |
| assert data["sittings"] == [{"unit_id": "k", "submitted_at": "2026-11-05T10:00:00Z"}] # only the assigned unit's | |
| assert len(data["today"]) == 10 | |
| async with client(app, OUTSIDER) as outsider: # in no class while classes exist: not let in at all | |
| assert (await outsider.get("/api/math/assignments")).status_code == 403 | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as guest: | |
| assert (await guest.get("/api/math/assignments")).status_code == 401 | |
| asyncio.run(check()) | |
| def test_without_the_servers_sittings_or_table_the_answers_say_so(): | |
| store = MemoryAssignments() | |
| app = make_app(store, Sittings(fail=True)) | |
| async def check(): | |
| async with client(app, MANAGER) as teacher: | |
| await teacher.post("/api/classes/3/assignments", json=GOOD) | |
| async with client(app, STUDENT) as student: | |
| data = (await student.get("/api/math/assignments")).json() | |
| assert len(data["assignments"]) == 1 and data["sittings"] is None # unit-quiz items cannot be told done | |
| store.fail = True | |
| async with client(app, STUDENT) as student, client(app, MANAGER) as teacher: | |
| r = await student.get("/api/math/assignments") | |
| assert (r.status_code, r.json()["detail"]) == (503, "assignments_unavailable") | |
| assert (await teacher.post("/api/classes/3/assignments", json=GOOD)).status_code == 503 | |
| asyncio.run(check()) | |
| def test_the_migration_is_additive_service_only_and_follows_its_class(postgres_server): | |
| psycopg = pytest.importorskip("psycopg") | |
| database = "asg_" + 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(Path("supabase/migrations/20261104210000_math_assignments.sql").read_text()) | |
| assert c.execute("select name from classes order by id").fetchall() == [("existing math class",), ("other class",)] | |
| c.execute("set role service_role") | |
| add = lambda cls, items='[{"kind":"skill","id":"t_ext"}]', title="ืืืจื", due="2026-11-12": c.execute( | |
| "insert into math_assignments(class_id,title,due_on,items) values(%s,%s,%s,%s::jsonb)", (cls, title, due, items)) | |
| add(1) | |
| add(1, title="ืฉื ื") | |
| add(2) | |
| for bad in (dict(cls=1, title=""), dict(cls=1, title="ื" * 81), dict(cls=1, items="[]"), dict(cls=1, items="{}"), | |
| dict(cls=1, items="[" + ",".join(['{"kind":"skill","id":"x"}'] * 21) + "]"), dict(cls=99)): | |
| with pytest.raises(psycopg.errors.Error): | |
| add(**bad) | |
| c.execute("reset role") | |
| c.execute("delete from classes where id = 1") # a deleted class takes its assignments with it | |
| assert c.execute("select count(*) from math_assignments").fetchone()[0] == 1 | |
| c.execute("set role authenticated") | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute("select * from math_assignments") | |
| finally: | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute("drop database " + database + " with (force)") | |
| def test_a_student_in_two_classes_gets_the_assignments_of_both_and_none_of_a_third(): | |
| app = make_app() | |
| app.state.auth.rows[1]["emails"].append("student@x.test") # the student is in class 4 as well | |
| app.state.auth.rows.append({"id": 5, "name": "ืืณ3", "lessons": ["t"], "teachers": ["other@x.test"], | |
| "emails": ["pupil4@x.test"], "nicknames": {}, "exams": []}) | |
| async def check(): | |
| async with client(app, MANAGER) as teacher, client(app, MANAGER) as other: | |
| await teacher.post("/api/classes/3/assignments", json={**GOOD, "title": "ืืืชื 3"}) | |
| await other.post("/api/classes/4/assignments", json={**GOOD, "title": "ืืืชื 4", "items": [{"kind": "skill", "id": "t_ext"}]}) | |
| await other.post("/api/classes/5/assignments", json={**GOOD, "title": "ืืืชื 5", "items": [{"kind": "skill", "id": "t_ext"}]}) | |
| async with client(app, STUDENT) as student: | |
| got = (await student.get("/api/math/assignments")).json()["assignments"] | |
| assert sorted((a["title"], a["class_name"]) for a in got) == [("ืืืชื 3", "ืืณ1"), ("ืืืชื 4", "ืืณ2")] | |
| asyncio.run(check()) | |
| # ---- the whole path: a real server and database, a real browser for the student | |
| def test_an_assignment_given_by_the_teacher_is_completed_by_the_student_and_shows_done_in_the_report(answer_store, monkeypatch): | |
| """The teacher gives a class two skills and a unit quiz. The student's home card follows them as the student finishes | |
| the skills in the browser and hands the quiz in; the teacher's report then carries what is needed to show the student | |
| as done, and the rules of the page agree.""" | |
| import socket | |
| import threading | |
| import time | |
| import urllib.parse | |
| import uvicorn | |
| from scan_support import OTHER, OWNER, ScanAuth | |
| from test_answer_browser import session | |
| from test_sky_browser import answer_skill_right | |
| from test_unit_quiz import check, quiz_answer | |
| playwright = pytest.importorskip("playwright.sync_api") | |
| monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0") | |
| mail = lambda uid: uid + "@test.invalid" | |
| class SchoolAuth(ScanAuth): | |
| teachers = frozenset() # OTHER is the class teacher, OWNER a student | |
| developers = frozenset({mail(OTHER)}) # skills and unit quizzes in an assignment are for developers | |
| def __init__(self): | |
| self.progress = {} | |
| def user(self, token): | |
| return {"id": token, "email": mail(token)} if token in (OWNER, OTHER) else None | |
| def classes(self, fresh=False): | |
| return [{"id": 3, "name": "ืืณ1", "lessons": ["t", "k"], "teachers": [mail(OTHER)], | |
| "emails": [mail(OWNER), mail(OTHER)], "nicknames": {}, "exams": []}] | |
| def user_accounts(self, emails): | |
| return {mail(uid): {"id": uid, "name": ""} for uid in (OWNER, OTHER) if mail(uid) in emails} | |
| def _admin(self, path, method="GET", body=None, prefer=""): | |
| from app.auth import AuthUnavailable | |
| table, _, query = path.partition("?") | |
| if table != "/rest/v1/math_progress": | |
| raise AuthUnavailable("supabase answered 404: no other tables here") | |
| if method == "POST": | |
| self.progress[body["user_id"]] = {"data": body["data"], "updated_at": body["updated_at"]} | |
| return None | |
| wanted = dict(urllib.parse.parse_qsl(query))["user_id"] | |
| ids = wanted.removeprefix("in.(").removesuffix(")").split(",") if wanted.startswith("in.") else [wanted.removeprefix("eq.")] | |
| return [{"user_id": i, **self.progress[i]} for i in ids if i in self.progress] | |
| auth = SchoolAuth() | |
| app = create_app(auth=auth) | |
| app.state.answers, app.state.assignments = answer_store, MemoryAssignments() | |
| plan = {"title": "ืืืจื ืขื ืืคืืืื", "note": "", "due_on": "2099-01-01", | |
| "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_simplify"}, {"kind": "unit_quiz", "unit": "t"}]} | |
| with socket.socket() as sock: | |
| sock.bind(("127.0.0.1", 0)) | |
| server = uvicorn.Server(uvicorn.Config(app, log_level="error")) | |
| thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) | |
| thread.start() | |
| try: | |
| while not server.started: | |
| time.sleep(.01) | |
| url = f"http://127.0.0.1:{sock.getsockname()[1]}" | |
| as_ = lambda uid: {"Authorization": f"Bearer {uid}"} | |
| made = httpx.post(url + "/api/classes/3/assignments", json=plan, headers=as_(OTHER)) | |
| assert made.status_code == 201 | |
| with playwright.sync_playwright() as p: | |
| browser = p.chromium.launch() | |
| try: | |
| page = session(browser, mobile=True).new_page() | |
| errors = [] | |
| page.on("pageerror", lambda e: errors.append(str(e))) | |
| page.goto(url) | |
| page.locator('.mx-nav [data-view="home"]').click() | |
| card = page.locator(".mx-asgs .mx-asg") | |
| playwright.expect(card).to_contain_text("ืืืจื ืขื ืืคืืืื") | |
| playwright.expect(card).to_contain_text("0 ืืชืื 3 ืืืฉืืื") | |
| page.locator('.mx-nav [data-view="path"]').click() # the markers are on the trail | |
| playwright.expect(page.locator('.mx-star[data-id="t_ext"] .mx-asg-pin')).to_have_count(1) | |
| for skill, count in (("t_ext", 1), ("t_simplify", 2)): # the student finishes the skills | |
| page.locator(f'.mx-star[data-id="{skill}"]').click() | |
| answer_skill_right(page) | |
| while page.locator(".sky-cele [data-sky=go]").count(): | |
| page.locator(".sky-cele [data-sky=go]").first.click() | |
| page.locator(".mx-x").last.click() | |
| page.locator('.mx-nav [data-view="home"]').click() # the card is on home | |
| playwright.expect(card).to_contain_text(f"{count} ืืชืื 3 ืืืฉืืื") | |
| page.locator('.mx-nav [data-view="path"]').click() | |
| page.locator('.mx-nav [data-view="home"]').click() | |
| playwright.expect(card.locator("[data-asg-go]")).to_contain_text("ืืืื ืืืืืง") # what is left | |
| # the unit quiz: photographed, checked and handed in | |
| ids = [quiz_answer(answer_store, i) for i in range(4)] | |
| for aid in ids: | |
| check(answer_store, aid, 100) | |
| handed = httpx.post(url + "/api/math/quiz-sittings", headers=as_(OWNER), json={ | |
| "unit_id": "t", "attempts": ids, "client_key": str(uuid.uuid4())}) | |
| assert handed.status_code == 200 and handed.json()["grade"] == 100 | |
| page.reload() | |
| page.locator('.mx-nav [data-view="home"]').click() | |
| playwright.expect(page.locator(".mx-asgs .mx-asg")).to_have_count(0) # nothing open is left | |
| playwright.expect(page.locator(".mx-asg-done summary")).to_have_text("ืืืฉืืื (1)") | |
| assert page.locator(".mx-asg-pin").count() == 0 # and no markers | |
| # what the student's device saved reaches the server; then the teacher's report | |
| deadline = time.monotonic() + 10 | |
| while time.monotonic() < deadline and not {"t_ext", "t_simplify"} <= set(auth.progress.get(OWNER, {}).get("data", {}).get("done", {})): | |
| time.sleep(.2) | |
| report = httpx.get(url + "/api/classes/3/report", headers=as_(OTHER)).json() | |
| (student,) = report["students"] | |
| (assignment,) = report["assignments"] | |
| assert assignment["id"] == made.json()["id"] and student["email"] == mail(OWNER) | |
| assert {"t_ext", "t_simplify"} <= set(student["progress"]["done"]) | |
| assert [s["grade"] for s in student["quiz_sittings"]] == [100] | |
| seen = page.evaluate("([a, s]) => MathlySky.assignmentState(a, s.progress, s.quiz_sittings)", [assignment, student]) | |
| assert seen["finished"] and seen["doneCount"] == 3 and not seen["overdue"] and not seen["late"] | |
| assert not errors | |
| finally: | |
| browser.close() | |
| finally: | |
| server.should_exit = True | |
| thread.join(timeout=5) | |
| CLASSMATE = "c-id" | |
| class FocusAuth(ClassAuth): | |
| """ClassAuth with a second student in class 3.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.rows[0]["emails"].append("classmate@x.test") | |
| def user(self, token): | |
| return {"id": token, "email": "classmate@x.test"} if token == CLASSMATE else super().user(token) | |
| def test_a_class_teacher_gives_focus_practice_of_skills_to_chosen_students_only(): | |
| app = create_app(auth=FocusAuth()) | |
| app.state.assignments = store = MemoryAssignments() | |
| app.state.answers = Sittings() | |
| focus = {"title": "ืืืืืง ืคืืืื", "due_on": "2026-11-15", "items": [{"kind": "skill", "id": "t_ext"}, | |
| {"kind": "skill", "id": "t_simplify"}], "student_emails": ["Student@x.test"]} | |
| async def check(): | |
| async with client(app, TEACHER) as teacher, client(app, STUDENT) as student, client(app, CLASSMATE) as classmate: | |
| made = await teacher.post("/api/classes/3/assignments", json=focus) | |
| assert made.status_code == 201, made.text | |
| assert made.json()["student_emails"] == ["student@x.test"] and made.json()["personal"] is True | |
| for bad, why in ((["pupil4@x.test"], "not in this class"), (["teacher@x.test"], "not in this class"), ([], "at least one")): | |
| r = await teacher.post("/api/classes/3/assignments", json={**focus, "student_emails": bad}) | |
| assert r.status_code == 400 and why in r.json()["detail"], bad | |
| quiz = await teacher.post("/api/classes/3/assignments", json={**focus, "items": [{"kind": "unit_quiz", "unit": "k"}]}) | |
| assert (quiz.status_code, quiz.json()["detail"]) == (400, "teachers_give_worksheets_only") | |
| six = [{"kind": "skill", "id": s["id"]} for s in algebra9()["skills"] if s["unit"] in ("t", "k")][:6] | |
| assert len(six) == 6 | |
| assert (await teacher.post("/api/classes/3/assignments", json={**focus, "items": six})).json()["detail"] == "teachers_give_worksheets_only" | |
| whole = await teacher.post("/api/classes/3/assignments", json={**focus, "student_emails": None}) | |
| assert (whole.status_code, whole.json()["detail"]) == (400, "teachers_give_worksheets_only") # skills to all: managers | |
| mine = (await student.get("/api/math/assignments")).json()["assignments"] | |
| assert [(a["title"], a["personal"]) for a in mine] == [("ืืืืืง ืคืืืื", True)] | |
| assert "student_emails" not in mine[0] # a student does not see whom else it was given to | |
| assert (await classmate.get("/api/math/assignments")).json()["assignments"] == [] | |
| # the teacher moves it to the classmate | |
| moved = await teacher.put(f"/api/classes/3/assignments/{made.json()['id']}", | |
| json={**focus, "student_emails": ["classmate@x.test"]}) | |
| assert moved.status_code == 200 | |
| assert (await student.get("/api/math/assignments")).json()["assignments"] == [] | |
| assert len((await classmate.get("/api/math/assignments")).json()["assignments"]) == 1 | |
| listed = (await teacher.get("/api/classes/3/assignments")).json()["assignments"] | |
| assert listed[0]["student_emails"] == ["classmate@x.test"] | |
| asyncio.run(check()) | |
| def test_the_audience_migration_keeps_old_assignments_class_wide(postgres_server): | |
| psycopg = pytest.importorskip("psycopg") | |
| database = "aud_" + 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(Path("supabase/migrations/20261104210000_math_assignments.sql").read_text()) | |
| c.execute("insert into math_assignments(class_id,title,due_on,items) values(1,'old','2026-11-12','[{\"kind\":\"skill\",\"id\":\"t_ext\"}]')") | |
| c.execute(Path("supabase/migrations/20261106100000_math_assignment_audience.sql").read_text()) | |
| assert c.execute("select student_emails from math_assignments").fetchone()[0] is None | |
| c.execute("insert into math_assignments(class_id,title,due_on,items,student_emails) " | |
| "values(1,'focus','2026-11-12','[{\"kind\":\"skill\",\"id\":\"t_ext\"}]',array['a@x.test'])") | |
| with pytest.raises(psycopg.errors.CheckViolation): | |
| c.execute("insert into math_assignments(class_id,title,due_on,items,student_emails) " | |
| "values(1,'none','2026-11-12','[{\"kind\":\"skill\",\"id\":\"t_ext\"}]','{}')") | |
| finally: | |
| with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c: | |
| c.execute("drop database " + database + " with (force)") | |