File size: 32,840 Bytes
0815e01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a4cb0f
0815e01
 
 
 
 
 
 
 
 
 
4a4cb0f
0815e01
 
 
 
 
 
 
 
 
 
 
4a4cb0f
0815e01
 
 
 
 
4a4cb0f
 
 
0815e01
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
"""Assignments through the API: the teacher's side (add-written-work 2.2); the student's and the inbox are below."""
import io
import uuid
from datetime import date, datetime, timedelta, timezone

import pytest
from fastapi.testclient import TestClient
from PIL import Image

from mathly2 import clock
from mathly2.bank import load_bank
from mathly2.config import Settings
from mathly2.main import create_app
from mathly2.store import InMemoryStore

TEACHER, OTHER_TEACHER, KID, KID2, OUTSIDER, MGR, DEV = ("t@school.test", "t2@school.test", "kid@school.test", "kid2@school.test", "out@school.test",
                                                           "mgr@school.test", "dev@school.test")
MON = datetime(2026, 11, 9, 9, 0, tzinfo=timezone.utc)
DAY = timedelta(days=1)


def uid(email):
    return str(uuid.uuid5(uuid.NAMESPACE_DNS, email))


def auth(email):
    return {"Authorization": f"Bearer dev:{email}"}


def image(fmt="JPEG", color="red"):
    out = io.BytesIO()
    Image.new("RGB", (80, 50), color).save(out, fmt)
    return out.getvalue()


def make_pdf(pages):
    import pypdfium2 as pdfium
    doc = pdfium.PdfDocument.new()
    for _ in range(pages):
        doc.new_page(200, 300)
    out = io.BytesIO()
    doc.save(out)
    return out.getvalue()


@pytest.fixture(scope="module")
def bank():
    return load_bank()


@pytest.fixture
def world(bank, monkeypatch):
    """Class A (teacher T; students KID, KID2) studies a lesson with an authored written quiz and one without; class B (teacher T2; OUTSIDER)."""
    with_quiz = [u["id"] for u in bank.data["units"] if u.get("quiz")]
    without = next(u["id"] for u in bank.data["units"] if not u.get("quiz"))
    store = InMemoryStore()
    store.give_role(MGR, "manager", "setup")
    a = store.create_class("setup", "A", lessons=[with_quiz[0], without], teacher_email=TEACHER)
    b = store.create_class("setup", "B", lessons=[with_quiz[1]], teacher_email=OTHER_TEACHER)
    for email in (KID, KID2):
        store.add_member(a["id"], email)
        store.link_user(email, uid(email))
    store.add_member(b["id"], OUTSIDER)
    client = TestClient(create_app(Settings(developers=frozenset({DEV})), bank=bank, store=store))
    moment = {"now": MON}
    monkeypatch.setattr(clock, "now", lambda: moment["now"])
    client.set_time = lambda value: moment.update(now=value)
    client.store, client.a, client.b, client.quiz_lesson, client.plain_lesson, client.other_quiz = store, a, b, with_quiz[0], without, with_quiz[1]
    return client


def upload_pages(client, data, kind="image/jpeg", who=TEACHER):
    return client.post("/api/assignments/pages", content=data, headers={**auth(who), "Content-Type": kind})


def give(client, who=TEACHER, class_id=None, **body):
    due = (clock.now() + 7 * DAY).date().isoformat()
    return client.post(f"/api/classes/{class_id or client.a['id']}/assignments", json={"title": "ืžืฉื™ืžื”", "due_date": due, **body}, headers=auth(who))


# --- 2.2 giving: the five sources ---

def test_from_an_uploaded_image_the_board_or_a_photo_the_pages_become_the_assignments_pages(world):
    first = upload_pages(world, image("JPEG")).json()["pictures"]
    second = upload_pages(world, image("PNG", "blue"), "image/png").json()["pictures"]         # what the board sends: a PNG page
    assert len(first) == 1 and len(second) == 1
    got = give(world, kind="pages", pages=first + second, note="ืœืคืชื•ืจ ืขื“ ื™ื•ื ื“ืณ")
    assert got.status_code == 201
    body = got.json()
    assert body["kind"] == "pages" and body["pages"] == first + second and body["note"] == "ืœืคืชื•ืจ ืขื“ ื™ื•ื ื“ืณ" and body["days_left"] == 7
    assert body["students"] == 2 and body["handed_in"] == 0 and body["graded"] == 0 and body["class"]["name"] == "A"
    assert all(world.store.get_picture(p)["kind"] == "page" and world.store.get_picture(p)["owner_id"] == uid(TEACHER) for p in first + second)


def test_a_pdf_becomes_one_page_picture_per_sheet(world):
    ids = upload_pages(world, make_pdf(3), "application/pdf").json()["pictures"]
    assert len(ids) == 3 and len(set(ids)) == 3
    assert upload_pages(world, b"hello", "application/pdf").status_code == 415
    assert upload_pages(world, b"nonsense", "image/jpeg").status_code == 400
    assert give(world, kind="pages", pages=ids).json()["pages"] == ids


def test_from_the_banks_written_quiz_only_for_a_class_lesson_that_has_one(world, bank):
    got = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ื‘ื•ื—ืŸ ื›ืชื•ื‘")
    assert got.status_code == 201 and got.json()["quiz_lesson"]["id"] == world.quiz_lesson and got.json()["quiz_lesson"]["name"]
    assert give(world, kind="quiz", quiz_lesson=world.plain_lesson).status_code == 422          # a class lesson with no written quiz
    assert give(world, kind="quiz", quiz_lesson=world.other_quiz).status_code == 422            # a quiz of a lesson the class does not study
    assert give(world, kind="quiz").status_code == 422


def test_an_exercise_test_of_a_test_of_the_class_can_be_sent_as_an_assignment(world):
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    other = world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
    got = give(world, kind="exercise_test", test_id=test["id"], exercise_number=2)
    assert got.status_code == 201 and got.json()["test"] == {"id": test["id"], "number": 2, "date": test["date"].isoformat()} and got.json()["done"] == 0
    assert give(world, kind="exercise_test", test_id=other["id"], exercise_number=1).status_code == 422        # another class's test
    assert give(world, kind="exercise_test", test_id=test["id"], exercise_number=3).status_code == 422
    assert give(world, kind="exercise_test", test_id=999, exercise_number=1).status_code == 422
    world.store.remove_test(test["id"])
    assert give(world, kind="exercise_test", test_id=test["id"], exercise_number=1).status_code == 422


def test_giving_needs_a_class_the_teacher_teaches_a_future_date_and_pages_that_are_their_own(world):
    assert give(world, who=OTHER_TEACHER, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 404
    assert give(world, who=KID, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 403
    assert give(world, who=MGR, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 403
    past = world.post(f"/api/classes/{world.a['id']}/assignments", json={"title": "x", "due_date": (clock.now() - DAY).date().isoformat(), "kind": "quiz",
                                                                       "quiz_lesson": world.quiz_lesson}, headers=auth(TEACHER))
    assert past.status_code == 422
    assert give(world, kind="pages", pages=[]).status_code == 422
    mine = upload_pages(world, image()).json()["pictures"]
    theirs = upload_pages(world, image(), who=OTHER_TEACHER).json()["pictures"]
    assert give(world, kind="pages", pages=theirs).status_code == 422                          # a page someone else uploaded
    answer = world.post("/api/pictures", content=image(), headers={**auth(KID), "Content-Type": "image/jpeg"}).json()["id"]
    assert give(world, kind="pages", pages=[answer]).status_code == 422                        # an answer picture is not a page
    assert give(world, kind="pages", pages=[str(uuid.uuid4())]).status_code == 422
    assert give(world, kind="pages", pages=mine, title="").status_code == 422
    assert give(world, who=DEV, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 201     # a developer may give to any class


def test_the_list_has_counts_and_a_developer_sees_every_class(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    world.store.save_submission(quiz["id"], uid(KID), pictures=["p1"])
    world.store.save_submission(quiz["id"], uid(KID2), pictures=["p2"], state="graded", grade=80)
    listed = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()
    assert [c["class"]["name"] for c in listed] == ["A"]
    one = listed[0]["assignments"][0]
    assert (one["handed_in"], one["graded"], one["returned"], one["waiting"], one["students"]) == (1, 1, 0, 1, 2)
    assert listed[0]["class"]["lessons"]
    assert [c["class"]["name"] for c in world.get("/api/teaching/assignments", headers=auth(DEV)).json()] == ["A", "B"]
    assert world.get("/api/teaching/assignments", headers=auth(OTHER_TEACHER)).json()[0]["assignments"] == []


def test_removing_an_assignment_hides_it_and_keeps_the_grades_already_given(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    world.store.save_submission(quiz["id"], uid(KID), pictures=["p1"], state="graded", grade=90, teacher_note="ื™ืคื”")
    assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(OTHER_TEACHER)).status_code == 404
    assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(KID)).status_code == 403
    assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER)).status_code == 204
    assert world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["assignments"] == []
    assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER)).status_code == 404
    kept = world.store.get_submission(quiz["id"], uid(KID))
    assert (kept["grade"], kept["teacher_note"]) == (90, "ื™ืคื”") and world.store.get_assignment(quiz["id"])["removed_at"] is not None


# --- 2.3 the student's side ---

def answer_picture(client, who=KID):
    got = client.post("/api/pictures", content=image(), headers={**auth(who), "Content-Type": "image/jpeg"})
    assert got.status_code == 201, got.text
    return got.json()["id"]


def hand(client, assignment_id, pictures, who=KID):
    return client.post(f"/api/assignments/{assignment_id}/submission", json={"pictures": pictures}, headers=auth(who))


def mine(client, who=KID):
    return client.get("/api/assignments", headers=auth(who)).json()


def test_a_student_sees_only_their_classes_assignments_with_state_and_due_date(world):
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ื‘ื•ื—ืŸ ื›ืชื•ื‘").json()
    listed = mine(world)
    assert [x["id"] for x in listed] == [a["id"]] and listed[0]["state"] == "not_handed" and listed[0]["days_left"] == 7 and listed[0]["class"]["name"] == "A"
    assert mine(world, KID2)[0]["id"] == a["id"] and mine(world, OUTSIDER) == []                      # another class sees nothing
    assert world.get(f"/api/assignments/{a['id']}", headers=auth(OUTSIDER)).status_code == 404


def test_a_written_quiz_shows_its_questions_with_points_and_a_pages_assignment_its_pages(world, bank):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    detail = world.get(f"/api/assignments/{quiz['id']}", headers=auth(KID)).json()
    questions = detail["quiz"]["questions"]
    assert len(questions) == 4 and sum(q["points"] for q in questions) == 100 == detail["quiz"]["points"] and detail["quiz"]["minutes"] == 45
    assert all(q["question"] for q in questions)
    pages = upload_pages(world, image()).json()["pictures"]
    sheet = give(world, kind="pages", pages=pages).json()
    assert world.get(f"/api/assignments/{sheet['id']}", headers=auth(KID)).json()["pages"] == pages
    assert world.get(f"/api/pictures/{pages[0]}", headers=auth(KID)).status_code == 200            # the class's students read the pages


def test_handing_in_one_to_ten_of_the_students_own_answer_pictures(world):
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    pics = [answer_picture(world) for _ in range(3)]
    got = hand(world, a["id"], pics)
    assert got.status_code == 200 and got.json()["state"] == "handed_in" and got.json()["late"] is False
    detail = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
    assert [p["id"] for p in detail["pictures"]] == pics and detail["state"] == "handed_in"
    assert hand(world, a["id"], []).status_code == 422 and hand(world, a["id"], [answer_picture(world) for _ in range(11)]).status_code == 422
    assert hand(world, a["id"], [answer_picture(world, KID2)]).status_code == 422                    # someone else's picture
    page = upload_pages(world, image()).json()["pictures"][0]
    assert hand(world, a["id"], [page]).status_code == 422                                           # a page is not an answer
    assert hand(world, a["id"], [str(uuid.uuid4())]).status_code == 422
    gone = answer_picture(world)
    world.store.mark_picture_deleted(gone)
    assert hand(world, a["id"], [gone]).status_code == 422
    assert world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()["pictures"] == detail["pictures"]    # refused tries changed nothing
    assert hand(world, a["id"], pics, who=OUTSIDER).status_code == 404


def test_a_handed_in_submission_can_be_replaced_a_returned_one_handed_in_again_and_a_graded_one_not_changed(world):
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    first = answer_picture(world)
    hand(world, a["id"], [first])
    second = answer_picture(world)
    assert hand(world, a["id"], [second]).json()["state"] == "handed_in"                              # replaced while handed in
    assert [p["id"] for p in world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()["pictures"]] == [second]
    sub = world.store.get_submission(a["id"], uid(KID))
    world.store.save_submission(a["id"], uid(KID), state="returned", teacher_note="ื—ืกืจ ื ื™ืžื•ืง ื‘ืกืขื™ืฃ 2", checked_by=TEACHER)
    shown = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
    assert shown["state"] == "returned" and shown["teacher_note"] == "ื—ืกืจ ื ื™ืžื•ืง ื‘ืกืขื™ืฃ 2" and mine(world)[0]["state"] == "returned"
    third = answer_picture(world)
    assert hand(world, a["id"], [third]).json()["state"] == "handed_in"                               # handed in again
    world.store.save_submission(a["id"], uid(KID), state="graded", grade=88, teacher_note="ื™ืคื”")
    assert hand(world, a["id"], [answer_picture(world)]).status_code == 409                          # graded: no more changes
    done = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
    assert (done["state"], done["grade"], done["teacher_note"]) == ("graded", 88, "ื™ืคื”") and sub["id"] == world.store.get_submission(a["id"], uid(KID))["id"]


def test_a_late_hand_in_is_accepted_and_marked_late(world):
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    world.set_time(MON + 9 * DAY)                                                                     # two days after the due date
    assert mine(world)[0]["overdue"] is True and mine(world)[0]["days_left"] == -2
    got = hand(world, a["id"], [answer_picture(world)])
    assert got.status_code == 200 and got.json()["late"] is True and got.json()["overdue"] is False


def test_an_exercise_test_assignment_is_done_after_the_student_took_it_with_the_best_grade_and_cannot_be_handed_in(world):
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    a = give(world, kind="exercise_test", test_id=test["id"], exercise_number=1).json()
    assert mine(world)[0]["state"] == "not_done" and mine(world)[0]["test"] == {"id": test["id"], "number": 1}
    assert hand(world, a["id"], [answer_picture(world)]).status_code == 422
    for grade in (60, 85):
        row = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:1", MON, MON + timedelta(minutes=45), [])
        world.store.save_attempt(row["id"], submitted_at=MON + timedelta(minutes=5), grade=grade)
    other = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:2", MON, MON + timedelta(minutes=45), [])
    world.store.save_attempt(other["id"], submitted_at=MON, grade=99)                                  # a different exercise test
    done = mine(world)[0]
    assert done["state"] == "done" and done["grade"] == 85
    assert mine(world, KID2)[0]["state"] == "not_done"
    teacher_view = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["assignments"][0]
    assert teacher_view["done"] == 1 and "waiting" not in teacher_view                                 # nothing waits in an inbox for it


def test_open_assignments_come_first_by_due_date_and_removed_ones_are_gone(world):
    far = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ืจื—ื•ืง").json()
    near = world.post(f"/api/classes/{world.a['id']}/assignments", json={"title": "ืงืจื•ื‘", "due_date": (clock.now() + 2 * DAY).date().isoformat(), "kind": "quiz",
                                                                        "quiz_lesson": world.quiz_lesson}, headers=auth(TEACHER)).json()
    done = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ื”ื•ื’ืฉ").json()
    hand(world, done["id"], [answer_picture(world)])
    world.store.save_submission(done["id"], uid(KID), state="graded", grade=70)
    assert [x["title"] for x in mine(world)] == ["ืงืจื•ื‘", "ืจื—ื•ืง", "ื”ื•ื’ืฉ"]
    world.delete(f"/api/assignments/{near['id']}", headers=auth(TEACHER))
    assert [x["title"] for x in mine(world)] == ["ืจื—ื•ืง", "ื”ื•ื’ืฉ"] and world.get(f"/api/assignments/{near['id']}", headers=auth(KID)).status_code == 404
    assert far["id"] != done["id"]


# --- 2.4 the inbox and checking ---

def inbox(client, who=TEACHER):
    return client.get("/api/teaching/inbox", headers=auth(who)).json()


def submit(client, assignment, who, day_offset=0):
    """A student hands in at a given time; returns the submission row."""
    client.set_time(MON + day_offset * DAY)
    got = hand(client, assignment, [answer_picture(client, who)], who)
    assert got.status_code == 200
    return client.store.get_submission(assignment, uid(who))


def test_the_inbox_lists_what_waits_oldest_first_and_a_returned_one_is_not_in_it(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    tuesday = submit(world, quiz["id"], KID2, 1)                                                      # KID2 hands in on Tuesday
    monday = submit(world, quiz["id"], KID, 0)                                                        # KID's was Monday: first in the inbox even though made second
    items = inbox(world)
    assert [i["id"] for i in items] == [monday["id"], tuesday["id"]] and [i["student"] for i in items] == [KID, KID2]
    assert items[0]["kind"] == "submission" and items[0]["assignment"]["title"] == "ืžืฉื™ืžื”" and items[0]["assignment"]["class"]["name"] == "A" and items[0]["pictures"]
    world.store.save_submission(quiz["id"], uid(KID), state="returned", teacher_note="ืชืงื ื•")
    assert [i["id"] for i in inbox(world)] == [tuesday["id"]]
    assert inbox(world, OTHER_TEACHER) == [] and [i["id"] for i in inbox(world, DEV)] == [tuesday["id"]]


def test_the_teacher_opens_a_submission_and_grades_it_and_the_student_sees_grade_and_note(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    sub = submit(world, quiz["id"], KID)
    opened = world.get(f"/api/submissions/{sub['id']}", headers=auth(TEACHER)).json()
    assert opened["state"] == "handed_in" and len(opened["quiz"]["questions"]) == 4 and opened["pictures"] == [{"id": sub["pictures"][0], "deleted": False}]
    assert world.get(f"/api/pictures/{sub['pictures'][0]}", headers=auth(TEACHER)).status_code == 200      # the teacher reads the answer
    graded = world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 85, "note": "ื ื™ืžื•ืง ื™ืคื”", "seen": opened["seen"]}, headers=auth(TEACHER))
    assert graded.status_code == 200 and graded.json() == {"id": sub["id"], "state": "graded", "grade": 85}
    assert inbox(world) == []
    mine_now = mine(world)[0]
    assert (mine_now["state"], mine_now["grade"], mine_now["teacher_note"]) == ("graded", 85, "ื ื™ืžื•ืง ื™ืคื”")
    row = world.store.get_submission(quiz["id"], uid(KID))
    assert row["checked_by"] == TEACHER and row["checked_at"] is not None


def test_a_grade_is_a_whole_number_from_0_to_100(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    sub = submit(world, quiz["id"], KID)
    seen = sub["handed_at"].isoformat()
    for bad in (101, -1, 85.5, "ื’ื‘ื•ื”"):
        assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": bad, "seen": seen}, headers=auth(TEACHER)).status_code == 422
    assert world.store.get_submission(quiz["id"], uid(KID))["state"] == "handed_in"
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 0, "seen": seen}, headers=auth(TEACHER)).status_code == 200          # the edges
    other = submit(world, quiz["id"], KID2)
    assert world.post(f"/api/submissions/{other['id']}/grade", json={"grade": 100, "seen": other["handed_at"].isoformat()}, headers=auth(TEACHER)).status_code == 200


def test_sending_back_needs_a_note_and_the_student_then_hands_in_again(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    sub = submit(world, quiz["id"], KID)
    seen = sub["handed_at"].isoformat()
    assert world.post(f"/api/submissions/{sub['id']}/return", json={"note": "", "seen": seen}, headers=auth(TEACHER)).status_code == 422
    assert world.post(f"/api/submissions/{sub['id']}/return", json={"note": "   ", "seen": seen}, headers=auth(TEACHER)).status_code == 422
    sent = world.post(f"/api/submissions/{sub['id']}/return", json={"note": "ื—ืกืจ ื ื™ืžื•ืง ื‘ืกืขื™ืฃ 2", "seen": seen}, headers=auth(TEACHER))
    assert sent.status_code == 200 and sent.json()["state"] == "returned" and inbox(world) == []
    shown = mine(world)[0]
    assert shown["state"] == "returned" and shown["teacher_note"] == "ื—ืกืจ ื ื™ืžื•ืง ื‘ืกืขื™ืฃ 2"
    submit(world, quiz["id"], KID, 1)                                                                  # handed in again: back in the inbox
    assert [i["id"] for i in inbox(world)] == [sub["id"]]


def test_a_stale_view_a_second_check_and_another_classs_teacher_are_refused(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    sub = submit(world, quiz["id"], KID)
    seen = sub["handed_at"].isoformat()
    submit(world, quiz["id"], KID, 1)                                                                  # the student replaced it after the teacher opened it
    stale = world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": seen}, headers=auth(TEACHER))
    assert stale.status_code == 409 and "ื”ืฉืชื ืชื”" in stale.json()["detail"]
    fresh = world.store.get_submission(quiz["id"], uid(KID))["handed_at"].isoformat()
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(OTHER_TEACHER)).status_code == 404
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(KID)).status_code == 403
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": "not a time"}, headers=auth(TEACHER)).status_code == 422
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(TEACHER)).status_code == 200
    assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 90, "seen": fresh}, headers=auth(TEACHER)).status_code == 409     # already checked
    assert world.get("/api/submissions/999", headers=auth(TEACHER)).status_code == 404


def test_exercise_test_assignments_never_reach_the_inbox_and_removed_assignments_leave_it(world):
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    give(world, kind="exercise_test", test_id=test["id"], exercise_number=1)
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    sub = submit(world, quiz["id"], KID)
    assert [i["id"] for i in inbox(world)] == [sub["id"]]
    world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))
    assert inbox(world) == []


def test_the_grades_list_has_graded_assignments_and_done_exercise_tests_and_a_removed_one_keeps_its_grade(world):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ื‘ื•ื—ืŸ ื›ืชื•ื‘").json()
    waiting = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="ืขื•ื“ ืœื ื ื‘ื“ืง").json()
    sub = submit(world, quiz["id"], KID)
    submit(world, waiting["id"], KID)
    assert world.get("/api/grades", headers=auth(KID)).json()["assignments"] == []                      # nothing graded yet
    world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 92, "note": "ื™ืคื” ืžืื•ื“", "seen": sub["handed_at"].isoformat()}, headers=auth(TEACHER))
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    exam = give(world, kind="exercise_test", test_id=test["id"], exercise_number=1, title="ืชืจื’ื•ืœ").json()
    row = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:1", MON, MON + timedelta(minutes=45), [])
    world.store.save_attempt(row["id"], submitted_at=MON, grade=77)
    got = world.get("/api/grades", headers=auth(KID)).json()["assignments"]
    assert {(g["assignment"]["title"], g["grade"], g["note"]) for g in got} == {("ื‘ื•ื—ืŸ ื›ืชื•ื‘", 92, "ื™ืคื” ืžืื•ื“"), ("ืชืจื’ื•ืœ", 77, "")}
    world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))
    kept = [g for g in world.get("/api/grades", headers=auth(KID)).json()["assignments"] if g["assignment"]["title"] == "ื‘ื•ื—ืŸ ื›ืชื•ื‘"]
    assert kept[0]["grade"] == 92 and kept[0]["assignment"]["removed"] is True and exam["id"]
    assert world.get("/api/grades", headers=auth(KID2)).json()["assignments"] == []


# --- 2.7 deleting a student's pictures on request, and the hourly cleanup ---

def handed_in_with_pictures(world, who=KID):
    quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    pics = [answer_picture(world, who), answer_picture(world, who)]
    hand(world, quiz["id"], pics, who)
    world.store.save_submission(quiz["id"], uid(who), state="graded", grade=88, teacher_note="ื™ืคื”")
    return quiz, pics


def delete_pictures(world, who, email=KID, class_id=None):
    return world.delete(f"/api/classes/{class_id or world.a['id']}/pictures/{email}", headers=auth(who))


def test_the_classs_teacher_a_manager_and_a_developer_can_delete_a_students_pictures_and_the_grade_stays(world):
    for who in (TEACHER, MGR, DEV):
        quiz, pics = handed_in_with_pictures(world)
        got = delete_pictures(world, who)
        assert got.status_code == 200 and got.json() == {"deleted": 2}, who
        assert all(world.store.get_picture(p)["deleted_at"] for p in pics)
        assert world.get(f"/api/pictures/{pics[0]}", headers=auth(KID)).status_code == 410           # gone for the student too
        sub = world.store.get_submission(quiz["id"], uid(KID))
        assert (sub["state"], sub["grade"], sub["teacher_note"]) == ("graded", 88, "ื™ืคื”")             # the grade and note stay
        shown = world.get(f"/api/assignments/{quiz['id']}", headers=auth(KID)).json()
        assert shown["grade"] == 88 and all(p["deleted"] for p in shown["pictures"])
        world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))


def test_another_classs_teacher_a_student_and_a_missing_student_cannot(world):
    handed_in_with_pictures(world)
    assert delete_pictures(world, OTHER_TEACHER).status_code == 404
    assert delete_pictures(world, KID2).status_code == 403
    assert delete_pictures(world, KID).status_code == 403
    assert delete_pictures(world, TEACHER, email="nobody@school.test").status_code == 404
    assert delete_pictures(world, TEACHER, class_id=999).status_code == 404
    assert len(world.store.pictures()) >= 2 and all(not p["deleted_at"] for p in world.store.pictures())


def test_only_that_students_pictures_for_that_class_go(world):
    quiz, mine_pics = handed_in_with_pictures(world, KID)
    other_quiz, theirs = handed_in_with_pictures(world, KID2)
    assert delete_pictures(world, TEACHER, KID).json() == {"deleted": 2}
    assert all(not world.store.get_picture(p)["deleted_at"] for p in theirs) and other_quiz["id"] != quiz["id"]
    assert delete_pictures(world, TEACHER, KID).json() == {"deleted": 0}                              # twice is fine
    never = world.store.add_member(world.a["id"], "newkid@school.test")
    assert delete_pictures(world, TEACHER, "newkid@school.test").json() == {"deleted": 0} and never is None      # never signed in: nothing handed in


def test_the_hourly_cleanup_starts_with_the_real_database_and_not_without(world, monkeypatch):
    from mathly2 import cleanup
    started = []

    def fake_start(store, backend, every=3600.0):
        import threading
        started.append((store, backend))
        return threading.Event()
    monkeypatch.setattr(cleanup, "start_hourly", fake_start)
    with TestClient(world.app):                                                                     # no Supabase: nothing starts
        pass
    assert started == []
    live = create_app(Settings(supabase_url="https://p.test", supabase_key="pub", supabase_secret="sec"), store=InMemoryStore(), authenticator=object())
    with TestClient(live):
        pass
    assert len(started) == 1 and started[0][0] is live.state.store and started[0][1] is live.state.pictures


def test_the_teachers_list_says_which_lessons_have_a_written_quiz_and_lists_the_classs_tests(world):
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    got = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["class"]
    assert {l["id"]: l["has_quiz"] for l in got["lessons"]} == {world.quiz_lesson: True, world.plain_lesson: False}
    assert got["tests"] == [{"id": test["id"], "date": test["date"].isoformat(), "lessons": got["tests"][0]["lessons"]}] and got["tests"][0]["lessons"]


# --- work-tabs-and-hints 2.1: the numbers on the teacher's tabs ---

def counts(client, who=TEACHER):
    return client.get("/api/teaching/counts", headers=auth(who)).json()


def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
    assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0, "work": 0}
    for _ in range(3):
        world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    b = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
    exam = world.store.create_test(world.a["id"], clock.school_day() + 5 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    give(world, kind="exercise_test", test_id=exam["id"], exercise_number=1)
    submit(world, a["id"], KID)
    submit(world, a["id"], KID2)
    submit(world, b["id"], KID)
    assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3, "progress": 2, "ai": 0, "work": 0}


def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
    a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
    test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
    sub = submit(world, a["id"], KID)
    assert counts(world)["inbox"] == 1
    world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 80, "seen": sub["handed_at"].isoformat()}, headers=auth(TEACHER))
    assert counts(world)["inbox"] == 0
    world.store.remove_test(test["id"])
    world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
    assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0, "work": 0}


def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
    give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
    world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
    assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0, "progress": 1, "ai": 0, "work": 0}
    assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0, "progress": 2, "ai": 0, "work": 0}
    assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0, "progress": 3, "ai": 0, "work": 0}
    assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403