diff --git a/docs/tasks/prd-mathly.md b/docs/tasks/prd-mathly.md index f676792acef4590929c72ec8f87e2cb720125418..b5f5d705b5a26f10b1eae2ffa6596e3ebdca2559 100644 --- a/docs/tasks/prd-mathly.md +++ b/docs/tasks/prd-mathly.md @@ -18,7 +18,7 @@ The app has five roles (see 4.1): Students can also get help with a photographed problem and have handwritten answers checked, always through their teacher. - **Teachers** give and check assignments, set tests, review the AI's checks of their students' work, and see the progress of the classes they teach, per skill and per lesson. -- **Managers** create the classes: name, lessons, teacher and students. +- **Managers** create the classes: name, teacher and students. - **Admins** give roles to accounts. - **Developers** (the app's operators) have every permission and the developer tools. @@ -34,7 +34,7 @@ reused as files (see 7.1); everything else is rebuilt. | skill | מיומנות | A specific topic, e.g. פירוק טרינום. Shown on the path as a star | | exercise | תרגיל | A question on a certain topic. Can be either open-ended or multiple choice | | lesson | שיעור | A set of skills, e.g. טכניקה אלגברית 1. Shown on the path as a hub with its skills around it | -| class | כיתה | A named group of students with one teacher and the lessons it studies | +| class | כיתה | A named group of students with one teacher. Every class studies the whole course | | quiz | בוחן | A test on a single lesson. Can be retaken; best grade wins; each retake draws new questions. It is the only quiz type (no one-time summary quiz); its best grade is the lesson's grade | | exercise test | מבחן תרגול | A timed test on a set of lessons. Can be retaken; best grade wins | | simulation test | מבחן סימולציה | A timed test on a set of lessons. One time only | @@ -82,7 +82,7 @@ reused as files (see 7.1); everything else is rebuilt. - As a teacher, I want to see how much of each lesson each student has covered. **Manager** -- As a manager, I want to create a class with its lessons, its teacher and its students, so a new group can start practicing. +- As a manager, I want to create a class with its teacher and its students, so a new group can start practicing. - As a manager, I want to edit or delete a class and set its daily AI budget, so I can keep rosters and costs right. **Admin** @@ -135,15 +135,14 @@ Requirements are grouped; each id (for example `ACC-3`) is stable and is what Op - **CLS-1 Who manages classes.** Only a manager (and a developer) can create, edit and delete classes. They do it in the classes screen (MGR-1). A class's teacher can also add and remove the students of that class, and nothing else about it (TCH-9). -- **CLS-2 A class** has: a **name** (1–80 characters, unique ignoring case), the **lessons** it studies (chosen from the exercise bank, at least one), - its **students** (email addresses), one **teacher** (an email address), an **AI switch** (off for a new class; see AI-10) and a **daily AI budget** (a number of AI checks a day, default 150, used only while the AI is on). +- **CLS-2 A class** has: a **name** (1–80 characters, unique ignoring case), its **students** (email addresses), one **teacher** (an email address), an **AI switch** (off for a new class; see AI-10) and a **daily AI budget** (a number of AI checks a day, default 150, used only while the AI is on). - **CLS-3 Adding students.** The manager (or the class's teacher) pastes email addresses, one per line (commas and spaces also separate them). Repeated addresses are ignored and invalid ones are reported. A student may belong to more than one class. - **CLS-4 The teacher.** A class has at most one teacher, given by email. A teacher is never also a student of the same class. A teacher may teach several classes. -- **CLS-5 Editing.** The manager can change a class's name, lessons, students, teacher and AI budget. Removing a student from a class does not delete that student's progress. +- **CLS-5 Editing.** The manager can change a class's name, students, teacher and AI budget. Removing a student from a class does not delete that student's progress. - **CLS-6 Deleting.** The manager can delete a class after confirming. Deleting a class does not delete its students' progress. -- **CLS-7 Class list.** The list shows, for each class, its name, lessons, number of students and their emails, and its teacher, with shortcuts to edit and delete. -- **CLS-8 Lessons in the path.** A student's path shows the lessons of their classes (all of them, when the student is in several classes). +- **CLS-7 Class list.** The list shows, for each class, its name, number of students and their emails, and its teacher, with shortcuts to edit and delete. +- **CLS-8 Lessons in the path.** A class has no chosen lessons: every class studies every lesson of the bank, one course called "מתמטיקה כיתה ט - מצוינות". A student in at least one class sees every lesson in the path. Within those lessons everything is open (PTH-4). ### 4.3 The exercise bank (BNK) @@ -179,8 +178,10 @@ The path is the student's main screen: a trail of stars. - **PTH-7 Accessibility.** Each star is a control whose accessible name is its number, the skill's name and its state in words. It can be reached and opened from the keyboard. Animations stop when the device asks for reduced motion. The path is dark, like the rest of the app. - **PTH-9 Choosing a subject.** After sign-in a student first sees the subjects of their classes as cards (as in mathly1), each with an icon, the subject's name - and a bar "N מתוך M מיומנויות" (skills mastered of all skills in the student's lessons). Today the only subject is math (מתמטיקה), so there is one card. - Tapping a card opens that subject's home (STU-1, as in mathly1), and the tab bar leads from there to the path, the grades and the collection; the subject's screens have a way back to the subjects. A reload or a link to a skill (`#/skill/…`) opens its path directly. + and a bar "N מתוך M מיומנויות" (skills mastered of all skills in the student's lessons). Today the only subject is math, named "מתמטיקה כיתה ט - מצוינות", so there is one card. + Tapping a card opens that subject's home (STU-1, as in mathly1), and the tab bar leads from there to the path, the submissions, the grades and the collection; the subject's screens have a way back to the subjects. A reload or a link to a skill (`#/skill/…`) opens its path directly. +- **PTH-10 Completed lessons.** A lesson is **completed** when every one of its skills is mastered (about 50 exercises: five skills of ten questions), or when its quiz was passed (QZ-7). The hub says "הושלם". +- **PTH-11 The curriculum.** Each lesson may have a **date** by which it should be completed, one curriculum for the whole school, set by a manager (MGR-3). The path marks every lesson that should be finished by now: its date has passed and it is not completed. The hub shows a marker and "היה אמור להסתיים ב־", and the top of the path says how many lessons are behind. A lesson without a date, with a date ahead, or completed is not marked. Nothing is locked (PTH-4). - **PTH-8 Phone first.** The path fits a 360px-wide screen without sideways scrolling, including the lesson with the most skills. ### 4.5 The exercise screen (EXR) @@ -271,10 +272,11 @@ The exercise screen is where a student practices one skill. - **QZ-1 Quiz per lesson.** Every lesson has one quiz: the lesson's "boss level", a node on the trail after the lesson's ring. It is always open (PTH-4). The student can retake it at any time; the best grade counts, and the quiz's best grade is the lesson's grade. -- **QZ-2 Format.** A quiz is **10 multiple-choice questions** drawn from the bank questions of the lesson's skills, at least one per skill and the rest spread evenly across the skills, in **15 minutes**. +- **QZ-2 Format.** A quiz is **15 multiple-choice questions** drawn from the bank questions of the lesson's skills, at least one per skill and the rest spread evenly across the skills, in **20 minutes**. It is **passed** with a grade of at least 87 (13 right out of 15); a pass stays whatever a later retake scores (QZ-7). The timer runs on the server; when it ends the quiz is submitted as it stands and unanswered questions count as wrong. Skill names are not shown and there is no feedback until the end. The smart board and the calculator are available. -- **QZ-3 Retakes draw new questions.** Each retake prefers questions the student has not had in earlier attempts at this lesson's quiz, the least-used first. The bank has at least 10 questions per skill, so this is always possible. +- **QZ-3 Retakes draw new questions.** Each retake prefers questions the student has not had in earlier attempts at this lesson's quiz, the least-used first. While the lesson has enough unused questions (four skills of ten give two quizzes in a row without a repeat), none repeats; after that the least-used come first. +- **QZ-7 Passing.** A quiz is passed with a grade of at least 87 (13 of 15). Passing completes the lesson (PTH-10) and does not change any skill's mastery. - **QZ-4 After a quiz.** The student sees the grade, the skills where points were lost (each linked to its star, so they can practice before retaking) and, for each missed question, the name and tip of the mistake they made (as in EXR-5). The correct answers are never shown, so a retake tests understanding and not memory. - **QZ-5 Grades on the server.** Quiz grades and timers are computed and stored on the server. @@ -382,13 +384,14 @@ The exercise screen is where a student practices one skill. ### 4.16 Student screens (STU) -A tab bar at the bottom of the screen leads between them. +A tab bar at the bottom of the screen leads between them: home, path, submissions (STU-7), grades and collection. -- **STU-1 Home:** my assignments (המשימות שלי) from the teacher; the upcoming-test reminder (TST-3); a button that continues the path at the next skill; my AI requests and their state (AI-1). +- **STU-1 Home** shows only what is critical: the nearest test's reminder with its readiness and today's task (TST-3), a required simulation, the **new** assignments (המשימות שלי: not handed in yet), and a button that continues the path at the next skill; my AI requests and their state (AI-1). - **STU-2 Path:** see 4.4. - **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades. - **STU-4 Collection:** see MST-7. - **STU-5 Assignments.** The student opens an assignment, works on it (on the smart board, or by photographing paper; ANS-1) and hands it in; they see the teacher's grade and note, or that it was sent back for correction. +- **STU-7 Submissions tab (הגשות).** Lists every assignment from the student's teachers, grouped by state: new, sent back for correction, handed in and waiting for a check, and checked (with the grade and note). Each opens its page; one past its due date and not handed in is marked "באיחור". The switch for phone notifications (PSH-1) is at the bottom of this tab, not on home. - **STU-6 Photo help.** A camera button lets the student photograph a problem (AI-3). Pressing it opens the device's camera or gallery. The picture is shrunk on the device and its metadata is dropped before it is sent (JPEG, PNG or WebP). A sheet then shows the student's picture and the request's state, and the hints once the teacher has sent it. The button appears in two places, and only for students of a class with the AI on (AI-10): @@ -399,7 +402,7 @@ A tab bar at the bottom of the screen leads between them. ### 4.17 Teacher screens (TCH) -- **TCH-1 Teacher home.** A teacher's home lists the classes they teach, each with its students and a shortcut to its progress. A teacher does not see the student path as their main screen. A teacher who teaches no class is told that a manager chooses who teaches each class. +- **TCH-1 Teacher home.** A teacher's home lists the classes they teach, each with its students and a shortcut to its progress. A teacher does not see the student path as their main screen. A teacher who teaches no class is told that a manager chooses who teaches each class. Each tab of the teacher's screen shows how many items are in it: classes, tests, assignments, and submissions waiting in the inbox. - **TCH-2 Give an assignment.** The teacher creates an assignment (title, note, due date) and loads its content from: - the computer (file upload: PDF or images); - a photo taken with the device camera; @@ -424,6 +427,7 @@ A tab bar at the bottom of the screen leads between them. ### 4.18 Manager screens (MGR) - **MGR-1 Classes.** The manager's home lists all classes (CLS-7) with a button for a new class, and an editor for creating and changing a class (CLS-2 – CLS-6). When a save is refused (for example the teacher is also one of the students), the editor says why right above the save button, in view, and keeps what was typed. +- **MGR-3 Curriculum.** A tab "תכנית לימודים" lists every lesson in the bank's order, with the bank's month as a hint and a date the manager can set, change or clear (PTH-11). - **MGR-2 Students.** A list of all students with the classes each belongs to. ### 4.19 Admin screens (ADM) @@ -460,6 +464,7 @@ A tab bar at the bottom of the screen leads between them. - Hebrew, right-to-left. Works on phones first; the teacher, manager, admin and developer views (heat map, coverage, classes, roles) must also work well on a computer. - The look follows the path's "sky" theme: a dark-blue night with aurora and starlight accents, gold for mastery, and an owl mascot that perches on the next skill. The app is always dark, as mathly1 is: the page background is a dark night with two soft glows and small fixed stars; it does not move. There is no light mode. +- A button whose action takes longer than about a third of a second shows it (a spinner, a second press ignored), everywhere in the app; with reduced motion it shows "…". - Math is written in `$…$` text in the exercise bank; the app must render it as formulas. - State and result never rely on color alone: every state has a symbol or a word (stars, heat map cells, feedback). - Mastered skills must look clearly different from "learning" and "not started" ones. @@ -502,6 +507,7 @@ Rebuilt, not copied as a whole; read the places named in each requirement group. Settings come from, in order, real environment variables, the app's own `.env` if it has one, and the repository's `.env` (the one mathly1 uses, with the same keys); earlier sources win. With Supabase unset, local development uses an in-memory store and a "dev only" email sign-in, and a half-configured server refuses to start. The app page and its styles and scripts are sent with `Cache-Control: no-cache`, so after an update every device gets the new version without clearing its cache (an unchanged file is still answered from the cache). The page declares mathly1's icon (`static/icons/`), shown in the browser tab. The server reads the same fact once per request, makes independent database reads at the same time, and reuses its connections: a student's screen waits for the database at most 3 times in a row and a practice answer at most 4 (each call to a remote database costs about 0.4 seconds). + The server remembers the classes, their students, the roles, the tests and the assignments for at most 30 seconds (`MATHLY_CACHE_SECONDS`, 0 turns it off) and forgets them at once when the app changes one; a screen opened again shows what it last knew and refreshes it, without redrawing over something being typed. One server process is assumed. Further settings: the AI key and model (`ANTHROPIC_API_KEY`, `MATH_MODEL`), the push keys (`VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`), the private bucket for answer pictures, and the teacher and developer email lists. - **Server decides.** Correctness of answers, the date of each answer, mastery, grades, the budgets, and the simulation and exercise-test timers are decided and stored on the server. Grades that matter cannot be changed from the device. diff --git a/mathly2/assignments.py b/mathly2/assignments.py new file mode 100644 index 0000000000000000000000000000000000000000..a8aa7e5c2d7719c5d266157cd132561ccd5d008e --- /dev/null +++ b/mathly2/assignments.py @@ -0,0 +1,390 @@ +"""Assignments: a teacher gives work to a class (pages from a file, a photo or the board; one of the bank's written quizzes; or an exercise test +of a real test), students hand in pictures, the teacher checks them from one inbox.""" +from __future__ import annotations + +from datetime import date as Day +from datetime import datetime +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from . import attempts, cleanup, clock, pictures, reads +from .access import allowed, learner, need +from .pictures import PictureError +from .realtests import _my_class, lesson_names +from .written import picture_error + +router = APIRouter(prefix="/api") +MAX_PAGES = 20 + + +class NewAssignment(BaseModel): + title: str = Field(min_length=1, max_length=200) + note: str = Field(default="", max_length=1000) + due_date: Day + kind: Literal["pages", "quiz", "exercise_test"] + pages: list[str] = Field(default_factory=list, max_length=MAX_PAGES) + quiz_lesson: str | None = None + test_id: int | None = None + exercise_number: Literal[1, 2] | None = None + + +def written_quiz(bank, lesson_id: str) -> dict | None: + """The authored written quiz of a lesson (minutes and questions with points), or None.""" + unit = next((u for u in bank.data["units"] if u["id"] == lesson_id), None) + return (unit or {}).get("quiz") or None + + +def quiz_view(bank, lesson_id: str) -> dict | None: + quiz = written_quiz(bank, lesson_id) + if not quiz: + return None + return {"minutes": quiz["minutes"], "points": sum(q["points"] for q in quiz["questions"]), + "questions": [{k: q[k] for k in ("question", "points", "figure", "graph") if k in q} for q in quiz["questions"]]} + + +# ---- the teacher: pages, giving, listing, removing ---- + +@router.post("/assignments/pages", status_code=201) +async def upload_pages(request: Request, user: dict = Depends(need("class.teach"))) -> dict: + """One file of an assignment: an image (one page) or a PDF (a page per sheet). Returns the page pictures' ids, in order.""" + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > pictures.MAX_PDF + 1024: + raise picture_error(PictureError(413, "pdf_too_large")) + data = await request.body() + kind = request.headers.get("content-type", "").split(";")[0].strip().lower() + + def work() -> list[str]: + sheets = pictures.render_pdf(data) if kind == "application/pdf" else [pictures.clean_image(data, kind)] + made = [pictures.store_picture(request.app.state.store, request.app.state.pictures, user["id"], "page", jpeg) for jpeg in sheets] + return [row["id"] for row in made] + try: + ids = await run_in_threadpool(work) + except PictureError as exc: + raise picture_error(exc) from exc + return {"pictures": ids} + + +def _checked_pages(request: Request, user: dict, ids: list[str]) -> list[str]: + store = request.app.state.store + found = list(dict.fromkeys(ids)) + for pid in found: + pic = store.get_picture(pid) + if pic is None or pic["kind"] != "page" or pic["owner_id"] != user["id"] or pic["deleted_at"]: + raise HTTPException(422, "אחד העמודים לא תקין") + return found + + +@router.post("/classes/{class_id}/assignments", status_code=201) +def give_assignment(class_id: int, body: NewAssignment, request: Request, user: dict = Depends(need("class.teach"))) -> dict: + klass = _my_class(request, user, class_id) + store, bank, today = request.app.state.store, request.app.state.bank, clock.school_day() + if body.due_date < today: + raise HTTPException(422, "תאריך ההגשה כבר עבר") + fields: dict = {} + if body.kind == "pages": + fields["pages"] = _checked_pages(request, user, body.pages) + if not fields["pages"]: + raise HTTPException(422, "צריך לפחות עמוד אחד") + elif body.kind == "quiz": + if body.quiz_lesson not in klass["lessons"] or not written_quiz(bank, body.quiz_lesson): + raise HTTPException(422, "לשיעור הזה אין בוחן כתוב, או שהוא לא של הכיתה") + fields["quiz_lesson"] = body.quiz_lesson + else: + test = store.get_test(body.test_id) if body.test_id is not None else None + if test is None or test["removed_at"] or test["class_id"] != class_id or body.exercise_number is None: + raise HTTPException(422, "המבחן לא נמצא בכיתה הזו") + fields.update(test_id=body.test_id, exercise_number=body.exercise_number) + row = store.create_assignment(class_id, body.title.strip(), body.due_date, user["email"], body.kind, note=body.note.strip(), + created_at=clock.now(), **fields) + return teacher_assignment(request, row, klass, [], today) + + +def teacher_assignment(request: Request, row: dict, klass: dict, subs: list[dict], today: Day) -> dict: + """An assignment as its teacher sees it, with how many students handed in, were checked, were sent back, and (for an exercise test) did it.""" + store, here = request.app.state.store, reads.of(request) + members = store.members(klass["id"]) + out = {"id": row["id"], "class": {"id": klass["id"], "name": klass["name"]}, "title": row["title"], "note": row["note"], "kind": row["kind"], + "due_date": row["due_date"].isoformat(), "days_left": (row["due_date"] - today).days, "students": len(members), "pages": row["pages"]} + if row["kind"] == "quiz": + out["quiz_lesson"] = {"id": row["quiz_lesson"], "name": (lesson_names(request, [row["quiz_lesson"]]) or [""])[0]} + if row["kind"] == "exercise_test": + test = store.get_test(row["test_id"]) + out["test"] = {"id": row["test_id"], "number": row["exercise_number"], "date": test["date"].isoformat() if test else None} + scope = f'{row["test_id"]}:{row["exercise_number"]}' + ids = [m["user_id"] for m in members if m["user_id"]] + here.prefetch(*[("attempts_of", uid, "exercise_test") for uid in ids]) + out["done"] = sum(1 for uid in ids if any(r["scope"] == scope and not attempts.is_open(r) for r in here.attempts_of(uid, "exercise_test"))) + else: + out.update(handed_in=sum(1 for s in subs if s["state"] == "handed_in"), graded=sum(1 for s in subs if s["state"] == "graded"), + returned=sum(1 for s in subs if s["state"] == "returned")) + out["waiting"] = out["handed_in"] + return out + + +@router.get("/teaching/assignments") +def teaching_assignments(request: Request, user: dict = Depends(need("class.teach"))) -> list[dict]: + """The assignments of the classes I teach (a developer: every class), with the counts.""" + store, today = request.app.state.store, clock.school_day() + classes = store.classes() if allowed(user["roles"], "*") else store.classes_taught(user["email"]) + result = [] + for klass in classes: + rows = store.assignments_of_classes([klass["id"]]) + subs = store.submissions_of_assignments([r["id"] for r in rows]) + bank = request.app.state.bank + result.append({"class": {"id": klass["id"], "name": klass["name"], + "lessons": [{"id": i, "name": n, "has_quiz": bool(written_quiz(bank, i))} for i, n in zip(klass["lessons"], lesson_names(request, klass["lessons"]))], + "tests": [{"id": t["id"], "date": t["date"].isoformat(), "lessons": lesson_names(request, t["lessons"])} for t in store.tests_of_classes([klass["id"]])]}, + "assignments": [teacher_assignment(request, r, klass, [s for s in subs if s["assignment_id"] == r["id"]], today) for r in rows]}) + return result + + +@router.delete("/assignments/{assignment_id}", status_code=204) +def remove_assignment(assignment_id: int, request: Request, user: dict = Depends(need("class.teach"))) -> None: + store = request.app.state.store + row = store.get_assignment(assignment_id) + if row is None or row["removed_at"]: + raise HTTPException(404, "המשימה לא נמצאה") + _my_class(request, user, row["class_id"]) + store.remove_assignment(assignment_id, clock.now()) + + +# ---- the student: my assignments and handing in ---- + +MAX_ANSWER_PICTURES = 10 +OPEN_STATES = ("not_handed", "returned", "not_done") + + +def _my_assignment(request: Request, user: dict, assignment_id: int) -> dict: + """An assignment of one of the student's classes (404 for anything else, and for a removed one).""" + here = reads.of(request) + row = here.get_assignment(assignment_id) + mine = {c["id"] for c in here.classes_of_student(user["email"])} + if row is None or row["removed_at"] or row["class_id"] not in mine: + raise HTTPException(404, "המשימה לא נמצאה") + return row + + +def _exercise_result(request: Request, user: dict, row: dict) -> tuple[str, int | None]: + """The state of an exercise-test assignment: done once the student has a submitted attempt at it, and the best grade of those.""" + scope = f'{row["test_id"]}:{row["exercise_number"]}' + grades = [r["grade"] for r in reads.of(request).attempts_of(user["id"], "exercise_test") if r["scope"] == scope and not attempts.is_open(r)] + return ("done", max(grades)) if grades else ("not_done", None) + + +def student_assignment(request: Request, user: dict, row: dict, sub: dict | None, today: Day) -> dict: + """An assignment as its student sees it: its state, grade and the teacher's note.""" + out = {"id": row["id"], "title": row["title"], "note": row["note"], "kind": row["kind"], "due_date": row["due_date"].isoformat(), + "days_left": (row["due_date"] - today).days, "grade": None, "teacher_note": "", "late": False} + klass = reads.of(request).get_class(row["class_id"]) + out["class"] = {"id": row["class_id"], "name": klass["name"] if klass else ""} + if row["kind"] == "exercise_test": + out["state"], out["grade"] = _exercise_result(request, user, row) + out["test"] = {"id": row["test_id"], "number": row["exercise_number"]} + elif sub is None: + out["state"] = "not_handed" + else: + out.update(state=sub["state"], grade=sub["grade"], teacher_note=sub["teacher_note"], late=sub["late"], handed_at=sub["handed_at"].isoformat()) + out["overdue"] = out["days_left"] < 0 and out["state"] in OPEN_STATES + return out + + +@router.get("/assignments") +def my_assignments(request: Request, user: dict = Depends(learner)) -> list[dict]: + """The student's assignments: the open ones first by due date, then the done ones.""" + here, today = reads.of(request), clock.school_day() + class_ids = [c["id"] for c in here.classes_of_student(user["email"])] + here.prefetch(("assignments_of_classes", class_ids), ("submissions_of_user", user["id"]), ("attempts_of", user["id"], "exercise_test")) + subs = {s["assignment_id"]: s for s in here.submissions_of_user(user["id"])} + shown = [student_assignment(request, user, r, subs.get(r["id"]), today) for r in here.assignments_of_classes(class_ids)] + return sorted(shown, key=lambda a: (a["state"] not in OPEN_STATES, a["due_date"], a["id"])) + + +@router.get("/assignments/{assignment_id}") +def my_assignment(assignment_id: int, request: Request, user: dict = Depends(learner)) -> dict: + row = _my_assignment(request, user, assignment_id) + here, bank, today = reads.of(request), request.app.state.bank, clock.school_day() + sub = here.get_submission(assignment_id, user["id"]) + out = student_assignment(request, user, row, sub, today) + out["pages"] = row["pages"] + if row["kind"] == "quiz": + out["quiz"] = quiz_view(bank, row["quiz_lesson"]) + pics = [] + for pid in (sub["pictures"] if sub else []): + pic = request.app.state.store.get_picture(pid) + pics.append({"id": pid, "deleted": bool(pic and pic["deleted_at"])}) + out["pictures"] = pics + return out + + +class Handing(BaseModel): + pictures: list[str] = Field(min_length=1, max_length=MAX_ANSWER_PICTURES) + + +@router.post("/assignments/{assignment_id}/submission") +def hand_in(assignment_id: int, body: Handing, request: Request, user: dict = Depends(learner)) -> dict: + """Hand in (or replace, or hand in again after a return) the student's picture answers. A graded submission cannot be changed.""" + store, today = request.app.state.store, clock.school_day() + row = _my_assignment(request, user, assignment_id) + if row["kind"] == "exercise_test": + raise HTTPException(422, "מבחן תרגול לא מגישים בתמונה: עושים אותו באפליקציה") + existing = store.get_submission(assignment_id, user["id"]) + if existing and existing["state"] == "graded": + raise HTTPException(409, "המשימה כבר נבדקה ולא אפשר לשנות אותה") + ids = list(dict.fromkeys(body.pictures)) + for pid in ids: + pic = store.get_picture(pid) + if pic is None or pic["kind"] != "answer" or pic["owner_id"] != user["id"] or pic["deleted_at"]: + raise HTTPException(422, "אחת התמונות לא תקינה") + saved = store.save_submission(assignment_id, user["id"], pictures=ids, state="handed_in", late=today > row["due_date"], handed_at=clock.now(), + grade=None, teacher_note="" if not existing else existing["teacher_note"], checked_at=None, checked_by=None) + return student_assignment(request, user, row, saved, today) + + +# ---- the teacher: the inbox and checking ---- + +class Check(BaseModel): + grade: int = Field(ge=0, le=100) + note: str = Field(default="", max_length=2000) + seen: str # the handed_at the teacher was looking at + + +class SendBack(BaseModel): + note: str = Field(min_length=1, max_length=2000) + seen: str + + +def _teachable_classes(request: Request, user: dict) -> list[dict]: + store = request.app.state.store + return store.classes() if allowed(user["roles"], "*") else store.classes_taught(user["email"]) + + +def _student_emails(request: Request, class_id: int) -> dict[str, str]: + return {m["user_id"]: m["email"] for m in request.app.state.store.members(class_id) if m["user_id"]} + + +def inbox_item(request: Request, sub: dict, assignment: dict, klass: dict, emails: dict[str, str]) -> dict: + return {"kind": "submission", "id": sub["id"], "assignment": {"id": assignment["id"], "title": assignment["title"], "kind": assignment["kind"], + "class": {"id": klass["id"], "name": klass["name"]}}, + "student": emails.get(sub["user_id"], ""), "handed_at": sub["handed_at"].isoformat(), "late": sub["late"], "seen": sub["handed_at"].isoformat(), + "pictures": [{"id": p} for p in sub["pictures"]]} + + +@router.get("/teaching/inbox") +def inbox(request: Request, user: dict = Depends(need("class.teach"))) -> list[dict]: + """Everything the teacher's students handed in that waits for them, oldest first. A submission sent back waits for the student and is not here.""" + store, items = request.app.state.store, [] + for klass in _teachable_classes(request, user): + rows = {r["id"]: r for r in store.assignments_of_classes([klass["id"]]) if r["kind"] != "exercise_test"} + emails = _student_emails(request, klass["id"]) + items += [inbox_item(request, s, rows[s["assignment_id"]], klass, emails) for s in store.submissions_of_assignments(list(rows)) if s["state"] == "handed_in"] + return sorted(items, key=lambda i: (i["handed_at"], i["id"])) + + +def _my_submission(request: Request, user: dict, submission_id: int) -> tuple[dict, dict, dict]: + store = request.app.state.store + sub = store.get_submission_by_id(submission_id) + row = store.get_assignment(sub["assignment_id"]) if sub else None + if sub is None or row is None or row["removed_at"]: + raise HTTPException(404, "ההגשה לא נמצאה") + return sub, row, _my_class(request, user, row["class_id"]) + + +@router.get("/submissions/{submission_id}") +def open_submission(submission_id: int, request: Request, user: dict = Depends(need("class.teach"))) -> dict: + sub, row, klass = _my_submission(request, user, submission_id) + out = inbox_item(request, sub, row, klass, _student_emails(request, klass["id"])) + out.update(state=sub["state"], grade=sub["grade"], teacher_note=sub["teacher_note"], pages=row["pages"], note=row["note"], + quiz=quiz_view(request.app.state.bank, row["quiz_lesson"]) if row["kind"] == "quiz" else None) + out["pictures"] = [{"id": p, "deleted": bool((request.app.state.store.get_picture(p) or {}).get("deleted_at"))} for p in sub["pictures"]] + return out + + +def _check(request: Request, user: dict, submission_id: int, seen: str) -> tuple[dict, dict]: + sub, row, _ = _my_submission(request, user, submission_id) + try: + saw = datetime.fromisoformat(seen) + except ValueError: + raise HTTPException(422, "seen לא תקין") from None + if sub["state"] != "handed_in": + raise HTTPException(409, "ההגשה כבר נבדקה או הוחזרה") + if saw != sub["handed_at"]: + raise HTTPException(409, "ההגשה השתנתה. פתחו אותה שוב.") + return sub, row + + +@router.post("/submissions/{submission_id}/grade") +def grade_submission(submission_id: int, body: Check, request: Request, user: dict = Depends(need("class.teach"))) -> dict: + sub, _ = _check(request, user, submission_id, body.seen) + saved = request.app.state.store.save_submission(sub["assignment_id"], sub["user_id"], state="graded", grade=body.grade, teacher_note=body.note.strip(), + checked_at=clock.now(), checked_by=user["email"]) + return {"id": saved["id"], "state": saved["state"], "grade": saved["grade"]} + + +@router.post("/submissions/{submission_id}/return") +def return_submission(submission_id: int, body: SendBack, request: Request, user: dict = Depends(need("class.teach"))) -> dict: + sub, _ = _check(request, user, submission_id, body.seen) + if not body.note.strip(): + raise HTTPException(422, "צריך לכתוב מה לתקן") + saved = request.app.state.store.save_submission(sub["assignment_id"], sub["user_id"], state="returned", grade=None, teacher_note=body.note.strip(), + checked_at=clock.now(), checked_by=user["email"]) + return {"id": saved["id"], "state": saved["state"]} + + +# ---- the grades screen's rows ---- + +def grade_rows(request: Request, user: dict) -> list[dict]: + """Graded assignments and done exercise-test assignments, with the teacher's note. A removed assignment keeps its grade.""" + store, here = request.app.state.store, reads.of(request) + class_ids = [c["id"] for c in here.classes_of_student(user["email"])] + rows = {r["id"]: r for r in store.assignments_of_classes(class_ids, include_removed=True)} + out = [] + subs = {s["assignment_id"]: s for s in store.submissions_of_user(user["id"])} + here.prefetch(("attempts_of", user["id"], "exercise_test")) + for row in rows.values(): + item = {"assignment": {"id": row["id"], "title": row["title"], "kind": row["kind"], "due_date": row["due_date"].isoformat(), "removed": bool(row["removed_at"])}} + if row["kind"] == "exercise_test": + state, grade = _exercise_result(request, user, row) + if state == "done": + out.append({**item, "grade": grade, "note": ""}) + elif row["id"] in subs and subs[row["id"]]["state"] == "graded": + out.append({**item, "grade": subs[row["id"]]["grade"], "note": subs[row["id"]]["teacher_note"]}) + return sorted(out, key=lambda r: (r["assignment"]["due_date"], r["assignment"]["id"])) + + +# ---- deleting a student's pictures on request ---- + +@router.delete("/classes/{class_id}/pictures/{email:path}") +def delete_student_pictures(class_id: int, email: str, request: Request, user: dict = Depends(need("class.roster"))) -> dict: + """Delete a student's answer pictures for this class's assignments now. The class's teacher, a manager or a developer may. The submissions + keep their grade and note.""" + store = request.app.state.store + klass = store.get_class(class_id) + is_teacher = klass is not None and (klass["teacher_email"] or "").lower() == user["email"] + if klass is None or not (is_teacher or allowed(user["roles"], "classes.manage") or allowed(user["roles"], "*")): + raise HTTPException(404, "הכיתה לא נמצאה") + member = next((m for m in store.members(class_id) if m["email"] == email.strip().lower()), None) + if member is None: + raise HTTPException(404, "התלמיד לא נמצא בכיתה") + if not member["user_id"]: + return {"deleted": 0} # never signed in: nothing was handed in + ids = [a["id"] for a in store.assignments_of_classes([class_id], include_removed=True)] + return {"deleted": cleanup.delete_answer_pictures(store, request.app.state.pictures, ids, member["user_id"], clock.now())} + + +# ---- the numbers on the teacher's tabs ---- + +@router.get("/teaching/counts") +def teaching_counts(request: Request, user: dict = Depends(need("class.teach"))) -> dict: + """How many items each tab of the teacher's screen holds: the classes they teach, their tests, their assignments, and the submissions waiting.""" + store = request.app.state.store + classes = _teachable_classes(request, user) + ids = [c["id"] for c in classes] + here = reads.of(request) + here.prefetch(("tests_of_classes", ids), ("assignments_of_classes", ids)) + tests, assignments = here.tests_of_classes(ids), here.assignments_of_classes(ids) + graded_by_student = [a["id"] for a in assignments if a["kind"] != "exercise_test"] + waiting = sum(1 for s in store.submissions_of_assignments(graded_by_student) if s["state"] == "handed_in") + return {"classes": len(classes), "tests": len(tests), "assignments": len(assignments), "inbox": waiting} diff --git a/mathly2/attempts.py b/mathly2/attempts.py index 396f5e53d2e6f51e2173f2870a14c8a3e493028b..f960f30f2e1266a54a08e29761f34ac8ecc82ece 100644 --- a/mathly2/attempts.py +++ b/mathly2/attempts.py @@ -6,8 +6,9 @@ from __future__ import annotations from datetime import datetime -QUIZ_SIZE = 10 -QUIZ_MINUTES = 15 +QUIZ_SIZE = 15 +QUIZ_MINUTES = 20 +PASS_RIGHT = 13 # right answers out of QUIZ_SIZE that pass a lesson quiz def percent(right: int, total: int) -> int: @@ -15,6 +16,9 @@ def percent(right: int, total: int) -> int: return 0 if total <= 0 else (200 * right + total) // (2 * total) +PASS_GRADE = percent(PASS_RIGHT, QUIZ_SIZE) # 87: a quiz is passed with this grade or better + + def _right(bank, question: dict, answers: dict) -> bool: choice = answers.get(question["key"]) graded = bank.grade(question["skill_id"], question["key"], choice) if isinstance(choice, int) else None diff --git a/mathly2/cached.py b/mathly2/cached.py new file mode 100644 index 0000000000000000000000000000000000000000..8e40f6d546a4cb9dbe2d3bccd4fa9601232cded7 --- /dev/null +++ b/mathly2/cached.py @@ -0,0 +1,69 @@ +"""A store that remembers slow-changing reads (classes, members, roles, tests, assignments) for a short time. + +Any change made through it to one of those forgets everything remembered at once. Rows come back as copies. +""" +from __future__ import annotations + +import copy +import threading +import time + +READS = frozenset({ + "classes", "classes_of_student", "classes_taught", "members", "get_class", "roles_of", + "tests_of_classes", "get_test", "assignments_of_classes", "get_assignment", "all_students", "accounts", "curriculum", +}) +CHANGES = frozenset({ + "create_class", "update_class", "delete_class", "add_member", "remove_member", "give_role", "take_role", "link_user", + "create_test", "update_test", "remove_test", "create_assignment", "remove_assignment", "set_lesson_dates", +}) + + +def _key(name: str, args: tuple, kwargs: dict): + def freeze(v): + if isinstance(v, (set, frozenset)): + return tuple(sorted(v, key=repr)) + if isinstance(v, (list, tuple)): + return tuple(freeze(x) for x in v) + return v + return name, freeze(args), tuple(sorted((k, freeze(v)) for k, v in kwargs.items())) + + +class CachedStore: + def __init__(self, store, seconds: float, clock=time.monotonic) -> None: + self._store, self._seconds, self._clock = store, seconds, clock + self._lock = threading.Lock() + self._kept: dict = {} # key -> (expires, value) + self._generation = 0 # bumped by every change, so a read that began before one is not kept + + def forget(self) -> None: + with self._lock: + self._kept.clear() + self._generation += 1 + + def __getattr__(self, name: str): + target = getattr(self._store, name) + if self._seconds <= 0 or not callable(target): + return target + if name in CHANGES: + def change(*args, **kwargs): + try: + return target(*args, **kwargs) + finally: + self.forget() + return change + if name in READS: + def read(*args, **kwargs): + key = _key(name, args, kwargs) + now = self._clock() + with self._lock: + hit = self._kept.get(key) + if hit and hit[0] > now: + return copy.deepcopy(hit[1]) + generation = self._generation + value = target(*args, **kwargs) + with self._lock: + if generation == self._generation: + self._kept[key] = (now + self._seconds, copy.deepcopy(value)) + return value + return read + return target diff --git a/mathly2/classes.py b/mathly2/classes.py index 0a8bc6e1ea45b49e6a44c403105da6bd924f164f..72291aca0b1dde1865ba9921ead2f2175c6f716c 100644 --- a/mathly2/classes.py +++ b/mathly2/classes.py @@ -3,10 +3,12 @@ removes its students; a teacher sees the classes they teach; a manager sees ever from __future__ import annotations import re +from datetime import date from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field +from . import reads from .access import allowed, need from .auth import EMAIL from .store import ClassExists, MemberExists, TeacherIsStudent @@ -19,7 +21,7 @@ teaching = APIRouter(prefix="/api") class ClassBody(BaseModel): name: str = Field(max_length=200) - lessons: list[str] = Field(default_factory=list, max_length=200) + lessons: list[str] = Field(default_factory=list, max_length=200) # ignored: kept so older clients still work teacher_email: str | None = Field(default=None, max_length=300) students: str | None = Field(default=None, max_length=40000) # pasted text: the whole roster of the class ai_enabled: bool = False @@ -55,10 +57,7 @@ def checked(request: Request, body: ClassBody) -> dict: name = " ".join(body.name.split()) if not 1 <= len(name) <= MAX_NAME: raise HTTPException(422, f"שם הכיתה צריך להיות בין תו אחד ל־{MAX_NAME} תווים") - known = [lesson["id"] for lesson in request.app.state.bank.lessons] - lessons = [lesson for lesson in dict.fromkeys(body.lessons) if lesson in known] - if len(lessons) != len(set(body.lessons)) or not lessons: - raise HTTPException(422, "יש לבחור לפחות שיעור אחד מהרשימה") + lessons = [lesson["id"] for lesson in request.app.state.bank.lessons] # every class studies the whole course teacher = (body.teacher_email or "").strip().lower() or None if teacher and not EMAIL.match(teacher): raise HTTPException(422, "כתובת המורה לא נראית כמו מייל") @@ -100,13 +99,6 @@ def list_classes(request: Request, user: dict = Depends(need("classes.manage"))) return [shown(request, c) for c in request.app.state.store.classes()] -@router.get("/lessons") -def lessons_to_choose(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]: - """The bank's lessons, for choosing what a class studies (a manager has no student path, so no /api/lessons).""" - return [{"id": lesson["id"], "name": lesson["name"], "skills": len(lesson["skills"])} - for lesson in request.app.state.bank.lessons_with_skills()] - - @router.post("", status_code=201) def create_class(body: ClassBody, request: Request, user: dict = Depends(need("classes.manage"))) -> dict: fields = checked(request, body) @@ -199,3 +191,36 @@ def my_classes(request: Request, user: dict = Depends(need("class.teach"))) -> l @teaching.get("/students") def all_students(request: Request, user: dict = Depends(need("students.list"))) -> list[dict]: return request.app.state.store.all_students() + + +# ---- the curriculum: the date by which each lesson should be completed (one for the whole school) ---- + +curriculum = APIRouter(prefix="/api/curriculum") + + +class Dates(BaseModel): + dates: dict[str, str | None] = Field(max_length=200) # lesson id -> "YYYY-MM-DD", or null to clear + + +@curriculum.get("") +def read_curriculum(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]: + """Every lesson in the bank's order with its date (or null) and the bank's month as a hint.""" + dates = reads.of(request).curriculum() + return [{"id": lesson["id"], "name": lesson["name"], "month": lesson.get("when", ""), "due_date": dates[lesson["id"]].isoformat() if lesson["id"] in dates else None} + for lesson in request.app.state.bank.lessons] + + +@curriculum.put("") +def change_curriculum(body: Dates, request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]: + known = {lesson["id"] for lesson in request.app.state.bank.lessons} + parsed: dict[str, date | None] = {} + for lesson, text in body.dates.items(): + if lesson not in known: + raise HTTPException(422, "שיעור לא מוכר") + try: + parsed[lesson] = date.fromisoformat(text) if text else None + except ValueError: + raise HTTPException(422, "תאריך לא תקין") from None + request.app.state.store.set_lesson_dates(parsed) + request.state.reads = None # what this request read is out of date now + return read_curriculum(request, user) diff --git a/mathly2/cleanup.py b/mathly2/cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..56947484c0886e5b16bcc994571b63f9d3a1208f --- /dev/null +++ b/mathly2/cleanup.py @@ -0,0 +1,83 @@ +"""The hourly cleanup of pictures, and deleting a student's pictures on request. `python -m mathly2.cleanup` runs it once; the server runs it +every hour when it is connected to Supabase.""" +from __future__ import annotations + +import logging +import threading +from datetime import datetime + +from . import clock, pictures, retention + +log = logging.getLogger("mathly.cleanup") + + +def run(store, backend, now: datetime | None = None) -> dict: + """Remove pictures nothing refers to (after a day), answer pictures past their school year, and the pages of removed assignments. + Returns how many of each were removed. A picture whose bytes cannot be deleted now is left for the next run.""" + now = now or clock.now() + today = clock.school_day(now) + removed = {"orphans": 0, "expired": 0, "removed_assignments": 0} + for pic in store.pictures(): + refs = store.picture_refs(pic["id"]) + referenced = any(refs.values()) + if pic["kind"] == "answer" and retention.is_expired(clock.school_day(pic["created_at"]), today): + reason = "expired" + elif not referenced and now - pic["created_at"] > retention.ORPHAN_AGE: + reason = "orphans" + elif pic["kind"] == "page" and refs["assignments"] and all((store.get_assignment(a) or {}).get("removed_at") for a in refs["assignments"]): + reason = "removed_assignments" + else: + continue + try: + pictures.remove_picture(store, backend, pic, now) + removed[reason] += 1 + except pictures.PictureError as exc: + log.warning("could not delete a picture (%s); it stays for the next run", exc.code) + return removed + + +def delete_answer_pictures(store, backend, assignment_ids, user_id: str, now: datetime | None = None) -> int: + """Delete a student's answer pictures for these assignments now (a teacher of the class, a manager or a developer asked). The submissions + keep their grade and note. Returns how many pictures were deleted.""" + count = 0 + for sub in store.submissions_of_assignments(assignment_ids): + if sub["user_id"] != user_id: + continue + for pid in sub["pictures"]: + pic = store.get_picture(pid) + if pic and pic["kind"] == "answer" and pic["owner_id"] == user_id and not pic["deleted_at"]: + pictures.remove_picture(store, backend, pic, now) + count += 1 + return count + + +def start_hourly(store, backend, every: float = 3600.0) -> threading.Event: + """Run the cleanup now and then every `every` seconds in a background thread. Returns an event that stops it when set.""" + stop = threading.Event() + + def loop() -> None: + while True: + try: + log.info("cleanup removed %s", run(store, backend)) + except Exception: # a failed run must not stop the next one + log.exception("the picture cleanup failed") + if stop.wait(every): + return + + threading.Thread(target=loop, name="picture-cleanup", daemon=True).start() + return stop + + +def main() -> None: + from .config import load_settings + from .store import SupabaseStore + settings = load_settings() + if not settings.supabase_on: + raise SystemExit("Set the Supabase settings: the cleanup works on the real database.") + store = SupabaseStore(settings.supabase_url, settings.supabase_secret) + backend = pictures.SupabasePictures(settings.supabase_url, settings.supabase_secret, settings.answer_bucket) + print("Removed", run(store, backend)) + + +if __name__ == "__main__": + main() diff --git a/mathly2/config.py b/mathly2/config.py index 8006b6aa96d39875abf29d2110fa8a05a800b3e0..ab907e958a5abf27b82286a9c860070b86a36c5b 100644 --- a/mathly2/config.py +++ b/mathly2/config.py @@ -24,6 +24,10 @@ class Settings: teachers: frozenset[str] = field(default_factory=frozenset) developers: frozenset[str] = field(default_factory=frozenset) # the developer role comes only from here admins: frozenset[str] = field(default_factory=frozenset) # the first admins; others are given the role by an admin + answer_bucket: str = "m2-answers" # the private bucket for answer pictures (not mathly1's) + cache_seconds: float = 30 # how long slow-changing reads are remembered; 0 is off + vapid_private_key: str = "" # the server's push key; push notifications are off without it + vapid_subject: str = "" # a contact the push services may write to ("mailto:...") @property def supabase_on(self) -> bool: @@ -51,4 +55,8 @@ def load_settings(env_file: Path | None = None) -> Settings: teachers=_emails("TEACHER_EMAILS"), developers=_emails("DEVELOPER_EMAILS"), admins=_emails("ADMIN_EMAILS"), + answer_bucket=env("MATHLY_M2_ANSWER_BUCKET") or "m2-answers", + cache_seconds=float(env("MATHLY_CACHE_SECONDS") or 30), + vapid_private_key=env("VAPID_PRIVATE_KEY"), + vapid_subject=env("VAPID_SUBJECT"), ).check() diff --git a/mathly2/main.py b/mathly2/main.py index 92f8ecf09508854c14dd33de657368df469117e4..9f08a9fd304070c0e1ea46aa105e2b3728a51cab 100644 --- a/mathly2/main.py +++ b/mathly2/main.py @@ -3,19 +3,24 @@ from __future__ import annotations import json import logging +from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI, HTTPException from fastapi import Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .access import RoleResolver, learner, permissions_of from .auth import DevAuthenticator, SupabaseAuthenticator, current_user from .bank import Bank, load_bank -from . import accounts, classes, quizzes, reads, realtests, skills + +from . import accounts, assignments, classes, cleanup, quizzes, reads, realtests, skills, written from .config import Settings, load_settings +from .cached import CachedStore from .store import InMemoryStore, StoreUnavailable, SupabaseStore +from .pictures import MemoryPictures, PictureError, SupabasePictures +from .push import Pusher, default_subject STATIC = Path(__file__).parent / "static" @@ -31,29 +36,49 @@ class FreshStaticFiles(StaticFiles): log = logging.getLogger("mathly2") -def create_app(settings: Settings | None = None, bank: Bank | None = None, store=None, authenticator=None) -> FastAPI: - app = FastAPI(title="Mathly 2") +def create_app(settings: Settings | None = None, bank: Bank | None = None, store=None, authenticator=None, picture_store=None, pusher=None) -> FastAPI: + @asynccontextmanager + async def lifespan(application: FastAPI): + # Against the real database the pictures are tidied every hour (nothing runs without Supabase, so local runs and tests are quiet). + stop = cleanup.start_hourly(application.state.store, application.state.pictures) if application.state.settings.supabase_on else None + application.state.cleanup_stop = stop + yield + if stop: + stop.set() + + app = FastAPI(title="Mathly 2", lifespan=lifespan) app.state.settings = settings = (settings or load_settings()).check() app.state.bank = bank or load_bank() # a broken bank stops the server from starting if settings.supabase_on: - app.state.store = store or SupabaseStore(settings.supabase_url, settings.supabase_secret) + app.state.store = store or CachedStore(SupabaseStore(settings.supabase_url, settings.supabase_secret), settings.cache_seconds) app.state.authenticator = authenticator or SupabaseAuthenticator(settings.supabase_url, settings.supabase_key) else: app.state.store = store or InMemoryStore() app.state.authenticator = authenticator or DevAuthenticator() + app.state.pictures = picture_store or (SupabasePictures(settings.supabase_url, settings.supabase_secret, settings.answer_bucket) + if settings.supabase_on else MemoryPictures()) + app.state.pusher = pusher or Pusher(app.state.store, settings.vapid_private_key, default_subject(settings)) + app.state.notify_request = written.notify_new_request app.state.roles = RoleResolver(settings, app.state.store) @app.exception_handler(StoreUnavailable) async def store_unavailable(request: Request, exc: StoreUnavailable) -> JSONResponse: log.warning("database problem on %s %s: %s", request.method, request.url.path, exc) return JSONResponse({"detail": "מסד הנתונים לא ענה או סירב לבקשה. נסו שוב, ואם זה חוזר, בדקו שהמיגרציות הוחלו (ראו את יומן השרת)."}, status_code=503) + @app.exception_handler(PictureError) + async def picture_trouble(request: Request, exc: PictureError) -> JSONResponse: + return JSONResponse({"detail": written.MESSAGES.get(exc.code, exc.code)}, status_code=exc.status) + app.include_router(classes.router) app.include_router(classes.teaching) + app.include_router(classes.curriculum) app.include_router(accounts.router) app.include_router(skills.router) app.include_router(quizzes.router) app.include_router(realtests.router) + app.include_router(written.router) + app.include_router(assignments.router) @app.get("/api/health") def health() -> dict: @@ -78,8 +103,7 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store everything = app.state.bank.lessons_with_skills() if set(user["roles"]) - {"student"}: return everything - mine = {lesson for c in reads.of(request).classes_of_student(user["email"]) for lesson in c["lessons"]} - return [lesson for lesson in everything if lesson["id"] in mine] + return everything if reads.of(request).classes_of_student(user["email"]) else [] @app.get("/", include_in_schema=False) def index() -> HTMLResponse: @@ -90,6 +114,12 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store html = (STATIC / "index.html").read_text(encoding="utf-8").replace("__CONFIG__", text) return HTMLResponse(html, headers={"Cache-Control": "no-cache"}) + @app.get("/service-worker.js", include_in_schema=False) + def service_worker() -> FileResponse: + # served from the root so it covers the whole app; the browser must check it for changes each time + return FileResponse(STATIC / "service-worker.js", media_type="text/javascript", + headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"}) + app.mount("/static", FreshStaticFiles(directory=STATIC), name="static") return app diff --git a/mathly2/pictures.py b/mathly2/pictures.py new file mode 100644 index 0000000000000000000000000000000000000000..9a151d4e1ba79d8212888d20f77dcadc86bfc606 --- /dev/null +++ b/mathly2/pictures.py @@ -0,0 +1,193 @@ +"""Pictures: students' written answers and the pages of a teacher's assignment. The server is the only door to them. + +An uploaded image is checked (JPEG, PNG or WebP, at most 5 MB and 20 million pixels), turned upright, stripped of its metadata and +re-encoded as JPEG. A teacher's PDF becomes one picture per page. The bytes live in a private bucket read and written only with the server's +key (`SupabasePictures`; `MemoryPictures` for tests and local development); the rows that name them are in `m2_pictures`.""" +from __future__ import annotations + +import io +import json +import logging +import time +import urllib.error +import urllib.parse +import uuid +import warnings +from datetime import datetime + +from PIL import Image, ImageOps, UnidentifiedImageError + +from . import pooled + +log = logging.getLogger("mathly.pictures") + +MAX_IMAGE = 5_000_000 +MAX_PDF = 10_000_000 +MAX_PAGES = 20 +MAX_SIDE = 2400 # the longest side of a stored picture, in pixels +PAGE_WIDTH = 1600 # a rendered PDF page +RENDER_BUDGET = 30.0 # seconds for all the pages of one PDF +ALLOWED = {"image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WEBP"} +Image.MAX_IMAGE_PIXELS = 20_000_000 + + +class PictureError(Exception): + """A picture that cannot be taken, with the HTTP status to answer and a short code.""" + + def __init__(self, status: int, code: str) -> None: + super().__init__(code) + self.status, self.code = status, code + + +def clean_image(data: bytes, content_type: str) -> bytes: + """A stored picture from an uploaded one: checked, upright, without metadata, as JPEG.""" + if len(data) > MAX_IMAGE: + raise PictureError(413, "image_too_large") + expected = ALLOWED.get((content_type or "").split(";")[0].strip().lower()) + if expected is None: + raise PictureError(415, "unsupported_image") + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + image = Image.open(io.BytesIO(data)) + if image.format != expected: + raise PictureError(415, "unsupported_image") + image.load() + except PictureError: + raise + except (UnidentifiedImageError, Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, ValueError, SyntaxError) as exc: + raise PictureError(400, "bad_image") from exc + image = ImageOps.exif_transpose(image) + if image.mode in ("RGBA", "LA", "P"): # flatten transparency onto white + rgba = image.convert("RGBA") + flat = Image.new("RGB", rgba.size, "white") + flat.paste(rgba, mask=rgba.getchannel("A")) + image = flat + else: + image = image.convert("RGB") + image.thumbnail((MAX_SIDE, MAX_SIDE)) + out = io.BytesIO() + image.save(out, "JPEG", quality=88) # a new file: none of the original's metadata comes along + return out.getvalue() + + +def render_pdf(data: bytes) -> list[bytes]: + """The pages of a teacher's PDF as stored pictures (JPEG); PictureError 413, 415 or 400 when it is not one we take.""" + if len(data) > MAX_PDF: + raise PictureError(413, "pdf_too_large") + if not data.startswith(b"%PDF-"): + raise PictureError(415, "not_a_pdf") + import pypdfium2 as pdfium + try: + doc = pdfium.PdfDocument(data) + except pdfium.PdfiumError as exc: + raise PictureError(400, "bad_pdf") from exc + try: + if len(doc) == 0: + raise PictureError(400, "bad_pdf") + if len(doc) > MAX_PAGES: + raise PictureError(413, "pdf_too_many_pages") + started, pages = time.monotonic(), [] + for index in range(len(doc)): + if time.monotonic() - started > RENDER_BUDGET: + raise PictureError(413, "pdf_too_slow") + page = doc[index] + width = page.get_width() or 1 + image = page.render(scale=PAGE_WIDTH / width).to_pil().convert("RGB") + out = io.BytesIO() + image.save(out, "JPEG", quality=85) + pages.append(out.getvalue()) + return pages + finally: + doc.close() + + +class MemoryPictures: + """The bytes of pictures in memory: tests and local development.""" + + def __init__(self) -> None: + self.files: dict[str, bytes] = {} + + def put(self, path: str, data: bytes, content_type: str = "image/jpeg") -> None: + self.files[path] = data + + def get(self, path: str) -> bytes: + try: + return self.files[path] + except KeyError: + raise PictureError(404, "picture_missing") from None + + def delete(self, path: str) -> None: + self.files.pop(path, None) + + +class SupabasePictures: + """The bytes of pictures in a private Supabase Storage bucket, read and written with the secret key. The bucket is made when first needed.""" + + def __init__(self, url: str, secret: str, bucket: str = "m2-answers") -> None: + self.url, self.secret, self.bucket = url.rstrip("/"), secret, bucket + self._ready = False + + def _headers(self, content_type: str | None = None) -> dict: + headers = {"apikey": self.secret, "Authorization": f"Bearer {self.secret}", "User-Agent": "mathly2/1.0"} + if content_type: + headers["Content-Type"] = content_type + return headers + + def _object(self, path: str) -> str: + return f"{self.url}/storage/v1/object/{self.bucket}/{urllib.parse.quote(path)}" + + def ensure_bucket(self) -> None: + """Create the private bucket if it is not there (idempotent).""" + if self._ready: + return + body = json.dumps({"id": self.bucket, "name": self.bucket, "public": False}).encode() + try: + pooled.request("POST", f"{self.url}/storage/v1/bucket", self._headers("application/json"), body, 15) + except urllib.error.HTTPError as exc: + if exc.code not in (400, 409): # 400/409: it already exists + log.error("cannot create the picture bucket: %s", exc.code) + raise PictureError(503, "answer_storage_unavailable") from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise PictureError(503, "answer_storage_unavailable") from exc + self._ready = True + + def put(self, path: str, data: bytes, content_type: str = "image/jpeg") -> None: + self.ensure_bucket() + try: + pooled.request("POST", self._object(path), {**self._headers(content_type), "x-upsert": "true"}, data, 30) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc: + log.error("cannot store a picture: %s", exc) + raise PictureError(503, "answer_storage_unavailable") from exc + + def get(self, path: str) -> bytes: + try: + return pooled.request("GET", self._object(path), self._headers(), None, 30)[1] + except urllib.error.HTTPError as exc: + if exc.code in (400, 404): + raise PictureError(404, "picture_missing") from exc + raise PictureError(503, "answer_storage_unavailable") from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise PictureError(503, "answer_storage_unavailable") from exc + + def delete(self, path: str) -> None: + try: + pooled.request("DELETE", self._object(path), self._headers(), None, 30) + except urllib.error.HTTPError as exc: + if exc.code not in (400, 404): # already gone is fine + raise PictureError(503, "answer_storage_unavailable") from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise PictureError(503, "answer_storage_unavailable") from exc + + +def store_picture(store, backend, owner_id: str, kind: str, jpeg: bytes, created_at: datetime | None = None) -> dict: + """Keep one cleaned picture: its bytes in the bucket, its row in the database. Returns the row.""" + path = f"{owner_id}/{uuid.uuid4()}.jpg" + backend.put(path, jpeg, "image/jpeg") + return store.create_picture(owner_id, kind, path, "image/jpeg", len(jpeg), created_at) + + +def remove_picture(store, backend, picture: dict, at: datetime | None = None) -> None: + """Delete a picture's bytes and mark its row deleted (the row stays, so what referred to it can say "the pictures were deleted").""" + backend.delete(picture["path"]) + store.mark_picture_deleted(picture["id"], at) diff --git a/mathly2/push.py b/mathly2/push.py new file mode 100644 index 0000000000000000000000000000000000000000..1efe3f0af43bc92a72c5e80e038e20ab55eb8013 --- /dev/null +++ b/mathly2/push.py @@ -0,0 +1,133 @@ +"""Push notifications: the student's phone is told when a computer asks for a photo of an answer (PRD PSH-1, PSH-2). + +The phone already looks for requests while the app is open; the notification brings the student to the app when it is closed. Only phones +subscribe, so the computer that asked gets none. A subscription the push service no longer knows (404 or 410) is deleted, and changing the +server's key (VAPID_PRIVATE_KEY) drops every subscription: each phone turns notifications on again. Without a key, notifications are off and +everything else works. On an iPhone they work only from the app added to the home screen (iOS 16.4 and later). + +Make a key with `python -m mathly2.push` and keep it as a secret.""" +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +from datetime import datetime + +log = logging.getLogger("mathly.push") +MAX_ENDPOINT = 1000 + + +class Gone(Exception): + """The push service no longer knows this subscription (404 or 410).""" + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def new_private_key() -> str: + """A new VAPID private key as VAPID_PRIVATE_KEY keeps it: the raw 32 bytes, base64url.""" + from cryptography.hazmat.primitives.asymmetric import ec + return _b64(ec.generate_private_key(ec.SECP256R1()).private_numbers().private_value.to_bytes(32, "big")) + + +def public_key(private: str) -> str: + """The browser's applicationServerKey for a private key: the uncompressed point, base64url. ValueError for a key that is not one.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + try: + raw = base64.urlsafe_b64decode(private + "=" * (-len(private) % 4)) + except ValueError as exc: + raise ValueError("not a 32-byte base64url key") from exc + if len(raw) != 32 or _b64(raw) != private: + raise ValueError("not a 32-byte base64url key") + key = ec.derive_private_key(int.from_bytes(raw, "big"), ec.SECP256R1()) + return _b64(key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)) + + +def request_payload(request: dict, assignment: dict | None) -> dict: + """What the service worker shows for a photo request. No question text or name: it may show on a locked screen. The tag is the assignment's, + so a newer request for the same answer replaces the older notification.""" + return {"title": "📷 בקשת צילום מהמחשב", "body": f"קוד {request['code']} · צלמו את התשובה ושלחו למחשב.", + "url": f"/#/assignment/{request['assignment_id']}", "tag": f"photo-{request['assignment_id']}"} + + +def seconds_left(request: dict, now: datetime) -> int: + """How long the push service should keep trying: until the request expires (a dead request is not worth a ring).""" + return max(0, min(900, int((request["expires_at"] - now).total_seconds()))) + + +def web_push(subscription: dict, payload: dict, private: str, subject: str, ttl: int) -> None: + """The real sender (pywebpush). Raises Gone for a subscription the push service no longer knows.""" + from pywebpush import WebPushException, webpush + try: + webpush({"endpoint": subscription["endpoint"], "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}}, + json.dumps(payload, ensure_ascii=False), vapid_private_key=private, vapid_claims={"sub": subject}, ttl=ttl, timeout=10, + headers={"Urgency": "high"}) # the student is waiting at the computer + except WebPushException as exc: + if getattr(exc.response, "status_code", None) in (404, 410): + raise Gone() from exc + raise + + +class Pusher: + """Subscriptions and sending, on the store's table. `sender(subscription, payload, ttl)` is injectable so tests do not touch the network.""" + + def __init__(self, store, private: str = "", subject: str = "", sender=None) -> None: + self.store, self.private, self.subject = store, private.strip(), subject + self.public, self.why, self.key_id = "", "", "" + if not self.private: + self.why = "VAPID_PRIVATE_KEY is not set" + else: + try: + self.public = public_key(self.private) + self.key_id = hashlib.sha256(self.public.encode()).hexdigest()[:16] + except ValueError as exc: + log.error("VAPID_PRIVATE_KEY is not a valid key: push notifications are off (%s)", exc) + self.why = "VAPID_PRIVATE_KEY is not a valid key" + self.sender = sender or (lambda sub, payload, ttl: web_push(sub, payload, self.private, self.subject, ttl)) + + @property + def on(self) -> bool: + return not self.why + + def subscribe(self, user_id: str, body: dict) -> None: + """Keep a browser's subscription for this account (ValueError when it is not one). Subscriptions made under another key are dropped first.""" + endpoint, keys = str(body.get("endpoint") or ""), body.get("keys") or {} + if not endpoint.startswith("https://") or len(endpoint) > MAX_ENDPOINT or not isinstance(keys, dict): + raise ValueError("not a push subscription") + p256dh, auth = str(keys.get("p256dh") or ""), str(keys.get("auth") or "") + if not (0 < len(p256dh) <= 200 and 0 < len(auth) <= 100): + raise ValueError("not a push subscription") + self.store.drop_push_subscriptions_not_of(self.key_id) + self.store.add_push_subscription(user_id, endpoint, p256dh, auth, self.key_id) + + def notify(self, user_id: str, payload: dict, ttl: int) -> int: + """Send to the account's phones; how many went. A dead subscription is deleted, other failures are logged (the phone still finds the + request when the app opens).""" + if not self.on or ttl <= 0: + return 0 + self.store.drop_push_subscriptions_not_of(self.key_id) + sent = 0 + for sub in self.store.push_subscriptions_of(user_id): + try: + self.sender(sub, payload, ttl) + sent += 1 + except Gone: + self.store.delete_push_subscription(sub["endpoint"]) + except Exception as exc: + log.warning("push failed: %s", exc) + return sent + + +def default_subject(settings) -> str: + if settings.vapid_subject: + return settings.vapid_subject + first = sorted(settings.developers) + return f"mailto:{first[0]}" if first else "mailto:admin@example.com" + + +if __name__ == "__main__": + key = new_private_key() + print(f"VAPID_PRIVATE_KEY={key}\n# its public key (the page reads it from the server): {public_key(key)}") diff --git a/mathly2/quiz.py b/mathly2/quiz.py index 54d0af329cb1fd392793bee51f5cc791de75a2c3..170b205c6f024ab415383a70a26f5a53f01740cc 100644 --- a/mathly2/quiz.py +++ b/mathly2/quiz.py @@ -1,8 +1,9 @@ -"""Drawing a lesson quiz: 10 multiple-choice questions from the lesson's skills, spread evenly, new questions first on a retake.""" +"""Drawing a lesson quiz: 15 multiple-choice questions from the lesson's skills, spread evenly, new questions first on a retake.""" from __future__ import annotations import random +from . import attempts from .attempts import QUIZ_SIZE @@ -35,15 +36,15 @@ def draw(bank, lesson_id: str, used: dict[str, int] | None = None, rng: random.R def facts(store, bank, user_id: str, now, reads=None) -> dict[str, dict]: """Per lesson the student has attempted: {best, attempts, open, last}. Overdue open attempts are settled first, so a quiz abandoned past its deadline is graded from the answers given. `best` is None until an attempt is submitted.""" - from . import attempts found: dict[str, dict] = {} for row in (reads or store).attempts_of(user_id, "quiz"): row = attempts.settle(store, bank, row, now) - item = found.setdefault(row["scope"], {"best": None, "attempts": 0, "open": None, "last": None}) + item = found.setdefault(row["scope"], {"best": None, "attempts": 0, "open": None, "last": None, "passed": False}) if attempts.is_open(row): item["open"] = {"id": row["id"], "due_at": row["due_at"]} continue item["attempts"] += 1 + item["passed"] = item["passed"] or row["grade"] >= attempts.PASS_GRADE item["best"] = row["grade"] if item["best"] is None else max(item["best"], row["grade"]) item["last"] = row["submitted_at"] return found diff --git a/mathly2/quizzes.py b/mathly2/quizzes.py index 6bec779a23580e53d5d0bb89206ff4ffd180360c..14c49fcc217965d1cfb8f257e6c73abbb5558626 100644 --- a/mathly2/quizzes.py +++ b/mathly2/quizzes.py @@ -163,4 +163,5 @@ def grades(request: Request, user: dict = Depends(learner)) -> dict: simulations.append(item) exercise.sort(key=lambda g: (g["test"]["date"], g["test"]["id"], g["number"])) simulations.sort(key=lambda g: (g["test"]["date"], g["test"]["id"])) - return {"quizzes": lessons, "exercise_tests": exercise, "simulations": simulations} + from .assignments import grade_rows + return {"quizzes": lessons, "exercise_tests": exercise, "simulations": simulations, "assignments": grade_rows(request, user)} diff --git a/mathly2/reads.py b/mathly2/reads.py index 07b92d5a5ddf258dc07406d73010526d40238b26..a5f0878cceceabd1d6d2c4dee71d60de6f1b69fb 100644 --- a/mathly2/reads.py +++ b/mathly2/reads.py @@ -11,7 +11,7 @@ from fastapi import Request POOL = ThreadPoolExecutor(max_workers=16, thread_name_prefix="db") READS = frozenset({"classes_of_student", "classes_taught", "progress_of", "last_answered", "daily_correct", "recent_skills", "attempts_of", - "tests_of_classes", "get_progress", "answers_of", "reward_state", "comets_of", "members", "get_class", "get_test", "roles_of"}) + "tests_of_classes", "get_progress", "answers_of", "reward_state", "comets_of", "members", "get_class", "get_test", "roles_of", "assignments_of_classes", "submissions_of_user", "get_assignment", "get_submission", "curriculum"}) def _key(name: str, args: tuple) -> tuple: diff --git a/mathly2/requirements.txt b/mathly2/requirements.txt index 759911558e4a5b80a4ac5de1b652be5865412912..8e91215e3519c5160d41fc22cb75905313708d4a 100644 --- a/mathly2/requirements.txt +++ b/mathly2/requirements.txt @@ -3,3 +3,6 @@ uvicorn[standard]>=0.29 anyio>=4.10,<4.11 # Python 3.10: newer releases can hang Starlette's worker-thread bridge python-dotenv>=1.0 tzdata>=2024.1 +Pillow==12.3.0 # checks and cleans answer pictures (mathly2/pictures.py) +pypdfium2>=4.30,<6 # a teacher's PDF to page images +pywebpush>=2.0,<3 # notifications to a student's phone for a photo request diff --git a/mathly2/retention.py b/mathly2/retention.py new file mode 100644 index 0000000000000000000000000000000000000000..4c9f9de875d60e06e21699050aaa511df45aac1b --- /dev/null +++ b/mathly2/retention.py @@ -0,0 +1,17 @@ +"""How long pictures are kept (PRD ANS-3): answer pictures until the end of the school year (31 August), unreferenced pictures for a day, +assignment pages until their assignment is removed. Grades and teachers' notes are never touched: only the pictures go.""" +from __future__ import annotations + +from datetime import date, timedelta + +ORPHAN_AGE = timedelta(hours=24) + + +def expires_on(day: date) -> date: + """The last day an answer picture made on `day` is kept: 31 August of its school year (1 September to 31 August).""" + return date(day.year + 1 if day.month >= 9 else day.year, 8, 31) + + +def is_expired(created_day: date, today: date) -> bool: + """An answer picture is removed from 1 September on.""" + return today > expires_on(created_day) diff --git a/mathly2/skills.py b/mathly2/skills.py index 69ff2af0a7f4d1e195412e9ad1b7c4201970cddd..36dc5cae5e166b95595a32f77af665e8eddf9116 100644 --- a/mathly2/skills.py +++ b/mathly2/skills.py @@ -82,10 +82,12 @@ def _round_view(request: Request, skill_id: str, rows: list[dict], round_start: def visible_lesson_ids(request: Request, user: dict) -> set[str] | None: - """The lessons the student can see: those of their classes. None means all (a teacher or a developer).""" + """The lessons the student can see: every lesson once they are in a class, none before. None means all (a teacher or a developer).""" if set(user["roles"]) - {"student"}: return None - return {lesson for c in reads.of(request).classes_of_student(user["email"]) for lesson in c["lessons"]} + if not reads.of(request).classes_of_student(user["email"]): + return set() + return {lesson["id"] for lesson in request.app.state.bank.lessons} @router.get("/skills/{skill_id}") @@ -199,11 +201,11 @@ def _comet_due(request: Request, user_id: str, today_now: dict, state: dict | No @router.get("/path") def path(request: Request, user: dict = Depends(learner)) -> dict: store, here, uid = request.app.state.store, reads.of(request), user["id"] - here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz")) + here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("curriculum",)) states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)} last, today = here.last_answered(uid), clock.school_day() quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here) - shown = visible_lesson_ids(request, user) + shown, dates = visible_lesson_ids(request, user), here.curriculum() lessons, order = [], [] for lesson in request.app.state.bank.lessons_with_skills(): if shown is not None and lesson["id"] not in shown: @@ -214,11 +216,15 @@ def path(request: Request, user: dict = Depends(learner)) -> dict: order += [s["id"] for s in skills] mastered = sum(1 for s in skills if s["state"] == MASTERED) fact = quizzes_facts.get(lesson["id"], {}) + passed = bool(fact.get("passed")) + completed = passed or (bool(skills) and mastered == len(skills)) + due = dates.get(lesson["id"]) lessons.append({"id": lesson["id"], "name": lesson["name"], "skills": skills, "mastered": mastered, - "completed": bool(skills) and mastered == len(skills), + "completed": completed, "passed": passed, "due_date": due.isoformat() if due else None, + "behind": bool(due and due < today and not completed), "quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") else None}}) daily = here.daily_correct(uid) - return {"lessons": lessons, "next": next_skill(order, states, here.recent_skills(uid, RECENT)), + return {"lessons": lessons, "behind": sum(1 for l in lessons if l["behind"]), "next": next_skill(order, states, here.recent_skills(uid, RECENT)), "today": today_progress(daily, today), "streak": streak(daily, today)} diff --git a/mathly2/static/api.js b/mathly2/static/api.js index 47c2defc02c501a2c5ef5ab4dcbc2b8bb1cb4825..9820ecd894abddb30869b8fd741e8543dda4c447 100644 --- a/mathly2/static/api.js +++ b/mathly2/static/api.js @@ -1,4 +1,5 @@ -import { token } from "./session.js"; +import { track } from "./busy.js"; +import { onSignOut, token } from "./session.js"; export class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } @@ -6,12 +7,49 @@ export class ApiError extends Error { const MESSAGES = { 503: "שירות ההתחברות לא זמין כרגע, נסו שוב בעוד רגע", 403: "אין הרשאה לפעולה הזו" }; -export async function api(path, { method = "GET", body } = {}) { +const remembered = new Map(); // GET path -> the last answer +let changes = 0; // counts changes, so an answer to a read that began before one is not kept +onSignOut(() => { remembered.clear(); changes++; }); + +async function request(path, { method = "GET", body } = {}) { + const started = changes; + if (method !== "GET") { remembered.clear(); changes++; } // after a change of theirs, no old answer is ever shown const headers = { Authorization: "Bearer " + await token() }; if (body !== undefined) headers["Content-Type"] = "application/json"; const response = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); - if (response.ok) return response.status === 204 ? null : response.json(); + if (response.ok) { + const data = response.status === 204 ? null : await response.json(); + if (method === "GET") { if (started === changes) remembered.set(path, data); } + else { remembered.clear(); changes++; } + return data; + } let detail = ""; try { detail = (await response.json()).detail; } catch { /* no body */ } throw new ApiError(response.status, typeof detail === "string" && detail ? detail : (MESSAGES[response.status] || "משהו השתבש, נסו שוב")); } + +/** A request to the server. The button that was just pressed shows it is working if the answer takes a moment (busy.js). */ +export const api = (path, options) => track(request(path, options)); + +/** The last answer for this GET path, or undefined (nothing yet, or something was changed since). */ +export const lastAnswer = path => remembered.get(path); + +let screen = 0; +const busyNow = () => { + const here = document.activeElement; + return !!(here && here.matches?.("input, textarea, select, [contenteditable]")) || !!document.querySelector("main details[open], .sheet, dialog[open]"); +}; + +/** Shows a screen: draws `render(data)` at once from the last answer if there is one, then draws again when the fresh answer + * differs, unless the person has moved on, is typing, or has something open. Without a last answer it waits, as before. + * `path` may be a list: then `render` gets the answers as a list. */ +export async function swr(path, render) { + const paths = Array.isArray(path) ? path : [path], one = !Array.isArray(path); + const give = list => render(one ? list[0] : list); + const mine = ++screen, hash = location.hash, before = paths.map(p => remembered.get(p)); + if (before.some(x => x === undefined)) return give(await Promise.all(paths.map(p => api(p)))); + give(before); + Promise.all(paths.map(p => api(p))).then(fresh => { + if (mine === screen && hash === location.hash && JSON.stringify(fresh) !== JSON.stringify(before) && !busyNow()) give(fresh); + }).catch(() => { /* the screen already shows what it knew */ }); +} diff --git a/mathly2/static/app.css b/mathly2/static/app.css index e95430e2296d7cdcb061c99c4e20ff9b85620e5c..3e84ace9a2dac0376a6dcdd46d38382663f74618 100644 --- a/mathly2/static/app.css +++ b/mathly2/static/app.css @@ -168,6 +168,11 @@ button.danger { color: var(--danger); border-color: var(--danger); } .orbit .hub h3 { margin: 0; font-size: 13px; line-height: 1.25; } .orbit .hub .count { display: inline-block; margin-top: 4px; font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; color: var(--muted); } .orbit.completed .hub { box-shadow: 0 0 0 3px var(--sky-gold); } +.orbit.behind .hub { box-shadow: 0 0 0 3px var(--sky-warn, #ff9f6b); } +.lesson-status { margin: 0 auto 6px; text-align: center; font-size: 13px; font-weight: 600; } +.lesson-status.late, .behind-note { color: var(--sky-warn, #ff9f6b); } +.lesson-status.done { color: var(--sky-gold); } +.behind-note { margin: 8px 0 0; font-weight: 600; } .star { position: absolute; left: calc(50% + var(--dx) * var(--R)); top: calc(50% + var(--dy) * var(--R) * 1.1); transform: translate(-50%, -26px); width: 84px; display: flex; flex-direction: column; align-items: center; gap: 3px; text-decoration: none; color: var(--text); border-radius: 12px; } .star:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } @@ -309,7 +314,7 @@ ul.grades .bar, ul.grades .muted { grid-column: 1 / -1; } /* The student's tab bar sits at the bottom of the screen, above the page and below the sheets */ .tabs { position: fixed; inset-inline: 0; bottom: 0; z-index: 40; margin: 0; gap: 4px; padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px)); background: var(--surface); border-top: 1px solid var(--line); } -.tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; } +.tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; font-size: 15px; padding-inline: 2px; } body:has(.tabs) main { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } /* Home */ @@ -370,3 +375,64 @@ ol.plan li.done .mark { color: var(--sky-aurora); } .ex-score { margin: 0; font-size: 18px; } .ex-score b { font-size: 44px; line-height: 1; color: var(--sky-gold); } .ex-missed { padding-inline-start: 22px; display: grid; gap: 12px; } + +/* My assignments */ +ul.assignments li { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; } +ul.assignments li a { color: var(--text); text-decoration: none; } +ul.assignments li .tag { grid-column: 2; grid-row: 1 / span 2; white-space: nowrap; } +ul.assignments li[data-state="returned"] .tag, ul.assignments li[data-state="not_handed"] .tag { border-color: var(--sky-gold); color: var(--text); } + +/* An assignment */ +.pages { display: grid; gap: 10px; } +.pages img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid var(--line); background: #fff; } +.qz-written { padding-inline-start: 22px; display: grid; gap: 14px; } +.drafts { display: flex; flex-wrap: wrap; gap: 10px; } +.drafts li { position: relative; list-style: none; border: 1px solid var(--line); border-radius: 10px; padding: 4px; max-width: 46%; } +.drafts img { display: block; max-width: 100%; max-height: 160px; border-radius: 8px; background: #fff; } +.drafts .remove { position: absolute; inset-block-start: -8px; inset-inline-end: -8px; width: 28px; height: 28px; min-height: 0; padding: 0; border-radius: 50%; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; } +.button.file { cursor: pointer; } +.returned { border: 2px solid var(--sky-gold); border-radius: 12px; padding: 10px 14px; background: color-mix(in srgb, var(--sky-gold) 12%, var(--surface)); } +.grade-big b { font-size: 40px; color: var(--sky-gold); } +.teacher-note { margin: 6px 0; } + +/* The photo request */ +.photo-request { border: 2px dashed var(--sky-accent); border-radius: 14px; padding: 10px 14px; margin-top: 8px; } +.code { font-size: 34px; letter-spacing: 6px; color: var(--sky-gold); } +.old-requests { margin-top: 6px; font-size: 14px; color: var(--muted); } +.photo-window { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 16px; background: rgb(0 0 0 / .7); } +.photo-window .sheet { width: min(100%, 360px); background: var(--surface); border: 2px solid var(--sky-gold); border-radius: 18px; padding: 16px; display: grid; gap: 6px; text-align: center; } +.photo-window h3 { margin: 0; } + +/* The teacher's work tab */ +.assignment-form { display: grid; gap: 8px; margin-top: 8px; } +.assignment-form label { display: grid; gap: 4px; } +.assignment-form label.check, .assignment-form label.file { display: inline-flex; gap: 6px; align-items: center; } +.assignment-form fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0; } +.tag.waiting { border-color: var(--sky-gold); font-weight: 700; } + +.open-submission { width: 100%; min-height: 56px; text-align: start; } + +/* A button whose action takes time: a spinner, and a second press does nothing (busy.js). With reduced motion: "…" */ +.busy { pointer-events: none; opacity: .85; } +.busy::after { content: ""; display: inline-block; width: 14px; height: 14px; margin-inline-start: 8px; vertical-align: -2px; border-radius: 50%; + border: 2px solid currentColor; border-top-color: transparent; animation: ex-spin .8s linear infinite; } +@media (prefers-reduced-motion: reduce) { .busy::after { content: "…"; width: auto; height: auto; border: 0; border-radius: 0; animation: none; vertical-align: baseline; } } + +/* The number of items in a teacher's tab */ +.tab-count { display: inline-block; min-width: 22px; padding: 0 6px; margin-inline-start: 4px; border-radius: 999px; font-size: 13px; line-height: 20px; text-align: center; + background: color-mix(in srgb, var(--text) 16%, transparent); font-weight: 800; } +.tab-count:empty { display: none; } +[aria-pressed="true"] > .tab-count { background: color-mix(in srgb, var(--accent-text) 22%, transparent); } + +/* The submissions tab */ +.group { margin-top: 12px; } +.group h3 { margin: 0 0 6px; font-size: 16px; } + +/* A student's row on the teacher's class card: the address, its state and two buttons must wrap on a phone */ +ul.students li { flex-wrap: wrap; } +ul.students li [dir="ltr"] { overflow-wrap: anywhere; min-width: 0; } +.curriculum li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 6px 0; border-bottom: 1px solid var(--line); } +.curriculum label { flex: 1 1 200px; } +.curriculum input[type=date] { flex: 0 0 auto; } diff --git a/mathly2/static/assignment.js b/mathly2/static/assignment.js new file mode 100644 index 0000000000000000000000000000000000000000..e417f8aa2f3f9cc935b8e019181e5304c1ed3aa0 --- /dev/null +++ b/mathly2/static/assignment.js @@ -0,0 +1,144 @@ +// One assignment: its material (pages or the bank's written quiz), the student's answer as pictures (from the board, the camera, a file or +// the phone) and handing it in; afterwards its state, and the grade and note or the request to correct. +import { api } from "./api.js"; +import { loadBoard } from "./exercise.js"; +import { figures } from "./figures.js"; +import { assignmentState } from "./home.js"; +import { withBusy } from "./busy.js"; +import { esc, fmt } from "./math.js"; +import { isTouch, pictureUrl, prepare, sendPicture } from "./media.js"; +import * as phone from "./phone.js"; +import { shortDate, tabs } from "./ui.js"; + +let draw = null, current = null, draft = [], message = ""; + +const draftKey = id => `assignment-draft:${id}`; +const saveDraft = () => { try { sessionStorage.setItem(draftKey(current.id), JSON.stringify(draft)); } catch { /* private window */ } }; +const loadDraft = id => { try { return JSON.parse(sessionStorage.getItem(draftKey(id)) || "null"); } catch { return null; } }; +const q = selector => document.querySelector(selector); +const editable = a => ["not_handed", "handed_in", "returned"].includes(a.state); + +/** Fill an with a private picture (fetched with the sign-in). */ +async function fill(img, id) { + try { img.src = await pictureUrl(id); img.hidden = false; } catch (error) { img.replaceWith(Object.assign(document.createElement("span"), { className: "muted", textContent: error.message })); } +} + +function material(a) { + if (a.kind === "exercise_test") return `

למבחן התרגול

`; + if (a.kind === "quiz" && a.quiz) { + return `

הבוחן הכתוב (${a.quiz.minutes} דקות · ${a.quiz.points} נקודות)

    ${a.quiz.questions.map(x => + `
  1. ${fmt(x.question)}

    ${figures(x)}${x.points} נק׳
  2. `).join("")}
`; + } + return a.pages.length ? `

הדף

${a.pages.map((id, i) => ``).join("")}
` : ""; +} + +function stateBox(a) { + const s = assignmentState(a); + const note = a.teacher_note ? `

הערת המורה: ${fmt(a.teacher_note)}

` : ""; + const head = `

${s.word}

`; + if (a.state === "returned") return `${head}
המורה החזיר/ה את המשימה לתיקון.${note}

תקנו והגישו שוב.

`; + if (a.state === "graded") return `${head}

הציון: ${a.grade}

${note}`; + return head; +} + +function draftHtml() { + return draft.length ? draft.map(id => `
  • +
  • `).join("") : `
  • עוד לא הוספתם תמונה.
  • `; +} + +function renderDraft() { + q("#draft").innerHTML = draftHtml(); + q("#draft").querySelectorAll("img[data-picture]").forEach(img => fill(img, img.dataset.picture)); + q("#hand-in").disabled = !draft.length; + saveDraft(); +} + +function answerBox(a) { + if (a.kind === "exercise_test") return ""; + if (!editable(a)) { + return a.pictures.length ? `

    התשובה שהגשתי

    ${a.pictures.map(p => p.deleted + ? `

    התמונות נמחקו

    ` : ``).join("")}
    ` : ""; + } + return `

    ${a.state === "handed_in" ? "התשובה שהגשתי (אפשר להחליף עד שהמורה בודק/ת)" : "התשובה שלי"}

    + +

    + ${isTouch() ? `` : ""} + + ${!isTouch() ? `` : ""}

    +
    + +

    +

    ${esc(message)}

    `; +} + +function add(id) { + if (!q("#draft")) return; // the page was left while a photo was on its way + if (!draft.includes(id)) draft.push(id); + renderDraft(); +} + +async function addFiles(files) { + const error = q("#add-error"); + error.hidden = true; + for (const file of files) { + if (draft.length >= 10) { error.textContent = "אפשר להגיש עד 10 תמונות"; error.hidden = false; break; } + try { + add((await sendPicture("/api/pictures", await prepare(file))).id); + } catch (e) { error.textContent = e.message; error.hidden = false; } + } +} + +async function openBoard() { + await loadBoard(); + if (!window.MathlyBoard?.openFull) return; + const host = Object.assign(document.createElement("div"), { className: "mx-layer" }); + document.body.append(host); + window.MathlyBoard.openFull({ + host, key: `assignment:${current.id}`, title: "כתיבה על הלוח", sendLabel: "הוספה להגשה", + submit: async (page, blob) => { + if (draft.length >= 10) return { error: "אפשר להגיש עד 10 תמונות" }; + try { add((await sendPicture("/api/pictures", blob)).id); return {}; } catch (e) { return { error: e.message }; } + }, + done: () => host.remove(), + }); +} + +async function handIn() { + const button = q("#hand-in"); + button.disabled = true; + q("#hand-msg").textContent = "שולחים…"; + try { + await api(`/api/assignments/${current.id}/submission`, { method: "POST", body: { pictures: draft } }); + try { sessionStorage.removeItem(draftKey(current.id)); } catch { /* nothing */ } + message = "ההגשה נשלחה. המורה יבדוק/תבדוק."; + await show(draw, current.id); + } catch (error) { + button.disabled = false; + q("#hand-msg").textContent = ""; + q("#add-error").textContent = error.message || "משהו השתבש"; + q("#add-error").hidden = false; + } +} + +export async function show(view, id) { + draw = view; + current = await api(`/api/assignments/${encodeURIComponent(id)}`); + const saved = loadDraft(current.id); + draft = editable(current) ? (saved ?? current.pictures.filter(p => !p.deleted).map(p => p.id)) : []; + view(`${tabs("submissions")}

    ‹ ההגשות

    +

    ${fmt(current.title)}

    +

    ${esc(current.class.name)} · עד ${shortDate(current.due_date)}${current.overdue ? " · באיחור" : ""}

    + ${current.note ? `

    ${fmt(current.note)}

    ` : ""}${stateBox(current)}
    +
    ${material(current)}
    ${answerBox(current)}`); + document.querySelectorAll("img[data-picture]:not(#draft img)").forEach(img => fill(img, img.dataset.picture)); + if (!editable(current) || current.kind === "exercise_test") return; + renderDraft(); + q("#board-btn").addEventListener("click", openBoard); + q("#hand-in").addEventListener("click", handIn); + q("#draft").addEventListener("click", event => { + const id = event.target.closest("[data-remove]")?.dataset.remove; + if (id) { draft = draft.filter(x => x !== id); renderDraft(); } + }); + document.querySelectorAll("[data-file]").forEach(input => input.addEventListener("change", async () => { const files = [...input.files]; input.value = ""; await withBusy(input.closest("label"), addFiles(files)); })); + q("#ask-phone")?.addEventListener("click", () => phone.askFromComputer(current, id => add(id), q("#phone-panel"))); +} diff --git a/mathly2/static/busy.js b/mathly2/static/busy.js new file mode 100644 index 0000000000000000000000000000000000000000..eb736b9974dd40335d2997ded8721abec912c39d --- /dev/null +++ b/mathly2/static/busy.js @@ -0,0 +1,54 @@ +// A button whose action takes time shows it. One hook for the whole app: the button (or the form's submit button) that was just pressed is +// remembered, and every request made right after that marks it busy if the answer takes longer than about a third of a second: a spinner, and +// a second press does nothing, until the request ends. Quick actions never flash it. Uploads (a file chooser's label) use `withBusy` directly. +const DELAY = 300; // milliseconds before the button shows it is working +const WINDOW = 600; // a request counts as the button's when it starts within this time of the press +const SKIP = ".ex-choice, .qz-choice, .mx-board, [data-board-toggle]"; // these show their own state + +let pressed = null, pressedAt = 0; +const counts = new WeakMap(); + +function target(event) { + const el = event.target?.closest?.("button, .button, [type=submit]"); + return el && !el.closest(SKIP) ? el : null; +} + +function mark(el) { + counts.set(el, (counts.get(el) || 0) + 1); + el.classList.add("busy"); + el.setAttribute("aria-busy", "true"); +} + +function unmark(el) { + const left = (counts.get(el) || 1) - 1; + counts.set(el, left); + if (left <= 0) { el.classList.remove("busy"); el.removeAttribute("aria-busy"); } +} + +document.addEventListener("click", event => { + const el = target(event); + if (!el) return; + if (el.classList.contains("busy")) { event.preventDefault(); event.stopImmediatePropagation(); return; } // a second press while it works + pressed = el; + pressedAt = performance.now(); +}, true); + +document.addEventListener("submit", event => { + if (event.submitter?.classList.contains("busy")) { event.preventDefault(); event.stopImmediatePropagation(); return; } + if (event.submitter && !event.submitter.closest(SKIP)) { pressed = event.submitter; pressedAt = performance.now(); } +}, true); + +/** Follow a promise for a button: after a third of a second the button shows it is working. Returns the promise. */ +export function withBusy(el, promise) { + if (!el) return promise; + const timer = setTimeout(() => { if (el.isConnected) { el.dataset.busyOn = "1"; mark(el); } }, DELAY); + const done = () => { clearTimeout(timer); if (el.dataset.busyOn) { delete el.dataset.busyOn; unmark(el); } }; + promise.then(done, done); + return promise; +} + +/** Follow a request for the button that was just pressed (if one was pressed a moment ago). Used by `api()`. */ +export function track(promise) { + const el = performance.now() - pressedAt < WINDOW && pressed?.isConnected ? pressed : null; + return withBusy(el, promise); +} diff --git a/mathly2/static/grades.js b/mathly2/static/grades.js index 2cf842a7ce452d7ece205233bd2c130985abb397..096b2f9a39bce32ffb7d27249bbb45510fef7312 100644 --- a/mathly2/static/grades.js +++ b/mathly2/static/grades.js @@ -1,6 +1,6 @@ // The student's grades: each lesson's best quiz grade, and each real test's exercise tests and simulation (best grade, tries, last date). // Assignments will add their rows later. -import { api } from "./api.js"; +import { swr } from "./api.js"; import { esc, fmt } from "./math.js"; import { shortDate, tabs } from "./ui.js"; @@ -16,15 +16,21 @@ function row({ attrs, href, title, best, attempts, last }) { const testTitle = (t, suffix) => `מבחן ב־${shortDate(t.date)}: ${suffix}${t.removed ? " (הוסר)" : ""}`; -export async function show(view) { - const { quizzes, exercise_tests: exercises = [], simulations = [] } = await api("/api/grades"); - const none = !quizzes.length && !exercises.length && !simulations.length; +export const show = view => swr("/api/grades", data => draw(view, data)); + +function draw(view, { quizzes, exercise_tests: exercises = [], simulations = [], assignments = [] }) { + const none = !quizzes.length && !exercises.length && !simulations.length && !assignments.length; view(`${tabs("grades")}

    הציונים שלי

    ${none ? `

    עוד אין ציונים. הבוחן של כל שיעור מחכה בסוף השיעור במסלול.

    ` : ""} ${quizzes.length ? `

    בחנים

    ` : ""} ${exercises.length ? `

    מבחני תרגול

    ` : ""} + ${assignments.length ? `

    משימות

    ` : ""} ${simulations.length ? `

    מבחני סימולציה

    ` : ""}
    `); } diff --git a/mathly2/static/home.js b/mathly2/static/home.js index 352d70eadaeb2e1b0ab4687e97c8b5c0191404dd..b05a0709611055234cd532e5e033cf08e434bf99 100644 --- a/mathly2/static/home.js +++ b/mathly2/static/home.js @@ -1,6 +1,6 @@ // The student's home for a subject: the nearest test with its readiness and one task for today, the required simulation, // the other tests, and a button that continues the path. -import { api } from "./api.js"; +import { swr } from "./api.js"; import { esc, fmt } from "./math.js"; import { PATH, backToSubjects, daysText, shortDate, tabs } from "./ui.js"; @@ -40,16 +40,41 @@ function nearest(test, task, next) {

    כל התוכנית והמבחנים

    `; } -export async function show(view) { - const data = await api("/api/home"); - const [first, ...others] = data.tests; +/** An assignment's state in a symbol and a word (never colour alone). */ +export function assignmentState(a) { + if (a.kind === "exercise_test") return a.state === "done" ? { symbol: "✓", word: `ציון ${a.grade}` } : a.overdue ? { symbol: "⚠", word: "באיחור" } : { symbol: "○", word: "עוד לא נעשה" }; + switch (a.state) { + case "handed_in": return { symbol: "◐", word: a.late ? "הוגש באיחור" : "הוגש" }; + case "graded": return { symbol: "✓", word: `ציון ${a.grade}` }; + case "returned": return { symbol: "↩", word: "הוחזר לתיקון" }; + default: return a.overdue ? { symbol: "⚠", word: "באיחור" } : { symbol: "○", word: "לא הוגש" }; + } +} +export const assignmentHref = a => a.kind === "exercise_test" ? `#/exam/${a.test.id}/${a.test.number}` : `#/assignment/${a.id}`; + +/** Home shows only the new assignments: those not handed in yet (or an exercise test not taken). The rest are in the "הגשות" tab. */ +const isNew = a => a.state === "not_handed" || a.state === "not_done"; + +function assignmentsCard(all) { + const list = all.filter(isNew); + if (!list.length) return ""; + return `

    המשימות שלי

    `; +} + +export const show = view => swr(["/api/home", "/api/assignments"], ([data, assignments]) => draw(view, data, assignments)); + +function draw(view, data, assignments) { + const [first] = data.tests; const required = data.tests.filter(t => t.simulation.required); view(`${tabs("home")}${backToSubjects()} ${first ? nearest(first, data.task, data.next_skill) : `

    הבית

    המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.

    `} + ${assignmentsCard(assignments)} ${required.map(t => `חובה מבחן סימולציה למבחן ב־${shortDate(t.date)}`).join("")}

    ${data.next_skill ? `להמשיך במסלול: ${fmt(data.next_skill.name)} ` - : `למסלול: כל הכוכבים פתוחים לתרגול `}

    - ${others.length ? `

    מבחנים נוספים

    ` : ""}`); + : `למסלול: כל הכוכבים פתוחים לתרגול `}

    `); } diff --git a/mathly2/static/index.html b/mathly2/static/index.html index 508657d8e7a10f5eb459129e0c0f8e3b505759e4..036c2780cd946728484719553db1241261c73e07 100644 --- a/mathly2/static/index.html +++ b/mathly2/static/index.html @@ -6,6 +6,8 @@ Mathly + + diff --git a/mathly2/static/manager.js b/mathly2/static/manager.js index 260413afb9749f5a6c232282b4bb3107ad54574c..c0db0c54bcc6e18503864bfb12913cbfb9e98df0 100644 --- a/mathly2/static/manager.js +++ b/mathly2/static/manager.js @@ -2,16 +2,16 @@ import { api } from "./api.js"; import { esc } from "./math.js"; -let draw, lessons = [], tab = "classes", editing = null, draft = null, flash = { message: "", error: "" }; +let draw, tab = "classes", editing = null, draft = null, flash = { message: "", error: "" }; // draft: what was typed in the editor when a save was refused, so the form keeps it and the person can fix one thing const note = () => `${flash.error && tab !== "edit" ? `` : ""}${flash.message ? `

    ${esc(flash.message)}

    ` : ""}`; const tabs = () => `
    - ${[["classes", "כיתות"], ["edit", editing ? "עריכת כיתה" : "כיתה חדשה"], ["students", "תלמידים"]].map(([id, label]) => + ${[["classes", "כיתות"], ["edit", editing ? "עריכת כיתה" : "כיתה חדשה"], ["students", "תלמידים"], ["curriculum", "תכנית לימודים"]].map(([id, label]) => ``).join("")}
    `; async function render() { - const body = tab === "classes" ? await classesView() : tab === "students" ? await studentsView() : editorView(); + const body = tab === "classes" ? await classesView() : tab === "students" ? await studentsView() : tab === "curriculum" ? await curriculumView() : editorView(); draw(`

    מנהל כיתות

    ${tabs()}${note()}${body}
    `); document.getElementById("mgr-tabs").addEventListener("click", event => { const next = event.target.closest("[data-tab]")?.dataset.tab; @@ -21,6 +21,7 @@ async function render() { go(next); }); if (tab === "classes") wireClasses(); + if (tab === "curriculum") wireCurriculum(); if (tab === "edit") wireEditor(); if (tab === "edit" && flash.error) { // the form is long: bring the reason into view, next to the buttons that were pressed const reason = document.getElementById("form-error"); @@ -30,19 +31,37 @@ async function render() { flash = { message: "", error: "" }; } +/** The curriculum: the date by which each lesson should be completed, one for the whole school. */ +async function curriculumView() { + const lessons = await api("/api/curriculum"); + return `

    תכנית לימודים

    +

    התאריך שעד אליו כל שיעור אמור להסתיים. תלמיד שלא סיים שיעור אחרי התאריך רואה אותו מסומן בפיגור. אפשר להשאיר תאריך ריק.

    + +

    `; +} + +function wireCurriculum() { + document.getElementById("curriculum-form").addEventListener("submit", async event => { + event.preventDefault(); + const dates = Object.fromEntries([...document.querySelectorAll("#curriculum-form [data-lesson]")].map(i => [i.dataset.lesson, i.value || null])); + try { await api("/api/curriculum", { method: "PUT", body: { dates } }); flash = { message: "התכנית נשמרה.", error: "" }; } + catch (e) { flash = { message: "", error: e.message }; } + go("curriculum"); + }); +} + const go = next => { tab = next; return render(); }; async function classesView() { const classes = await api("/api/classes"); - const names = Object.fromEntries(lessons.map(l => [l.id, l.name])); return `
    ${classes.length} כיתות
    ${classes.length ? `
    ${classes.map(c => `

    ${esc(c.name)}

    -

    שיעורים: ${esc(c.lessons.map(id => names[id] || id).join(", "))}
    - מורה: ${esc(c.teacher_email || "עוד לא נבחר")}
    +

    מורה: ${esc(c.teacher_email || "עוד לא נבחר")}
    ${c.students} תלמידים · AI: ${c.ai_enabled ? "פועל" : "כבוי"} · ${c.ai_daily_checks} בדיקות ביום

    `).join("")}
    ` - : `

    עוד אין כיתות. פתחו כיתה ראשונה, בחרו את השיעורים והוסיפו את התלמידים.

    `}`; + : `

    עוד אין כיתות. פתחו כיתה ראשונה והוסיפו את התלמידים.

    `}`; } function wireClasses() { @@ -62,12 +81,9 @@ function wireClasses() { function editorView() { const c = editing, d = draft; // a refused save keeps what was typed - const chosen = new Set(d ? d.lessons : c?.lessons || []); const value = (typed, saved, fallback = "") => d ? (typed ?? fallback) : (saved ?? fallback); return `

    ${c ? `עריכת ${esc(c.name)}` : "כיתה חדשה"}

    - -
    ${lessons.map(l => ``).join("")}
    @@ -85,7 +101,6 @@ function wireEditor() { event.preventDefault(); const body = { name: document.getElementById("c-name").value, - lessons: [...document.querySelectorAll("#c-lessons input:checked")].map(i => i.value), teacher_email: document.getElementById("c-teacher").value.trim() || null, students: document.getElementById("c-students").value, ai_enabled: document.getElementById("c-ai").checked, @@ -110,6 +125,5 @@ async function studentsView() { export async function show(me, view) { draw = view; tab = "classes"; editing = null; - lessons = await api("/api/classes/lessons"); await render(); } diff --git a/mathly2/static/manifest.webmanifest b/mathly2/static/manifest.webmanifest new file mode 100644 index 0000000000000000000000000000000000000000..67c3bdd7eaa90d6cf4bd7e6b71ab12e81a50e963 --- /dev/null +++ b/mathly2/static/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "id": "/", + "name": "לומדים בכיף", + "short_name": "לומדים בכיף", + "description": "תרגול ולמידה לכיתה ט׳", + "lang": "he", + "dir": "rtl", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#060A19", + "theme_color": "#060A19", + "icons": [ + {"src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, + {"src": "/static/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} + ] +} diff --git a/mathly2/static/media.js b/mathly2/static/media.js new file mode 100644 index 0000000000000000000000000000000000000000..ff541edd598003d1c633315f6eec47d95061f674 --- /dev/null +++ b/mathly2/static/media.js @@ -0,0 +1,55 @@ +// Pictures in the browser: reading a private picture through the server (a plain cannot send the sign-in), shrinking a photo before +// it is sent, uploading it, and telling a phone from a computer. +import { token } from "./session.js"; +import { ApiError } from "./api.js"; + +const cache = new Map(); + +/** A picture's bytes as a blob URL, fetched with the sign-in (the server is the only door to pictures). Rejects with ApiError (410: deleted). */ +export function pictureUrl(id) { + if (!cache.has(id)) { + cache.set(id, (async () => { + const response = await fetch(`/api/pictures/${encodeURIComponent(id)}`, { headers: { Authorization: "Bearer " + await token() } }); + if (!response.ok) { cache.delete(id); throw new ApiError(response.status, response.status === 410 ? "התמונה נמחקה" : "לא הצלחנו לפתוח את התמונה"); } + return URL.createObjectURL(await response.blob()); + })()); + } + return cache.get(id); +} + +/** A photo shrunk to at most `side` pixels and re-encoded as JPEG (this also drops its metadata). Falls back to the file itself when the browser + * cannot decode it, and the server then decides. */ +export async function prepare(file, side = 2000) { + try { + const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" }); + const scale = Math.min(1, side / Math.max(bitmap.width, bitmap.height)); + const canvas = Object.assign(document.createElement("canvas"), { width: Math.round(bitmap.width * scale), height: Math.round(bitmap.height * scale) }); + const context = canvas.getContext("2d"); + context.fillStyle = "#fff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const blob = await new Promise(done => canvas.toBlob(done, "image/jpeg", 0.88)); + return blob || file; + } catch { return file; } +} + +/** Send picture bytes (a Blob or File) to `url`; resolves to the JSON answer. */ +export async function sendPicture(url, blob) { + const response = await fetch(url, { method: "POST", headers: { Authorization: "Bearer " + await token(), "Content-Type": blob.type || "image/jpeg" }, body: blob }); + let data = null; + try { data = await response.json(); } catch { /* no body */ } + if (!response.ok) throw new ApiError(response.status, (data && typeof data.detail === "string" && data.detail) || "משהו השתבש, נסו שוב"); + return data; +} + +/** A phone or tablet (a touch screen): where the camera opens directly and notifications are offered. */ +export const isTouch = () => !!window.matchMedia?.("(pointer: coarse)").matches; + +/** This browser's own id, so a photo request knows which device asked and the phone does not ask itself. */ +export function deviceId() { + try { + let id = localStorage.getItem("mathly2.device"); + if (!id) { id = crypto.randomUUID(); localStorage.setItem("mathly2.device", id); } + return id; + } catch { return "device-" + Math.random().toString(36).slice(2); } +} diff --git a/mathly2/static/notifications.js b/mathly2/static/notifications.js new file mode 100644 index 0000000000000000000000000000000000000000..03a9776d30a029a98bfdb6be35c1a1a376fd42fd --- /dev/null +++ b/mathly2/static/notifications.js @@ -0,0 +1,40 @@ +// Notifications for photo requests, on a phone only: a switch on home. The service worker shows the notification when the app is closed and, on a +// tap, tells the open app to look for requests. Without a push key on the server the switch is not offered. +import { api } from "./api.js"; +import { isTouch } from "./media.js"; + +const bytes = base64 => Uint8Array.from(atob(base64.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - base64.length % 4) % 4)), c => c.charCodeAt(0)); + +/** Draw the switch into `box` (nothing at all on a computer, without notification support, or when the server has no key). */ +export async function setup(box) { + if (!isTouch() || !("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) return; + let server; + try { server = await api("/api/push/key"); } catch { return; } + if (!server.enabled) return; + let registration; + try { registration = await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }); } catch { return; } + let subscription = await registration.pushManager.getSubscription(); + const draw = (note = "") => { + box.hidden = false; + box.innerHTML = `

    התראות

    כשהמחשב מבקש תמונה של תשובה, הטלפון יקבל התראה גם כשהאפליקציה סגורה. באייפון זה עובד רק מהאפליקציה שהוספתם למסך הבית.

    +

    ${subscription ? ` התראות פועלות ` + : ``}

    ${note}

    `; + box.querySelector("#notif-on")?.addEventListener("click", enable); + box.querySelector("#notif-off")?.addEventListener("click", disable); + }; + async function enable() { + try { + if (await Notification.requestPermission() !== "granted") return draw("ההתראות חסומות בדפדפן. אפשר לאשר אותן בהגדרות."); + subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes(server.key) }); + await api("/api/push/subscribe", { method: "POST", body: subscription.toJSON() }); + draw(); + } catch (error) { subscription = null; draw(error.message || "לא הצלחנו להפעיל התראות."); } + } + async function disable() { + const endpoint = subscription?.endpoint; + try { await subscription?.unsubscribe(); if (endpoint) await api("/api/push/unsubscribe", { method: "POST", body: { endpoint } }); } catch { /* it is off here either way */ } + subscription = null; + draw(); + } + draw(); +} diff --git a/mathly2/static/path.js b/mathly2/static/path.js index 933000e7d053f3fac1845f8128cee000fce5e390..67dd25d3c6945f9ffc605e4e809822b0d201f56d 100644 --- a/mathly2/static/path.js +++ b/mathly2/static/path.js @@ -1,9 +1,9 @@ // The path: a trail of lessons. Each lesson is a hub with its skills as numbered stars in a ring around it; the owl perches on the // suggested next skill. Every star is a link to its exercise screen (#/skill/) and says its state in a symbol and a word. -import { api } from "./api.js"; +import { api, swr } from "./api.js"; import { esc, fmt } from "./math.js"; import { owl, ringSvg, spark } from "./sky.js"; -import { HOME, backToSubjects, goalBar, tabs } from "./ui.js"; +import { HOME, backToSubjects, goalBar, shortDate, tabs } from "./ui.js"; export const STATES = { not_started: { symbol: "○", word: "לא התחלת" }, @@ -32,18 +32,27 @@ function quizNode(l) { aria-label="בוחן ${esc(plain(l.name))}: ${state}">בוחן${state}`; } +/** "הושלם", or, for a lesson whose date has passed without it being completed, the marker with the date in words. */ +function lessonStatus(l) { + if (l.completed) return `

    הושלם${l.passed ? " (עברת את הבוחן)" : ""}

    `; + if (l.behind) return `

    היה אמור להסתיים ב־${shortDate(l.due_date)}

    `; + return ""; +} + +const behindNote = n => n ? `

    ${n === 1 ? "שיעור אחד בפיגור" : `${n} שיעורים בפיגור`} לפי התכנית

    ` : ""; + function lesson(l, next) { const n = l.skills.length; - return `
    + return `
    ${ringSvg(l.skills.map(s => s.state))}

    ${fmt(l.name)}

    ${l.mastered} מתוך ${n} נשלטו${l.completed ? " ✓" : ""}
    - ${l.skills.map((s, i) => star(s, i, n, s.id === next)).join("")}
    ${quizNode(l)}`; + ${l.skills.map((s, i) => star(s, i, n, s.id === next)).join("")}${lessonStatus(l)}
    ${quizNode(l)}`; } export function pathHtml(path) { return `${tabs("path")}${backToSubjects()}

    המסלול שלי

    -

    כל השיעורים פתוחים, בכל סדר. הינשוף מציע איפה להמשיך, אבל אפשר ללחוץ על כל כוכב.

    ${goalBar(path.today, path.streak)}
    +

    כל השיעורים פתוחים, בכל סדר. הינשוף מציע איפה להמשיך, אבל אפשר ללחוץ על כל כוכב.

    ${goalBar(path.today, path.streak)}${behindNote(path.behind)}
    ${owl(44)}התחלה
    ${path.lessons.map(l => lesson(l, path.next)).join("")} @@ -51,8 +60,12 @@ export function pathHtml(path) { } /** Draw the path. `scroll`: bring the next skill to the middle of the screen (when the path opens); a refresh keeps the place. */ -export async function show(view, { scroll = true } = {}) { - const path = await api("/api/path"); +export const show = (view, { scroll = true } = {}) => { + let first = true; // a refresh of the same screen keeps the place + return swr("/api/path", path => { const result = drawPath(view, path, scroll && first); first = false; return result; }); +}; + +function drawPath(view, path, scroll) { const place = window.scrollY; view(pathHtml(path)); if (scroll) document.querySelector(".star.next")?.scrollIntoView({ block: "center" }); @@ -67,7 +80,7 @@ export async function showSubjects(view) { view(`

    במה נתרגל היום?

    - מתמטיקה + מתמטיקה כיתה ט - מצוינות ${lit} מתוך ${total} מיומנויות
    `); return path; diff --git a/mathly2/static/phone.js b/mathly2/static/phone.js new file mode 100644 index 0000000000000000000000000000000000000000..4aa017aa9b36fc5db5bc208500ffbfdcb643329d --- /dev/null +++ b/mathly2/static/phone.js @@ -0,0 +1,117 @@ +// The photo request, both ends. The computer asks for a photo of an answer: it shows a 4-digit code and the request's state in words. +// The student's phone (any other device signed in to the same account) shows a window with the same code and a camera button. +import { api, ApiError } from "./api.js"; +import { withBusy } from "./busy.js"; +import { esc, fmt } from "./math.js"; +import { deviceId, prepare, sendPicture } from "./media.js"; + +export const WORDS = { waiting: "ממתינים לטלפון", uploading: "מעלים את התשובה…", received: "התשובה התקבלה", failed: "ההעלאה נכשלה", expired: "הבקשה פגה. אפשר לבקש שוב.", + superseded: "הוחלפה בבקשה חדשה" }; +const DONE = new Set(["received", "failed", "expired", "superseded"]); +const everyComputer = () => window.__photoPollComputer ?? 2000; // the computer looks for the phone's answer this often (milliseconds) +const everyPhone = () => window.__photoPollPhone ?? 5000; // the phone looks for new requests this often + +// ---------- the computer ---------- + +let runs = 0; // a newer request (or leaving the page) stops the older one's looking + +/** Ask the student's phone for a photo of this assignment's answer. `onPicture(id)` is called when the picture arrives. */ +export async function askFromComputer(assignment, onPicture, panel) { + const mine = ++runs; + const old = [...panel.querySelectorAll(".photo-request[data-code]")].map(el => `
  • קוד ${esc(el.dataset.code)}: ${WORDS.superseded}
  • `).join("") + + (panel.querySelector(".old-requests")?.innerHTML || ""); + let request; + try { + request = await api("/api/photo-requests", { method: "POST", body: { assignment_id: assignment.id, device: deviceId() } }); + } catch (error) { + panel.innerHTML = ``; + return; + } + const draw = state => { + panel.innerHTML = `
    +

    הקוד של הבקשה: ${esc(request.code)}

    +

    פתחו את האפליקציה בטלפון. אם הקוד שם זהה, צלמו את התשובה.

    +

    ${WORDS[state] || state}

    ${old ? `
      ${old}
    ` : ""}`; + }; + draw(request.state); + while (mine === runs && !DONE.has(request.state)) { + await new Promise(done => setTimeout(done, everyComputer())); + if (mine !== runs) return; + try { request = await api(`/api/photo-requests/${request.id}`); } catch { return; } + draw(request.state); + } + if (mine === runs && request.state === "received" && request.picture_id) onPicture(request.picture_id); +} + +/** Stop looking for an answer (the page was left). */ +export const stopComputer = () => { runs += 1; }; + +// ---------- the phone ---------- + +let watching = null, handled = new Set(), shown = null; + +function closeWindow() { + document.getElementById("photo-window")?.remove(); + shown = null; +} + +function windowHtml(request) { + return ``; +} + +async function takePhoto(request, file) { + const status = document.getElementById("phone-state"); + status.textContent = "מעלים את התשובה…"; + try { + await api(`/api/photo-requests/${request.id}/start`, { method: "POST" }); + await sendPicture(`/api/photo-requests/${request.id}/picture`, await prepare(file)); + handled.add(request.id); + status.textContent = "התמונה נשלחה למחשב ✓"; + setTimeout(closeWindow, 1500); + } catch (error) { + status.textContent = (error instanceof ApiError && error.message) || "ההעלאה נכשלה. נסו שוב."; + } +} + +async function look() { + let open; + try { open = await api(`/api/photo-requests/open?device=${encodeURIComponent(deviceId())}`); } catch { return; } + const wanted = open.find(r => !handled.has(r.id)); + if (!wanted) { if (shown && !document.getElementById("phone-state")?.textContent.includes("נשלחה")) closeWindow(); return; } + if (shown === wanted.id) return; + closeWindow(); + shown = wanted.id; + document.body.insertAdjacentHTML("beforeend", windowHtml(wanted)); + const box = document.getElementById("photo-window"); + box.querySelector("[data-photo]").addEventListener("change", event => { const file = event.target.files[0]; event.target.value = ""; if (file) withBusy(box.querySelector("#phone-camera"), takePhoto(wanted, file)); }); + box.querySelector("#phone-later").addEventListener("click", () => { handled.add(wanted.id); closeWindow(); }); + box.querySelector("#phone-camera").focus?.(); +} + +/** Start looking for photo requests made by the student's other devices: now, on a timer, when the page comes back, and when a notification was tapped. */ +export function startWatching() { + if (watching) return; + handled = new Set(); + const again = () => { look(); }; + const timer = () => { watching = setTimeout(async () => { await look(); if (watching) timer(); }, everyPhone()); }; + look(); + timer(); + const visible = () => { if (document.visibilityState === "visible") again(); }; + const message = event => { if (event.data?.type === "notified") again(); }; + document.addEventListener("visibilitychange", visible); + navigator.serviceWorker?.addEventListener("message", message); + startWatching.stop = () => { + clearTimeout(watching); watching = null; closeWindow(); + document.removeEventListener("visibilitychange", visible); + navigator.serviceWorker?.removeEventListener("message", message); + }; +} + +export const stopWatching = () => { startWatching.stop?.(); }; +window.addEventListener("mathly:leave", () => { stopComputer(); stopWatching(); }); diff --git a/mathly2/static/service-worker.js b/mathly2/static/service-worker.js new file mode 100644 index 0000000000000000000000000000000000000000..fa64f44f012749d978681cb665808fbec89aa7f1 --- /dev/null +++ b/mathly2/static/service-worker.js @@ -0,0 +1,30 @@ +/* Mathly's service worker: push notifications only (app/push.py). It has no fetch handler and keeps no cache. + An older version served /static/ from its own cache forever; activating this one deletes those caches. */ +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", event => event.waitUntil((async () => { + await Promise.all((await caches.keys()).map(key => caches.delete(key))); + await self.clients.claim(); +})())); + +// a photo request from the computer (or any later message): shown even when the app is closed +self.addEventListener("push", event => { + let msg = {}; + try { msg = event.data ? event.data.json() : {}; } catch { msg = { body: event.data && event.data.text() }; } + event.waitUntil(self.registration.showNotification(msg.title || "לומדים בכיף", { + body: msg.body || "", tag: msg.tag || "mathly", renotify: true, requireInteraction: false, + lang: "he", dir: "rtl", + icon: "/static/icons/icon-192.png", badge: "/static/icons/icon-192.png", + data: { url: msg.url || "/" } + })); +}); + +// a tap: the app already open is brought forward and told to look for requests now; otherwise it is opened +self.addEventListener("notificationclick", event => { + event.notification.close(); + const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href; + event.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(wins => { + const app = wins.find(w => new URL(w.url).origin === self.location.origin && !new URL(w.url).pathname.startsWith("/static/")); + if (app) { app.postMessage({ type: "notified", url }); return app.focus(); } + return self.clients.openWindow(url); + })); +}); diff --git a/mathly2/static/session.js b/mathly2/static/session.js index d31dcd7737fed6e9e7dccbe8719d66ea7407e7c3..18dbba222f6b32fc5a78688ef09e38d9a5c2ffae 100644 --- a/mathly2/static/session.js +++ b/mathly2/static/session.js @@ -30,7 +30,12 @@ export function signInDev(email) { try { localStorage.setItem(DEV_KEY, email.trim().toLowerCase()); } catch { /* private window: stays signed out */ } } +const outListeners = []; +/** Calls `fn` when the person signs out (so nothing remembered about them is shown to the next one). */ +export const onSignOut = fn => outListeners.push(fn); + export async function signOut() { + outListeners.forEach(fn => fn()); if (client) await client.auth.signOut(); try { localStorage.removeItem(DEV_KEY); } catch { /* nothing stored */ } } diff --git a/mathly2/static/student.js b/mathly2/static/student.js index 5f82f000509f238bb81eff56a593c1b36f23d359..9d0b8ca86588543ced7db92999a06d25fb3a2995 100644 --- a/mathly2/static/student.js +++ b/mathly2/static/student.js @@ -2,6 +2,7 @@ import { esc } from "./math.js"; import * as exercise from "./exercise.js"; import * as home from "./home.js"; +import * as phone from "./phone.js"; import * as path from "./path.js"; import * as quiz from "./quiz.js"; import { route as onRoute } from "./router.js"; @@ -15,6 +16,7 @@ export async function show(me, view) { } let lastSkill = ""; async function route() { + phone.stopComputer(); // a page that was waiting for the phone's photo is left const raw = location.hash.replace(/^#/, ""); const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen try { @@ -28,6 +30,12 @@ export async function show(me, view) { const [lesson, attempt] = where.slice(6).split("/").map(decodeURIComponent); if (!document.getElementById("path")) await path.show(view); // a link or a reload: the path first, then the sheet if (attempt) await quiz.open(attempt); else await quiz.start(lesson); // the node starts or resumes; the address then names the attempt + } else if (where === "/submissions") { + exercise.close(); quiz.close(); + await (await import("./submissions.js")).show(view); + } else if (where.startsWith("/assignment/")) { + exercise.close(); quiz.close(); + await (await import("./assignment.js")).show(view, decodeURIComponent(where.slice(12))); } else if (where.startsWith("/exam/") || where.startsWith("/sim/")) { exercise.close(); const [kind, ...rest] = where.slice(1).split("/").map(decodeURIComponent); @@ -65,5 +73,6 @@ export async function show(me, view) { if (!where.startsWith("/skill/") && !exercise.isOpen()) { /* the path scrolls itself */ } } onRoute(route); + phone.startWatching(); // a photo request from the student's computer shows here when this is the phone await route(); } diff --git a/mathly2/static/submissions.js b/mathly2/static/submissions.js new file mode 100644 index 0000000000000000000000000000000000000000..7fa25c38c0111517e8958ff517dc53d61aa8094c --- /dev/null +++ b/mathly2/static/submissions.js @@ -0,0 +1,33 @@ +// "הגשות": every assignment from the student's teachers, grouped by state, and (on a phone) the switch for notifications. +import { swr } from "./api.js"; +import { assignmentHref, assignmentState } from "./home.js"; +import { esc, fmt } from "./math.js"; +import * as notifications from "./notifications.js"; +import { shortDate, tabs } from "./ui.js"; + +/** The groups, in the order a student needs them: what to do first, then what waits, then what is done. */ +const GROUPS = [ + { id: "new", title: "חדשות: עוד לא הוגשו", states: ["not_handed", "not_done"] }, + { id: "returned", title: "הוחזרו לתיקון", states: ["returned"] }, + { id: "waiting", title: "הוגשו וממתינות לבדיקה", states: ["handed_in"] }, + { id: "checked", title: "נבדקו", states: ["graded", "done"] }, +]; + +function item(a) { + const s = assignmentState(a); + const note = a.teacher_note ? `הערת המורה: ${fmt(a.teacher_note)}` : ""; + return `
  • ${fmt(a.title)} + ${esc(a.class.name)} · עד ${shortDate(a.due_date)} ${s.word}${note}
  • `; +} + +export const show = view => swr("/api/assignments", list => draw(view, list)); + +function draw(view, list) { + const groups = GROUPS.map(g => ({ ...g, items: list.filter(a => g.states.includes(a.state)) })).filter(g => g.items.length); + view(`${tabs("submissions")}

    הגשות

    + ${groups.length ? groups.map(g => `

    ${g.title} ${g.items.length}

    +
      ${g.items.map(item).join("")}
    `).join("") + : `

    המורים עוד לא נתנו משימות.

    `}
    + `); + notifications.setup(document.getElementById("notifications")); +} diff --git a/mathly2/static/teacher.js b/mathly2/static/teacher.js index 6a39a3455aeadfc717f999f25a17c4d79d085923..ecc0ba341e793288783cf83f6ff54ad72d98c89f 100644 --- a/mathly2/static/teacher.js +++ b/mathly2/static/teacher.js @@ -1,16 +1,31 @@ // The teacher's home: the classes I teach, each with its students; I add (a pasted list) and remove students of my own classes. -import { api } from "./api.js"; +import { api, lastAnswer, swr } from "./api.js"; import { esc, fmt } from "./math.js"; import { daysText, shortDate } from "./ui.js"; let draw; // the app's view() function const tabs = active => `
    - -
    `; + + + +
    `; + +const work = () => import("./teacher_work.js"); +const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() }; + +/** The number of items in each tab, from the server, filled in after the screen is drawn (and so refreshed after every action). */ +async function fillCounts() { + const show = counts => document.querySelectorAll("#teacher-tabs [data-count]").forEach(el => { el.textContent = counts[el.dataset.count]; }); + const known = lastAnswer("/api/teaching/counts"); + if (known) show(known); // the last numbers at once, the fresh ones when they arrive + try { show(await api("/api/teaching/counts")); } catch { /* the tabs work without the numbers */ } +} function wireTabs() { - document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => (b.dataset.tab === "tests" ? showTests() : showClasses()))); + fillCounts(); + const screens = { classes: showClasses, tests: showTests, assignments: () => work().then(m => m.showAssignments(context)), inbox: () => work().then(m => m.showInbox(context)) }; + document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => screens[b.dataset.tab]())); } function report(result) { @@ -22,15 +37,17 @@ function report(result) { return lines; } -async function showClasses(notes = {}) { - const classes = await api("/api/teaching"); +const showClasses = (notes = {}) => swr("/api/teaching", classes => drawClasses(classes, notes)); + +async function drawClasses(classes, notes) { draw(`${tabs("classes")}

    הכיתות שלי

    ${classes.length ? `
    ${classes.map(c => `

    ${esc(c.name)}

    ${c.students.length} תלמידים

    ${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}

    ${c.students.length ? `
      ${c.students.map(s => `
    • ${esc(s.email)} ${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"} -
    • `).join("")}
    ` + + `).join("")}` : `

    עוד אין תלמידים בכיתה.

    `} @@ -49,6 +66,20 @@ async function showClasses(notes = {}) { } catch (e) { await showClasses({ [id]: { error: e.message } }); } }); card.addEventListener("click", async event => { + const wipe = event.target.closest("[data-wipe]"); + if (wipe) { // the student's answer pictures, on request: two steps (the grades stay) + wipe.outerHTML = ` `; + return; + } + if (event.target.closest("[data-cancel-wipe]")) { await showClasses(); return; } + const sure = event.target.closest("[data-confirm-wipe]")?.dataset.confirmWipe; + if (sure) { + try { + const done = await api(`/api/classes/${id}/pictures/${encodeURIComponent(sure)}`, { method: "DELETE" }); + await showClasses({ [id]: { lines: [`נמחקו ${done.deleted} תמונות של ${esc(sure)}. הציונים וההערות נשארו.`] } }); + } catch (e) { await showClasses({ [id]: { error: e.message } }); } + return; + } const email = event.target.closest("[data-remove]")?.dataset.remove; if (!email) return; try { @@ -90,8 +121,9 @@ function testCard(c, t) {
    `; } -async function showTests() { - const classes = await api("/api/teaching/tests"); +const showTests = () => swr("/api/teaching/tests", drawTests); + +async function drawTests(classes) { draw(`${tabs("tests")}

    מבחנים

    ${classes.length ? classes.map(({ class: c, tests }) => `

    ${esc(c.name)}

    ${tests.length ? tests.map(t => testCard(c, t)).join("") : `

    עוד לא נקבע מבחן לכיתה.

    `} diff --git a/mathly2/static/teacher_work.js b/mathly2/static/teacher_work.js new file mode 100644 index 0000000000000000000000000000000000000000..a422f66a8595368a0d978100039ff998be51141d --- /dev/null +++ b/mathly2/static/teacher_work.js @@ -0,0 +1,167 @@ +// The teacher's work tab: give an assignment (a file, a photo, the board, one of the bank's written quizzes, or an exercise test) and see +// how many students handed in. `ctx` is the teacher's screen: { draw(html), tabs(active), wire() }. +import { api, swr } from "./api.js"; +import { loadBoard } from "./exercise.js"; +import { withBusy } from "./busy.js"; +import { esc, fmt } from "./math.js"; +import { isTouch, pictureUrl, prepare, sendPicture } from "./media.js"; +import { daysText, shortDate } from "./ui.js"; + +const today = () => new Date().toISOString().slice(0, 10); +const SOURCES = [["pages", "קובץ, צילום או כתיבה על הלוח"], ["quiz", "בוחן כתוב מהמאגר"], ["exercise_test", "מבחן תרגול"]]; + +function sourceFields(c) { + const quizzes = c.lessons.filter(l => l.has_quiz); + return `

    הוסיפו עמודים: קובץ (תמונה או PDF), צילום או כתיבה על הלוח.

    +
    • עוד לא נוספו עמודים.
    +

    + ${isTouch() ? `` : ""} +

    + + `; +} + +function newForm(c) { + return ` + + +
    התוכן${SOURCES.map(([v, label], i) => ``).join("")}
    + ${sourceFields(c)}

    `; +} + +function row(a) { + const counts = a.kind === "exercise_test" ? `${a.done} מתוך ${a.students} עשו` + : `${a.handed_in + a.graded + a.returned} מתוך ${a.students} הגישו${a.graded} נבדקו${a.waiting ? `${a.waiting} מחכות לבדיקה` : ""}`; + const what = a.kind === "quiz" ? `בוחן כתוב: ${fmt(a.quiz_lesson.name)}` : a.kind === "exercise_test" ? `מבחן תרגול ${a.test.number}` : `${a.pages.length} עמודים`; + return `

    ${fmt(a.title)}

    עד ${shortDate(a.due_date)} (${daysText(a.days_left)})
    +

    ${what}${a.note ? ` · ${fmt(a.note)}` : ""}

    ${counts}

    +

    `; +} + +export const showAssignments = ctx => swr("/api/teaching/assignments", classes => drawAssignments(ctx, classes)); + +async function drawAssignments(ctx, classes) { + ctx.draw(`${ctx.tabs("assignments")}

    משימות

    + ${classes.length ? classes.map(({ class: c, assignments }) => `

    ${esc(c.name)}

    + ${assignments.length ? assignments.map(row).join("") : `

    עוד לא נתנו משימה לכיתה.

    `} +
    משימה חדשה${newForm(c)}
    `).join("") + : `

    עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.

    `}
    `); + ctx.wire(); + const root = document.getElementById("work"); + root.querySelectorAll(".assignment-form").forEach(form => wireForm(form, ctx)); + root.addEventListener("click", async event => { + const button = event.target.closest("[data-remove-assignment]"); + if (button) { // two steps, so a slip does not remove it + button.outerHTML = ` `; + return; + } + if (event.target.closest("[data-cancel-remove]")) return showAssignments(ctx); + const confirm = event.target.closest("[data-confirm-remove-assignment]")?.dataset.confirmRemoveAssignment; + if (confirm) { await api(`/api/assignments/${confirm}`, { method: "DELETE" }); await showAssignments(ctx); } + }); +} + +function wireForm(form, ctx) { + let pages = []; + const error = form.querySelector(".form-error"); + const fail = text => { error.textContent = text; error.hidden = false; error.scrollIntoView({ block: "nearest" }); }; + const list = form.querySelector("[data-pages]"); + const drawPages = () => { + list.innerHTML = pages.length ? pages.map(id => `
  • +
  • `).join("") : `
  • עוד לא נוספו עמודים.
  • `; + list.querySelectorAll("img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.remove(); } }); + }; + const upload = async (blob, type) => { + const got = await fetch("/api/assignments/pages", { method: "POST", headers: { Authorization: "Bearer " + await (await import("./session.js")).token(), "Content-Type": type || blob.type }, body: blob }); + const data = await got.json().catch(() => ({})); + if (!got.ok) throw new Error(typeof data.detail === "string" ? data.detail : "משהו השתבש"); + pages.push(...data.pictures); + drawPages(); + }; + const addFiles = async files => { + error.hidden = true; + for (const file of files) { + try { await upload(file.type === "application/pdf" ? file : await prepare(file), file.type === "application/pdf" ? file.type : "image/jpeg"); } catch (e) { fail(e.message); } + } + }; + form.querySelectorAll("[data-pages-file], [data-pages-camera]").forEach(input => input.addEventListener("change", async () => { const files = [...input.files]; input.value = ""; await withBusy(input.closest("label"), addFiles(files)); })); + list.addEventListener("click", event => { const id = event.target.closest("[data-remove-page]")?.dataset.removePage; if (id) { pages = pages.filter(p => p !== id); drawPages(); } }); + form.querySelector("[data-pages-board]").addEventListener("click", async () => { + await loadBoard(); + if (!window.MathlyBoard?.openFull) return; + const host = Object.assign(document.createElement("div"), { className: "mx-layer" }); + document.body.append(host); + window.MathlyBoard.openFull({ host, key: `assignment-new:${form.dataset.class}`, title: "כתיבת משימה על הלוח", sendLabel: "הוספה למשימה", + submit: async (page, blob) => { try { await upload(blob, "image/png"); return {}; } catch (e) { return { error: e.message }; } }, done: () => host.remove() }); + }); + form.querySelectorAll("[name=source]").forEach(radio => radio.addEventListener("change", () => { + form.querySelectorAll(".source").forEach(box => { box.hidden = box.dataset.source !== form.querySelector("[name=source]:checked").value; }); + })); + form.addEventListener("submit", async event => { + event.preventDefault(); + error.hidden = true; + const data = new FormData(form), kind = data.get("source"); + const body = { title: data.get("title"), note: data.get("note") || "", due_date: data.get("due_date"), kind }; + if (kind === "pages") body.pages = pages; + if (kind === "quiz") body.quiz_lesson = data.get("quiz_lesson"); + if (kind === "exercise_test") { body.test_id = Number(data.get("test")); body.exercise_number = Number(data.get("exercise_number")); } + try { + await api(`/api/classes/${form.dataset.class}/assignments`, { method: "POST", body }); + await showAssignments(ctx); + } catch (e) { fail(e.message); } // the form keeps what was typed + }); +} + +// ---------- the inbox: everything that waits for the teacher, oldest first ---------- + +const when = iso => new Date(iso).toLocaleString("he-IL", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" }); + +export const showInbox = (ctx, notice = "") => swr("/api/teaching/inbox", items => drawInbox(ctx, items, notice)); + +async function drawInbox(ctx, items, notice) { + ctx.draw(`${ctx.tabs("inbox")}

    תיבת בדיקה

    ${notice ? `

    ${esc(notice)}

    ` : ""} + ${items.length ? `
      ${items.map(i => `
    • `).join("")}
    ` + : `

    אין מה לבדוק. כל ההגשות נבדקו.

    `}
    `); + ctx.wire(); + document.getElementById("inbox").addEventListener("click", event => { + const id = event.target.closest("[data-open]")?.dataset.open; + if (id) openSubmission(ctx, id); + }); +} + +async function openSubmission(ctx, id) { + const s = await api(`/api/submissions/${id}`); + const material = s.quiz ? `

    הבוחן הכתוב

      ${s.quiz.questions.map(x => `
    1. ${fmt(x.question)}

      ${x.points} נק׳
    2. `).join("")}
    ` + : s.pages.length ? `

    הדף שניתן

    ${s.pages.map(p => ``).join("")}
    ` : ""; + ctx.draw(`${ctx.tabs("inbox")}

    +

    ${esc(s.student)}: ${fmt(s.assignment.title)}

    ${esc(s.assignment.class.name)} · הוגש ${when(s.handed_at)}${s.late ? " · באיחור" : ""}

    +

    התשובה

    ${s.pictures.map(p => p.deleted ? `

    התמונה נמחקה

    ` : ``).join("")}
    +
    מה ביקשנו${material || `

    אין תוכן להצגה.

    `}
    +
    + + +

    `); + ctx.wire(); + document.querySelectorAll("#check img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.remove(); } }); + const form = document.getElementById("check-form"), error = document.getElementById("check-error"); + const fail = text => { error.textContent = text; error.hidden = false; }; + document.getElementById("back-to-inbox").addEventListener("click", () => showInbox(ctx)); + const send = async (path, body, done) => { + error.hidden = true; + try { await api(`/api/submissions/${id}/${path}`, { method: "POST", body: { ...body, seen: s.seen } }); await showInbox(ctx, done); } + catch (e) { fail(e.status === 409 ? `${e.message} ` : e.message); if (e.status === 409) error.insertAdjacentHTML("beforeend", ``); document.getElementById("reopen")?.addEventListener("click", () => showInbox(ctx)); } + }; + form.addEventListener("submit", event => { + event.preventDefault(); + const grade = form.elements.grade.value; + if (grade === "" || !Number.isInteger(Number(grade)) || Number(grade) < 0 || Number(grade) > 100) return fail("הציון הוא מספר שלם בין 0 ל־100."); + send("grade", { grade: Number(grade), note: form.elements.note.value }, "הציון נשמר."); + }); + document.getElementById("send-back").addEventListener("click", () => { + if (!form.elements.note.value.trim()) return fail("צריך לכתוב מה לתקן."); + send("return", { note: form.elements.note.value }, "המשימה הוחזרה לתלמיד/ה לתיקון."); + }); +} diff --git a/mathly2/static/testcard.js b/mathly2/static/testcard.js index 1631c119ba5526f1a21c62ee8c5eedd69c46fad5..b9c43bbf495b5c2a5dba622a2c1e7726e9ec4458 100644 --- a/mathly2/static/testcard.js +++ b/mathly2/static/testcard.js @@ -32,7 +32,8 @@ function simulation(test) { } export async function show(view, id) { - const test = await api(`/api/tests/${encodeURIComponent(id)}`); + const [test, home] = await Promise.all([api(`/api/tests/${encodeURIComponent(id)}`), api("/api/home")]); + const others = home.tests.filter(t => t.id !== test.id); view(`${tabs("home")}

    ‹ הבית

    מבחן ב־${shortDate(test.date)} (${daysText(test.days_left)})

    ${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` · ${esc(test.note)}` : ""}
    ${test.questions} שאלות · ${test.minutes} דקות

    @@ -41,5 +42,7 @@ export async function show(view, id) {

    מבחני תרגול

    ${exercises(test)} ${test.practice_first.length ? `

    כדאי לתרגל קודם: ${test.practice_first.map(s => `${fmt(s.name)}`).join(" ")}

    אלה מיומנויות שעוד לא התחלתם, ולכן אין להן שאלות במבחני התרגול.

    ` : ""}
    -

    מבחן סימולציה

    ${simulation(test)}
    `); +

    מבחן סימולציה

    ${simulation(test)}
    + ${others.length ? `

    מבחנים נוספים

    ` : ""}`); } diff --git a/mathly2/static/ui.js b/mathly2/static/ui.js index eba960f4dd10e78efc58a688731e8a875074c5fc..cfd86f7270d92adadb027037e7bde4455762b629 100644 --- a/mathly2/static/ui.js +++ b/mathly2/static/ui.js @@ -17,6 +17,7 @@ export const PATH = "#/subject/math/path"; export const tabs = active => ``; diff --git a/mathly2/store.py b/mathly2/store.py index b5cffbe504ad099fb00787d514fca11b9d0c0b56..1c43fa5de221e2406d468014a1236cb393f88f8f 100644 --- a/mathly2/store.py +++ b/mathly2/store.py @@ -2,6 +2,7 @@ InMemoryStore (tests and local development, lost on restart) and SupabaseStore (PostgREST with the secret key).""" from __future__ import annotations +import re import itertools import json import threading @@ -50,7 +51,9 @@ def _copy_attempt(row: dict) -> dict: def _when(value): if value is None or isinstance(value, datetime): return value - return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + # Python 3.10 reads a fraction of exactly 3 or 6 digits only, and Postgres drops trailing zeros ("...57.63003+00:00") + text = re.sub(r"\.(\d+)", lambda m: "." + m.group(1)[:6].ljust(6, "0"), str(value).replace("Z", "+00:00"), count=1) + return datetime.fromisoformat(text) def _attempt_row(row: dict) -> dict: @@ -78,6 +81,25 @@ def _test_row(row: dict) -> dict: return {**row, "date": day, "lessons": list(row.get("lessons") or []), "created_at": _when(row["created_at"]), "removed_at": _when(row.get("removed_at"))} +PICTURE_KINDS = ("page", "answer") +SUBMISSION_FIELDS = {"pictures", "state", "late", "handed_at", "grade", "teacher_note", "checked_at", "checked_by"} +REQUEST_FIELDS = {"state", "picture_id"} + + +def _stamped(row: dict, *names: str) -> dict: + """A database row with the named timestamp columns as datetimes (the in-memory store keeps datetimes already).""" + return {**row, **{n: _when(row.get(n)) for n in names}} + + +def _assignment_row(row: dict) -> dict: + day = row["due_date"] if isinstance(row["due_date"], date) else date.fromisoformat(str(row["due_date"])[:10]) + return {**_stamped(row, "created_at", "removed_at"), "due_date": day, "pages": [str(x) for x in (row.get("pages") or [])]} + + +def _submission_row(row: dict) -> dict: + return {**_stamped(row, "handed_at", "checked_at"), "pictures": [str(x) for x in (row.get("pictures") or [])]} + + class InMemoryStore: def __init__(self) -> None: self._lock = threading.Lock() @@ -91,6 +113,12 @@ class InMemoryStore: self._attempts: dict[str, dict] = {} # attempt id -> row self._reward: dict[str, dict] = {} # user_id -> {last_comet_day, last_catch_day} self._tests: dict[int, dict] = {} # test id -> row + self._pictures: dict[str, dict] = {} + self._curriculum: dict[str, date] = {} # lesson id -> the date it should be completed by + self._assignments: dict[int, dict] = {} + self._submissions: dict[int, dict] = {} + self._requests: dict[str, dict] = {} + self._subs: dict[int, dict] = {} # push subscriptions # --- classes --- @@ -332,6 +360,177 @@ class InMemoryStore: rows = [r for r in self._tests.values() if r["class_id"] in wanted and (include_removed or not r["removed_at"])] return [dict(r, lessons=list(r["lessons"])) for r in sorted(rows, key=lambda r: (r["date"], r["id"]))] + # --- pictures, assignments and submissions (written work) --- + + def create_picture(self, owner_id: str, kind: str, path: str, content_type: str, size: int, created_at: datetime | None = None) -> dict: + if kind not in PICTURE_KINDS: + raise ValueError(f"unknown picture kind {kind!r}") + row = {"id": str(uuid.uuid4()), "owner_id": owner_id, "kind": kind, "path": path, "content_type": content_type, "size": size, + "created_at": created_at or datetime.now(timezone.utc), "deleted_at": None} + with self._lock: + self._pictures[row["id"]] = row + return dict(row) + + def get_picture(self, picture_id: str) -> dict | None: + with self._lock: + row = self._pictures.get(str(picture_id)) + return dict(row) if row else None + + def mark_picture_deleted(self, picture_id: str, at: datetime | None = None) -> bool: + with self._lock: + row = self._pictures.get(str(picture_id)) + if row is None or row["deleted_at"]: + return False + row["deleted_at"] = at or datetime.now(timezone.utc) + return True + + def pictures(self, kind: str | None = None, owner_id: str | None = None, include_deleted: bool = False) -> list[dict]: + with self._lock: + return [dict(r) for r in sorted(self._pictures.values(), key=lambda r: r["created_at"]) + if (kind is None or r["kind"] == kind) and (owner_id is None or r["owner_id"] == owner_id) and (include_deleted or not r["deleted_at"])] + + def picture_refs(self, picture_id: str) -> dict: + """What refers to a picture: assignments (as a page), submissions (as an answer) and photo requests.""" + pid = str(picture_id) + with self._lock: + return {"assignments": sorted(a["id"] for a in self._assignments.values() if pid in a["pages"]), + "submissions": sorted(s["id"] for s in self._submissions.values() if pid in s["pictures"]), + "requests": sorted(r["id"] for r in self._requests.values() if r["picture_id"] == pid)} + + def create_assignment(self, class_id: int, title: str, due_date: date, created_by: str, kind: str, *, note: str = "", pages=(), + quiz_lesson: str | None = None, test_id: int | None = None, exercise_number: int | None = None, + created_at: datetime | None = None) -> dict: + ok = {"pages": bool(pages), "quiz": bool(quiz_lesson), "exercise_test": test_id is not None and exercise_number in (1, 2)} + if not ok.get(kind) or not 1 <= len(title) <= 200: + raise ValueError("an assignment needs a title and its content") + with self._lock: + row = {"id": next(self._ids), "class_id": class_id, "title": title, "note": note, "due_date": due_date, "kind": kind, + "pages": [str(p) for p in pages], "quiz_lesson": quiz_lesson, "test_id": test_id, "exercise_number": exercise_number, + "created_by": created_by, "created_at": created_at or datetime.now(timezone.utc), "removed_at": None} + self._assignments[row["id"]] = row + return dict(row, pages=list(row["pages"])) + + def get_assignment(self, assignment_id: int) -> dict | None: + with self._lock: + row = self._assignments.get(assignment_id) + return dict(row, pages=list(row["pages"])) if row else None + + def assignments_of_classes(self, class_ids, include_removed: bool = False) -> list[dict]: + """The assignments of these classes, the nearest due date first.""" + wanted = set(class_ids) + with self._lock: + rows = [r for r in self._assignments.values() if r["class_id"] in wanted and (include_removed or not r["removed_at"])] + return [dict(r, pages=list(r["pages"])) for r in sorted(rows, key=lambda r: (r["due_date"], r["id"]))] + + def remove_assignment(self, assignment_id: int, at: datetime | None = None) -> bool: + with self._lock: + row = self._assignments.get(assignment_id) + if row is None or row["removed_at"]: + return False + row["removed_at"] = at or datetime.now(timezone.utc) + return True + + def get_submission(self, assignment_id: int, user_id: str) -> dict | None: + with self._lock: + row = next((r for r in self._submissions.values() if r["assignment_id"] == assignment_id and r["user_id"] == user_id), None) + return dict(row, pictures=list(row["pictures"])) if row else None + + def get_submission_by_id(self, submission_id: int) -> dict | None: + with self._lock: + row = self._submissions.get(submission_id) + return dict(row, pictures=list(row["pictures"])) if row else None + + def save_submission(self, assignment_id: int, user_id: str, **fields) -> dict: + """Create the student's submission or change the given fields of it.""" + unknown = set(fields) - SUBMISSION_FIELDS + if unknown: + raise ValueError(f"unknown submission fields {sorted(unknown)}") + with self._lock: + row = next((r for r in self._submissions.values() if r["assignment_id"] == assignment_id and r["user_id"] == user_id), None) + candidate = dict(row) if row else {"id": next(self._ids), "assignment_id": assignment_id, "user_id": user_id, "pictures": [], "state": "handed_in", + "late": False, "handed_at": datetime.now(timezone.utc), "grade": None, "teacher_note": "", "checked_at": None, + "checked_by": None} + candidate.update({k: ([str(x) for x in v] if k == "pictures" else v) for k, v in fields.items()}) + if candidate["state"] == "graded" and candidate["grade"] is None: + raise ValueError("a graded submission has a grade") # refused before anything is stored + if candidate["grade"] is not None and not 0 <= candidate["grade"] <= 100: + raise ValueError("a grade is 0 to 100") + self._submissions[candidate["id"]] = candidate + return dict(candidate, pictures=list(candidate["pictures"])) + + def submissions_of_assignments(self, assignment_ids) -> list[dict]: + wanted = set(assignment_ids) + with self._lock: + return [dict(r, pictures=list(r["pictures"])) for r in sorted(self._submissions.values(), key=lambda r: (r["handed_at"], r["id"])) + if r["assignment_id"] in wanted] + + def submissions_of_user(self, user_id: str) -> list[dict]: + with self._lock: + return [dict(r, pictures=list(r["pictures"])) for r in self._submissions.values() if r["user_id"] == user_id] + + # --- photo requests and push subscriptions --- + + def create_photo_request(self, user_id: str, assignment_id: int, device: str, code: str, created_at: datetime, expires_at: datetime) -> dict: + """A new request; the student's earlier waiting request for the same assignment becomes superseded.""" + with self._lock: + for r in self._requests.values(): + if r["user_id"] == user_id and r["assignment_id"] == assignment_id and r["state"] == "waiting": + r["state"] = "superseded" + row = {"id": str(uuid.uuid4()), "user_id": user_id, "assignment_id": assignment_id, "device": device, "code": code, "state": "waiting", + "created_at": created_at, "expires_at": expires_at, "picture_id": None} + self._requests[row["id"]] = row + return dict(row) + + def get_photo_request(self, request_id: str) -> dict | None: + with self._lock: + row = self._requests.get(str(request_id)) + return dict(row) if row else None + + def save_photo_request(self, request_id: str, **fields) -> dict: + unknown = set(fields) - REQUEST_FIELDS + if unknown: + raise ValueError(f"unknown photo request fields {sorted(unknown)}") + with self._lock: + row = self._requests.get(str(request_id)) + if row is None: + raise KeyError(request_id) + row.update(fields) + return dict(row) + + def open_photo_requests(self, user_id: str) -> list[dict]: + """The student's requests still waiting for or receiving a picture, the newest first (expiry is decided by the caller).""" + with self._lock: + return [dict(r) for r in sorted(self._requests.values(), key=lambda r: r["created_at"], reverse=True) + if r["user_id"] == user_id and r["state"] in ("waiting", "uploading")] + + def add_push_subscription(self, user_id: str, endpoint: str, p256dh: str, auth: str, key_id: str) -> dict: + with self._lock: + row = next((r for r in self._subs.values() if r["endpoint"] == endpoint), None) + if row is None: + row = {"id": next(self._ids), "endpoint": endpoint, "created_at": datetime.now(timezone.utc)} + self._subs[row["id"]] = row + row.update({"user_id": user_id, "p256dh": p256dh, "auth": auth, "key_id": key_id}) + return dict(row) + + def push_subscriptions_of(self, user_id: str) -> list[dict]: + with self._lock: + return [dict(r) for r in self._subs.values() if r["user_id"] == user_id] + + def delete_push_subscription(self, endpoint: str) -> bool: + with self._lock: + found = [i for i, r in self._subs.items() if r["endpoint"] == endpoint] + for i in found: + del self._subs[i] + return bool(found) + + def drop_push_subscriptions_not_of(self, key_id: str) -> int: + """The server's push key changed: subscriptions made under another key are dropped, and each phone turns notifications on again.""" + with self._lock: + gone = [i for i, r in self._subs.items() if r["key_id"] != key_id] + for i in gone: + del self._subs[i] + return len(gone) + # --- timed attempts (quizzes; later exercise tests and the simulation) --- def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict: @@ -384,6 +583,21 @@ class InMemoryStore: row.update(fields) return dict(row) + # --- the curriculum: the date by which each lesson should be completed --- + + def curriculum(self) -> dict[str, date]: + with self._lock: + return dict(self._curriculum) + + def set_lesson_dates(self, dates: dict[str, date | None]) -> None: + """Set each lesson's date; None clears it.""" + with self._lock: + for lesson, day in dates.items(): + if day is None: + self._curriculum.pop(lesson, None) + else: + self._curriculum[lesson] = day + def last_answered(self, user_id: str) -> dict[str, date]: """The day of the student's newest answer on each skill (helped and wrong answers count): what fading reads.""" with self._lock: @@ -633,6 +847,146 @@ class SupabaseStore: params["removed_at"] = "is.null" return [_test_row(r) for r in self._request("GET", "m2_tests", params)] + # --- pictures, assignments and submissions (written work) --- + + def create_picture(self, owner_id: str, kind: str, path: str, content_type: str, size: int, created_at: datetime | None = None) -> dict: + if kind not in PICTURE_KINDS: + raise ValueError(f"unknown picture kind {kind!r}") + body = {"owner_id": owner_id, "kind": kind, "path": path, "content_type": content_type, "size": size} + if created_at: + body["created_at"] = created_at.isoformat() + return _stamped(self._request("POST", "m2_pictures", body=body, returning=True)[0], "created_at", "deleted_at") + + def get_picture(self, picture_id: str) -> dict | None: + try: + rows = self._request("GET", "m2_pictures", {"id": self._eq(picture_id), "select": "*"}) + except urllib.error.HTTPError as exc: + if exc.code == 400: # not a valid id + return None + raise + return _stamped(rows[0], "created_at", "deleted_at") if rows else None + + def mark_picture_deleted(self, picture_id: str, at: datetime | None = None) -> bool: + stamp = (at or datetime.now(timezone.utc)).isoformat() + return bool(self._request("PATCH", "m2_pictures", {"id": self._eq(picture_id), "deleted_at": "is.null"}, {"deleted_at": stamp}, returning=True)) + + def pictures(self, kind: str | None = None, owner_id: str | None = None, include_deleted: bool = False) -> list[dict]: + params = {"select": "*", "order": "created_at"} + if kind: + params["kind"] = self._eq(kind) + if owner_id: + params["owner_id"] = self._eq(owner_id) + if not include_deleted: + params["deleted_at"] = "is.null" + return [_stamped(r, "created_at", "deleted_at") for r in self._request("GET", "m2_pictures", params)] + + def picture_refs(self, picture_id: str) -> dict: + pid = str(picture_id) + return {"assignments": sorted(r["id"] for r in self._request("GET", "m2_assignments", {"pages": f"cs.{{{pid}}}", "select": "id"})), + "submissions": sorted(r["id"] for r in self._request("GET", "m2_submissions", {"pictures": f"cs.{{{pid}}}", "select": "id"})), + "requests": sorted(r["id"] for r in self._request("GET", "m2_photo_requests", {"picture_id": self._eq(pid), "select": "id"}))} + + def create_assignment(self, class_id: int, title: str, due_date: date, created_by: str, kind: str, *, note: str = "", pages=(), + quiz_lesson: str | None = None, test_id: int | None = None, exercise_number: int | None = None, + created_at: datetime | None = None) -> dict: + ok = {"pages": bool(pages), "quiz": bool(quiz_lesson), "exercise_test": test_id is not None and exercise_number in (1, 2)} + if not ok.get(kind) or not 1 <= len(title) <= 200: + raise ValueError("an assignment needs a title and its content") + body = {"class_id": class_id, "title": title, "note": note, "due_date": due_date.isoformat(), "kind": kind, "pages": [str(p) for p in pages], + "quiz_lesson": quiz_lesson, "test_id": test_id, "exercise_number": exercise_number, "created_by": created_by} + if created_at: + body["created_at"] = created_at.isoformat() + return _assignment_row(self._request("POST", "m2_assignments", body=body, returning=True)[0]) + + def get_assignment(self, assignment_id: int) -> dict | None: + rows = self._request("GET", "m2_assignments", {"id": self._eq(assignment_id), "select": "*"}) + return _assignment_row(rows[0]) if rows else None + + def assignments_of_classes(self, class_ids, include_removed: bool = False) -> list[dict]: + ids = sorted(set(class_ids)) + if not ids: + return [] + params = {"class_id": f"in.({','.join(str(i) for i in ids)})", "select": "*", "order": "due_date,id"} + if not include_removed: + params["removed_at"] = "is.null" + return [_assignment_row(r) for r in self._request("GET", "m2_assignments", params)] + + def remove_assignment(self, assignment_id: int, at: datetime | None = None) -> bool: + stamp = (at or datetime.now(timezone.utc)).isoformat() + return bool(self._request("PATCH", "m2_assignments", {"id": self._eq(assignment_id), "removed_at": "is.null"}, {"removed_at": stamp}, returning=True)) + + def get_submission(self, assignment_id: int, user_id: str) -> dict | None: + rows = self._request("GET", "m2_submissions", {"assignment_id": self._eq(assignment_id), "user_id": self._eq(user_id), "select": "*"}) + return _submission_row(rows[0]) if rows else None + + def get_submission_by_id(self, submission_id: int) -> dict | None: + rows = self._request("GET", "m2_submissions", {"id": self._eq(submission_id), "select": "*"}) + return _submission_row(rows[0]) if rows else None + + def save_submission(self, assignment_id: int, user_id: str, **fields) -> dict: + unknown = set(fields) - SUBMISSION_FIELDS + if unknown: + raise ValueError(f"unknown submission fields {sorted(unknown)}") + body = {k: (v.isoformat() if isinstance(v, datetime) else ([str(x) for x in v] if k == "pictures" else v)) for k, v in fields.items()} + rows = self._request("POST", "m2_submissions", {"on_conflict": "assignment_id,user_id"}, + {"assignment_id": assignment_id, "user_id": user_id, **body}, returning=True, upsert=True) + return _submission_row(rows[0]) + + def submissions_of_assignments(self, assignment_ids) -> list[dict]: + ids = sorted(set(assignment_ids)) + if not ids: + return [] + rows = self._request("GET", "m2_submissions", {"assignment_id": f"in.({','.join(str(i) for i in ids)})", "select": "*", "order": "handed_at,id"}) + return [_submission_row(r) for r in rows] + + def submissions_of_user(self, user_id: str) -> list[dict]: + return [_submission_row(r) for r in self._request("GET", "m2_submissions", {"user_id": self._eq(user_id), "select": "*"})] + + # --- photo requests and push subscriptions --- + + def create_photo_request(self, user_id: str, assignment_id: int, device: str, code: str, created_at: datetime, expires_at: datetime) -> dict: + self._request("PATCH", "m2_photo_requests", {"user_id": self._eq(user_id), "assignment_id": self._eq(assignment_id), "state": "eq.waiting"}, + {"state": "superseded"}) + rows = self._request("POST", "m2_photo_requests", body={"user_id": user_id, "assignment_id": assignment_id, "device": device, "code": code, + "created_at": created_at.isoformat(), "expires_at": expires_at.isoformat()}, returning=True) + return _stamped(rows[0], "created_at", "expires_at") + + def get_photo_request(self, request_id: str) -> dict | None: + try: + rows = self._request("GET", "m2_photo_requests", {"id": self._eq(request_id), "select": "*"}) + except urllib.error.HTTPError as exc: + if exc.code == 400: + return None + raise + return _stamped(rows[0], "created_at", "expires_at") if rows else None + + def save_photo_request(self, request_id: str, **fields) -> dict: + unknown = set(fields) - REQUEST_FIELDS + if unknown: + raise ValueError(f"unknown photo request fields {sorted(unknown)}") + rows = self._request("PATCH", "m2_photo_requests", {"id": self._eq(request_id)}, fields, returning=True) + if not rows: + raise KeyError(request_id) + return _stamped(rows[0], "created_at", "expires_at") + + def open_photo_requests(self, user_id: str) -> list[dict]: + rows = self._request("GET", "m2_photo_requests", {"user_id": self._eq(user_id), "state": "in.(waiting,uploading)", "select": "*", "order": "created_at.desc"}) + return [_stamped(r, "created_at", "expires_at") for r in rows] + + def add_push_subscription(self, user_id: str, endpoint: str, p256dh: str, auth: str, key_id: str) -> dict: + rows = self._request("POST", "m2_push_subscriptions", {"on_conflict": "endpoint"}, + {"user_id": user_id, "endpoint": endpoint, "p256dh": p256dh, "auth": auth, "key_id": key_id}, returning=True, upsert=True) + return _stamped(rows[0], "created_at") + + def push_subscriptions_of(self, user_id: str) -> list[dict]: + return [_stamped(r, "created_at") for r in self._request("GET", "m2_push_subscriptions", {"user_id": self._eq(user_id), "select": "*"})] + + def delete_push_subscription(self, endpoint: str) -> bool: + return bool(self._request("DELETE", "m2_push_subscriptions", {"endpoint": self._eq(endpoint)}, returning=True)) + + def drop_push_subscriptions_not_of(self, key_id: str) -> int: + return len(self._request("DELETE", "m2_push_subscriptions", {"key_id": f"neq.{key_id}"}, returning=True) or []) + # --- timed attempts --- def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict: @@ -687,6 +1041,19 @@ class SupabaseStore: def reward_state_row(row: dict) -> dict: return {**row, **{k: date.fromisoformat(row[k]) if row.get(k) else None for k in ("last_comet_day", "last_catch_day")}} + # --- the curriculum --- + + def curriculum(self) -> dict[str, date]: + return {r["lesson_id"]: date.fromisoformat(str(r["due_date"])[:10]) for r in self._request("GET", "m2_curriculum", {"select": "lesson_id,due_date"})} + + def set_lesson_dates(self, dates: dict[str, date | None]) -> None: + cleared = [lesson for lesson, day in dates.items() if day is None] + if cleared: + self._request("DELETE", "m2_curriculum", {"lesson_id": "in.(" + ",".join(f'"{x}"' for x in cleared) + ")"}) + rows = [{"lesson_id": lesson, "due_date": day.isoformat()} for lesson, day in dates.items() if day is not None] + if rows: + self._request("POST", "m2_curriculum", {"on_conflict": "lesson_id"}, rows, upsert=True) + def last_answered(self, user_id: str) -> dict[str, date]: rows = self._request("GET", "m2_last_answered", {"user_id": self._eq(user_id), "select": "skill_id,day"}) return {r["skill_id"]: date.fromisoformat(r["day"]) for r in rows} diff --git a/mathly2/supabase/migrations/20261112090000_m2_written_work.sql b/mathly2/supabase/migrations/20261112090000_m2_written_work.sql new file mode 100644 index 0000000000000000000000000000000000000000..03caddd752f846a0b327abe027c6f2c1bfc9642a --- /dev/null +++ b/mathly2/supabase/migrations/20261112090000_m2_written_work.sql @@ -0,0 +1,94 @@ +-- Mathly 2: written work. Additive (new objects only). Read and written only by the server with the secret key: row-level security is on, +-- there are no policies, and the browser roles get no grants (as in m2_classes). +-- Pictures are not stored here: m2_pictures holds their rows, the bytes are in the private Storage bucket (MATHLY_M2_ANSWER_BUCKET, default m2-answers), +-- which the server creates on first use. +-- Rollback: drop table m2_push_subscriptions, m2_photo_requests, m2_submissions, m2_assignments, m2_pictures; +begin; + +-- A picture: a page of an assignment (kind 'page') or a student's answer ('answer'). `deleted_at` is set when its bytes are removed. +create table public.m2_pictures ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null, + kind text not null check (kind in ('page', 'answer')), + path text not null unique, + content_type text not null, + size integer not null check (size between 1 and 10000000), + created_at timestamptz not null default now(), + deleted_at timestamptz +); +create index m2_pictures_owner on public.m2_pictures (owner_id); + +-- Work a teacher gives a class: pages (pictures), one of the bank's written quizzes, or an exercise test of a real test. +create table public.m2_assignments ( + id bigint generated always as identity primary key, + class_id bigint not null references public.m2_classes(id) on delete cascade, + title text not null check (char_length(title) between 1 and 200), + note text not null default '', + due_date date not null, + kind text not null check (kind in ('pages', 'quiz', 'exercise_test')), + pages uuid[] not null default '{}', + quiz_lesson text, + test_id bigint references public.m2_tests(id) on delete cascade, + exercise_number integer check (exercise_number in (1, 2)), + created_by text not null, + created_at timestamptz not null default now(), + removed_at timestamptz, + check (case kind when 'pages' then cardinality(pages) > 0 + when 'quiz' then quiz_lesson is not null + else test_id is not null and exercise_number is not null end) +); +create index m2_assignments_class on public.m2_assignments (class_id) where removed_at is null; + +-- What a student handed in: one row per student and assignment, rewritten when they hand in again after a return. +create table public.m2_submissions ( + id bigint generated always as identity primary key, + assignment_id bigint not null references public.m2_assignments(id) on delete cascade, + user_id uuid not null, + pictures uuid[] not null default '{}', + state text not null default 'handed_in' check (state in ('handed_in', 'graded', 'returned')), + late boolean not null default false, + handed_at timestamptz not null default now(), + grade integer check (grade between 0 and 100), + teacher_note text not null default '', + checked_at timestamptz, + checked_by text, + unique (assignment_id, user_id), + check (state <> 'graded' or grade is not null) +); + +-- A computer asking the student's phone for a photo of one answer. +create table public.m2_photo_requests ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + assignment_id bigint not null references public.m2_assignments(id) on delete cascade, + device text not null, + code text not null check (code ~ '^[0-9]{4}$'), + state text not null default 'waiting' check (state in ('waiting', 'uploading', 'received', 'failed', 'expired', 'superseded')), + created_at timestamptz not null default now(), + expires_at timestamptz not null, + picture_id uuid references public.m2_pictures(id) on delete set null, + check (expires_at > created_at) +); +create index m2_photo_requests_user on public.m2_photo_requests (user_id, created_at); + +-- A phone that turned notifications on. `key_id` is a fingerprint of the server's push key: a subscription made under another key is dropped. +create table public.m2_push_subscriptions ( + id bigint generated always as identity primary key, + user_id uuid not null, + endpoint text not null unique, + p256dh text not null, + auth text not null, + key_id text not null, + created_at timestamptz not null default now() +); +create index m2_push_subscriptions_user on public.m2_push_subscriptions (user_id); + +alter table public.m2_pictures enable row level security; +alter table public.m2_assignments enable row level security; +alter table public.m2_submissions enable row level security; +alter table public.m2_photo_requests enable row level security; +alter table public.m2_push_subscriptions enable row level security; +revoke all on public.m2_pictures, public.m2_assignments, public.m2_submissions, public.m2_photo_requests, public.m2_push_subscriptions from public, anon, authenticated; +grant all on public.m2_pictures, public.m2_assignments, public.m2_submissions, public.m2_photo_requests, public.m2_push_subscriptions to service_role; +grant usage, select on sequence public.m2_assignments_id_seq, public.m2_submissions_id_seq, public.m2_push_subscriptions_id_seq to service_role; +commit; diff --git a/mathly2/supabase/migrations/20261113090000_m2_curriculum.sql b/mathly2/supabase/migrations/20261113090000_m2_curriculum.sql new file mode 100644 index 0000000000000000000000000000000000000000..2bf29669d284e2635002e85929179933f88639f6 --- /dev/null +++ b/mathly2/supabase/migrations/20261113090000_m2_curriculum.sql @@ -0,0 +1,12 @@ +-- Mathly 2: the curriculum. One date per lesson for the whole school: the day by which the lesson should be completed. +-- A lesson without a row has no date. Lesson ids are the exercise bank's ids (text), as in m2_classes.lessons. +-- Additive (one new table). Rollback: drop table public.m2_curriculum; +begin; +create table public.m2_curriculum ( + lesson_id text primary key, + due_date date not null +); +alter table public.m2_curriculum enable row level security; +revoke all on public.m2_curriculum from public, anon, authenticated; +grant all on public.m2_curriculum to service_role; +commit; diff --git a/mathly2/tests/serve.py b/mathly2/tests/serve.py index cd3eb9810d9369fbef98f4d832bfdb2a02b0c5dc..3653749a1681b7c5204d4719ba597d59ba432cc9 100644 --- a/mathly2/tests/serve.py +++ b/mathly2/tests/serve.py @@ -32,3 +32,10 @@ def offline_formulas(page): """For tests that show no formulas: do not wait for the formula library from the network (a slow CDN made them time out).""" page.route("**/mathjax@*/**", lambda route: route.abort()) return page + + +def only(bank, lessons): + """A bank with just these lessons (and their skills), for tests that want a short path: every class now studies the whole bank.""" + from mathly2.bank import Bank + keep = set(lessons) + return Bank({**bank.data, "units": [u for u in bank.lessons if u["id"] in keep], "skills": [s for s in bank.skills if s["unit"] in keep]}) diff --git a/mathly2/tests/test_assignments_api.py b/mathly2/tests/test_assignments_api.py new file mode 100644 index 0000000000000000000000000000000000000000..230aac2e5aed0b9664a4d5ea430b01cd94443c32 --- /dev/null +++ b/mathly2/tests/test_assignments_api.py @@ -0,0 +1,502 @@ +"""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} + 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} + + +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} + + +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} + assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0} + assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0} + assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403 diff --git a/mathly2/tests/test_busy_browser.py b/mathly2/tests/test_busy_browser.py new file mode 100644 index 0000000000000000000000000000000000000000..3efc3f3d458d16d5f184e96e8d3141154bf3b945 --- /dev/null +++ b/mathly2/tests/test_busy_browser.py @@ -0,0 +1,110 @@ +"""A button whose action takes time shows it (work-tabs-and-hints 1.1), in a real browser.""" +import pytest + +playwright = pytest.importorskip("playwright.sync_api") + +from mathly2.tests.serve import offline_formulas, running +from mathly2.tests.test_written_browser import (DESKTOP, KID, PHONE, TEACHER, bank, browser, day, jpeg, one_assignment, sign_in, teacher_work, world) # noqa: F401 + +OBSERVER = """ +window.__everBusy = false; +new MutationObserver(() => { if (document.querySelector('.busy')) window.__everBusy = true; }).observe(document, { subtree: true, attributes: true, attributeFilter: ['class'] }); +""" + + +def hold(page, pattern): + """Hold every request that matches until `release()`; returns (held list, release function).""" + held = [] + page.route(pattern, lambda route: held.append(route)) + return held, lambda: [r.continue_() for r in list(held)] and held.clear() + + +def test_a_plain_button_shows_a_spinner_after_a_moment_ignores_a_second_press_and_clears_after_success(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#answer") + page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}]) + page.wait_for_selector("#draft li[data-picture-id]") + held, release = hold(page, "**/api/assignments/*/submission") + page.click("#hand-in") + page.wait_for_selector("#hand-in.busy") # after about a third of a second + assert page.get_attribute("#hand-in", "aria-busy") == "true" + page.click("#hand-in", force=True) # a second press does nothing + page.wait_for_timeout(300) + assert len(held) == 1 + release() + page.wait_for_selector("#assignment[data-state='handed_in']") + assert page.locator(".busy").count() == 0 + + +def test_a_forms_submit_button_shows_it_too_and_a_quick_action_never_does(browser, bank): + app, store, klass, lessons = world(bank) + with running(app) as url: + page = offline_formulas(browser.new_page(viewport=DESKTOP)) + page.add_init_script(OBSERVER) + page.goto(url) + page.fill("#email", TEACHER) + page.click("#dev button") + page.wait_for_selector("#teaching") + page.click("#teacher-tabs [data-tab='tests']") + page.wait_for_selector("#tests") + page.click(".set-test summary") + page.fill("[name=date]", day(10).isoformat()) + held, release = hold(page, "**/api/classes/*/tests") + page.click(".set-test button[type=submit]") + page.wait_for_selector(".set-test button[type=submit].busy") + release() + page.wait_for_selector(".test-row") + assert page.locator(".busy").count() == 0 + page.evaluate("window.__everBusy = false") # a quick action: the button never turns busy + page.click("#teacher-tabs [data-tab='classes']") + page.wait_for_selector("#teaching") + page.click("#teacher-tabs [data-tab='tests']") + page.wait_for_selector("#tests") + assert page.evaluate("window.__everBusy") is False + + +def test_a_picture_upload_from_a_file_chooser_marks_its_button(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#answer") + held, release = hold(page, "**/api/pictures") + page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}]) + page.wait_for_selector("label.file.busy") + release() + page.wait_for_selector("#draft li[data-picture-id]") + assert page.locator("label.file.busy").count() == 0 + + +def test_with_reduced_motion_the_button_shows_dots_and_nothing_turns(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}", reduced_motion="reduce") + page.wait_for_selector("#answer") + page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}]) + page.wait_for_selector("#draft li[data-picture-id]") + held, release = hold(page, "**/api/assignments/*/submission") + page.click("#hand-in") + page.wait_for_selector("#hand-in.busy") + assert page.evaluate("getComputedStyle(document.querySelector('#hand-in'), '::after').content") == '"…"' + assert page.evaluate("document.getAnimations().filter(a => a.animationName === 'ex-spin').length") == 0 + release() + page.wait_for_selector("#assignment[data-state='handed_in']") + + +def test_the_state_clears_after_an_error_and_the_exercise_choices_keep_their_own_indicator(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#answer") + page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}]) + page.wait_for_selector("#draft li[data-picture-id]") + held = [] + page.route("**/api/assignments/*/submission", lambda route: held.append(route)) + page.click("#hand-in") + page.wait_for_selector("#hand-in.busy") + held[0].fulfill(status=500, body='{"detail": "x"}', content_type="application/json") + page.wait_for_selector("#add-error:not([hidden])") + assert page.locator(".busy").count() == 0 and not page.is_disabled("#hand-in") diff --git a/mathly2/tests/test_cached_store.py b/mathly2/tests/test_cached_store.py new file mode 100644 index 0000000000000000000000000000000000000000..5883d625596973b326e87235ed1c68e7222a0d40 --- /dev/null +++ b/mathly2/tests/test_cached_store.py @@ -0,0 +1,97 @@ +from datetime import date + +from mathly2.cached import CachedStore +from mathly2.store import InMemoryStore + + +class Counting(InMemoryStore): + def __init__(self): + super().__init__() + self.calls = 0 + + def classes_taught(self, email): + self.calls += 1 + return super().classes_taught(email) + + +def make(seconds=30, now=None): + base = Counting() + clock = now or (lambda: 0.0) + return base, CachedStore(base, seconds, clock=clock) + + +def test_repeat_read_is_remembered(): + base, store = make() + store.classes_taught("t@x.com"); store.classes_taught("t@x.com") + assert base.calls == 1 + + +def test_time_passing_asks_again(): + t = [0.0] + base, store = make(now=lambda: t[0]) + store.classes_taught("t@x.com"); t[0] = 31 + store.classes_taught("t@x.com") + assert base.calls == 2 + + +def test_changes_forget(): + base, store = make() + assert store.classes_taught("t@x.com") == [] + store.create_class("t@x.com", "A", teacher_email="t@x.com") + assert len(store.classes_taught("t@x.com")) == 1 + cid = store.classes_taught("t@x.com")[0]["id"] + store.add_member(cid, "s@x.com") + assert [m["email"] for m in store.members(cid)] == ["s@x.com"] + store.remove_member(cid, "s@x.com") + assert store.members(cid) == [] + store.give_role("a@x.com", "teacher", "root") + assert "teacher" in store.roles_of("a@x.com") + a = store.create_assignment(cid, "T", date(2030, 1, 1), "t@x.com", "pages", pages=["p1"]) + assert len(store.assignments_of_classes([cid])) == 1 + store.remove_assignment(a["id"]) + assert store.assignments_of_classes([cid]) == [] + store.delete_class(cid) + assert store.classes_taught("t@x.com") == [] + + +def test_read_started_before_a_change_is_not_kept(): + base, store = make() + real = base.classes_taught + + def slow(email): + rows = real(email) + store.create_class("t@x.com", "B", teacher_email="t@x.com") # a change lands while this read is running + return rows + base.classes_taught = slow + store.classes_taught("t@x.com") + base.classes_taught = real + assert len(store.classes_taught("t@x.com")) == 1 + + +def test_returned_rows_are_copies(): + base, store = make() + store.create_class("t@x.com", "A", teacher_email="t@x.com") + store.classes_taught("t@x.com")[0]["name"] = "changed" + assert store.classes_taught("t@x.com")[0]["name"] == "A" + + +def test_zero_turns_it_off(): + base, store = make(seconds=0) + store.classes_taught("t@x.com"); store.classes_taught("t@x.com") + assert base.calls == 2 + + +def test_progress_writes_do_not_forget(): + base, store = make() + store.classes_taught("t@x.com") + store.add_comet("u", "c") + store.classes_taught("t@x.com") + assert base.calls == 1 + + +def test_database_times_with_any_number_of_fraction_digits_are_read(): + from datetime import datetime, timezone + from mathly2.store import _when + for text in ("2026-11-09T10:10:57.63003+00:00", "2026-11-09T10:10:57.6+00:00", "2026-11-09T10:10:57+00:00", "2026-11-09T10:10:57.630031Z", "2026-11-09T10:10:57.1234567+00:00"): + assert _when(text).tzinfo is not None + assert _when("2026-11-09T10:10:57.63003+00:00") == datetime(2026, 11, 9, 10, 10, 57, 630030, tzinfo=timezone.utc) diff --git a/mathly2/tests/test_cached_waits.py b/mathly2/tests/test_cached_waits.py new file mode 100644 index 0000000000000000000000000000000000000000..9d072088406dd7a9e7ee052161ebb2c97af75d2f --- /dev/null +++ b/mathly2/tests/test_cached_waits.py @@ -0,0 +1,48 @@ +"""Slow-changing facts are remembered (cache-and-instant-screens 1.2): with every store call made to wait, the second opening is quicker, +and a change shows at once.""" +import time +from datetime import timedelta + +from fastapi.testclient import TestClient + +from mathly2 import clock +from mathly2.bank import load_bank +from mathly2.cached import CachedStore +from mathly2.config import Settings +from mathly2.main import create_app +from mathly2.tests.test_database_waits import DELAY, SlowStore + +TEACHER = "t@school.test" +H = {"Authorization": f"Bearer dev:{TEACHER}"} + + +def test_second_opening_is_quicker_and_a_change_shows_at_once(): + bank = load_bank() + slow = SlowStore() + lessons = [l["id"] for l in bank.lessons][:3] + klass = slow.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER) + slow.add_member(klass["id"], "kid@school.test") + cached = CachedStore(slow, 30) + client = TestClient(create_app(Settings(), bank=bank, store=cached)) + client.get("/api/me", headers=H) + slow.slow = True + + def took(path): + start = time.perf_counter() + r = client.get(path, headers=H) + assert r.status_code == 200, r.text + return time.perf_counter() - start, r.json() + + for path in ("/api/teaching/counts", "/api/teaching/assignments", "/api/teaching/tests"): + cached.forget() + first, _ = took(path) + second, _ = took(path) + assert second < first - DELAY / 2, (path, first, second) + + _, before = took("/api/teaching/assignments") + assert before[0]["assignments"] == [] + r = client.post(f"/api/classes/{klass['id']}/assignments", headers=H, + json={"title": "חדש", "kind": "quiz", "quiz_lesson": lessons[0], "due_date": str(clock.school_day() + timedelta(days=5))}) + assert r.status_code in (200, 201), r.text + _, after = took("/api/teaching/assignments") + assert len(after[0]["assignments"]) == 1 diff --git a/mathly2/tests/test_classes.py b/mathly2/tests/test_classes.py index 478ecd201252daa59b03e74aa69df6403ef4dda7..8b7b54aa8b9d73d7c17e40515d191797a99d2b1f 100644 --- a/mathly2/tests/test_classes.py +++ b/mathly2/tests/test_classes.py @@ -36,12 +36,12 @@ def test_parse_emails_handles_pasted_lists(): # ---- the manager makes classes (spec: Manager creates classes) -def test_a_manager_creates_a_class_with_lessons_teacher_and_students(world): +def test_a_manager_creates_a_class_with_teacher_and_students_and_it_studies_every_lesson(world): client, _ = world created = make(client, teacher_email=T1, students="s1@school.test\ns2@school.test not-an-email") assert created.status_code == 201 row = created.json() - assert row["name"] == "ט'3" and row["lessons"] == ["t", "t2"] and row["teacher_email"] == T1 + assert row["name"] == "ט'3" and len(row["lessons"]) == 24 and row["teacher_email"] == T1 assert row["ai_enabled"] is False and row["ai_daily_checks"] == 150 assert row["students"] == 2 and row["student_emails"] == ["s1@school.test", "s2@school.test"] and row["invalid"] == ["not-an-email"] assert [c["name"] for c in client.get("/api/classes", headers=auth(MGR)).json()] == ["ט'3"] @@ -69,12 +69,10 @@ def test_class_name_is_trimmed_checked_and_unique_ignoring_case(world): assert make(client, name="x" * 80).status_code == 201 -def test_a_class_needs_at_least_one_known_lesson(world): +def test_lessons_in_a_request_are_ignored_every_class_studies_every_lesson(world): client, _ = world - assert make(client, lessons=[]).status_code == 422 - assert make(client, lessons=["nope"]).status_code == 422 - assert make(client, lessons=["t", "nope"]).status_code == 422 - assert make(client, name="B", lessons=["t", "t"]).json()["lessons"] == ["t"] # repeats collapse + assert len(make(client, lessons=[]).json()["lessons"]) == 24 + assert len(make(client, name="B", lessons=["nope"]).json()["lessons"]) == 24 def test_the_teacher_must_be_an_email_and_not_also_a_student(world): @@ -92,11 +90,9 @@ def test_the_ai_budget_is_limited(world): assert make(client, name="C", ai_daily_checks=40, ai_enabled=True).json()["ai_enabled"] is True -def test_the_manager_can_list_the_lessons_to_choose_from(world): +def test_there_is_no_lesson_list_to_choose_from(world): client, _ = world - lessons = client.get("/api/classes/lessons", headers=auth(MGR)).json() - assert len(lessons) == 24 and set(lessons[0]) == {"id", "name", "skills"} and lessons[0]["skills"] > 0 - assert client.get("/api/classes/lessons", headers=auth(T1)).status_code == 403 + assert client.get("/api/classes/lessons", headers=auth(MGR)).status_code in (404, 405, 422) # ---- editing and deleting (spec: Editing and deleting a class) @@ -104,9 +100,9 @@ def test_the_manager_can_list_the_lessons_to_choose_from(world): def test_a_manager_edits_everything_and_the_roster_follows(world): client, store = world cid = make(client, teacher_email=T1, students="a@school.test b@school.test").json()["id"] - edited = client.put(f"/api/classes/{cid}", json=body("ט'4", lessons=["k"], teacher_email=T2, students="b@school.test c@school.test", + edited = client.put(f"/api/classes/{cid}", json=body("ט'4", teacher_email=T2, students="b@school.test c@school.test", ai_enabled=True, ai_daily_checks=60), headers=auth(MGR)).json() - assert (edited["name"], edited["lessons"], edited["teacher_email"], edited["ai_enabled"], edited["ai_daily_checks"]) == ("ט'4", ["k"], T2, True, 60) + assert (edited["name"], edited["lessons"], edited["teacher_email"], edited["ai_enabled"], edited["ai_daily_checks"]) == ("ט'4", edited["lessons"], T2, True, 60) assert edited["student_emails"] == ["b@school.test", "c@school.test"] diff --git a/mathly2/tests/test_curriculum_api.py b/mathly2/tests/test_curriculum_api.py new file mode 100644 index 0000000000000000000000000000000000000000..a24bc927b419b7ad0968bf7bb053242e48894b84 --- /dev/null +++ b/mathly2/tests/test_curriculum_api.py @@ -0,0 +1,54 @@ +"""The curriculum (add-lesson-schedule 2.1, 3.1): a date per lesson, who sets it, and what the path says about it.""" +from datetime import timedelta + +from mathly2 import clock +from mathly2.tests.test_skills_api import KID, KID_ID, MGR, MON, OTHER, TEACHER, auth, raw, world # noqa: F401 + + +def rows(client, who=KID): + return {l["id"]: l for l in client.get("/api/path", headers=auth(who)).json()["lessons"]} + + +def put(client, dates, who=MGR): + return client.put("/api/curriculum", json={"dates": dates}, headers=auth(who)) + + +def test_a_manager_sets_changes_and_clears_a_date(world): + client, _, bank = world + first, second = bank.lessons[0]["id"], bank.lessons[1]["id"] + got = client.get("/api/curriculum", headers=auth(MGR)).json() + assert [r["id"] for r in got] == [l["id"] for l in bank.lessons] and all(r["due_date"] is None for r in got) and got[0]["month"] + assert put(client, {first: "2026-11-03", second: "2026-12-01"}).json()[0]["due_date"] == "2026-11-03" + assert put(client, {first: "2026-11-05", second: None}).status_code == 200 + now = {r["id"]: r["due_date"] for r in client.get("/api/curriculum", headers=auth(MGR)).json()} + assert now[first] == "2026-11-05" and now[second] is None + + +def test_only_managers_change_it_and_bad_input_is_refused(world): + client, _, bank = world + first = bank.lessons[0]["id"] + for who in (TEACHER, KID, OTHER): + assert put(client, {first: "2026-11-03"}, who).status_code in (401, 403) + assert client.get("/api/curriculum", headers=auth(who)).status_code in (401, 403) + assert put(client, {"no-such-lesson": "2026-11-03"}).status_code == 422 + assert put(client, {first: "3.11"}).status_code == 422 + assert client.get("/api/curriculum", headers=auth(MGR)).json()[0]["due_date"] is None # nothing changed + + +def test_a_lesson_is_behind_only_when_dated_past_and_not_completed(world): + client, store, bank = world + ids = [l["id"] for l in bank.lessons_with_skills()][:5] + today = clock.school_day() + yesterday, tomorrow = (today - timedelta(days=1)).isoformat(), (today + timedelta(days=7)).isoformat() + put(client, {ids[0]: yesterday, ids[1]: tomorrow, ids[2]: yesterday, ids[3]: yesterday}) # ids[4]: no date + path = client.get("/api/path", headers=auth()).json() + by = {l["id"]: l for l in path["lessons"]} + assert by[ids[0]]["behind"] is True and by[ids[0]]["due_date"] == yesterday + assert by[ids[1]]["behind"] is False and by[ids[4]]["behind"] is False and by[ids[4]]["due_date"] is None + # a completed lesson (all skills mastered) is not behind, and neither is one whose quiz was passed + for skill in by[ids[2]]["skills"]: + store.save_progress(KID_ID, skill["id"], started_at=MON, mastered_at=MON) + assert rows(client)[ids[2]]["completed"] is True and rows(client)[ids[2]]["behind"] is False + assert path["behind"] == 3 # before the third was completed + after = client.get("/api/path", headers=auth()).json() + assert after["behind"] == 2 and {l["id"] for l in after["lessons"] if l["behind"]} == {ids[0], ids[3]} diff --git a/mathly2/tests/test_foundation_screens_browser.py b/mathly2/tests/test_foundation_screens_browser.py index db7393f0a3ab5c4b592e3a8778d57b1851efe449..a4e963a5260266e5672a5430c4a6752d08814d16 100644 --- a/mathly2/tests/test_foundation_screens_browser.py +++ b/mathly2/tests/test_foundation_screens_browser.py @@ -158,8 +158,7 @@ def test_a_person_with_one_role_sees_no_chips_and_a_developer_sees_all_five(brow def fill_class(page, name, lessons, teacher, students): page.fill("#c-name", name) - for lesson in lessons: - page.check(f"#c-lessons input[value='{lesson}']") + assert page.locator("#c-lessons, input[name=lesson]").count() == 0 # a class has no chosen lessons page.fill("#c-teacher", teacher) page.fill("#c-students", students) @@ -189,9 +188,8 @@ def test_a_manager_creates_edits_and_deletes_classes_without_scrolling_sideways( assert columns >= 2 # the wide screen is used page.click("#classes article >> nth=0 >> [data-edit]") page.wait_for_selector("#class-form") - assert page.input_value("#c-name") == "ט'3" and page.is_checked("#c-lessons input[value='t2']") + assert page.input_value("#c-name") == "ט'3" page.fill("#c-name", "ט'3 חדשה") - page.uncheck("#c-lessons input[value='t2']") page.check("#c-ai") page.click("#c-save") page.wait_for_selector("#classes article") diff --git a/mathly2/tests/test_home_browser.py b/mathly2/tests/test_home_browser.py index 59c0b09ea3b35a993ad56a6c313f8923c668783b..1b75dce65adb84d25cdf9323dddd7a55966255d9 100644 --- a/mathly2/tests/test_home_browser.py +++ b/mathly2/tests/test_home_browser.py @@ -12,7 +12,7 @@ from mathly2.bank import load_bank from mathly2.config import Settings from mathly2.main import create_app from mathly2.store import InMemoryStore -from mathly2.tests.serve import offline_formulas, running +from mathly2.tests.serve import offline_formulas, only, running KID, TEACHER = "kid@school.test", "t@school.test" KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID)) @@ -39,7 +39,7 @@ def world(bank): store = InMemoryStore() klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER) store.add_member(klass["id"], KID) - return create_app(Settings(), bank=bank, store=store), store, klass, lessons + return create_app(Settings(), bank=only(bank, lessons), store=store), store, klass, lessons def add_test(store, klass, lessons, days_ahead, set_days_ago=20, **kw): @@ -81,8 +81,8 @@ def test_the_card_opens_home_and_each_tab_opens_its_screen(browser, bank): page = sign_in(browser, url) to_home(page) assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "בית" - assert page.locator(".tabs a").all_inner_texts() == ["בית", "מסלול", "ציונים", "האוסף"] - for href, marker in (("#/subject/math/path", ".orbit"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/subject/math", "#no-test")): + assert page.locator(".tabs a").all_inner_texts() == ["בית", "מסלול", "הגשות", "ציונים", "האוסף"] + for href, marker in (("#/subject/math/path", ".orbit"), ("#/submissions", "#submissions"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/subject/math", "#no-test")): page.click(f".tabs a[href='{href}']") page.wait_for_selector(marker) assert page.errors == [] @@ -178,15 +178,18 @@ def test_the_readiness_bar_shows_every_part_and_loses_the_cap_after_the_simulati assert page.locator(".required").count() == 0 # the simulation is taken -def test_other_tests_are_listed_small_and_the_closest_one_gives_the_task(browser, bank): +def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(browser, bank): app, store, klass, lessons = world(bank) far = add_test(store, klass, lessons, 20) near = add_test(store, klass, lessons, 9) with running(app) as url: page = sign_in(browser, url) to_home(page) - assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) - assert page.locator("#other-tests li").count() == 1 and page.get_attribute("#other-tests a", "href") == f"#/test/{far['id']}" + assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) and page.get_attribute("#today-task", "href") == f"#/exam/{near['id']}/1" + assert page.locator("#other-tests").count() == 0 and page.locator("#notifications").count() == 0 # only what is critical + page.click("#nearest-test a[href^='#/test/']") + page.wait_for_selector("#other-tests") + assert page.get_attribute("#other-tests a", "href") == f"#/test/{far['id']}" # --- 3.3 a test's card --- diff --git a/mathly2/tests/test_instant_browser.py b/mathly2/tests/test_instant_browser.py new file mode 100644 index 0000000000000000000000000000000000000000..fbac14f2e716e688c543eea8354ec6c6e3ca9590 --- /dev/null +++ b/mathly2/tests/test_instant_browser.py @@ -0,0 +1,67 @@ +"""A screen opened again shows what it last knew (cache-and-instant-screens 2.1), in a real browser.""" +import pytest + +playwright = pytest.importorskip("playwright.sync_api") + +from mathly2.tests.serve import running +from mathly2.tests.test_busy_browser import hold +from mathly2.tests.test_written_browser import (DESKTOP, KID, PHONE, TEACHER, bank, browser, day, jpeg, one_assignment, sign_in, teacher_work, world) # noqa: F401 + + +def counts(page): + return page.eval_on_selector_all("#teacher-tabs [data-count]", "els => els.map(e => e.textContent)") + + +def test_going_back_to_a_tab_shows_its_content_and_numbers_at_once_then_the_fresh_ones(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = teacher_work(browser, url, tab="assignments") + page.wait_for_function("[...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.textContent)") + known = counts(page) + page.click("#teacher-tabs [data-tab='classes']") + page.wait_for_selector("#teaching") + held, release = hold(page, "**/api/teaching/**") + page.click("#teacher-tabs [data-tab='assignments']") + page.wait_for_selector("#work") # drawn from memory while the server is still being asked + assert held and counts(page) == known + # something changes on the server meanwhile; once the answer arrives the fresh content replaces the remembered one + other = store.create_assignment(row["class_id"], "משימה אחרת", day(7), TEACHER, "quiz", quiz_lesson=row["quiz_lesson"]) + release() + page.wait_for_function("document.querySelectorAll('#work .assignment, #work article').length >= 2") + assert not page.errors + + +def test_after_the_teachers_own_change_no_old_inbox_is_shown(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = teacher_work(browser, url, tab="assignments") + page.click("#work [data-remove-assignment]") + page.click("[data-confirm-remove-assignment]") + page.wait_for_selector("#work") + held, release = hold(page, "**/api/teaching/assignments") + page.click("#teacher-tabs [data-tab='inbox']") + page.click("#teacher-tabs [data-tab='assignments']") + page.wait_for_timeout(300) + assert not page.query_selector("#work .assignment[data-id], #work [data-remove-assignment]") # nothing remembered of the removed one + release() + + +def test_a_refresh_does_not_redraw_over_what_is_being_typed(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = teacher_work(browser, url, tab="assignments") + page.click("#teacher-tabs [data-tab='classes']") + page.wait_for_selector("#teaching") + page.click("#teacher-tabs [data-tab='assignments']") + page.wait_for_selector("#work") + page.wait_for_timeout(500) + held, release = hold(page, "**/api/teaching/**") + page.click("#teacher-tabs [data-tab='classes']") + page.click("#teacher-tabs [data-tab='assignments']") + page.wait_for_selector("#work") + store.create_assignment(row["class_id"], "משימה אחרת", day(7), TEACHER, "quiz", quiz_lesson=row["quiz_lesson"]) + page.click("#work summary") + page.fill("#work .assignment-form input", "טיוטה") + release() + page.wait_for_timeout(500) + assert page.input_value("#work .assignment-form input") == "טיוטה" diff --git a/mathly2/tests/test_migrations.py b/mathly2/tests/test_migrations.py index 04a55c5d04dd26128f2751115280500b32d35e8c..6f2d881c1fb9dedb0a9fc408b7d65b432ba0b1b4 100644 --- a/mathly2/tests/test_migrations.py +++ b/mathly2/tests/test_migrations.py @@ -296,3 +296,100 @@ def test_the_server_role_can_use_tests_and_rls_has_no_policies(db): with psycopg.connect(db, autocommit=True) as c: assert c.execute("select relrowsecurity from pg_class where relname = 'm2_tests'").fetchone() == (True,) assert c.execute("select count(*) from pg_policies where tablename = 'm2_tests'").fetchone() == (0,) + + +# --- add-written-work 1.1 and 1.2 --- + +U1, U2 = "44444444-4444-4444-4444-444444444444", "55555555-5555-5555-5555-555555555555" + + +def _assignment(c, class_id, **kw): + cols = {"title": "x", "due_date": "2026-11-20", "kind": "quiz", "quiz_lesson": "t", "created_by": "t@school.test", **kw} + names = ", ".join(["class_id"] + list(cols)); marks = ", ".join(["%s"] * (len(cols) + 1)) + return c.execute(f"insert into public.m2_assignments({names}) values ({marks}) returning id", (class_id, *cols.values())).fetchone()[0] + + +def test_written_work_rows_have_their_defaults_and_checks(db): + with psycopg.connect(db, autocommit=True) as c: + cid = _class(c, "כתיבה-1") + pic = c.execute("insert into public.m2_pictures(owner_id, kind, path, content_type, size) values (%s, 'answer', 'a/1.jpg', 'image/jpeg', 100) returning id, deleted_at", (U1,)).fetchone() + assert pic[1] is None + aid = _assignment(c, cid) + assert c.execute("select note, removed_at, cardinality(pages) from public.m2_assignments where id = %s", (aid,)).fetchone() == ("", None, 0) + sub = c.execute("insert into public.m2_submissions(assignment_id, user_id) values (%s, %s) returning state, late, grade, teacher_note", (aid, U1)).fetchone() + assert sub == ("handed_in", False, None, "") + with pytest.raises(psycopg.errors.UniqueViolation): + c.execute("insert into public.m2_submissions(assignment_id, user_id) values (%s, %s)", (aid, U1)) + req = c.execute("insert into public.m2_photo_requests(user_id, assignment_id, device, code, expires_at) values (%s, %s, 'd', '0042', now() + interval '10 minutes') returning state", (U1, aid)).fetchone() + assert req == ("waiting",) + + +def test_written_work_checks_refuse_bad_values(db): + with psycopg.connect(db, autocommit=True) as c: + cid = _class(c, "כתיבה-2") + bad = [ + "insert into public.m2_pictures(owner_id, kind, path, content_type, size) values ('%s', 'scan', 'b/1.jpg', 'image/jpeg', 5)" % U1, # kind + "insert into public.m2_pictures(owner_id, kind, path, content_type, size) values ('%s', 'answer', 'b/2.jpg', 'image/jpeg', 0)" % U1, # size + ] + for sql in bad: + with pytest.raises(psycopg.errors.CheckViolation): + c.execute(sql) + for kw in (dict(kind="pages"), dict(kind="quiz", quiz_lesson=None), dict(kind="exercise_test", quiz_lesson=None), dict(title="")): + with pytest.raises(psycopg.errors.CheckViolation): + _assignment(c, cid, **kw) + aid = _assignment(c, cid) + for sql in ("insert into public.m2_submissions(assignment_id, user_id, grade) values (%s, '%s', 101)" % (aid, U2), + "insert into public.m2_submissions(assignment_id, user_id, state) values (%s, '%s', 'graded')" % (aid, U2), # graded without a grade + "insert into public.m2_submissions(assignment_id, user_id, state) values (%s, '%s', 'lost')" % (aid, U2), + "insert into public.m2_photo_requests(user_id, assignment_id, device, code, expires_at) values ('%s', %s, 'd', '12', now() + interval '1 minute')" % (U2, aid), + "insert into public.m2_photo_requests(user_id, assignment_id, device, code, expires_at) values ('%s', %s, 'd', '1234', now() - interval '1 minute')" % (U2, aid)): + with pytest.raises(psycopg.errors.CheckViolation): + c.execute(sql) + + +def test_written_work_cascades_with_the_class_and_push_endpoints_are_unique(db): + with psycopg.connect(db, autocommit=True) as c: + cid = _class(c, "כתיבה-3") + aid = _assignment(c, cid) + c.execute("insert into public.m2_submissions(assignment_id, user_id) values (%s, %s)", (aid, U1)) + c.execute("delete from public.m2_classes where id = %s", (cid,)) + assert c.execute("select count(*) from public.m2_assignments where id = %s", (aid,)).fetchone() == (0,) + assert c.execute("select count(*) from public.m2_submissions where assignment_id = %s", (aid,)).fetchone() == (0,) + c.execute("insert into public.m2_push_subscriptions(user_id, endpoint, p256dh, auth, key_id) values (%s, 'https://push/x', 'p', 'a', 'k')", (U1,)) + with pytest.raises(psycopg.errors.UniqueViolation): + c.execute("insert into public.m2_push_subscriptions(user_id, endpoint, p256dh, auth, key_id) values (%s, 'https://push/x', 'p', 'a', 'k')", (U2,)) + + +@pytest.mark.parametrize("role", ["authenticated", "anon"]) +@pytest.mark.parametrize("table", ["m2_pictures", "m2_assignments", "m2_submissions", "m2_photo_requests", "m2_push_subscriptions"]) +def test_browser_roles_cannot_touch_written_work(db, role, table): + with pytest.raises(psycopg.errors.InsufficientPrivilege): + as_role(db, role, f"select * from public.{table}") + + +def test_the_server_role_uses_written_work_tables_and_rls_has_no_policies(db): + with psycopg.connect(db, autocommit=True) as c: + cid = _class(c, "כתיבה-4") + aid = as_role(db, "service_role", "insert into public.m2_assignments(class_id, title, due_date, kind, quiz_lesson, created_by) values (%s, 'x', '2026-11-20', 'quiz', 't', 'x') returning id", cid) + assert aid is not None + with psycopg.connect(db, autocommit=True) as c: + on = dict(c.execute("select relname, relrowsecurity from pg_class where relname in ('m2_pictures','m2_assignments','m2_submissions','m2_photo_requests','m2_push_subscriptions')").fetchall()) + assert all(on.values()) and len(on) == 5 + assert c.execute("select count(*) from pg_policies where tablename in ('m2_pictures','m2_assignments','m2_submissions','m2_photo_requests','m2_push_subscriptions')").fetchone() == (0,) + + +@pytest.mark.parametrize("role", ["authenticated", "anon"]) +def test_browser_roles_cannot_touch_the_curriculum(db, role): + with pytest.raises(psycopg.errors.InsufficientPrivilege): + as_role(db, role, "select * from public.m2_curriculum") + + +def test_the_server_role_keeps_one_date_per_lesson_and_rls_has_no_policies(db): + as_role(db, "service_role", "insert into public.m2_curriculum(lesson_id, due_date) values ('t', '2026-11-03') on conflict (lesson_id) do update set due_date = excluded.due_date") + as_role(db, "service_role", "insert into public.m2_curriculum(lesson_id, due_date) values ('t', '2026-11-10') on conflict (lesson_id) do update set due_date = excluded.due_date") + assert as_role(db, "service_role", "select count(*) from public.m2_curriculum where lesson_id = 't'") == [(1,)] + with pytest.raises(psycopg.errors.NotNullViolation): + as_role(db, "service_role", "insert into public.m2_curriculum(lesson_id) values ('x')") + with psycopg.connect(db, autocommit=True) as c: + assert c.execute("select relrowsecurity from pg_class where relname = 'm2_curriculum'").fetchone() == (True,) + assert c.execute("select count(*) from pg_policies where tablename = 'm2_curriculum'").fetchone() == (0,) diff --git a/mathly2/tests/test_path_browser.py b/mathly2/tests/test_path_browser.py index 6711043513bf7993def6955d82c88302771e9a27..9ccc1e96b83ac4f3d2521877041e61d9ef56e61a 100644 --- a/mathly2/tests/test_path_browser.py +++ b/mathly2/tests/test_path_browser.py @@ -10,7 +10,7 @@ from mathly2.bank import load_bank from mathly2.config import Settings from mathly2.main import create_app from mathly2.store import InMemoryStore -from mathly2.tests.serve import offline_formulas, running +from mathly2.tests.serve import offline_formulas, only, running KID = "kid@school.test" KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID)) @@ -33,7 +33,7 @@ def bank(): def world(bank, lessons): store = InMemoryStore() store.add_member(store.create_class("setup", "A", lessons=list(lessons))["id"], KID) - return create_app(Settings(), bank=bank, store=store), store + return create_app(Settings(), bank=only(bank, lessons), store=store), store @pytest.fixture @@ -193,7 +193,7 @@ def test_after_sign_in_the_student_sees_the_math_card_then_the_path_and_can_go_b page.click("#dev button") page.wait_for_selector(".subject-card") assert page.locator(".orbit").count() == 0 and page.locator(".subject-card").count() == 1 # the subjects first, no path yet - assert "מתמטיקה" in page.inner_text(".subject-card") and f"1 מתוך {total} מיומנויות" in page.inner_text(".subject-card") + assert "מתמטיקה כיתה ט - מצוינות" in page.inner_text(".subject-card") and f"1 מתוך {total} מיומנויות" in page.inner_text(".subject-card") assert page.get_attribute(".subject-card [role=progressbar]", "aria-valuenow") == "1" assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.click(".subject-card") diff --git a/mathly2/tests/test_permissions.py b/mathly2/tests/test_permissions.py index 4fa174c8c05efbd7cf473637001b9a8520a9e66a..05d4eff73764a7691ad0eb5b828c4edcde78ed0d 100644 --- a/mathly2/tests/test_permissions.py +++ b/mathly2/tests/test_permissions.py @@ -31,6 +31,29 @@ NEEDS = { # (method, path) -> permission ("PUT", "/api/attempts/{attempt_id}/answers"): "path.use", ("POST", "/api/attempts/{attempt_id}/submit"): "path.use", ("GET", "/api/grades"): "path.use", + ("DELETE", "/api/classes/{class_id}/pictures/{email}"): "class.roster", + ("GET", "/api/teaching/counts"): "class.teach", + ("GET", "/api/teaching/inbox"): "class.teach", + ("GET", "/api/submissions/{submission_id}"): "class.teach", + ("POST", "/api/submissions/{submission_id}/grade"): "class.teach", + ("POST", "/api/submissions/{submission_id}/return"): "class.teach", + ("GET", "/api/assignments"): "path.use", + ("GET", "/api/assignments/{assignment_id}"): "path.use", + ("POST", "/api/assignments/{assignment_id}/submission"): "path.use", + ("POST", "/api/assignments/pages"): "class.teach", + ("POST", "/api/classes/{class_id}/assignments"): "class.teach", + ("GET", "/api/teaching/assignments"): "class.teach", + ("DELETE", "/api/assignments/{assignment_id}"): "class.teach", + ("GET", "/api/push/key"): "path.use", + ("POST", "/api/push/subscribe"): "path.use", + ("POST", "/api/push/unsubscribe"): "path.use", + ("POST", "/api/photo-requests"): "path.use", + ("GET", "/api/photo-requests/open"): "path.use", + ("GET", "/api/photo-requests/{request_id}"): "path.use", + ("POST", "/api/photo-requests/{request_id}/start"): "path.use", + ("POST", "/api/photo-requests/{request_id}/picture"): "path.use", + ("POST", "/api/pictures"): "path.use", + ("GET", "/api/pictures/{picture_id}"): "path.use", ("GET", "/api/comets"): "path.use", ("POST", "/api/comets/catch"): "path.use", ("GET", "/api/skills/{skill_id}"): "path.use", @@ -38,10 +61,11 @@ NEEDS = { # (method, path) -> permission ("POST", "/api/skills/{skill_id}/answer"): "path.use", ("POST", "/api/skills/{skill_id}/round"): "path.use", ("GET", "/api/classes"): "classes.manage", - ("GET", "/api/classes/lessons"): "classes.manage", ("POST", "/api/classes"): "classes.manage", ("GET", "/api/classes/{class_id}"): "classes.manage", ("PUT", "/api/classes/{class_id}"): "classes.manage", + ("GET", "/api/curriculum"): "classes.manage", + ("PUT", "/api/curriculum"): "classes.manage", ("DELETE", "/api/classes/{class_id}"): "classes.manage", ("POST", "/api/classes/{class_id}/students"): "class.roster", ("DELETE", "/api/classes/{class_id}/students/{email}"): "class.roster", @@ -73,7 +97,7 @@ def world(): def call(client, method, path, email): - url = path.format(skill_id="t_ext", lesson_id="t", attempt_id="x", test_id="1", number="1", class_id="1", email="someone@school.test", role="teacher") + url = path.format(skill_id="t_ext", lesson_id="t", attempt_id="x", test_id="1", number="1", picture_id="x", assignment_id="1", submission_id="1", request_id="x", class_id="1", email="someone@school.test", role="teacher") sends = method in ("POST", "PUT", "PATCH") return client.request(method, url, headers={"Authorization": f"Bearer dev:{email}"}, json={} if sends else None) diff --git a/mathly2/tests/test_photo_requests_api.py b/mathly2/tests/test_photo_requests_api.py new file mode 100644 index 0000000000000000000000000000000000000000..079f02ecc473cd8a484a2a530c66eeebd6a0aa87 --- /dev/null +++ b/mathly2/tests/test_photo_requests_api.py @@ -0,0 +1,142 @@ +"""The photo request: a computer asks the student's phone for a photo (add-written-work 2.5).""" +import io +import re +import uuid +from datetime import 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, KID, KID2, OUTSIDER = "t@school.test", "kid@school.test", "kid2@school.test", "out@school.test" +MON = datetime(2026, 11, 9, 9, 0, tzinfo=timezone.utc) +MIN = timedelta(minutes=1) + + +def uid(email): + return str(uuid.uuid5(uuid.NAMESPACE_DNS, email)) + + +def auth(email): + return {"Authorization": f"Bearer dev:{email}"} + + +def jpeg(): + out = io.BytesIO() + Image.new("RGB", (80, 50), "green").save(out, "JPEG") + return out.getvalue() + + +@pytest.fixture +def world(monkeypatch): + bank = load_bank() + lesson = next(u["id"] for u in bank.data["units"] if u.get("quiz")) + store = InMemoryStore() + a = store.create_class("setup", "A", lessons=[lesson], teacher_email=TEACHER) + b = store.create_class("setup", "B", lessons=[lesson], teacher_email="t2@school.test") + for email in (KID, KID2): + store.add_member(a["id"], email) + store.add_member(b["id"], OUTSIDER) + client = TestClient(create_app(Settings(), bank=bank, store=store)) + moment = {"now": MON} + monkeypatch.setattr(clock, "now", lambda: moment["now"]) + client.set_time = lambda value: moment.update(now=value) + due = (MON + timedelta(days=7)).date() + client.store, client.assignment = store, store.create_assignment(a["id"], "דף", due, TEACHER, "quiz", quiz_lesson=lesson) + client.other = store.create_assignment(b["id"], "אחר", due, "t2@school.test", "quiz", quiz_lesson=lesson) + client.exam = store.create_assignment(a["id"], "תרגול", due, TEACHER, "exercise_test", test_id=1, exercise_number=1) + return client + + +def ask(client, who=KID, device="laptop", assignment=None): + return client.post("/api/photo-requests", json={"assignment_id": (assignment or client.assignment)["id"], "device": device}, headers=auth(who)) + + +def phone_list(client, who=KID, device="phone"): + return client.get("/api/photo-requests/open", params={"device": device}, headers=auth(who)).json() + + +def test_a_request_has_a_four_digit_code_ten_minutes_and_starts_waiting(world): + got = ask(world) + assert got.status_code == 201 + body = got.json() + assert re.fullmatch(r"\d{4}", body["code"]) and body["state"] == "waiting" and body["picture_id"] is None and body["assignment_id"] == world.assignment["id"] + assert datetime.fromisoformat(body["expires_at"]) == MON + 10 * MIN + + +def test_the_same_code_is_on_the_computer_and_the_phone(world): + world.app.state.photo_code = lambda: 42 + code = ask(world).json()["code"] + assert code == "0042" # zero padded + on_phone = phone_list(world) + assert len(on_phone) == 1 and on_phone[0]["code"] == code and on_phone[0]["assignment"]["title"] == "דף" + + +def test_the_phone_does_not_show_the_request_its_own_device_made_and_others_do(world): + ask(world, device="laptop") + assert phone_list(world, device="laptop") == [] # the computer that asked is not asked to photograph + assert len(phone_list(world, device="phone")) == 1 + assert phone_list(world, KID2) == [] # another student's phone sees nothing + + +def test_a_newer_request_for_the_same_answer_replaces_the_older(world): + first = ask(world).json() + second = ask(world).json() + assert world.get(f"/api/photo-requests/{first['id']}", headers=auth(KID)).json()["state"] == "superseded" + assert world.get(f"/api/photo-requests/{second['id']}", headers=auth(KID)).json()["state"] == "waiting" + assert [r["id"] for r in phone_list(world)] == [second["id"]] + late = world.post(f"/api/photo-requests/{first['id']}/picture", content=jpeg(), headers={**auth(KID), "Content-Type": "image/jpeg"}) + assert late.status_code == 409 and "הוחלפה" in late.json()["detail"] + + +def test_a_request_expires_after_ten_minutes_by_the_servers_clock(world): + made = ask(world).json() + world.set_time(MON + 9 * MIN) + assert world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID)).json()["state"] == "waiting" + world.set_time(MON + 10 * MIN) + assert world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID)).json()["state"] == "expired" + assert phone_list(world) == [] + upload = world.post(f"/api/photo-requests/{made['id']}/picture", content=jpeg(), headers={**auth(KID), "Content-Type": "image/jpeg"}) + assert upload.status_code == 409 and "פגה" in upload.json()["detail"] and world.store.pictures() == [] + assert world.post(f"/api/photo-requests/{made['id']}/start", headers=auth(KID)).status_code == 409 + + +def test_the_states_go_waiting_uploading_received_with_the_picture(world): + made = ask(world).json() + started = world.post(f"/api/photo-requests/{made['id']}/start", headers=auth(KID)) + assert started.status_code == 200 and started.json()["state"] == "uploading" + assert world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID)).json()["state"] == "uploading" and len(phone_list(world)) == 1 + done = world.post(f"/api/photo-requests/{made['id']}/picture", content=jpeg(), headers={**auth(KID), "Content-Type": "image/jpeg"}) + assert done.status_code == 200 and done.json()["state"] == "received" + pic = world.store.get_picture(done.json()["picture_id"]) + assert pic["kind"] == "answer" and pic["owner_id"] == uid(KID) + assert world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID)).json()["picture_id"] == pic["id"] and phone_list(world) == [] + assert world.get(f"/api/pictures/{pic['id']}", headers=auth(KID)).status_code == 200 # the computer can read it to add it to the answer + again = world.post(f"/api/photo-requests/{made['id']}/picture", content=jpeg(), headers={**auth(KID), "Content-Type": "image/jpeg"}) + assert again.status_code == 409 # one picture per request + + +def test_a_bad_upload_makes_the_request_failed_and_stores_nothing(world): + made = ask(world).json() + bad = world.post(f"/api/photo-requests/{made['id']}/picture", content=b"not an image", headers={**auth(KID), "Content-Type": "image/jpeg"}) + assert bad.status_code == 400 + assert world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID)).json()["state"] == "failed" and world.store.pictures() == [] + assert phone_list(world) == [] + + +def test_another_student_cannot_read_or_answer_a_request_and_only_class_assignments_work(world): + made = ask(world).json() + for call in (lambda: world.get(f"/api/photo-requests/{made['id']}", headers=auth(KID2)), + lambda: world.post(f"/api/photo-requests/{made['id']}/start", headers=auth(KID2)), + lambda: world.post(f"/api/photo-requests/{made['id']}/picture", content=jpeg(), headers={**auth(KID2), "Content-Type": "image/jpeg"})): + assert call().status_code == 404 + assert ask(world, assignment=world.other).status_code == 404 # an assignment of another class + assert ask(world, assignment=world.exam).status_code == 422 # an exercise test is not handed in as a picture + assert world.get("/api/photo-requests/nope", headers=auth(KID)).status_code == 404 + assert world.post("/api/photo-requests", json={"assignment_id": world.assignment["id"], "device": "x"}).status_code == 401 diff --git a/mathly2/tests/test_pictures.py b/mathly2/tests/test_pictures.py new file mode 100644 index 0000000000000000000000000000000000000000..b364f1d6b7e2aaeae98cc314e18c96e7d62f6e17 --- /dev/null +++ b/mathly2/tests/test_pictures.py @@ -0,0 +1,170 @@ +"""Pictures (add-written-work 1.3): cleaning, PDF pages and the byte stores.""" +import io +import urllib.error + +import pytest +from PIL import Image + +from mathly2 import pictures +from mathly2.pictures import MemoryPictures, PictureError, SupabasePictures, clean_image, render_pdf +from mathly2.store import InMemoryStore + + +def jpeg(size=(40, 20), color="red", exif=None, fmt="JPEG"): + out = io.BytesIO() + image = Image.new("RGB", size, color) + kwargs = {"exif": exif} if exif is not None else {} + image.save(out, fmt, **kwargs) + return out.getvalue() + + +def test_a_jpeg_with_metadata_comes_back_without_it_and_upright(): + exif = Image.Exif() + exif[0x0112] = 6 # "rotate 90": a phone held sideways + exif[0x010F] = "SecretPhone" # a maker name that must not survive + data = jpeg(size=(40, 20), exif=exif) + assert b"SecretPhone" in data + cleaned = clean_image(data, "image/jpeg") + image = Image.open(io.BytesIO(cleaned)) + assert image.format == "JPEG" and image.size == (20, 40) # turned upright: width and height swapped + assert b"SecretPhone" not in cleaned and not image.getexif() + + +def test_png_and_webp_are_taken_and_transparency_becomes_white(): + out = io.BytesIO() + Image.new("RGBA", (10, 10), (255, 0, 0, 0)).save(out, "PNG") # fully transparent red + cleaned = Image.open(io.BytesIO(clean_image(out.getvalue(), "image/png"))) + assert cleaned.format == "JPEG" and cleaned.getpixel((5, 5)) == (255, 255, 255) + assert Image.open(io.BytesIO(clean_image(jpeg(fmt="WEBP"), "image/webp"))).format == "JPEG" + + +def test_a_picture_is_shrunk_to_a_sensible_size(): + big = Image.open(io.BytesIO(clean_image(jpeg(size=(4000, 3000)), "image/jpeg"))) + assert max(big.size) == pictures.MAX_SIDE + + +@pytest.mark.parametrize("data,kind,status,code", [ + (b"not an image", "image/jpeg", 400, "bad_image"), + (b"%PDF-1.4 ...", "application/pdf", 415, "unsupported_image"), + (b"", "image/gif", 415, "unsupported_image"), + (b"x" * (pictures.MAX_IMAGE + 1), "image/jpeg", 413, "image_too_large"), +]) +def test_what_is_not_an_image_or_is_too_large_is_refused(data, kind, status, code): + with pytest.raises(PictureError) as caught: + clean_image(data, kind) + assert (caught.value.status, caught.value.code) == (status, code) + + +def test_a_png_sent_as_a_jpeg_and_an_image_with_too_many_pixels_are_refused(): + out = io.BytesIO() + Image.new("RGB", (8, 8)).save(out, "PNG") + with pytest.raises(PictureError) as wrong: + clean_image(out.getvalue(), "image/jpeg") + assert wrong.value.status == 415 + huge = io.BytesIO() + Image.new("1", (6000, 4000)).save(huge, "PNG") # 24 million pixels, tiny file + assert len(huge.getvalue()) < pictures.MAX_IMAGE + with pytest.raises(PictureError) as bomb: + clean_image(huge.getvalue(), "image/png") + assert bomb.value.status == 400 + + +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() + + +def test_a_pdf_gives_one_picture_per_page_and_bad_pdfs_are_refused(): + pages = render_pdf(make_pdf(3)) + assert len(pages) == 3 and all(Image.open(io.BytesIO(p)).format == "JPEG" for p in pages) + assert Image.open(io.BytesIO(pages[0])).size[0] == pictures.PAGE_WIDTH + for data, status in ((b"hello", 415), (b"%PDF-1.4 garbage", 400), (b"%PDF-" + b"0" * pictures.MAX_PDF, 413)): + with pytest.raises(PictureError) as caught: + render_pdf(data) + assert caught.value.status == status + + +def test_too_many_pages_are_refused(): + with pytest.raises(PictureError) as caught: + render_pdf(make_pdf(pictures.MAX_PAGES + 1)) + assert (caught.value.status, caught.value.code) == (413, "pdf_too_many_pages") + + +def test_the_memory_store_puts_gets_and_deletes_and_a_missing_one_is_404(): + m = MemoryPictures() + m.put("a/1.jpg", b"bytes") + assert m.get("a/1.jpg") == b"bytes" + m.delete("a/1.jpg") + m.delete("a/1.jpg") # deleting twice is fine + with pytest.raises(PictureError) as caught: + m.get("a/1.jpg") + assert caught.value.status == 404 + + +def test_store_and_remove_a_picture_keep_the_row_and_drop_the_bytes(): + store, backend = InMemoryStore(), MemoryPictures() + row = pictures.store_picture(store, backend, "user-1", "answer", b"jpegbytes") + assert row["path"].startswith("user-1/") and row["size"] == 9 and backend.get(row["path"]) == b"jpegbytes" and store.get_picture(row["id"])["kind"] == "answer" + pictures.remove_picture(store, backend, row) + assert store.get_picture(row["id"])["deleted_at"] is not None + with pytest.raises(PictureError): + backend.get(row["path"]) + + +class Calls: + """A stand-in for the HTTP calls of SupabasePictures: records them and answers from a list.""" + + def __init__(self, answers): + self.answers, self.calls = list(answers), [] + + def __call__(self, method, url, headers=None, body=None, timeout=10): + self.calls.append((method, url, dict(headers or {}), body)) + answer = self.answers.pop(0) + if isinstance(answer, Exception): + raise answer + return 200, answer + + +def http_error(code): + return urllib.error.HTTPError("u", code, "x", {}, io.BytesIO(b"{}")) + + +def test_supabase_pictures_make_a_private_bucket_once_then_store_read_and_delete(monkeypatch): + calls = Calls([b"{}", b"{}", b"bytes", b"{}"]) + monkeypatch.setattr(pictures.pooled, "request", calls) + s = SupabasePictures("https://p.test/", "secret", "m2-answers") + s.put("user 1/a b.jpg", b"jpeg") + s.put("user 1/c.jpg", b"jpeg2") # the bucket is made only the first time + assert [c[0] for c in calls.calls] == ["POST", "POST", "POST"] and calls.calls[0][1] == "https://p.test/storage/v1/bucket" + assert b'"public": false' in calls.calls[0][3] and b"m2-answers" in calls.calls[0][3] + assert calls.calls[1][1] == "https://p.test/storage/v1/object/m2-answers/user%201/a%20b.jpg" and calls.calls[1][3] == b"jpeg" + assert calls.calls[1][2]["Authorization"] == "Bearer secret" and calls.calls[1][2]["Content-Type"] == "image/jpeg" + calls.answers[:] = [b"bytes", b"{}"] + assert s.get("user 1/c.jpg") == b"bytes" + s.delete("user 1/c.jpg") + assert calls.calls[-1][0] == "DELETE" + + +def test_an_existing_bucket_is_fine_and_storage_trouble_is_a_503_or_404(monkeypatch): + monkeypatch.setattr(pictures.pooled, "request", Calls([http_error(409), b"{}"])) + SupabasePictures("https://p.test", "k").put("a/1.jpg", b"x") # "already exists" is not an error + monkeypatch.setattr(pictures.pooled, "request", Calls([http_error(500)])) + with pytest.raises(PictureError) as down: + SupabasePictures("https://p.test", "k").put("a/1.jpg", b"x") + assert down.value.status == 503 + s = SupabasePictures("https://p.test", "k") + monkeypatch.setattr(pictures.pooled, "request", Calls([http_error(404)])) + with pytest.raises(PictureError) as missing: + s.get("a/1.jpg") + assert missing.value.status == 404 + monkeypatch.setattr(pictures.pooled, "request", Calls([urllib.error.URLError("down")])) + with pytest.raises(PictureError) as unreachable: + s.get("a/1.jpg") + assert unreachable.value.status == 503 + monkeypatch.setattr(pictures.pooled, "request", Calls([http_error(404)])) + s.delete("a/1.jpg") # already gone is fine diff --git a/mathly2/tests/test_pictures_api.py b/mathly2/tests/test_pictures_api.py new file mode 100644 index 0000000000000000000000000000000000000000..0e4e8a51de49d42058362eb8c8960b91f10b5997 --- /dev/null +++ b/mathly2/tests/test_pictures_api.py @@ -0,0 +1,128 @@ +"""Pictures through the API (add-written-work 2.1): upload, the access rules, and the bytes only ever coming through the server.""" +import io +import uuid +from datetime import date + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +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") +KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID)) + + +def uid(email): + return str(uuid.uuid5(uuid.NAMESPACE_DNS, email)) + + +def auth(email): + return {"Authorization": f"Bearer dev:{email}"} + + +def jpeg_bytes(color="red", size=(60, 40), secret=None): + out = io.BytesIO() + exif = Image.Exif() + if secret: + exif[0x010F] = secret + Image.new("RGB", size, color).save(out, "JPEG", exif=exif) + return out.getvalue() + + +@pytest.fixture +def world(): + store = InMemoryStore() + store.give_role(MGR, "manager", "setup") + a = store.create_class("setup", "A", lessons=["t"], teacher_email=TEACHER) + b = store.create_class("setup", "B", lessons=["t"], teacher_email=OTHER_TEACHER) + for email in (KID, KID2): + store.add_member(a["id"], email) + store.add_member(b["id"], OUTSIDER) + app = create_app(Settings(developers=frozenset({DEV})), bank=load_bank(), store=store) + client = TestClient(app) + client.store, client.backend, client.class_a = store, app.state.pictures, a["id"] + return client + + +def upload(client, who=KID, data=None, kind="image/jpeg"): + return client.post("/api/pictures", content=data if data is not None else jpeg_bytes(), headers={**auth(who), "Content-Type": kind}) + + +def test_an_upload_is_cleaned_and_stored_and_the_uploader_reads_it_back(world): + got = upload(world, data=jpeg_bytes(secret="SecretPhone")) + assert got.status_code == 201 + body = got.json() + assert set(body) == {"id", "size"} and "path" not in body # no storage name is ever given out + row = world.store.get_picture(body["id"]) + assert row["owner_id"] == KID_ID and row["kind"] == "answer" and row["size"] == body["size"] + assert b"SecretPhone" not in world.backend.get(row["path"]) + read = world.get(f"/api/pictures/{body['id']}", headers=auth(KID)) + assert read.status_code == 200 and read.headers["content-type"] == "image/jpeg" and Image.open(io.BytesIO(read.content)).size == (60, 40) + assert "location" not in read.headers and row["path"] not in str(read.headers) + + +def test_not_an_image_the_wrong_type_and_too_large_are_refused_and_nothing_is_stored(world): + assert upload(world, data=b"hello", kind="image/jpeg").status_code == 400 + assert upload(world, data=jpeg_bytes(), kind="application/pdf").status_code == 415 + assert upload(world, data=b"x" * 6_000_000).status_code == 413 + assert world.store.pictures(include_deleted=True) == [] and world.backend.files == {} + + +def test_a_signed_out_request_and_a_person_in_no_class_cannot_upload(world): + assert world.post("/api/pictures", content=jpeg_bytes(), headers={"Content-Type": "image/jpeg"}).status_code == 401 + assert upload(world, who="nobody@school.test").status_code == 403 + + +def hand_in(world, who=KID): + pic = upload(world, who).json()["id"] + a = world.store.create_assignment(world.class_a, "דף", date(2026, 12, 1), TEACHER, "quiz", quiz_lesson="t") + world.store.save_submission(a["id"], uid(who), pictures=[pic]) + return pic + + +def test_who_can_read_a_handed_in_answer_picture(world): + pic = hand_in(world) + get = lambda who: world.get(f"/api/pictures/{pic}", headers=auth(who)).status_code + assert get(KID) == 200 and get(TEACHER) == 200 and get(DEV) == 200 # the owner, the class's teacher, a developer + assert get(MGR) == 403 # a manager has no learning data + assert get(KID2) == 404 and get(OUTSIDER) == 404 and get(OTHER_TEACHER) == 404 # another student, another class, another class's teacher + assert world.get(f"/api/pictures/{pic}").status_code == 401 + + +def test_a_picture_nothing_refers_to_is_only_the_uploaders(world): + pic = upload(world).json()["id"] + get = lambda who: world.get(f"/api/pictures/{pic}", headers=auth(who)).status_code + assert get(KID) == 200 and get(TEACHER) == 404 and get(KID2) == 404 and get(DEV) == 200 + + +def test_a_page_of_an_assignment_is_read_by_the_classs_students_and_teacher_only(world): + page = world.store.create_picture(TEACHER, "page", "t/1.jpg", "image/jpeg", 10) + world.backend.put("t/1.jpg", jpeg_bytes("blue")) + world.store.create_assignment(world.class_a, "דף", date(2026, 12, 1), TEACHER, "pages", pages=[page["id"]]) + get = lambda who: world.get(f"/api/pictures/{page['id']}", headers=auth(who)).status_code + assert get(KID) == 200 and get(KID2) == 200 and get(TEACHER) == 200 + assert get(OUTSIDER) == 404 and get(OTHER_TEACHER) == 404 + + +def test_a_deleted_picture_is_gone_and_an_unknown_one_is_404(world): + pic = hand_in(world) + world.store.mark_picture_deleted(pic) + assert world.get(f"/api/pictures/{pic}", headers=auth(KID)).status_code == 410 + assert world.get("/api/pictures/not-an-id", headers=auth(KID)).status_code == 404 + assert world.get(f"/api/pictures/{uuid.uuid4()}", headers=auth(KID)).status_code == 404 + + +def test_storage_trouble_is_a_503_with_a_hebrew_message(world): + from mathly2.pictures import PictureError + + class Down: + def put(self, *a, **k): + raise PictureError(503, "answer_storage_unavailable") + world.app.state.pictures = Down() + got = upload(world) + assert got.status_code == 503 and "האחסון" in got.json()["detail"] diff --git a/mathly2/tests/test_push_api.py b/mathly2/tests/test_push_api.py new file mode 100644 index 0000000000000000000000000000000000000000..80b9f5c9b81773fca54bc0404d710ba9245e165c --- /dev/null +++ b/mathly2/tests/test_push_api.py @@ -0,0 +1,199 @@ +"""Push notifications for photo requests (add-written-work 2.6), with a recording stand-in for the push service.""" +import json +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient + +from mathly2 import clock, push +from mathly2.bank import load_bank +from mathly2.config import Settings +from mathly2.main import create_app +from mathly2.push import Gone, Pusher +from mathly2.store import InMemoryStore + +TEACHER, KID, KID2 = "t@school.test", "kid@school.test", "kid2@school.test" +MON = datetime(2026, 11, 9, 9, 0, tzinfo=timezone.utc) +KEY_A, KEY_B = push.new_private_key(), push.new_private_key() + + +def uid(email): + return str(uuid.uuid5(uuid.NAMESPACE_DNS, email)) + + +def auth(email): + return {"Authorization": f"Bearer dev:{email}"} + + +def sub(n, host="https://push.example/"): + return {"endpoint": f"{host}{n}", "keys": {"p256dh": f"p{n}", "auth": f"a{n}"}} + + +class Recorder: + """A stand-in for the push service: records what would be sent, and can answer 'gone' or fail for chosen endpoints.""" + + def __init__(self): + self.sent, self.gone, self.broken = [], set(), set() + + def __call__(self, subscription, payload, ttl): + if subscription["endpoint"] in self.gone: + raise Gone() + if subscription["endpoint"] in self.broken: + raise OSError("the network is down") + self.sent.append((subscription["endpoint"], payload, ttl)) + + +@pytest.fixture +def world(monkeypatch): + bank = load_bank() + lesson = next(u["id"] for u in bank.data["units"] if u.get("quiz")) + store = InMemoryStore() + klass = store.create_class("setup", "A", lessons=[lesson], teacher_email=TEACHER) + for email in (KID, KID2): + store.add_member(klass["id"], email) + recorder = Recorder() + pusher = Pusher(store, KEY_A, "mailto:dev@school.test", sender=recorder) + client = TestClient(create_app(Settings(), bank=bank, store=store, pusher=pusher)) + moment = {"now": MON} + monkeypatch.setattr(clock, "now", lambda: moment["now"]) + client.set_time = lambda value: moment.update(now=value) + client.store, client.recorder, client.pusher = store, recorder, pusher + client.assignment = store.create_assignment(klass["id"], "דף", (MON + timedelta(days=7)).date(), TEACHER, "quiz", quiz_lesson=lesson) + return client + + +def subscribe(client, n, who=KID, host="https://push.example/"): + return client.post("/api/push/subscribe", json=sub(n, host), headers=auth(who)) + + +def ask(client, who=KID): + return client.post("/api/photo-requests", json={"assignment_id": client.assignment["id"], "device": "laptop"}, headers=auth(who)) + + +def test_the_key_endpoint_gives_the_public_key_when_push_is_on(world): + got = world.get("/api/push/key", headers=auth(KID)) + assert got.json() == {"enabled": True, "key": push.public_key(KEY_A)} and got.headers["cache-control"] == "no-store" + assert world.get("/api/push/key").status_code == 401 + + +def test_a_new_request_notifies_the_students_phones_with_the_requests_address_and_not_others(world): + assert subscribe(world, 1).status_code == 200 and subscribe(world, 2).status_code == 200 and subscribe(world, 3, KID2).status_code == 200 + made = ask(world).json() + sent = world.recorder.sent + assert sorted(e for e, _, _ in sent) == ["https://push.example/1", "https://push.example/2"] # KID2's phone is not told + payload, ttl = sent[0][1], sent[0][2] + assert payload["url"] == f"/#/assignment/{world.assignment['id']}" and made["code"] in payload["body"] and payload["tag"] == f"photo-{world.assignment['id']}" + assert payload["title"] and "דף" not in json.dumps(payload, ensure_ascii=False) # no assignment name on a locked screen + assert ttl == 600 # alive for the request's ten minutes + + +def test_a_computer_that_never_subscribed_gets_none_and_the_request_still_works(world): + assert ask(world).status_code == 201 and world.recorder.sent == [] + + +def test_a_subscription_the_push_service_no_longer_knows_is_deleted(world): + subscribe(world, 1) + subscribe(world, 2) + world.recorder.gone.add("https://push.example/1") + ask(world) + assert [e for e, _, _ in world.recorder.sent] == ["https://push.example/2"] + assert [s["endpoint"] for s in world.store.push_subscriptions_of(uid(KID))] == ["https://push.example/2"] + + +def test_a_failed_send_does_not_break_the_request_or_delete_the_subscription(world): + subscribe(world, 1) + world.recorder.broken.add("https://push.example/1") + assert ask(world).status_code == 201 + assert len(world.store.push_subscriptions_of(uid(KID))) == 1 + + +def test_changing_the_servers_key_drops_every_subscription(world): + subscribe(world, 1) + subscribe(world, 2, KID2) + other_key = Pusher(world.store, KEY_B, "mailto:x@y.test", sender=world.recorder) + assert other_key.key_id != world.pusher.key_id + assert other_key.notify(uid(KID), {"title": "x"}, 60) == 0 # the old phones are dropped, none is told + assert world.store.push_subscriptions_of(uid(KID)) == [] and world.store.push_subscriptions_of(uid(KID2)) == [] + other_key.subscribe(uid(KID), sub(9)) # a phone turns notifications on again + assert [s["endpoint"] for s in world.store.push_subscriptions_of(uid(KID))] == ["https://push.example/9"] + assert world.store.push_subscriptions_of(uid(KID))[0]["key_id"] == other_key.key_id + + +def test_without_a_key_push_is_off_and_says_so_while_everything_else_works(): + bank = load_bank() + lesson = next(u["id"] for u in bank.data["units"] if u.get("quiz")) + store = InMemoryStore() + klass = store.create_class("setup", "A", lessons=[lesson], teacher_email=TEACHER) + store.add_member(klass["id"], KID) + client = TestClient(create_app(Settings(), bank=bank, store=store)) + assert client.get("/api/push/key", headers=auth(KID)).json() == {"enabled": False, "key": ""} + assert client.post("/api/push/subscribe", json=sub(1), headers=auth(KID)).status_code == 503 + row = store.create_assignment(klass["id"], "x", (MON + timedelta(days=7)).date(), TEACHER, "quiz", quiz_lesson=lesson) + assert client.post("/api/photo-requests", json={"assignment_id": row["id"], "device": "d"}, headers=auth(KID)).status_code == 201 + + +def test_a_key_that_is_not_a_key_turns_push_off_without_stopping_the_server(): + bad = Pusher(InMemoryStore(), "not-a-key", "mailto:x@y.test") + assert bad.on is False and "valid" in bad.why and bad.public == "" + assert Pusher(InMemoryStore(), "", "").on is False + + +@pytest.mark.parametrize("body", [{"endpoint": "http://insecure/1", "keys": {"p256dh": "p", "auth": "a"}}, {"endpoint": "https://x/1", "keys": {}}, + {"endpoint": "https://x/1", "keys": {"p256dh": "p"}}, {"endpoint": "https://x/1", "keys": {"p256dh": "p" * 300, "auth": "a"}}]) +def test_a_subscription_that_is_not_one_is_refused(world, body): + assert world.post("/api/push/subscribe", json=body, headers=auth(KID)).status_code == 400 + assert world.store.push_subscriptions_of(uid(KID)) == [] + + +def test_the_same_phone_subscribing_again_keeps_one_row_and_unsubscribing_removes_only_mine(world): + subscribe(world, 1) + subscribe(world, 1) + subscribe(world, 2, KID2) + assert len(world.store.push_subscriptions_of(uid(KID))) == 1 + world.post("/api/push/unsubscribe", json={"endpoint": "https://push.example/2"}, headers=auth(KID)) # not KID's: nothing happens + assert len(world.store.push_subscriptions_of(uid(KID2))) == 1 + world.post("/api/push/unsubscribe", json={"endpoint": "https://push.example/1"}, headers=auth(KID)) + assert world.store.push_subscriptions_of(uid(KID)) == [] + + +def test_a_request_expiring_soon_has_a_short_lived_notification(world): + subscribe(world, 1) + world.set_time(MON) + made = world.store.create_photo_request(uid(KID), world.assignment["id"], "laptop", "1234", MON, MON + timedelta(seconds=45)) + assert push.seconds_left(made, MON) == 45 and push.seconds_left(made, MON + timedelta(minutes=5)) == 0 + assert world.pusher.notify(uid(KID), {"t": 1}, 0) == 0 # a dead request is not worth a ring + + +def test_the_service_worker_is_served_from_the_root_and_the_manifest_is_linked(world): + sw = world.get("/service-worker.js") + assert sw.status_code == 200 and "javascript" in sw.headers["content-type"] and sw.headers["service-worker-allowed"] == "/" and sw.headers["cache-control"] == "no-cache" + assert "addEventListener(\"push\"" in sw.text and "notificationclick" in sw.text + manifest = world.get("/static/manifest.webmanifest").json() + assert manifest["display"] == "standalone" and manifest["icons"] and manifest["dir"] == "rtl" + assert 'rel="manifest"' in world.get("/").text + + +def test_the_real_sender_turns_a_404_or_410_into_gone_and_lets_other_errors_through(monkeypatch): + import pywebpush + + class Response: + def __init__(self, code): + self.status_code = code + + def failing(code): + def fake(*a, **k): + raise pywebpush.WebPushException("push service said no", response=Response(code)) + return fake + row = {"endpoint": "https://x/1", "p256dh": "p", "auth": "a"} + for code in (404, 410): + monkeypatch.setattr(pywebpush, "webpush", failing(code)) + with pytest.raises(Gone): + push.web_push(row, {"t": 1}, KEY_A, "mailto:x@y.test", 60) + monkeypatch.setattr(pywebpush, "webpush", failing(500)) + with pytest.raises(pywebpush.WebPushException): + push.web_push(row, {"t": 1}, KEY_A, "mailto:x@y.test", 60) + seen = {} + monkeypatch.setattr(pywebpush, "webpush", lambda info, data, **kw: seen.update(info=info, data=data, **kw)) + push.web_push(row, {"title": "שלום"}, KEY_A, "mailto:x@y.test", 120) + assert seen["ttl"] == 120 and seen["vapid_claims"] == {"sub": "mailto:x@y.test"} and "שלום" in seen["data"] and seen["info"]["keys"] == {"p256dh": "p", "auth": "a"} diff --git a/mathly2/tests/test_quiz.py b/mathly2/tests/test_quiz.py index 9cb25415bc1986c164491a9e5ea43314e9e80443..e64f405c5e67555c362721bc97794469a6a9e16f 100644 --- a/mathly2/tests/test_quiz.py +++ b/mathly2/tests/test_quiz.py @@ -22,15 +22,15 @@ def per_skill(drawn): return Counter(q["skill_id"] for q in drawn) -def test_a_lesson_of_four_skills_gives_ten_questions_with_every_skill_at_least_two(bank): +def test_a_lesson_of_four_skills_gives_fifteen_questions_with_every_skill_at_least_three(bank): drawn = quiz.draw(bank, lesson_with(bank, 4), rng=random.Random(1)) - assert len(drawn) == 10 and len({q["key"] for q in drawn}) == 10 - assert sorted(per_skill(drawn).values()) == [2, 2, 3, 3] + assert len(drawn) == 15 and len({q["key"] for q in drawn}) == 15 + assert sorted(per_skill(drawn).values()) == [3, 4, 4, 4] -def test_six_skills_give_quotas_2_2_2_2_1_1_and_two_skills_give_5_and_5(bank): - assert sorted(per_skill(quiz.draw(bank, lesson_with(bank, 6), rng=random.Random(2))).values()) == [1, 1, 2, 2, 2, 2] - assert sorted(per_skill(quiz.draw(bank, lesson_with(bank, 2), rng=random.Random(3))).values()) == [5, 5] +def test_six_skills_give_quotas_2_2_2_3_3_3_and_two_skills_give_7_and_8(bank): + assert sorted(per_skill(quiz.draw(bank, lesson_with(bank, 6), rng=random.Random(2))).values()) == [2, 2, 2, 3, 3, 3] + assert sorted(per_skill(quiz.draw(bank, lesson_with(bank, 2), rng=random.Random(3))).values()) == [7, 8] def test_every_skill_is_in_the_quiz_whatever_the_rng(bank): @@ -40,11 +40,11 @@ def test_every_skill_is_in_the_quiz_whatever_the_rng(bank): assert set(per_skill(quiz.draw(bank, lesson, rng=random.Random(seed)))) == skills -def test_a_skill_without_questions_is_left_out_and_the_others_still_give_ten(bank): +def test_a_skill_without_questions_is_left_out_and_the_others_still_give_fifteen(bank): lesson = next(s["unit"] for s in bank.skills if not bank.question_keys(s["id"])) drawn = quiz.draw(bank, lesson, rng=random.Random(4)) empty = {s["id"] for s in bank.skills if not bank.question_keys(s["id"])} - assert len(drawn) == 10 and not empty & set(per_skill(drawn)) + assert len(drawn) == 15 and not empty & set(per_skill(drawn)) def test_a_retake_uses_new_questions_while_enough_remain_and_then_the_least_used(bank): @@ -56,7 +56,8 @@ def test_a_retake_uses_new_questions_while_enough_remain_and_then_the_least_used used.update(q["key"] for q in second) assert not {q["key"] for q in first} & {q["key"] for q in second} # two in a row never repeat third = quiz.draw(bank, lesson, used, random.Random(7)) - assert not {q["key"] for q in first + second} & {q["key"] for q in third} or all(used[q["key"]] == 1 for q in third) + unused = [q for q in third if used[q["key"]] == 0] + assert len(unused) == 10 # the 10 questions never drawn come first (40 in the lesson, 30 used) assert max(used[q["key"]] for q in third) <= 1 # repeats come from the least-used ones for _ in range(12): # many retakes: nothing is drawn far more than the rest batch = quiz.draw(bank, lesson, used, random.Random()) diff --git a/mathly2/tests/test_quiz_browser.py b/mathly2/tests/test_quiz_browser.py index 8d34b92e316fff4cda0a5253f69d916108f668e3..9a2894f60fc5059e588aeb026545f0899f83b13e 100644 --- a/mathly2/tests/test_quiz_browser.py +++ b/mathly2/tests/test_quiz_browser.py @@ -13,7 +13,7 @@ from mathly2.bank import load_bank from mathly2.config import Settings from mathly2.main import create_app from mathly2.store import InMemoryStore -from mathly2.tests.serve import offline_formulas, running +from mathly2.tests.serve import offline_formulas, only, running KID = "kid@school.test" KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID)) @@ -41,17 +41,17 @@ def lessons_of(bank, *sizes): def world(bank, lessons): store = InMemoryStore() store.add_member(store.create_class("setup", "A", lessons=list(lessons))["id"], KID) - return create_app(Settings(), bank=bank, store=store), store + return create_app(Settings(), bank=only(bank, lessons), store=store), store -def seed_attempt(store, bank, lesson, grade=None, minutes_left=None, answers=None, n=10, seconds_left=None): +def seed_attempt(store, bank, lesson, grade=None, minutes_left=None, answers=None, n=15, seconds_left=None): """An attempt in the store: submitted with `grade`, or open with `minutes_left` (or `seconds_left`) on its clock; negative: overdue.""" from mathly2 import quiz now = clock.now() drawn = quiz.draw(bank, lesson)[:n] if grade is None: - left = timedelta(seconds=seconds_left) if seconds_left is not None else timedelta(minutes=15 if minutes_left is None else minutes_left) - row = store.create_attempt(KID_ID, "quiz", lesson, now + left - timedelta(minutes=15), now + left, drawn) + left = timedelta(seconds=seconds_left) if seconds_left is not None else timedelta(minutes=20 if minutes_left is None else minutes_left) + row = store.create_attempt(KID_ID, "quiz", lesson, now + left - timedelta(minutes=20), now + left, drawn) else: row = store.create_attempt(KID_ID, "quiz", lesson, now - timedelta(hours=1), now - timedelta(minutes=45), drawn) store.save_attempt(row["id"], submitted_at=now - timedelta(minutes=50), grade=grade) @@ -113,7 +113,7 @@ def test_every_lesson_has_a_quiz_node_with_its_state_in_words_and_it_is_a_keyboa # --- 3.2 the sheet --- -def test_the_quiz_opens_with_ten_questions_no_skill_names_and_a_countdown(browser, bank): +def test_the_quiz_opens_with_fifteen_questions_no_skill_names_and_a_countdown(browser, bank): (lesson,) = lessons_of(bank, 4) app, store = world(bank, [lesson]) with running(app) as url: @@ -121,8 +121,8 @@ def test_the_quiz_opens_with_ten_questions_no_skill_names_and_a_countdown(browse page.click(f".quiz-node[data-quiz='{lesson}']") page.wait_for_selector("#qz-q") assert page.evaluate("location.hash").startswith(f"#/quiz/{lesson}/") # the address names the attempt - assert "מתוך 10" in page.inner_text("#qz-count") and page.locator(".qz-dot").count() == 10 - assert page.inner_text("#qz-timer").startswith("14:") or page.inner_text("#qz-timer") == "15:00" + assert "מתוך 15" in page.inner_text("#qz-count") and page.locator(".qz-dot").count() == 15 + assert page.inner_text("#qz-timer").startswith("19:") or page.inner_text("#qz-timer") == "20:00" sheet = page.inner_text(".mx-sheet") assert not any(s["name"] in sheet for s in bank.skills if s["unit"] == lesson) assert page.locator(".qz-choice").count() >= 2 and page.locator(".qz-choice.right, .qz-choice.wrong").count() == 0 @@ -153,7 +153,7 @@ def test_a_choice_is_saved_on_the_server_survives_a_reload_and_shows_no_right_or assert store.attempts_of(KID_ID, "quiz", lesson)[-1]["id"] == before and len(store.attempts_of(KID_ID, "quiz", lesson)) == 1 page.click(".qz-dot >> nth=0") assert page.locator(".qz-dot.done").count() == 1 - assert page.inner_text("#qz-timer") < "15:01" and page.inner_text("#qz-timer") >= "14:00" + assert page.inner_text("#qz-timer") < "20:01" and page.inner_text("#qz-timer") >= "19:00" def test_the_board_and_calculator_are_available_in_a_quiz(browser, bank): @@ -182,7 +182,7 @@ def test_submitting_with_unanswered_questions_warns_first(browser, bank): tap_choice(page, bank, ref) page.click("#qz-submit") page.wait_for_selector("#qz-warn .ex-note") - assert "9 שאלות בלי תשובה" in page.inner_text("#qz-warn") + assert "14 שאלות בלי תשובה" in page.inner_text("#qz-warn") page.click("#qz-back") # back to the quiz: nothing was submitted assert page.locator("#qz-warn .ex-note").count() == 0 and store.attempts_of(KID_ID, "quiz", lesson)[-1]["submitted_at"] is None page.click("#qz-submit") @@ -212,8 +212,8 @@ def test_the_result_shows_the_grade_the_lost_skill_with_its_link_and_the_mistake page.wait_for_selector("#qz-q") page.click("#qz-submit") page.wait_for_selector("#qz-grade") - assert page.inner_text("#qz-grade b") == "80" and "8 תשובות נכונות מתוך 10" in page.inner_text(".qz-result") - page.wait_for_function("document.querySelector('#qz-best').innerText.includes('80')") # the lesson's grade + assert page.inner_text("#qz-grade b") == "87" and "13 תשובות נכונות מתוך 15" in page.inner_text(".qz-result") + page.wait_for_function("document.querySelector('#qz-best').innerText.includes('87')") # the lesson's grade link = page.locator("#qz-lost a") assert link.count() == 1 and link.get_attribute("href") == f"#/skill/{lost_skill}" and "2 שאלות שגויות" in page.inner_text("#qz-lost") assert page.locator("#qz-missed li").count() == 2 @@ -242,7 +242,7 @@ def test_an_attempt_that_ran_out_of_time_shows_its_result_after_the_server_settl with running(app) as url: page = path_page(browser, url, f"#/quiz/{lesson}/{row['id']}") page.wait_for_selector("#qz-grade") - assert page.inner_text("#qz-grade b") == "60" and "הזמן נגמר" in page.inner_text(".qz-result") + assert page.inner_text("#qz-grade b") == "40" and "הזמן נגמר" in page.inner_text(".qz-result") def test_when_the_countdown_reaches_zero_the_quiz_is_handed_in_as_it_stands(browser, bank): @@ -254,7 +254,7 @@ def test_when_the_countdown_reaches_zero_the_quiz_is_handed_in_as_it_stands(brow page = path_page(browser, url, f"#/quiz/{lesson}/{row['id']}") page.wait_for_selector("#qz-q") page.wait_for_selector("#qz-grade", timeout=15000) - assert page.inner_text("#qz-grade b") == "30" and "הזמן נגמר" in page.inner_text(".qz-result") + assert page.inner_text("#qz-grade b") == "20" and "הזמן נגמר" in page.inner_text(".qz-result") def test_a_retake_draws_new_questions_and_a_second_quiz_does_not_replace_the_best_grade(browser, bank): @@ -275,8 +275,8 @@ def test_a_retake_draws_new_questions_and_a_second_quiz_does_not_replace_the_bes page.wait_for_selector("#qz-q") third = store.attempts_of(KID_ID, "quiz", lesson)[-1] used = {q["key"] for a in (first, second) for q in a["questions"]} - assert third["id"] not in (first["id"], second["id"]) and len(third["questions"]) == 10 - assert len({q["key"] for q in third["questions"]} & used) <= 4 # mostly new questions + assert third["id"] not in (first["id"], second["id"]) and len(third["questions"]) == 15 + assert len({q["key"] for q in third["questions"]} & used) <= 5 # mostly new questions # --- 3.4 the grades screen --- @@ -318,23 +318,23 @@ def test_a_whole_quiz_from_the_trail_to_the_grades_screen(browser, bank): by_skill = Counter(r["skill_id"] for r in refs) lost_skill = next(s for s, n in by_skill.items() if n >= 2) wrong = {r["key"] for r in [r for r in refs if r["skill_id"] == lost_skill][:2]} - for _ in range(10): # answer all ten, moving with "next" + for _ in range(15): # answer all fifteen, moving with "next" ref = next(r for r in refs if r["key"] == page.get_attribute("#qz-q", "data-key")) tap_choice(page, bank, ref, ok=ref["key"] not in wrong) - if page.locator("#qz-count").inner_text().startswith("שאלה 10"): + if page.locator("#qz-count").inner_text().startswith("שאלה 15"): break page.click(".qz-nav button >> nth=1") page.wait_for_function("(k) => document.querySelector('#qz-q').dataset.key !== k", arg=ref["key"]) - page.wait_for_function("document.querySelectorAll('.qz-dot.done').length === 10") + page.wait_for_function("document.querySelectorAll('.qz-dot.done').length === 15") page.click("#qz-submit") # all answered: no warning page.wait_for_selector("#qz-grade") - assert page.inner_text("#qz-grade b") == "80" + assert page.inner_text("#qz-grade b") == "87" assert page.locator("#qz-lost a").get_attribute("href") == f"#/skill/{lost_skill}" page.click("#qz-lost a") # practice the weak skill page.wait_for_selector("#ex-q") page.click(".mx-x") page.wait_for_selector(f".quiz-node[data-quiz='{lesson}'].graded") - assert "✓ הציון: 80" in page.inner_text(f".quiz-node[data-quiz='{lesson}']") # the node shows the grade + assert "✓ הציון: 87" in page.inner_text(f".quiz-node[data-quiz='{lesson}']") # the node shows the grade page.click(".tabs a[href='#/grades']") page.wait_for_selector("#quiz-grades") - assert page.locator(f"#quiz-grades li[data-lesson='{lesson}'] .gr-num").inner_text() == "80" + assert page.locator(f"#quiz-grades li[data-lesson='{lesson}'] .gr-num").inner_text() == "87" diff --git a/mathly2/tests/test_quizzes_api.py b/mathly2/tests/test_quizzes_api.py index f9ed3be9bf4b02d1d9e4b7d6f903c55888b4959e..15987c5f0d9d67b54c186c70861dcb1e50efa420 100644 --- a/mathly2/tests/test_quizzes_api.py +++ b/mathly2/tests/test_quizzes_api.py @@ -44,15 +44,15 @@ def take(client, bank, lesson, rights, who=KID): # --- 2.1 starting --- -def test_a_new_student_starts_a_quiz_of_ten_questions_due_in_fifteen_minutes(world): +def test_a_new_student_starts_a_quiz_of_fifteen_questions_due_in_twenty_minutes(world): client, _, bank = world lesson = lesson_id(bank) got = start(client, lesson) assert got.status_code == 200 quiz = got.json() - assert quiz["state"] == "open" and len(quiz["questions"]) == 10 and quiz["answers"] == {} + assert quiz["state"] == "open" and len(quiz["questions"]) == 15 and quiz["answers"] == {} from datetime import datetime - assert datetime.fromisoformat(quiz["due_at"]) - datetime.fromisoformat(quiz["now"]) == timedelta(minutes=15) + assert datetime.fromisoformat(quiz["due_at"]) - datetime.fromisoformat(quiz["now"]) == timedelta(minutes=20) assert quiz["lesson"]["id"] == lesson @@ -74,15 +74,14 @@ def test_starting_again_resumes_the_same_open_attempt(world): assert again["id"] == first["id"] and [q["key"] for q in again["questions"]] == [q["key"] for q in first["questions"]] assert len(again["answers"]) == 1 and again["due_at"] == first["due_at"] facts = client.get(f"/api/lessons/{lesson}/quiz", headers=auth()).json() - assert facts["open"]["id"] == first["id"] and facts["best"] is None and facts["attempts"] == 0 and facts["size"] == 10 and facts["minutes"] == 15 + assert facts["open"]["id"] == first["id"] and facts["best"] is None and facts["attempts"] == 0 and facts["size"] == 15 and facts["minutes"] == 20 -def test_a_lesson_the_students_classes_do_not_have_is_404_and_a_manager_is_refused(world): +def test_a_student_in_no_class_is_refused_a_missing_lesson_is_404_and_a_manager_is_refused(world): client, store, bank = world - store.add_member(store.create_class("setup", "B", lessons=["t"])["id"], "solo@school.test") other_lesson = next(l["id"] for l in bank.lessons if l["id"] != "t") - assert start(client, other_lesson, "solo@school.test").status_code == 404 - assert client.get(f"/api/lessons/{other_lesson}/quiz", headers=auth("solo@school.test")).status_code == 404 + assert start(client, other_lesson, "nobody@school.test").status_code == 403 # in no class: no lessons + assert client.get(f"/api/lessons/{other_lesson}/quiz", headers=auth("nobody@school.test")).status_code == 403 assert start(client, "no-such-lesson").status_code == 404 assert start(client, LESSON, MGR).status_code == 403 @@ -97,7 +96,7 @@ def test_an_answer_is_saved_can_be_changed_and_submitting_grades_it(world): answer(client, attempt, bank, key, right=True) # changed their mind assert client.get(f"/api/attempts/{attempt['id']}", headers=auth()).json()["answers"] == {key: right_index(bank, skill_of(key), key)} result = client.post(f"/api/attempts/{attempt['id']}/submit", headers=auth()).json() - assert result["state"] == "submitted" and result["grade"] == 10 and result["right"] == 1 and result["total"] == 10 and result["timed_out"] is False + assert result["state"] == "submitted" and result["grade"] == 7 and result["right"] == 1 and result["total"] == 15 and result["timed_out"] is False def test_submitting_twice_returns_the_same_result(world): @@ -113,11 +112,11 @@ def test_an_answer_after_the_deadline_is_refused_and_settles_the_attempt(world): client, _, bank = world attempt = start(client, lesson_id(bank)).json() answer(client, attempt, bank, attempt["questions"][0]["key"]) - client.set_time(MON + timedelta(minutes=16)) + client.set_time(MON + timedelta(minutes=21)) late = answer(client, attempt, bank, attempt["questions"][1]["key"]) assert late.status_code == 409 settled = client.get(f"/api/attempts/{attempt['id']}", headers=auth()).json() - assert settled["state"] == "submitted" and settled["timed_out"] is True and settled["grade"] == 10 and settled["right"] == 1 + assert settled["state"] == "submitted" and settled["timed_out"] is True and settled["grade"] == 7 and settled["right"] == 1 def test_closing_the_app_for_twenty_minutes_leaves_a_submitted_attempt_with_the_answers_given(world): @@ -128,7 +127,7 @@ def test_closing_the_app_for_twenty_minutes_leaves_a_submitted_attempt_with_the_ client.set_time(MON + timedelta(minutes=5)) # five minutes in, the app is closed client.set_time(MON + timedelta(minutes=25)) # it is opened again 20 minutes later back = client.get(f"/api/attempts/{attempt['id']}", headers=auth()).json() - assert back["state"] == "submitted" and back["timed_out"] is True and back["grade"] == 60 and back["right"] == 6 + assert back["state"] == "submitted" and back["timed_out"] is True and back["grade"] == 40 and back["right"] == 6 def test_the_deadline_is_the_servers_a_late_device_clock_changes_nothing(world): @@ -159,11 +158,11 @@ def test_another_student_gets_404_and_bad_answers_are_422(world): def test_the_best_grade_stays_and_a_better_retake_replaces_it(world): client, _, bank = world lesson = lesson_id(bank) - take(client, bank, lesson, [True] * 6 + [False] * 4) # 60 + take(client, bank, lesson, [True] * 9 + [False] * 6) # 60 assert client.get(f"/api/lessons/{lesson}/quiz", headers=auth()).json()["best"] == 60 - take(client, bank, lesson, [True] * 8 + [False] * 2) # 80 + take(client, bank, lesson, [True] * 12 + [False] * 3) # 80 assert client.get(f"/api/lessons/{lesson}/quiz", headers=auth()).json()["best"] == 80 - take(client, bank, lesson, [True] * 7 + [False] * 3) # 70: worse + take(client, bank, lesson, [True] * 10 + [False] * 5) # 67: worse facts = client.get(f"/api/lessons/{lesson}/quiz", headers=auth()).json() assert facts["best"] == 80 and facts["attempts"] == 3 @@ -171,15 +170,15 @@ def test_the_best_grade_stays_and_a_better_retake_replaces_it(world): def test_the_grades_screen_data_lists_only_lessons_with_a_quiz_and_settles_overdue_ones(world): client, _, bank = world lesson = lesson_id(bank) - assert client.get("/api/grades", headers=auth()).json() == {"quizzes": [], "exercise_tests": [], "simulations": []} - take(client, bank, lesson, [True] * 7 + [False] * 3) + assert client.get("/api/grades", headers=auth()).json() == {"quizzes": [], "exercise_tests": [], "simulations": [], "assignments": []} + take(client, bank, lesson, [True] * 10 + [False] * 5) other = lesson_id(bank, 3) abandoned = start(client, other).json() answer(client, abandoned, bank, abandoned["questions"][0]["key"]) assert [q["lesson"]["id"] for q in client.get("/api/grades", headers=auth()).json()["quizzes"]] == [lesson] # open: not listed yet client.set_time(MON + timedelta(hours=1)) # the overdue attempt is settled by the read grades = client.get("/api/grades", headers=auth()).json()["quizzes"] - assert {g["lesson"]["id"]: g["best"] for g in grades} == {lesson: 70, other: 10} + assert {g["lesson"]["id"]: g["best"] for g in grades} == {lesson: 67, other: 7} assert all(g["attempts"] == 1 and g["last"] for g in grades) @@ -188,10 +187,10 @@ def test_the_path_gives_each_lesson_its_quiz_state(world): lesson, other = lesson_id(bank), lesson_id(bank, 3) quizzes = lambda: {l["id"]: l["quiz"] for l in client.get("/api/path", headers=auth()).json()["lessons"]} assert quizzes()[lesson] == {"best": None, "open": False, "due_at": None} - take(client, bank, lesson, [True] * 9 + [False]) + take(client, bank, lesson, [True] * 14 + [False]) start(client, other) state = quizzes() - assert state[lesson]["best"] == 90 and state[lesson]["open"] is False + assert state[lesson]["best"] == 93 and state[lesson]["open"] is False assert state[other]["best"] is None and state[other]["open"] is True and state[other]["due_at"] @@ -202,7 +201,7 @@ def test_a_lesson_with_an_authored_written_quiz_still_gets_the_multiple_choice_q lesson = next(u["id"] for u in bank.data["units"] if u.get("quiz") and u["id"] == lesson_id(bank)) assert bank.data["units"][[u["id"] for u in bank.data["units"]].index(lesson)]["quiz"]["questions"] quiz = start(client, lesson).json() - assert len(quiz["questions"]) == 10 and all(len(q["choices"]) >= 2 for q in quiz["questions"]) + assert len(quiz["questions"]) == 15 and all(len(q["choices"]) >= 2 for q in quiz["questions"]) keys = {k for s in bank.skills if s["unit"] == lesson for k in bank.question_keys(s["id"])} assert {q["key"] for q in quiz["questions"]} <= keys # only bank questions of the lesson's skills @@ -220,5 +219,20 @@ def test_a_retake_has_new_questions_through_the_api(world): first_keys = {q["key"] for q in first["questions"]} client.post(f"/api/attempts/{first['id']}/submit", headers=auth()) second = start(client, lesson).json() - assert second["id"] != first["id"] and len(second["questions"]) == 10 + assert second["id"] != first["id"] and len(second["questions"]) == 15 assert not first_keys & {q["key"] for q in second["questions"]} # none of the first attempt's questions + + +# --- passing: 13 of 15, and the lesson is completed --- + +def test_thirteen_of_fifteen_passes_twelve_does_not_and_a_pass_stays(world): + client, _, bank = world + lesson = lesson_id(bank) + lesson_row = lambda: next(l for l in client.get("/api/path", headers=auth()).json()["lessons"] if l["id"] == lesson) + take(client, bank, lesson, [True] * 12 + [False] * 3) # 80 + assert lesson_row()["passed"] is False and lesson_row()["completed"] is False + take(client, bank, lesson, [True] * 13 + [False] * 2) # 87 + assert lesson_row()["passed"] is True and lesson_row()["completed"] is True + take(client, bank, lesson, [True] * 6 + [False] * 9) # 40: worse + assert lesson_row()["passed"] is True and lesson_row()["completed"] is True + assert all(s["state"] == "not_started" for s in lesson_row()["skills"]) # the skills themselves are untouched diff --git a/mathly2/tests/test_retention.py b/mathly2/tests/test_retention.py new file mode 100644 index 0000000000000000000000000000000000000000..dbb0eb231227bf77840946a404019bef2a2d8d26 --- /dev/null +++ b/mathly2/tests/test_retention.py @@ -0,0 +1,118 @@ +"""Keeping pictures (add-written-work 1.4): the school year, orphans, the pages of removed assignments, and deleting on request.""" +from datetime import date, datetime, timedelta, timezone + +import pytest + +from mathly2 import cleanup, retention +from mathly2.pictures import MemoryPictures, PictureError, store_picture +from mathly2.store import InMemoryStore + +U, V = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222" +OCT = datetime(2026, 10, 5, 10, 0, tzinfo=timezone.utc) + + +def test_an_answer_picture_is_kept_until_31_august_of_its_school_year(): + assert retention.expires_on(date(2026, 10, 5)) == date(2027, 8, 31) # made in October: kept until the next 31 August + assert retention.expires_on(date(2027, 5, 20)) == date(2027, 8, 31) # made in May: the same year's 31 August + assert retention.expires_on(date(2026, 9, 1)) == date(2027, 8, 31) and retention.expires_on(date(2027, 8, 31)) == date(2027, 8, 31) + assert not retention.is_expired(date(2026, 10, 5), date(2027, 8, 31)) and retention.is_expired(date(2026, 10, 5), date(2027, 9, 1)) + assert retention.is_expired(date(2027, 5, 20), date(2027, 9, 1)) + + +@pytest.fixture +def world(): + store, backend = InMemoryStore(), MemoryPictures() + return store, backend + + +def make(store, backend, owner=U, kind="answer", at=OCT): + return store_picture(store, backend, owner, kind, b"jpeg", at) + + +def test_an_unreferenced_picture_is_removed_after_a_day_and_a_referenced_or_young_one_stays(world): + store, backend = world + a = store.create_assignment(1, "x", date(2026, 11, 20), "t", "quiz", quiz_lesson="t") + orphan, young, kept = make(store, backend), make(store, backend, at=OCT + timedelta(hours=30)), make(store, backend) + store.save_submission(a["id"], U, pictures=[kept["id"]]) + got = cleanup.run(store, backend, OCT + timedelta(hours=31)) + assert got == {"orphans": 1, "expired": 0, "removed_assignments": 0} + assert store.get_picture(orphan["id"])["deleted_at"] is not None and store.get_picture(young["id"])["deleted_at"] is None + assert store.get_picture(kept["id"])["deleted_at"] is None + with pytest.raises(PictureError): + backend.get(orphan["path"]) + assert backend.get(kept["path"]) == b"jpeg" and cleanup.run(store, backend, OCT + timedelta(hours=32))["orphans"] == 0 # nothing left to do + + +def test_a_picture_a_photo_request_received_counts_as_referenced(world): + store, backend = world + a = store.create_assignment(1, "x", date(2026, 11, 20), "t", "quiz", quiz_lesson="t") + pic = make(store, backend) + r = store.create_photo_request(U, a["id"], "d", "1234", OCT, OCT + timedelta(minutes=10)) + store.save_photo_request(r["id"], state="received", picture_id=pic["id"]) + assert cleanup.run(store, backend, OCT + timedelta(days=3))["orphans"] == 0 + + +def test_answer_pictures_go_on_1_september_and_the_grade_and_note_stay(world): + store, backend = world + a = store.create_assignment(1, "x", date(2026, 11, 20), "t", "quiz", quiz_lesson="t") + pic = make(store, backend) + store.save_submission(a["id"], U, pictures=[pic["id"]], state="graded", grade=85, teacher_note="יפה", checked_by="t") + last_day = datetime(2027, 8, 31, 12, 0, tzinfo=timezone.utc) + assert cleanup.run(store, backend, last_day)["expired"] == 0 + assert cleanup.run(store, backend, datetime(2027, 9, 1, 12, 0, tzinfo=timezone.utc))["expired"] == 1 + sub = store.get_submission(a["id"], U) + assert (sub["state"], sub["grade"], sub["teacher_note"]) == ("graded", 85, "יפה") and sub["pictures"] == [pic["id"]] + assert store.get_picture(pic["id"])["deleted_at"] is not None + + +def test_assignment_pages_are_not_removed_by_the_end_of_the_year_but_with_their_removed_assignment(world): + store, backend = world + page = make(store, backend, owner="t", kind="page") + a = store.create_assignment(1, "דף", date(2026, 11, 20), "t", "pages", pages=[page["id"]]) + assert cleanup.run(store, backend, datetime(2027, 9, 5, tzinfo=timezone.utc)) == {"orphans": 0, "expired": 0, "removed_assignments": 0} + store.remove_assignment(a["id"], OCT) + assert cleanup.run(store, backend, datetime(2027, 9, 6, tzinfo=timezone.utc))["removed_assignments"] == 1 + assert store.get_picture(page["id"])["deleted_at"] is not None + + +def test_a_failed_delete_leaves_the_picture_for_the_next_run(world): + store, backend = world + make(store, backend) + + class Broken(MemoryPictures): + def delete(self, path): + raise PictureError(503, "answer_storage_unavailable") + assert cleanup.run(store, Broken(), OCT + timedelta(days=2)) == {"orphans": 0, "expired": 0, "removed_assignments": 0} + assert cleanup.run(store, backend, OCT + timedelta(days=2))["orphans"] == 1 # the next run, with storage back + + +def test_a_students_answer_pictures_can_be_deleted_early_and_only_theirs_for_those_assignments(world): + store, backend = world + a = store.create_assignment(1, "x", date(2026, 11, 20), "t", "quiz", quiz_lesson="t") + other = store.create_assignment(2, "y", date(2026, 11, 20), "t", "quiz", quiz_lesson="t") + mine1, mine2, theirs, elsewhere = make(store, backend), make(store, backend), make(store, backend, owner=V), make(store, backend) + store.save_submission(a["id"], U, pictures=[mine1["id"], mine2["id"]], state="graded", grade=90, teacher_note="יפה") + store.save_submission(a["id"], V, pictures=[theirs["id"]]) + store.save_submission(other["id"], U, pictures=[elsewhere["id"]]) + assert cleanup.delete_answer_pictures(store, backend, [a["id"]], U, OCT) == 2 + assert all(store.get_picture(p["id"])["deleted_at"] for p in (mine1, mine2)) + assert store.get_picture(theirs["id"])["deleted_at"] is None and store.get_picture(elsewhere["id"])["deleted_at"] is None + assert store.get_submission(a["id"], U)["grade"] == 90 and cleanup.delete_answer_pictures(store, backend, [a["id"]], U, OCT) == 0 + + +def test_the_hourly_thread_runs_now_keeps_going_after_an_error_and_stops_when_told(world): + import threading + store, backend = world + ran = threading.Event() + calls = [] + + class Flaky(InMemoryStore): + def pictures(self, *a, **k): + calls.append(1) + if len(calls) == 1: + raise RuntimeError("the database is down") + ran.set() + return [] + stop = cleanup.start_hourly(Flaky(), backend, every=0.05) + assert ran.wait(3) and len(calls) >= 2 # the first run failed; the second still ran + stop.set() diff --git a/mathly2/tests/test_schedule_browser.py b/mathly2/tests/test_schedule_browser.py new file mode 100644 index 0000000000000000000000000000000000000000..a12084ba12992f644dfe1e496dd862827987e713 --- /dev/null +++ b/mathly2/tests/test_schedule_browser.py @@ -0,0 +1,59 @@ +"""The curriculum and the lesson marks (add-lesson-schedule 2.2, 3.2), in a real browser.""" +from datetime import timedelta + +import pytest + +playwright = pytest.importorskip("playwright.sync_api") + +from mathly2 import clock +from mathly2.tests.serve import offline_formulas, only, running +from mathly2.tests.test_foundation_screens_browser import MGR, PHONE, browser, no_sideways_scroll, page_for, seeded, sign_in # noqa: F401 +from mathly2.tests.test_path_browser import KID, KID_ID, NOW, bank, lessons_by_size, path_page, world # noqa: F401 + + +def test_a_manager_sets_dates_and_they_are_shown_again_without_sideways_scroll(browser): + app, store = seeded() + with running(app) as url: + page = page_for(browser, PHONE) + sign_in(page, url, MGR) + page.click("#mgr-tabs [data-tab=curriculum]") + page.wait_for_selector("#curriculum-form") + assert page.locator("#curriculum-form input[type=date]").count() == 24 + no_sideways_scroll(page) + page.fill("#due-t", "2026-11-03") + page.fill("#due-t2", "2026-12-01") + page.click("#save-curriculum") + page.wait_for_selector("#msg") + assert page.input_value("#due-t") == "2026-11-03" and page.input_value("#due-t2") == "2026-12-01" + assert store.curriculum() == {"t": __import__("datetime").date(2026, 11, 3), "t2": __import__("datetime").date(2026, 12, 1)} + page.fill("#due-t2", "") + page.click("#save-curriculum") + page.wait_for_selector("#msg") + assert page.input_value("#due-t2") == "" and list(store.curriculum()) == ["t"] + + +def test_the_path_marks_a_behind_lesson_a_completed_one_and_leaves_the_rest_alone(browser, bank): + two, three, six = lessons_by_size(bank) + app, store = world(bank, [two, three, six]) + today = clock.school_day() + store.set_lesson_dates({two: today - timedelta(days=3), three: today - timedelta(days=3), six: today + timedelta(days=9)}) + for skill in (s["id"] for s in bank.skills if s["unit"] == three): + store.save_progress(KID_ID, skill, started_at=NOW, mastered_at=NOW) + with running(app) as url: + page = path_page(browser, url) + behind = page.locator(f"#lesson-{two} [data-status=behind]") + assert behind.count() == 1 and "היה אמור להסתיים ב־" in behind.inner_text() # said in words, with the date + assert page.locator(f"#lesson-{three} [data-status=completed]").inner_text().count("הושלם") == 1 + assert page.locator(f"#lesson-{three} [data-status=behind]").count() == 0 + assert page.locator(f"#lesson-{six} [data-status]").count() == 0 # a date still ahead: no mark + assert "שיעור אחד בפיגור" in page.inner_text("#behind-note") + assert page.locator(".star[href]").count() == page.locator(".star").count() # nothing is locked + no_sideways_scroll(page) + + +def test_no_dates_no_marks(browser, bank): + two, three, _ = lessons_by_size(bank) + app, _ = world(bank, [two, three]) + with running(app) as url: + page = path_page(browser, url) + assert page.locator("[data-status]").count() == 0 and page.locator("#behind-note").count() == 0 diff --git a/mathly2/tests/test_skills_api.py b/mathly2/tests/test_skills_api.py index c828e5881e48816ecfa1e4db7d12a7840a9ae7cf..6492b44dd6e7bf6bd68334c2fb10214aa8b03ca7 100644 --- a/mathly2/tests/test_skills_api.py +++ b/mathly2/tests/test_skills_api.py @@ -248,21 +248,21 @@ def test_help_for_an_unknown_skill_is_404(world): # --- the path (spec: learning-path) --- -def test_the_path_shows_the_lessons_of_the_students_classes_only(world): +def test_the_path_shows_every_lesson_even_for_a_class_stored_with_some(world): client, store, bank = world path = client.get("/api/path", headers=auth()).json() assert len(path["lessons"]) == 24 and sum(len(l["skills"]) for l in path["lessons"]) == 96 small = KID.replace("kid", "small") store.add_member(store.create_class("setup", "B", lessons=["t", "k"])["id"], small) - assert [l["id"] for l in client.get("/api/path", headers=auth(small)).json()["lessons"]] == ["t", "k"] + assert len(client.get("/api/path", headers=auth(small)).json()["lessons"]) == 24 -def test_a_student_of_two_classes_sees_the_union_in_the_banks_order(world): - client, store, _ = world +def test_a_student_of_two_classes_sees_every_lesson_once_in_the_banks_order(world): + client, store, bank = world two = "two@school.test" store.add_member(store.create_class("setup", "B", lessons=["k", "t"])["id"], two) store.add_member(store.create_class("setup", "C", lessons=["t2", "k"])["id"], two) - assert [l["id"] for l in client.get("/api/path", headers=auth(two)).json()["lessons"]] == ["t", "t2", "k"] + assert [l["id"] for l in client.get("/api/path", headers=auth(two)).json()["lessons"]] == [l["id"] for l in bank.lessons_with_skills()] def test_a_teacher_sees_every_lesson_and_a_manager_is_refused(world): diff --git a/mathly2/tests/test_student_class.py b/mathly2/tests/test_student_class.py index 3d2dc4d041984a37b784f120288760e99f48462f..19449b813a520356523ac250c76b5b780f83ee1d 100644 --- a/mathly2/tests/test_student_class.py +++ b/mathly2/tests/test_student_class.py @@ -1,6 +1,7 @@ import pytest from fastapi.testclient import TestClient +from mathly2.bank import load_bank from mathly2.config import Settings from mathly2.main import create_app from mathly2.store import InMemoryStore @@ -39,14 +40,14 @@ def test_first_sign_in_links_the_account_to_the_class_entry(setup): me = client.get("/api/me", headers=auth(KID)).json() assert me["classes"] == [{"id": cid, "name": "ט'3"}] assert store.members(cid)[0]["user_id"] - assert [lesson["id"] for lesson in client.get("/api/lessons", headers=auth(KID)).json()] == ["t"] + assert [lesson["id"] for lesson in client.get("/api/lessons", headers=auth(KID)).json()] == [l["id"] for l in load_bank().lessons_with_skills()] def test_a_student_of_two_classes_has_the_lessons_of_both(setup): client, _ = setup make(client, "A", ("t", "t2"), KID) make(client, "B", ("t2", "k"), KID) - assert sorted(lesson["id"] for lesson in client.get("/api/lessons", headers=auth(KID)).json()) == ["k", "t", "t2"] + assert len(client.get("/api/lessons", headers=auth(KID)).json()) == len(load_bank().lessons_with_skills()) assert [c["name"] for c in client.get("/api/me", headers=auth(KID)).json()["classes"]] == ["A", "B"] diff --git a/mathly2/tests/test_written_browser.py b/mathly2/tests/test_written_browser.py new file mode 100644 index 0000000000000000000000000000000000000000..61fbc5807b6bcf40fca850ff97dd4247f4eb731c --- /dev/null +++ b/mathly2/tests/test_written_browser.py @@ -0,0 +1,872 @@ +"""Written work in a real browser: the student's assignments, handing in, photo requests, notifications, and the teacher's side +(add-written-work 3.x and 4.x).""" +import io +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +playwright = pytest.importorskip("playwright.sync_api") + +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 +from mathly2.tests.serve import offline_formulas, running + +TEACHER, KID, KID2 = "t@school.test", "kid@school.test", "kid2@school.test" +PHONE = {"width": 360, "height": 800} +DESKTOP = {"width": 1280, "height": 800} + + +def uid(email): + return str(uuid.uuid5(uuid.NAMESPACE_DNS, email)) + + +def jpeg(color="red", size=(120, 80)): + out = io.BytesIO() + Image.new("RGB", size, color).save(out, "JPEG") + return out.getvalue() + + +@pytest.fixture(scope="module") +def bank(): + return load_bank() + + +@pytest.fixture +def browser(): + with playwright.sync_playwright() as p: + b = p.chromium.launch() + yield b + b.close() + + +def world(bank, **settings): + lessons = [u["id"] for u in bank.data["units"] if u.get("quiz")] + store = InMemoryStore() + klass = store.create_class("setup", "A", lessons=lessons[:2], teacher_email=TEACHER) + for email in (KID, KID2): + store.add_member(klass["id"], email) + store.link_user(email, uid(email)) + app = create_app(Settings(**settings), bank=bank, store=store) + return app, store, klass, lessons + + +def day(n): + return (clock.school_day() + timedelta(days=n)) + + +def sign_in(browser, url, email=KID, where="", viewport=PHONE, **context): + page = offline_formulas(browser.new_page(viewport=viewport, **context)) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto(url + where) + page.fill("#email", email) + page.click("#dev button") + page.errors = errors + return page + + +def to_home(page): + page.click(".subject-card") + page.wait_for_selector(".tabs") + + +def no_sideways_scroll(page): + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + +# --- 3.1 my assignments on home --- + +def seeded_assignments(bank): + app, store, klass, lessons = world(bank) + quiz = lambda title, n: store.create_assignment(klass["id"], title, day(n), TEACHER, "quiz", quiz_lesson=lessons[0]) + rows = {"fresh": quiz("חדש", 5), "handed": quiz("הוגש", 6), "graded": quiz("נבדק", 1), "returned": quiz("הוחזר", 7), "late": quiz("מאחר", -2)} + store.save_submission(rows["handed"]["id"], uid(KID), pictures=["p"]) + store.save_submission(rows["graded"]["id"], uid(KID), pictures=["p"], state="graded", grade=85, teacher_note="יפה") + store.save_submission(rows["returned"]["id"], uid(KID), pictures=["p"], state="returned", teacher_note="תקנו") + test = store.create_test(klass["id"], day(10), lessons[:1], TEACHER, created_at=clock.now()) + rows["exam"] = store.create_assignment(klass["id"], "מבחן תרגול", day(4), TEACHER, "exercise_test", test_id=test["id"], exercise_number=1) + rows["test"] = test + return app, store, rows + + +def test_home_lists_only_the_new_assignments_with_due_date_and_state_in_words(browser, bank): + app, store, rows = seeded_assignments(bank) + with running(app) as url: + page = sign_in(browser, url) + to_home(page) + page.wait_for_selector("#assignments") + listed = lambda a: page.locator(f"#assignments li[data-assignment='{a['id']}']") + assert "לא הוגש" in listed(rows["fresh"]).inner_text() and f"{day(5).day}.{day(5).month}" in listed(rows["fresh"]).inner_text() + assert "באיחור" in listed(rows["late"]).inner_text() and "עוד לא נעשה" in listed(rows["exam"]).inner_text() + assert page.get_attribute(f"#assignments li[data-assignment='{rows['fresh']['id']}'] a", "href") == f"#/assignment/{rows['fresh']['id']}" + assert page.get_attribute(f"#assignments li[data-assignment='{rows['exam']['id']}'] a", "href") == f"#/exam/{rows['test']['id']}/1" # an exercise test opens the test + assert page.locator("#assignments li b").all_inner_texts() == ["מאחר", "מבחן תרגול", "חדש"] # only the new ones, by due date + for hidden in ("handed", "graded", "returned"): + assert listed(rows[hidden]).count() == 0 # the rest are in "הגשות" + no_sideways_scroll(page) + assert page.errors == [] + + +def test_the_submissions_tab_lists_every_assignment_grouped_by_state(browser, bank): + app, store, rows = seeded_assignments(bank) + with running(app) as url: + page = sign_in(browser, url) + to_home(page) + page.click(".tabs a[href='#/submissions']") + page.wait_for_selector("#submissions") + groups = {g.get_attribute("data-group"): g for g in page.locator("#submissions .group").all()} + assert list(groups) == ["new", "returned", "waiting", "checked"] # what to do first, then what waits, then done + titles = lambda name: groups[name].locator("li b").all_inner_texts() + assert titles("new") == ["מאחר", "מבחן תרגול", "חדש"] and titles("returned") == ["הוחזר"] and titles("waiting") == ["הוגש"] and titles("checked") == ["נבדק"] + assert "תקנו" in groups["returned"].inner_text() and "ציון 85" in groups["checked"].inner_text() and "יפה" in groups["checked"].inner_text() + assert "באיחור" in groups["new"].locator("li", has_text="מאחר").inner_text() and "הוחזר לתיקון" in groups["returned"].inner_text() + assert sum(g.locator("li").count() for g in groups.values()) == 6 # every assignment from the teacher + assert page.locator(".tabs a[aria-current='page']").inner_text() == "הגשות" + page.click("#submissions li[data-state='returned'] a") # each opens its page + page.wait_for_selector("#returned") + assert page.locator(".tabs a[aria-current='page']").inner_text() == "הגשות" + no_sideways_scroll(page) + + +def test_a_student_with_no_assignments_sees_none_on_either_screen(browser, bank): + app, _, _, _ = world(bank) + with running(app) as url: + page = sign_in(browser, url) + to_home(page) + assert page.locator("#assignments").count() == 0 + page.click(".tabs a[href='#/submissions']") + page.wait_for_selector("#no-assignments") + + +def test_home_has_no_assignments_card_when_there_are_none(browser, bank): + app, _, _, _ = world(bank) + with running(app) as url: + page = sign_in(browser, url) + to_home(page) + assert page.locator("#assignments").count() == 0 + + +# --- 3.2 the assignment page --- + +def draw_on_board(page): + box = page.locator(".mx-board canvas").bounding_box() + page.mouse.move(box["x"] + 40, box["y"] + 60) + page.mouse.down() + page.mouse.move(box["x"] + 160, box["y"] + 120, steps=8) + page.mouse.up() + + +def upload(page, color="red", name="answer.jpg"): + page.set_input_files("[data-file=upload]", files=[{"name": name, "mimeType": "image/jpeg", "buffer": jpeg(color)}]) + + +def one_assignment(bank, kind="quiz", title="דף עבודה", days=5): + app, store, klass, lessons = world(bank) + if kind == "pages": + pic = store.create_picture(TEACHER, "page", "t/p1.jpg", "image/jpeg", 100) + app.state.pictures.put("t/p1.jpg", jpeg("blue", (300, 200))) + row = store.create_assignment(klass["id"], title, day(days), TEACHER, "pages", note="לפתור את כל התרגילים", pages=[pic["id"]]) + else: + row = store.create_assignment(klass["id"], title, day(days), TEACHER, "quiz", quiz_lesson=lessons[0]) + return app, store, row + + +def test_the_page_shows_the_note_the_pages_and_the_bank_quiz(browser, bank): + app, store, row = one_assignment(bank, "pages") + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#assignment") + assert "לפתור את כל התרגילים" in page.inner_text("#assignment-note") and "לא הוגש" in page.inner_text("#state") + page.wait_for_function("document.querySelector('#pages img').naturalWidth > 0") # the page picture came through the server + no_sideways_scroll(page) + app2, _, quiz = one_assignment(bank, "quiz") + with running(app2) as url: + page = sign_in(browser, url, where=f"#/assignment/{quiz['id']}") + page.wait_for_selector("#written-quiz") + assert page.locator("#written-quiz li").count() == 4 and "100 נקודות" in page.inner_text("#material") + + +def test_a_photo_from_a_file_joins_the_answer_and_handing_in_shows_it_as_handed_in(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#answer") + assert page.is_disabled("#hand-in") and page.locator("#add-actions [data-file=camera]").count() == 0 # a desktop: no camera button + upload(page) + upload(page, "green") + page.wait_for_selector("#draft li[data-picture-id] img:not([hidden])") + assert page.locator("#draft li[data-picture-id]").count() == 2 and not page.is_disabled("#hand-in") + page.click("#draft li[data-picture-id] .remove >> nth=0") # a picture can be taken out + assert page.locator("#draft li[data-picture-id]").count() == 1 + page.click("#hand-in") + page.wait_for_selector("#assignment[data-state='handed_in']") + sub = store.get_submission(row["id"], uid(KID)) + assert sub["state"] == "handed_in" and len(sub["pictures"]) == 1 and "ההגשה נשלחה" in page.inner_text("#hand-msg") + assert page.inner_text("#hand-in") == "החלפת ההגשה" # until checked, it can be replaced + assert page.errors == [] + + +def test_the_phone_gets_a_camera_button_and_a_photo_from_it_joins_the_answer(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}", has_touch=True, is_mobile=True) + page.wait_for_selector("#answer") + assert page.locator("[data-file=camera]").get_attribute("capture") == "environment" + page.set_input_files("[data-file=camera]", files=[{"name": "photo.jpg", "mimeType": "image/jpeg", "buffer": jpeg("orange", (3000, 2000))}]) + page.wait_for_selector("#draft li[data-picture-id]") + pid = page.get_attribute("#draft li[data-picture-id]", "data-picture-id") + size = store.get_picture(pid)["size"] + assert size < 400_000 and store.get_picture(pid)["kind"] == "answer" # shrunk on the device before it was sent + + +def test_a_stroke_on_the_board_is_added_to_the_answer_by_the_send_button(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}", viewport=DESKTOP) + page.wait_for_selector("#board-btn") + page.click("#board-btn") + page.wait_for_selector(".mx-board canvas") + draw_on_board(page) + page.click(".mx-board-send") + page.click(".mx-board-back") + page.wait_for_selector("#draft li[data-picture-id]") + pid = page.get_attribute("#draft li[data-picture-id]", "data-picture-id") + assert store.get_picture(pid)["kind"] == "answer" and not page.is_disabled("#hand-in") + + +def test_a_returned_assignment_shows_the_note_and_can_be_handed_in_again_but_a_graded_one_cannot_be_changed(browser, bank): + app, store, row = one_assignment(bank) + pic = store.create_picture(uid(KID), "answer", f"{uid(KID)}/old.jpg", "image/jpeg", 100) + app.state.pictures.put(pic["path"], jpeg()) + store.save_submission(row["id"], uid(KID), pictures=[pic["id"]], state="returned", teacher_note="חסר נימוק בסעיף 2") + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#returned") + assert "חסר נימוק בסעיף 2" in page.inner_text("#returned") and "הוחזר לתיקון" in page.inner_text("#state") + assert page.locator("#draft li[data-picture-id]").count() == 1 # the earlier picture is still there to fix + upload(page, "purple") + page.wait_for_function("document.querySelectorAll('#draft li[data-picture-id]').length === 2") + page.click("#hand-in") + page.wait_for_selector("#assignment[data-state='handed_in']") + assert store.get_submission(row["id"], uid(KID))["state"] == "handed_in" and len(store.get_submission(row["id"], uid(KID))["pictures"]) == 2 + store.save_submission(row["id"], uid(KID), state="graded", grade=91, teacher_note="יפה מאוד") + page.reload() + page.wait_for_selector("#grade") + assert page.inner_text("#grade b") == "91" and "יפה מאוד" in page.inner_text("#teacher-note") + assert page.locator("#hand-in").count() == 0 and page.locator("#add-actions").count() == 0 # a graded one is not changed + page.wait_for_function("document.querySelector('#submitted img')?.naturalWidth > 0") + + +def test_pictures_that_were_deleted_say_so(browser, bank): + app, store, row = one_assignment(bank) + pic = store.create_picture(uid(KID), "answer", "x/gone.jpg", "image/jpeg", 100) + store.mark_picture_deleted(pic["id"]) + store.save_submission(row["id"], uid(KID), pictures=[pic["id"]], state="graded", grade=80) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#answer") + assert "התמונות נמחקו" in page.inner_text("#submitted") and page.inner_text("#grade b") == "80" + + +def test_an_exercise_test_assignment_page_links_to_the_test_and_has_no_answer_box(browser, bank): + app, store, klass, lessons = world(bank) + test = store.create_test(klass["id"], day(10), lessons[:1], TEACHER, created_at=clock.now()) + row = store.create_assignment(klass["id"], "תרגול", day(3), TEACHER, "exercise_test", test_id=test["id"], exercise_number=2) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}") + page.wait_for_selector("#assignment") + assert page.get_attribute("#material a", "href") == f"#/exam/{test['id']}/2" and page.locator("#answer").count() == 0 + + +# --- 3.3 the photo request: the computer and the phone --- + +FAST = "window.__photoPollComputer = 200; window.__photoPollPhone = 200;" + + +def two_devices(browser, url, assignment_id): + """The same student on a computer and on a phone (two browsers: each has its own device id).""" + computer = browser.new_context(viewport=DESKTOP) + phone = browser.new_context(viewport=PHONE, has_touch=True, is_mobile=True) + pages = [] + for ctx in (computer, phone): + ctx.add_init_script(FAST) + page = offline_formulas(ctx.new_page()) + page.goto(url) + page.fill("#email", KID) + page.click("#dev button") + pages.append(page) + comp, ph = pages + comp.goto(f"{url}#/assignment/{assignment_id}") + comp.wait_for_selector("#ask-phone") + ph.wait_for_selector(".subject-card") + return comp, ph + + +def test_the_phone_shows_the_same_code_and_a_photo_from_it_reaches_the_computers_answer(browser, bank): + app, store, row = one_assignment(bank) + app.state.photo_code = lambda: 4821 + with running(app) as url: + comp, ph = two_devices(browser, url, row["id"]) + comp.click("#ask-phone") + comp.wait_for_selector("#request-code") + assert comp.inner_text("#request-code") == "4821" and "ממתינים לטלפון" in comp.inner_text("#request-state") + ph.wait_for_selector("#photo-window") + assert ph.inner_text("#phone-code") == "4821" and "דף עבודה" in ph.inner_text("#photo-window") + assert ph.locator("[data-photo]").get_attribute("capture") == "environment" + ph.set_input_files("[data-photo]", files=[{"name": "photo.jpg", "mimeType": "image/jpeg", "buffer": jpeg("teal", (2400, 1600))}]) + ph.wait_for_function("document.getElementById('phone-state')?.innerText.includes('נשלחה')") + comp.wait_for_function("document.getElementById('request-state')?.innerText.includes('התשובה התקבלה')") + comp.wait_for_selector("#draft li[data-picture-id] img:not([hidden])") # the photo joined the answer on the computer + assert comp.locator("#draft li[data-picture-id]").count() == 1 and not comp.is_disabled("#hand-in") + ph.wait_for_selector("#photo-window", state="detached") + assert store.pictures()[0]["kind"] == "answer" + + +def test_the_computer_that_asked_never_shows_its_own_request_window(browser, bank): + app, _, row = one_assignment(bank) + with running(app) as url: + page = sign_in(browser, url, where=f"#/assignment/{row['id']}", viewport=DESKTOP) + page.add_init_script(FAST) + page.wait_for_selector("#ask-phone") + page.evaluate("window.__photoPollPhone = 200") + page.click("#ask-phone") + page.wait_for_selector("#request-code") + page.goto(url + "#/subject/math") + page.reload() + page.wait_for_selector(".tabs") + page.wait_for_timeout(1500) + assert page.locator("#photo-window").count() == 0 + + +def test_a_newer_request_marks_the_older_one_replaced_and_not_now_closes_the_window_for_good(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + comp, ph = two_devices(browser, url, row["id"]) + comp.click("#ask-phone") + comp.wait_for_selector("#request-code") + first = comp.inner_text("#request-code") + comp.click("#ask-phone") + comp.wait_for_function("document.querySelector('.old-requests')?.innerText.includes('הוחלפה')") + assert first in comp.inner_text(".old-requests") + states = sorted(r["state"] for r in [store.get_photo_request(r["id"]) for r in store.open_photo_requests(uid(KID))] if r) + assert states == ["waiting"] # only the newer one is open + ph.wait_for_selector("#photo-window") + ph.click("#phone-later") + ph.wait_for_selector("#photo-window", state="detached") + ph.wait_for_timeout(1000) + assert ph.locator("#photo-window").count() == 0 # it does not come back + + +def test_a_request_that_expires_closes_the_phones_window_and_the_computer_says_so(browser, bank): + app, store, row = one_assignment(bank) + with running(app) as url: + comp, ph = two_devices(browser, url, row["id"]) + comp.click("#ask-phone") + comp.wait_for_selector("#request-code") + ph.wait_for_selector("#photo-window") + pending = store.open_photo_requests(uid(KID))[0] + store.save_photo_request(pending["id"], state="expired") + ph.wait_for_selector("#photo-window", state="detached") + comp.wait_for_function("document.getElementById('request-state')?.innerText.includes('פגה')") + + +# --- 3.4 notifications on the phone --- + +FAKE_PUSH = """ +(() => { + const sub = { endpoint: "https://push.test/phone-1", toJSON() { return { endpoint: this.endpoint, keys: { p256dh: "pp", auth: "aa" } }; }, + async unsubscribe() { window.__unsubscribed = true; return true; } }; + let current = null, listeners = []; + const registration = { pushManager: { async getSubscription() { return current; }, + async subscribe(options) { window.__subscribeOptions = options; current = sub; return sub; } } }; + Object.defineProperty(navigator, "serviceWorker", { configurable: true, value: { + async register(url, options) { window.__registered = [url, options && options.scope]; return registration; }, + addEventListener(type, fn) { if (type === "message") window.__swMessage = fn; }, removeEventListener() {} } }); + window.PushManager = window.PushManager || function () {}; + window.Notification = { requestPermission: async () => "granted", permission: "default" }; +})(); +""" + + +def phone_context(browser, with_push=True, touch=True): + ctx = browser.new_context(viewport=PHONE, has_touch=touch, is_mobile=touch) + if with_push: + ctx.add_init_script(FAKE_PUSH) + return ctx + + +def phone_home(ctx, url): + page = offline_formulas(ctx.new_page()) + page.goto(url) + page.fill("#email", KID) + page.click("#dev button") + to_home(page) + assert page.locator("#notifications").count() == 0 # not on home + page.click(".tabs a[href='#/submissions']") + page.wait_for_selector("#submissions") + return page + + +def test_the_switch_is_only_on_a_touch_device_and_only_when_the_server_has_a_key(browser, bank): + from mathly2 import push + key = push.new_private_key() + app_on, _, _, _ = world(bank, vapid_private_key=key) + with running(app_on) as url: + on = phone_home(phone_context(browser), url) + on.wait_for_selector("#notif-on") # a phone, a key: offered + assert on.evaluate("window.__registered") == ["/service-worker.js", "/"] + desktop = phone_home(phone_context(browser, touch=False), url) + desktop.wait_for_timeout(800) + assert desktop.locator("#notifications:not([hidden])").count() == 0 # a computer: not offered + app_off, _, _, _ = world(bank) + with running(app_off) as url: + off = phone_home(phone_context(browser), url) + off.wait_for_timeout(800) + assert off.locator("#notifications:not([hidden])").count() == 0 # no key on the server: not offered + + +def test_turning_notifications_on_sends_the_subscription_and_turning_them_off_removes_it(browser, bank): + from mathly2 import push + key = push.new_private_key() + app, store, _, _ = world(bank, vapid_private_key=key) + with running(app) as url: + page = phone_home(phone_context(browser), url) + page.click("#notif-on") + page.wait_for_selector("#notif-state") + assert "פועלות" in page.inner_text("#notif-state") + subs = store.push_subscriptions_of(uid(KID)) + assert [s["endpoint"] for s in subs] == ["https://push.test/phone-1"] and subs[0]["p256dh"] == "pp" and subs[0]["key_id"] + assert page.evaluate("window.__subscribeOptions.userVisibleOnly") is True and page.evaluate("window.__subscribeOptions.applicationServerKey.length") == 65 # the server's key + page.click("#notif-off") + page.wait_for_selector("#notif-on") + assert page.evaluate("window.__unsubscribed") is True and store.push_subscriptions_of(uid(KID)) == [] + + +def test_a_tapped_notification_makes_the_open_app_look_for_requests_at_once(browser, bank): + from mathly2 import push + app, store, _, _ = world(bank, vapid_private_key=push.new_private_key()) + row = store.create_assignment(store.classes()[0]["id"], "דף", day(5), TEACHER, "quiz", quiz_lesson=[u["id"] for u in bank.data["units"] if u.get("quiz")][0]) + with running(app) as url: + ctx = phone_context(browser) + ctx.add_init_script("window.__photoPollPhone = 600000;") # no timer: only the notification can make it look + page = phone_home(ctx, url) + page.wait_for_selector("#notif-on") + page.wait_for_timeout(300) + assert page.locator("#photo-window").count() == 0 + now = clock.now() + store.create_photo_request(uid(KID), row["id"], "some-laptop", "7777", now, now + timedelta(minutes=10)) + page.evaluate("window.__swMessage({ data: { type: 'notified' } })") # the service worker says a notification was tapped + page.wait_for_selector("#photo-window") + assert page.inner_text("#phone-code") == "7777" + + +# --- 3.5 the grades screen --- + +def test_graded_assignments_and_done_exercise_tests_appear_on_the_grades_screen_with_the_teachers_note(browser, bank): + app, store, klass, lessons = world(bank) + graded = store.create_assignment(klass["id"], "בוחן כתוב", day(-3), TEACHER, "quiz", quiz_lesson=lessons[0]) + removed = store.create_assignment(klass["id"], "דף שהוסר", day(-6), TEACHER, "quiz", quiz_lesson=lessons[0]) + waiting = store.create_assignment(klass["id"], "עוד לא נבדק", day(2), TEACHER, "quiz", quiz_lesson=lessons[0]) + store.save_submission(graded["id"], uid(KID), pictures=["p"], state="graded", grade=92, teacher_note="נימוק יפה") + store.save_submission(removed["id"], uid(KID), pictures=["p"], state="graded", grade=70) + store.save_submission(waiting["id"], uid(KID), pictures=["p"]) + store.remove_assignment(removed["id"]) + test = store.create_test(klass["id"], day(10), lessons[:1], TEACHER, created_at=clock.now()) + exam = store.create_assignment(klass["id"], "תרגול 1", day(4), TEACHER, "exercise_test", test_id=test["id"], exercise_number=1) + attempt = store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:1", clock.now() - timedelta(hours=2), clock.now() + timedelta(hours=1), []) + store.save_attempt(attempt["id"], submitted_at=clock.now(), grade=77) + with running(app) as url: + page = sign_in(browser, url, where="#/grades") + page.wait_for_selector("#assignment-grades") + row = lambda a: page.locator(f"#assignment-grades li[data-assignment='{a['id']}']") + assert row(graded).locator(".gr-num").inner_text() == "92" and "נימוק יפה" in row(graded).inner_text() + assert row(exam).locator(".gr-num").inner_text() == "77" and "מבחן תרגול" in row(exam).inner_text() + assert "הוסרה" in row(removed).inner_text() and row(removed).locator(".gr-num").inner_text() == "70" # a removed assignment keeps its grade + assert row(waiting).count() == 0 and page.locator("#no-grades").count() == 0 # not graded yet: not a grade + assert row(graded).locator("a").get_attribute("href") == f"#/assignment/{graded['id']}" + no_sideways_scroll(page) + + +# --- 4.1 the teacher's assignments tab --- + +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() + + +def teacher_work(browser, url, viewport=PHONE, tab="assignments", **context): + page = offline_formulas(browser.new_page(viewport=viewport, **context)) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto(url) + page.fill("#email", TEACHER) + page.click("#dev button") + page.wait_for_selector("#teaching") + page.click(f"#teacher-tabs [data-tab='{tab}']") + page.wait_for_selector("#work, #inbox") + page.errors = errors + return page + + +def fill_basics(page, title="משימה חדשה", days=6): + page.click(".new-assignment summary") + page.fill("[name=title]", title) + page.fill("[name=due_date]", day(days).isoformat()) + + +@pytest.mark.parametrize("viewport", [PHONE, DESKTOP]) +def test_a_teacher_gives_the_banks_written_quiz_to_the_class(browser, bank, viewport): + app, store, klass, lessons = world(bank) + with running(app) as url: + page = teacher_work(browser, url, viewport) + assert "עוד לא נתנו משימה" in page.inner_text(".class-work") + fill_basics(page, "בוחן כתוב לשיעור") + page.check("[name=source][value=quiz]") + page.select_option("[name=quiz_lesson]", lessons[0]) + page.click(".assignment-form button[type=submit]") + page.wait_for_selector(".assignment-row") + row = page.locator(".assignment-row") + assert "בוחן כתוב" in row.inner_text() and "0 מתוך 2 הגישו" in row.inner_text() and "בוחן כתוב לשיעור" in row.inner_text() + assert store.assignments_of_classes([klass["id"]])[0]["quiz_lesson"] == lessons[0] + no_sideways_scroll(page) + assert page.errors == [] + + +def test_a_teacher_gives_pages_from_an_image_a_pdf_and_a_photo(browser, bank): + app, store, klass, lessons = world(bank) + with running(app) as url: + page = teacher_work(browser, url, PHONE, has_touch=True, is_mobile=True) + fill_basics(page, "דף מהמחשב ומהמצלמה") + assert page.locator("[data-pages-camera]").get_attribute("capture") == "environment" # a phone: the camera is offered + page.set_input_files("[data-pages-file]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg("red")}]) + page.wait_for_selector("[data-pages] li[data-page]") + page.set_input_files("[data-pages-file]", files=[{"name": "sheets.pdf", "mimeType": "application/pdf", "buffer": make_pdf(3)}]) + page.wait_for_function("document.querySelectorAll('[data-pages] li[data-page]').length === 4") # one image + a PDF of three pages + page.set_input_files("[data-pages-camera]", files=[{"name": "photo.jpg", "mimeType": "image/jpeg", "buffer": jpeg("green", (2400, 1600))}]) + page.wait_for_function("document.querySelectorAll('[data-pages] li[data-page]').length === 5") + page.click("[data-pages] li[data-page] .remove >> nth=0") + assert page.locator("[data-pages] li[data-page]").count() == 4 + page.click(".assignment-form button[type=submit]") + page.wait_for_selector(".assignment-row") + made = store.assignments_of_classes([klass["id"]])[0] + assert made["kind"] == "pages" and len(made["pages"]) == 4 and "4 עמודים" in page.inner_text(".assignment-row") + assert all(store.get_picture(p)["kind"] == "page" for p in made["pages"]) + + +def test_a_teacher_writes_the_assignment_on_the_board(browser, bank): + app, store, klass, lessons = world(bank) + with running(app) as url: + page = teacher_work(browser, url, DESKTOP) + fill_basics(page, "נכתב על הלוח") + page.click("[data-pages-board]") + page.wait_for_selector(".mx-board canvas") + draw_on_board(page) + page.click(".mx-board-send") + page.click(".mx-board-back") + page.wait_for_selector("[data-pages] li[data-page]") + page.click(".assignment-form button[type=submit]") + page.wait_for_selector(".assignment-row") + assert store.assignments_of_classes([klass["id"]])[0]["kind"] == "pages" and "1 עמודים" in page.inner_text(".assignment-row") + + +def test_a_teacher_sends_an_exercise_test_and_sees_how_many_did_it(browser, bank): + app, store, klass, lessons = world(bank) + test = store.create_test(klass["id"], day(10), lessons[:1], TEACHER, created_at=clock.now()) + with running(app) as url: + page = teacher_work(browser, url) + fill_basics(page, "תרגול לכיתה") + page.check("[name=source][value=exercise_test]") + page.select_option("[name=exercise_number]", "2") + page.click(".assignment-form button[type=submit]") + page.wait_for_selector(".assignment-row") + made = store.assignments_of_classes([klass["id"]])[0] + assert (made["kind"], made["test_id"], made["exercise_number"]) == ("exercise_test", test["id"], 2) and "0 מתוך 2 עשו" in page.inner_text(".assignment-row") + + +def test_the_form_keeps_what_was_typed_when_the_server_refuses_and_removing_takes_two_steps(browser, bank): + app, store, klass, lessons = world(bank) + with running(app) as url: + page = teacher_work(browser, url) + fill_basics(page, "בלי עמודים") + page.fill("[name=note]", "לא לאבד את זה") + page.click(".assignment-form button[type=submit]") # pages chosen, none added: refused + page.wait_for_selector(".assignment-form .form-error:not([hidden])") + assert page.input_value("[name=note]") == "לא לאבד את זה" and page.input_value("[name=title]") == "בלי עמודים" and store.assignments_of_classes([klass["id"]]) == [] + page.check("[name=source][value=quiz]") + page.click(".assignment-form button[type=submit]") + page.wait_for_selector(".assignment-row") + sub_row = store.assignments_of_classes([klass["id"]])[0] + store.save_submission(sub_row["id"], uid(KID), pictures=["p"]) + store.save_submission(sub_row["id"], uid(KID2), pictures=["p"], state="graded", grade=80) + page.reload() + page.click("#teacher-tabs [data-tab='assignments']") + page.wait_for_selector(".assignment-row") + assert "2 מתוך 2 הגישו" in page.inner_text(".assignment-row") and "1 נבדקו" in page.inner_text(".assignment-row") and "1 מחכות לבדיקה" in page.inner_text(".assignment-row") + page.click("[data-remove-assignment]") + page.click("[data-confirm-remove-assignment]") + page.wait_for_selector(".assignment-row", state="detached") + assert store.assignments_of_classes([klass["id"]]) == [] and store.get_submission(sub_row["id"], uid(KID2))["grade"] == 80 + + +# --- 4.2 the inbox --- + +def two_submissions(bank, **settings): + app, store, klass, lessons = world(bank, **settings) + row = store.create_assignment(klass["id"], "בוחן כתוב", day(5), TEACHER, "quiz", quiz_lesson=lessons[0]) + made = [] + for email, offset, color in ((KID2, 1, "blue"), (KID, 0, "red")): # KID2's hand-in is Tuesday, KID's is Monday (made second) + pic = store.create_picture(uid(email), "answer", f"{uid(email)}/a.jpg", "image/jpeg", 100) + app.state.pictures.put(pic["path"], jpeg(color)) + made.append(store.save_submission(row["id"], uid(email), pictures=[pic["id"]], handed_at=datetime(2026, 11, 9 + offset, 10, 0, tzinfo=timezone.utc))) + return app, store, row, made + + +def test_the_inbox_lists_oldest_first_and_a_grade_empties_its_item(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + with running(app) as url: + page = teacher_work(browser, url, PHONE, tab="inbox") + page.wait_for_selector("#inbox-list") + items = page.locator("#inbox-list li") + assert items.count() == 2 and KID in items.nth(0).inner_text() and KID2 in items.nth(1).inner_text() # Monday's first + page.click(f"[data-open='{monday['id']}']") + page.wait_for_selector("#check") + page.wait_for_function("document.querySelector('#answer-pictures img')?.naturalWidth > 0") # the teacher sees the picture + page.fill("[name=grade]", "88") + page.fill("[name=note]", "נימוק יפה") + page.click("#give-grade") + page.wait_for_selector("#inbox-notice") + assert "הציון נשמר" in page.inner_text("#inbox-notice") and page.locator("#inbox-list li").count() == 1 and KID2 in page.inner_text("#inbox-list") + saved = store.get_submission(row["id"], uid(KID)) + assert (saved["state"], saved["grade"], saved["teacher_note"], saved["checked_by"]) == ("graded", 88, "נימוק יפה", TEACHER) + no_sideways_scroll(page) + assert page.errors == [] + + +def test_sending_back_needs_a_note_and_a_grade_must_be_a_whole_number_from_0_to_100(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + with running(app) as url: + page = teacher_work(browser, url, DESKTOP, tab="inbox") + page.click(f"[data-open='{tuesday['id']}']") + page.wait_for_selector("#check") + page.click("#send-back") + assert "צריך לכתוב מה לתקן" in page.inner_text("#check-error") and store.get_submission(row["id"], uid(KID2))["state"] == "handed_in" + for bad in ("", "101", "-3", "8.5"): + page.fill("[name=grade]", bad) + page.click("#give-grade") + assert "מספר שלם" in page.inner_text("#check-error"), bad + page.fill("[name=note]", "חסר נימוק בסעיף 2") + page.click("#send-back") + page.wait_for_selector("#inbox-notice") + assert "הוחזרה" in page.inner_text("#inbox-notice") and page.locator("#inbox-list li").count() == 1 # a returned one leaves the inbox + sent = store.get_submission(row["id"], uid(KID2)) + assert (sent["state"], sent["teacher_note"]) == ("returned", "חסר נימוק בסעיף 2") + + +def test_a_submission_the_student_replaced_meanwhile_says_so_and_reopens(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + with running(app) as url: + page = teacher_work(browser, url, PHONE, tab="inbox") + page.click(f"[data-open='{monday['id']}']") + page.wait_for_selector("#check") + store.save_submission(row["id"], uid(KID), handed_at=datetime(2026, 11, 11, 9, 0, tzinfo=timezone.utc)) # the student handed in again + page.fill("[name=grade]", "70") + page.click("#give-grade") + page.wait_for_selector("#check-error:not([hidden])") + assert "השתנתה" in page.inner_text("#check-error") and store.get_submission(row["id"], uid(KID))["state"] == "handed_in" + page.click("#reopen") + page.wait_for_selector("#inbox-list") + + +def test_an_empty_inbox_says_everything_was_checked_and_the_student_then_sees_the_result(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + with running(app) as url: + teacher = teacher_work(browser, url, PHONE, tab="inbox") + for sub_row, grade in ((monday, 90), (tuesday, 60)): + teacher.click(f"[data-open='{sub_row['id']}']") + teacher.fill("[name=grade]", str(grade)) + teacher.click("#give-grade") + teacher.wait_for_selector("#inbox-notice") + assert "אין מה לבדוק" in teacher.inner_text("#inbox-empty") + student = sign_in(browser, url, where=f"#/assignment/{row['id']}") + student.wait_for_selector("#grade") + assert student.inner_text("#grade b") == "90" + + +def test_a_teacher_deletes_a_students_pictures_from_the_class_screen_and_the_grade_stays(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + store.save_submission(row["id"], uid(KID), state="graded", grade=88, teacher_note="יפה") + with running(app) as url: + page = offline_formulas(browser.new_page(viewport=PHONE)) + page.goto(url) + page.fill("#email", TEACHER) + page.click("#dev button") + page.wait_for_selector("#teaching") + page.click(f"[data-wipe='{KID}']") + page.click("[data-confirm-wipe]") # two steps + page.wait_for_function("document.querySelector('.report')?.innerText.includes('נמחקו 1 תמונות')") + assert "הציונים וההערות נשארו" in page.inner_text(".report") + assert all(store.get_picture(p)["deleted_at"] for p in monday["pictures"]) and not any(store.get_picture(p)["deleted_at"] for p in tuesday["pictures"]) + assert store.get_submission(row["id"], uid(KID))["grade"] == 88 + student = sign_in(browser, url, where=f"#/assignment/{row['id']}") + student.wait_for_selector("#grade") + assert "התמונות נמחקו" in student.inner_text("#submitted") and student.inner_text("#grade b") == "88" + + +# --- 5.1 the whole flow --- + +def test_the_whole_written_work_flow_from_the_teachers_assignment_to_the_grades(browser, bank): + app, store, klass, lessons = world(bank) + with running(app) as url: + # the teacher gives an assignment from a file, and one from the bank's written quiz + teacher = teacher_work(browser, url, DESKTOP) + fill_basics(teacher, "דף מהקובץ") + teacher.set_input_files("[data-pages-file]", files=[{"name": "sheet.jpg", "mimeType": "image/jpeg", "buffer": jpeg("blue", (300, 200))}]) + teacher.wait_for_selector("[data-pages] li[data-page]") + teacher.click(".assignment-form button[type=submit]") + teacher.wait_for_selector(".assignment-row") + teacher.click(".new-assignment summary") + teacher.fill(".new-assignment [name=title]", "בוחן כתוב") + teacher.fill(".new-assignment [name=due_date]", day(6).isoformat()) + teacher.check(".new-assignment [name=source][value=quiz]") + teacher.click(".assignment-form button[type=submit]") + teacher.wait_for_function("document.querySelectorAll('.assignment-row').length === 2") + sheet = next(a for a in store.assignments_of_classes([klass["id"]]) if a["title"] == "דף מהקובץ") + + # the first student hands in from the board + first = sign_in(browser, url, KID, f"#/assignment/{sheet['id']}", DESKTOP) + first.wait_for_selector("#board-btn") + first.wait_for_function("document.querySelector('#pages img')?.naturalWidth > 0") + first.click("#board-btn") + first.wait_for_selector(".mx-board canvas") + draw_on_board(first) + first.click(".mx-board-send") + first.click(".mx-board-back") + first.wait_for_selector("#draft li[data-picture-id]") + first.click("#hand-in") + first.wait_for_selector("#assignment[data-state='handed_in']") + + # the second student asks their phone for a photo from the computer + ctx_computer, ctx_phone = browser.new_context(viewport=DESKTOP), browser.new_context(viewport=PHONE, has_touch=True, is_mobile=True) + pages = [] + for ctx in (ctx_computer, ctx_phone): + ctx.add_init_script(FAST) + page = offline_formulas(ctx.new_page()) + page.goto(url) + page.fill("#email", KID2) + page.click("#dev button") + pages.append(page) + computer, phone = pages + computer.goto(f"{url}#/assignment/{sheet['id']}") + computer.wait_for_selector("#ask-phone") + computer.click("#ask-phone") + phone.wait_for_selector("#photo-window") + phone.set_input_files("[data-photo]", files=[{"name": "p.jpg", "mimeType": "image/jpeg", "buffer": jpeg("orange", (1200, 800))}]) + computer.wait_for_selector("#draft li[data-picture-id]") + computer.click("#hand-in") + computer.wait_for_selector("#assignment[data-state='handed_in']") + + # the teacher's inbox shows both, the first student's first; one is graded and one is sent back + teacher.click("#teacher-tabs [data-tab='inbox']") + teacher.wait_for_selector("#inbox-list") + items = teacher.locator("#inbox-list li") + assert items.count() == 2 and KID in items.nth(0).inner_text() and KID2 in items.nth(1).inner_text() + items.nth(0).locator("button").click() + teacher.wait_for_function("document.querySelector('#answer-pictures img')?.naturalWidth > 0") + teacher.fill("[name=grade]", "90") + teacher.fill("[name=note]", "מצוין") + teacher.click("#give-grade") + teacher.wait_for_selector("#inbox-notice") + teacher.locator("#inbox-list li button").click() + teacher.fill("[name=note]", "חסר נימוק בסעיף 2") + teacher.click("#send-back") + teacher.wait_for_selector("#inbox-empty") + + # the second student sees why it came back, hands in again, and is graded + computer.reload() + computer.wait_for_selector("#returned") + assert "חסר נימוק בסעיף 2" in computer.inner_text("#returned") + computer.set_input_files("[data-file=upload]", files=[{"name": "fix.jpg", "mimeType": "image/jpeg", "buffer": jpeg("purple")}]) + computer.wait_for_function("document.querySelectorAll('#draft li[data-picture-id]').length === 2") + computer.click("#hand-in") + computer.wait_for_selector("#assignment[data-state='handed_in']") + teacher.click("#teacher-tabs [data-tab='inbox']") + teacher.wait_for_selector("#inbox-list") + teacher.locator("#inbox-list li button").click() + teacher.fill("[name=grade]", "75") + teacher.click("#give-grade") + teacher.wait_for_selector("#inbox-empty") + + # both grades are on the grades screens + for page, grade in ((first, "90"), (computer, "75")): + page.goto(f"{url}#/grades") + page.wait_for_selector("#assignment-grades") + assert page.locator("#assignment-grades .gr-num").inner_text() == grade + + # the teacher deletes the first student's pictures; the grade stays + teacher.click("#teacher-tabs [data-tab='classes']") + teacher.click(f"[data-wipe='{KID}']") + teacher.click("[data-confirm-wipe]") + teacher.wait_for_function("document.querySelector('.report')?.innerText.includes('נמחקו')") + first.goto(f"{url}#/assignment/{sheet['id']}") + first.wait_for_selector("#grade") + assert "התמונות נמחקו" in first.inner_text("#submitted") and first.inner_text("#grade b") == "90" + assert store.get_submission(sheet["id"], uid(KID2))["grade"] == 75 and any(not store.get_picture(p)["deleted_at"] for p in store.get_submission(sheet["id"], uid(KID2))["pictures"]) + + +# --- work-tabs-and-hints 2.1: the numbers on the teacher's tabs --- + +def test_every_teacher_tab_shows_its_number_and_the_numbers_change_after_grading(browser, bank): + app, store, row, (tuesday, monday) = two_submissions(bank) + klass = store.classes()[0] + lessons = klass["lessons"] + for n in (10, 12, 14): + store.create_test(klass["id"], day(n), lessons[:1], TEACHER, created_at=clock.now()) + store.create_assignment(klass["id"], "עוד אחת", day(4), TEACHER, "quiz", quiz_lesson=lessons[0]) + with running(app) as url: + page = offline_formulas(browser.new_page(viewport=PHONE)) + page.goto(url) + page.fill("#email", TEACHER) + page.click("#dev button") + page.wait_for_selector("#teaching") + page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''") + numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()} + assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"} + for tab in ("tests", "assignments", "inbox"): # on every tab + page.click(f"#teacher-tabs [data-tab='{tab}']") + page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab) + assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"}, tab + page.click(f"[data-open='{monday['id']}']") + page.fill("[name=grade]", "80") + page.click("#give-grade") + page.wait_for_selector("#inbox-notice") + page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText === '1'") # grading lowered the inbox number + assert "הכיתות שלי 1" in page.inner_text("#teacher-tabs").replace("\n", " ") + no_sideways_scroll(page) + + +def test_the_teachers_class_screen_fits_a_phone_with_students_and_their_buttons(browser, bank): + app, store, klass, lessons = world(bank) + store.add_member(klass["id"], "a-student-with-a-quite-long-address@school.example.test") + with running(app) as url: + page = offline_formulas(browser.new_page(viewport=PHONE)) + page.goto(url) + page.fill("#email", TEACHER) + page.click("#dev button") + page.wait_for_selector(".students li") + page.wait_for_function("document.querySelector('[data-count=classes]')?.innerText !== ''") + no_sideways_scroll(page) + for tab in ("tests", "assignments", "inbox"): + page.click(f"#teacher-tabs [data-tab='{tab}']") + page.wait_for_selector("#tests, #work, #inbox") + no_sideways_scroll(page) diff --git a/mathly2/tests/test_written_store.py b/mathly2/tests/test_written_store.py new file mode 100644 index 0000000000000000000000000000000000000000..a995096d29fb95f8003622650a76ae739a701a91 --- /dev/null +++ b/mathly2/tests/test_written_store.py @@ -0,0 +1,198 @@ +"""The store for written work (add-written-work 1.1 and 1.2): pictures, assignments, submissions, photo requests, push subscriptions.""" +from datetime import date, datetime, timedelta, timezone + +import pytest + +from mathly2.store import InMemoryStore +from mathly2.tests.test_store import Recorder + +U, V = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222" +NOW = datetime(2026, 11, 12, 9, 0, tzinfo=timezone.utc) +DUE = date(2026, 11, 20) + + +@pytest.fixture +def store(): + return InMemoryStore() + + +def picture(store, owner=U, kind="answer", path=None): + return store.create_picture(owner, kind, path or f"{owner}/{len(store.pictures(include_deleted=True))}.jpg", "image/jpeg", 1000, NOW) + + +# --- 1.1 pictures --- + +def test_a_picture_is_created_read_and_marked_deleted_once(store): + row = picture(store) + assert row["deleted_at"] is None and row["kind"] == "answer" and store.get_picture(row["id"]) == row and store.get_picture("nope") is None + assert store.mark_picture_deleted(row["id"], NOW) is True and store.mark_picture_deleted(row["id"]) is False + assert store.get_picture(row["id"])["deleted_at"] == NOW + with pytest.raises(ValueError): + store.create_picture(U, "scan", "x", "image/jpeg", 1) + + +def test_pictures_list_by_kind_and_owner_and_hide_deleted(store): + a, b, page = picture(store), picture(store, owner=V), picture(store, kind="page") + store.mark_picture_deleted(b["id"]) + assert [p["id"] for p in store.pictures()] == [a["id"], page["id"]] + assert [p["id"] for p in store.pictures(kind="page")] == [page["id"]] and [p["id"] for p in store.pictures(owner_id=U)] == [a["id"], page["id"]] + assert len(store.pictures(include_deleted=True)) == 3 + + +def test_picture_refs_find_assignments_submissions_and_requests(store): + pic, lone = picture(store, kind="page"), picture(store) + a = store.create_assignment(1, "דף", DUE, "t@x.test", "pages", pages=[pic["id"]]) + s = store.save_submission(a["id"], U, pictures=[lone["id"]]) + r = store.create_photo_request(U, a["id"], "d1", "1234", NOW, NOW + timedelta(minutes=10)) + store.save_photo_request(r["id"], state="received", picture_id=lone["id"]) + assert store.picture_refs(pic["id"]) == {"assignments": [a["id"]], "submissions": [], "requests": []} + assert store.picture_refs(lone["id"]) == {"assignments": [], "submissions": [s["id"]], "requests": [r["id"]]} + assert store.picture_refs(picture(store)["id"]) == {"assignments": [], "submissions": [], "requests": []} + + +# --- 1.1 assignments --- + +def test_an_assignment_needs_a_title_and_its_content(store): + page = picture(store, kind="page") + ok = store.create_assignment(1, "דף עבודה", DUE, "t@x.test", "pages", note="הערה", pages=[page["id"]]) + assert ok["pages"] == [page["id"]] and ok["note"] == "הערה" and ok["removed_at"] is None and store.get_assignment(ok["id"]) == ok + quiz = store.create_assignment(1, "בוחן כתוב", DUE, "t@x.test", "quiz", quiz_lesson="t") + exam = store.create_assignment(1, "מבחן תרגול", DUE, "t@x.test", "exercise_test", test_id=5, exercise_number=1) + assert quiz["quiz_lesson"] == "t" and exam["exercise_number"] == 1 + for bad in (dict(kind="pages"), dict(kind="quiz"), dict(kind="exercise_test", test_id=5), dict(kind="exercise_test", test_id=5, exercise_number=3), dict(kind="video")): + with pytest.raises(ValueError): + store.create_assignment(1, "x", DUE, "t", **bad) + with pytest.raises(ValueError): + store.create_assignment(1, "", DUE, "t", "quiz", quiz_lesson="t") + + +def test_assignments_of_classes_nearest_due_first_and_removal_hides_but_keeps(store): + late = store.create_assignment(1, "מאוחר", date(2026, 12, 1), "t", "quiz", quiz_lesson="t") + soon = store.create_assignment(1, "קרוב", date(2026, 11, 14), "t", "quiz", quiz_lesson="t") + other = store.create_assignment(2, "אחרת", date(2026, 11, 1), "t", "quiz", quiz_lesson="t") + assert [a["id"] for a in store.assignments_of_classes([1])] == [soon["id"], late["id"]] + assert store.remove_assignment(soon["id"], NOW) is True and store.remove_assignment(soon["id"]) is False + assert [a["id"] for a in store.assignments_of_classes([1, 2])] == [other["id"], late["id"]] + assert [a["id"] for a in store.assignments_of_classes([1], include_removed=True)] == [soon["id"], late["id"]] and store.assignments_of_classes([]) == [] + + +# --- 1.1 submissions --- + +def test_a_submission_is_one_per_student_and_assignment_and_is_rewritten(store): + a = store.create_assignment(1, "x", DUE, "t", "quiz", quiz_lesson="t") + first = store.save_submission(a["id"], U, pictures=["p1"], handed_at=NOW) + assert first["state"] == "handed_in" and first["grade"] is None and first["late"] is False and first["pictures"] == ["p1"] + again = store.save_submission(a["id"], U, pictures=["p2", "p3"], handed_at=NOW + timedelta(hours=1), late=True) + assert again["id"] == first["id"] and again["pictures"] == ["p2", "p3"] and again["late"] is True + assert store.get_submission(a["id"], U) == again and store.get_submission(a["id"], V) is None and store.get_submission_by_id(again["id"]) == again + assert store.save_submission(a["id"], V)["id"] != first["id"] + + +def test_checking_a_submission_sets_grade_note_and_who(store): + a = store.create_assignment(1, "x", DUE, "t", "quiz", quiz_lesson="t") + store.save_submission(a["id"], U, pictures=["p1"]) + graded = store.save_submission(a["id"], U, state="graded", grade=85, teacher_note="יפה", checked_at=NOW, checked_by="t@x.test") + assert (graded["state"], graded["grade"], graded["teacher_note"], graded["checked_by"]) == ("graded", 85, "יפה", "t@x.test") and graded["checked_at"] == NOW + returned = store.save_submission(a["id"], U, state="returned", teacher_note="חסר נימוק", grade=None) + assert returned["state"] == "returned" and returned["grade"] is None + with pytest.raises(ValueError): + store.save_submission(a["id"], V, state="graded") # a grade is required + with pytest.raises(ValueError): + store.save_submission(a["id"], U, owner="x") + with pytest.raises(ValueError): + store.save_submission(a["id"], U, grade=101) + assert store.get_submission(a["id"], V) is None # a refused write stores nothing + + +def test_submissions_of_assignments_oldest_first_and_of_a_user(store): + a = store.create_assignment(1, "x", DUE, "t", "quiz", quiz_lesson="t") + b = store.create_assignment(1, "y", DUE, "t", "quiz", quiz_lesson="t") + later = store.save_submission(a["id"], V, handed_at=NOW + timedelta(days=1)) + earlier = store.save_submission(a["id"], U, handed_at=NOW) + mine_b = store.save_submission(b["id"], U, handed_at=NOW + timedelta(hours=2)) + assert [s["id"] for s in store.submissions_of_assignments([a["id"]])] == [earlier["id"], later["id"]] + assert [s["id"] for s in store.submissions_of_assignments([a["id"], b["id"]])] == [earlier["id"], mine_b["id"], later["id"]] + assert {s["id"] for s in store.submissions_of_user(U)} == {earlier["id"], mine_b["id"]} and store.submissions_of_assignments([]) == [] + + +# --- 1.2 photo requests --- + +def test_a_photo_request_waits_and_a_newer_one_supersedes_the_older_for_the_same_assignment(store): + a = store.create_assignment(1, "x", DUE, "t", "quiz", quiz_lesson="t") + b = store.create_assignment(1, "y", DUE, "t", "quiz", quiz_lesson="t") + first = store.create_photo_request(U, a["id"], "laptop", "4821", NOW, NOW + timedelta(minutes=10)) + other = store.create_photo_request(U, b["id"], "laptop", "1111", NOW, NOW + timedelta(minutes=10)) + second = store.create_photo_request(U, a["id"], "laptop", "9090", NOW + timedelta(minutes=1), NOW + timedelta(minutes=11)) + assert first["state"] == "waiting" and first["picture_id"] is None and first["code"] == "4821" + assert store.get_photo_request(first["id"])["state"] == "superseded" and store.get_photo_request(second["id"])["state"] == "waiting" + assert store.get_photo_request(other["id"])["state"] == "waiting" # another assignment's request is not replaced + assert [r["id"] for r in store.open_photo_requests(U)] == [second["id"], other["id"]] and store.open_photo_requests(V) == [] + + +def test_a_photo_request_moves_through_its_states_and_leaves_the_open_list(store): + a = store.create_assignment(1, "x", DUE, "t", "quiz", quiz_lesson="t") + pic = picture(store) + r = store.create_photo_request(U, a["id"], "laptop", "0042", NOW, NOW + timedelta(minutes=10)) + assert store.save_photo_request(r["id"], state="uploading")["state"] == "uploading" and len(store.open_photo_requests(U)) == 1 + done = store.save_photo_request(r["id"], state="received", picture_id=pic["id"]) + assert done["picture_id"] == pic["id"] and store.open_photo_requests(U) == [] + with pytest.raises(ValueError): + store.save_photo_request(r["id"], user_id=V) + with pytest.raises(KeyError): + store.save_photo_request("missing", state="failed") + assert store.get_photo_request("missing") is None + + +# --- 1.2 push subscriptions --- + +def test_push_subscriptions_are_per_endpoint_deletable_and_dropped_when_the_key_changes(store): + a = store.add_push_subscription(U, "https://push/1", "p", "a", "key-A") + store.add_push_subscription(U, "https://push/2", "p", "a", "key-A") + store.add_push_subscription(V, "https://push/3", "p", "a", "key-B") + assert store.add_push_subscription(U, "https://push/1", "p2", "a2", "key-A")["id"] == a["id"] # the same phone again: one row + assert {s["endpoint"] for s in store.push_subscriptions_of(U)} == {"https://push/1", "https://push/2"} and store.push_subscriptions_of("nobody") == [] + assert store.delete_push_subscription("https://push/2") is True and store.delete_push_subscription("https://push/2") is False + assert store.drop_push_subscriptions_not_of("key-A") == 1 # V's was made under another key + assert [s["endpoint"] for s in store.push_subscriptions_of(U)] == ["https://push/1"] and store.push_subscriptions_of(V) == [] + + +# --- the Supabase store: what it asks the database --- + +def test_supabase_requests_for_pictures_and_assignments(): + pic = {"id": "p1", "owner_id": U, "kind": "answer", "path": "u/1.jpg", "content_type": "image/jpeg", "size": 10, "created_at": "2026-11-12T09:00:00+00:00", "deleted_at": None} + asg = {"id": 3, "class_id": 1, "title": "x", "note": "", "due_date": "2026-11-20", "kind": "pages", "pages": ["p1"], "quiz_lesson": None, "test_id": None, + "exercise_number": None, "created_by": "t", "created_at": "2026-11-12T09:00:00+00:00", "removed_at": None} + s = Recorder([[pic], [pic], [pic], [{**pic, "deleted_at": "x"}], [asg], [asg], [asg], [{"id": 3}], [{"id": 9}], [{"id": 4}]]) + made = s.create_picture(U, "answer", "u/1.jpg", "image/jpeg", 10, NOW) + assert s.calls[0][:2] == ("POST", "m2_pictures") and s.calls[0][3]["created_at"] == NOW.isoformat() and made["created_at"] == NOW + assert s.get_picture("p1")["id"] == "p1" and [p["id"] for p in s.pictures(kind="answer", owner_id=U)] == ["p1"] + assert s.calls[2][2] == {"select": "*", "order": "created_at", "kind": "eq.answer", "owner_id": f"eq.{U}", "deleted_at": "is.null"} + assert s.mark_picture_deleted("p1", NOW) is True and s.calls[3][2] == {"id": "eq.p1", "deleted_at": "is.null"} + made_a = s.create_assignment(1, "x", DUE, "t", "pages", pages=["p1"]) + assert s.calls[4][3]["due_date"] == "2026-11-20" and s.calls[4][3]["pages"] == ["p1"] and made_a["due_date"] == DUE + assert s.get_assignment(3)["pages"] == ["p1"] and [a["id"] for a in s.assignments_of_classes([2, 1])] == [3] + assert s.calls[6][2] == {"class_id": "in.(1,2)", "select": "*", "order": "due_date,id", "removed_at": "is.null"} + s2 = Recorder([[{"id": 3}], [{"id": 9}], [{"id": 4}]]) + assert s2.picture_refs("p1") == {"assignments": [3], "submissions": [9], "requests": [4]} + assert s2.calls[0][2] == {"pages": "cs.{p1}", "select": "id"} and s2.calls[1][2] == {"pictures": "cs.{p1}", "select": "id"} + + +def test_supabase_requests_for_submissions_requests_and_push(): + sub = {"id": 7, "assignment_id": 3, "user_id": U, "pictures": ["p1"], "state": "handed_in", "late": False, "handed_at": "2026-11-12T09:00:00+00:00", + "grade": None, "teacher_note": "", "checked_at": None, "checked_by": None} + req = {"id": "r1", "user_id": U, "assignment_id": 3, "device": "d", "code": "1234", "state": "waiting", "created_at": "2026-11-12T09:00:00+00:00", + "expires_at": "2026-11-12T09:10:00+00:00", "picture_id": None} + s = Recorder([[sub], [sub], [sub], [req], [req], [req], [req], [{"id": 1, "endpoint": "e", "created_at": "2026-11-12T09:00:00+00:00"}], [{"id": 1}], [{"id": 1}, {"id": 2}]]) + saved = s.save_submission(3, U, pictures=["p1", "p2"], state="graded", grade=80, checked_at=NOW) + assert s.calls[0][:3] == ("POST", "m2_submissions", {"on_conflict": "assignment_id,user_id"}) and s.calls[0][5] is True + assert s.calls[0][3]["pictures"] == ["p1", "p2"] and s.calls[0][3]["checked_at"] == NOW.isoformat() and saved["handed_at"] == NOW + assert s.get_submission(3, U)["id"] == 7 and s.get_submission_by_id(7)["id"] == 7 + s.create_photo_request(U, 3, "d", "1234", NOW, NOW + timedelta(minutes=10)) + assert s.calls[3][:2] == ("PATCH", "m2_photo_requests") and s.calls[3][2]["state"] == "eq.waiting" and s.calls[3][3] == {"state": "superseded"} + assert s.calls[4][:2] == ("POST", "m2_photo_requests") and s.calls[4][3]["expires_at"] == (NOW + timedelta(minutes=10)).isoformat() + assert s.save_photo_request("r1", state="received", picture_id="p1")["id"] == "r1" + assert s.open_photo_requests(U)[0]["id"] == "r1" and s.calls[6][2]["state"] == "in.(waiting,uploading)" + s.add_push_subscription(U, "e", "p", "a", "k") + assert s.calls[7][:3] == ("POST", "m2_push_subscriptions", {"on_conflict": "endpoint"}) and s.calls[7][5] is True + assert s.delete_push_subscription("e") is True and s.drop_push_subscriptions_not_of("k") == 2 + assert s.calls[9][:3] == ("DELETE", "m2_push_subscriptions", {"key_id": "neq.k"}) diff --git a/mathly2/written.py b/mathly2/written.py new file mode 100644 index 0000000000000000000000000000000000000000..deafcb1c52c9654b9e3844abeb3d6632b6b28c2a --- /dev/null +++ b/mathly2/written.py @@ -0,0 +1,256 @@ +"""Written answers: uploading pictures, reading them back (only through the server), photo requests to a phone, and push notifications.""" +from __future__ import annotations + +import secrets +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from . import clock, pictures, reads +from .access import allowed, learner +from .pictures import PictureError + +router = APIRouter(prefix="/api") + +MESSAGES = { + "image_too_large": "התמונה גדולה מדי (עד 5MB)", + "unsupported_image": "אפשר להעלות רק תמונות JPEG, PNG או WebP", + "bad_image": "הקובץ לא נראה כמו תמונה תקינה", + "not_a_pdf": "הקובץ אינו PDF", + "bad_pdf": "לא הצלחנו לקרוא את ה־PDF", + "pdf_too_large": "ה־PDF גדול מדי (עד 10MB)", + "pdf_too_many_pages": "ב־PDF יש יותר מדי עמודים (עד 20)", + "pdf_too_slow": "ה־PDF ארוך מדי לעיבוד", + "picture_missing": "התמונה לא נמצאה", + "answer_storage_unavailable": "האחסון של התמונות לא זמין כרגע. נסו שוב בעוד רגע.", +} + + +def picture_error(exc: PictureError) -> HTTPException: + return HTTPException(exc.status, MESSAGES.get(exc.code, exc.code)) + + +def _class_ids_of(request: Request, picture: dict) -> set[int]: + """The classes whose assignments or submissions refer to a picture.""" + store = request.app.state.store + refs = store.picture_refs(picture["id"]) + classes: set[int] = set() + for assignment_id in refs["assignments"]: + found = store.get_assignment(assignment_id) + if found: + classes.add(found["class_id"]) + for submission_id in refs["submissions"]: + sub = store.get_submission_by_id(submission_id) + found = store.get_assignment(sub["assignment_id"]) if sub else None + if found: + classes.add(found["class_id"]) + return classes + + +def can_read_picture(request: Request, user: dict, picture: dict) -> bool: + """Who may read a picture: its uploader; a developer; the teacher of a class whose assignment or submission refers to it; and, for a page + of an assignment, a student of that class. (A manager or an admin sees no learning data, so no pictures.)""" + if picture["owner_id"] == user["id"] or allowed(user["roles"], "*"): + return True + store, here = request.app.state.store, reads.of(request) + classes = _class_ids_of(request, picture) + if not classes: + return False + for class_id in classes: + found = store.get_class(class_id) + if found and (found["teacher_email"] or "").lower() == user["email"]: + return True + if picture["kind"] == "page": + return any(c["id"] in classes for c in here.classes_of_student(user["email"])) + return False + + +@router.post("/pictures", status_code=201) +async def upload_picture(request: Request, user: dict = Depends(learner)) -> dict: + """A student's written answer as a picture (raw image bytes; the Content-Type says which kind). Nothing refers to it until it is handed in.""" + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > pictures.MAX_IMAGE + 1024: + raise picture_error(PictureError(413, "image_too_large")) + data = await request.body() + kind = request.headers.get("content-type", "") + + def work() -> dict: + jpeg = pictures.clean_image(data, kind) + return pictures.store_picture(request.app.state.store, request.app.state.pictures, user["id"], "answer", jpeg) + try: + row = await run_in_threadpool(work) + except PictureError as exc: + raise picture_error(exc) from exc + return {"id": row["id"], "size": row["size"]} + + +@router.get("/pictures/{picture_id}") +def read_picture(picture_id: str, request: Request, user: dict = Depends(learner)) -> Response: + """The bytes of a picture, if the caller may see it. Never a link into storage.""" + picture = request.app.state.store.get_picture(picture_id) + if picture is None or not can_read_picture(request, user, picture): + raise HTTPException(404, MESSAGES["picture_missing"]) + if picture["deleted_at"]: + raise HTTPException(410, "התמונה נמחקה") + try: + data = request.app.state.pictures.get(picture["path"]) + except PictureError as exc: + raise picture_error(exc) from exc + return Response(data, media_type=picture["content_type"], headers={"Cache-Control": "private, max-age=3600"}) + + +# ---- the photo request: a computer asks the student's phone for a photo of one answer ---- + +REQUEST_MINUTES = 10 +REQUEST_MESSAGES = {"expired": "בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.", "superseded": "הבקשה הוחלפה בבקשה חדשה יותר.", + "received": "התמונה כבר התקבלה.", "failed": "ההעלאה נכשלה. צרו בקשה חדשה."} + + +class NewRequest(BaseModel): + assignment_id: int + device: str = Field(min_length=1, max_length=100) + + +def _new_code(request: Request) -> str: + maker = getattr(request.app.state, "photo_code", None) + return f"{(maker() if maker else secrets.randbelow(10000)):04d}" + + +def _settled(request: Request, row: dict) -> dict: + """A request as it stands now: one that waited past its time is expired (decided when it is read, so no job runs).""" + if row["state"] in ("waiting", "uploading") and clock.now() >= row["expires_at"]: + return request.app.state.store.save_photo_request(row["id"], state="expired") + return row + + +def _my_request(request: Request, user: dict, request_id: str) -> dict: + row = request.app.state.store.get_photo_request(request_id) + if row is None or row["user_id"] != user["id"]: + raise HTTPException(404, "בקשת הצילום לא נמצאה") + return _settled(request, row) + + +def request_view(row: dict) -> dict: + return {"id": row["id"], "state": row["state"], "code": row["code"], "assignment_id": row["assignment_id"], "picture_id": row["picture_id"], + "expires_at": row["expires_at"].isoformat()} + + +@router.post("/photo-requests", status_code=201) +def create_photo_request(body: NewRequest, request: Request, user: dict = Depends(learner)) -> dict: + """The computer asks for a photo of an answer: a 4-digit code (the phone shows the same one), valid for ten minutes. An older waiting + request for the same assignment is replaced.""" + from .assignments import _my_assignment + row = _my_assignment(request, user, body.assignment_id) + if row["kind"] == "exercise_test": + raise HTTPException(422, "מבחן תרגול לא מגישים בתמונה") + now = clock.now() + made = request.app.state.store.create_photo_request(user["id"], body.assignment_id, body.device, _new_code(request), now, now + timedelta(minutes=REQUEST_MINUTES)) + notify = getattr(request.app.state, "notify_request", None) + if notify: + notify(request, user, made, row) + return request_view(made) + + +@router.get("/photo-requests/open") +def open_photo_requests(request: Request, device: str = "", user: dict = Depends(learner)) -> list[dict]: + """The phone's list: requests waiting for a picture, except the ones made by this very device (the computer that asked).""" + store = request.app.state.store + found = [] + for row in store.open_photo_requests(user["id"]): + row = _settled(request, row) + if row["state"] in ("waiting", "uploading") and row["device"] != device: + assignment = store.get_assignment(row["assignment_id"]) + found.append({**request_view(row), "assignment": {"id": row["assignment_id"], "title": assignment["title"] if assignment else ""}}) + return found + + +@router.get("/photo-requests/{request_id}") +def read_photo_request(request_id: str, request: Request, user: dict = Depends(learner)) -> dict: + return request_view(_my_request(request, user, request_id)) + + +@router.post("/photo-requests/{request_id}/start") +def start_photo_upload(request_id: str, request: Request, user: dict = Depends(learner)) -> dict: + """The phone began: the computer shows "uploading".""" + row = _my_request(request, user, request_id) + if row["state"] not in ("waiting", "uploading"): + raise HTTPException(409, REQUEST_MESSAGES.get(row["state"], "הבקשה לא פתוחה")) + return request_view(request.app.state.store.save_photo_request(row["id"], state="uploading")) + + +@router.post("/photo-requests/{request_id}/picture") +async def photo_request_picture(request_id: str, request: Request, user: dict = Depends(learner)) -> dict: + """The phone's photo (raw image bytes): cleaned and kept; the request becomes "received" with its picture, or "failed".""" + row = _my_request(request, user, request_id) + if row["state"] not in ("waiting", "uploading"): + raise HTTPException(409, REQUEST_MESSAGES.get(row["state"], "הבקשה לא פתוחה")) + store = request.app.state.store + store.save_photo_request(row["id"], state="uploading") + data, kind = await request.body(), request.headers.get("content-type", "") + + def work() -> dict: + return pictures.store_picture(store, request.app.state.pictures, user["id"], "answer", pictures.clean_image(data, kind)) + try: + pic = await run_in_threadpool(work) + except PictureError as exc: + store.save_photo_request(row["id"], state="failed") + raise picture_error(exc) from exc + return request_view(store.save_photo_request(row["id"], state="received", picture_id=pic["id"])) + + +# ---- push notifications ---- + +class Subscription(BaseModel): + endpoint: str = Field(max_length=1000) + keys: dict = Field(default_factory=dict) + + +class Unsubscribe(BaseModel): + endpoint: str = Field(max_length=1000) + + +def _pusher(request: Request): + return request.app.state.pusher + + +@router.get("/push/key") +def push_key(request: Request, user: dict = Depends(learner)) -> Response: + """The server's public push key for the page to subscribe with; `enabled: false` (and no key) when notifications are off.""" + pusher = _pusher(request) + body = {"enabled": bool(pusher and pusher.on), "key": pusher.public if pusher and pusher.on else ""} + return Response(__import__("json").dumps(body), media_type="application/json", headers={"Cache-Control": "no-store"}) + + +@router.post("/push/subscribe") +def push_subscribe(body: Subscription, request: Request, user: dict = Depends(learner)) -> dict: + pusher = _pusher(request) + if not pusher or not pusher.on: + raise HTTPException(503, "התראות כבויות בשרת") + try: + pusher.subscribe(user["id"], body.model_dump()) + except ValueError as exc: + raise HTTPException(400, "זו לא הרשמה להתראות") from exc + return {"ok": True} + + +@router.post("/push/unsubscribe") +def push_unsubscribe(body: Unsubscribe, request: Request, user: dict = Depends(learner)) -> dict: + store = request.app.state.store + mine = {s["endpoint"] for s in store.push_subscriptions_of(user["id"])} + if body.endpoint in mine: + store.delete_push_subscription(body.endpoint) + return {"ok": True} + + +def notify_new_request(request: Request, user: dict, made: dict, assignment: dict) -> None: + """A new photo request: the student's phones are told, with the request's time left as the notification's life. Never raises.""" + from . import push + pusher = _pusher(request) + try: + if pusher and pusher.on: + pusher.notify(user["id"], push.request_payload(made, assignment), push.seconds_left(made, clock.now())) + except Exception: # the phone still finds the request when the app opens + import logging + logging.getLogger("mathly.push").exception("photo request not pushed") diff --git a/openspec/changes/add-lesson-schedule/.openspec.yaml b/openspec/changes/add-lesson-schedule/.openspec.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca7e6273f6d99d2742600212cc8024ce08e30dfe --- /dev/null +++ b/openspec/changes/add-lesson-schedule/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-09 diff --git a/openspec/changes/add-lesson-schedule/proposal.md b/openspec/changes/add-lesson-schedule/proposal.md new file mode 100644 index 0000000000000000000000000000000000000000..a82b5c548f7f9586f2eb47aed2157db299e5c7da --- /dev/null +++ b/openspec/changes/add-lesson-schedule/proposal.md @@ -0,0 +1,26 @@ +# Proposal + +## Why + +The course follows a curriculum: each lesson is meant to be finished by a certain date. Students cannot see whether they are on time, and finishing a lesson takes about 50 exercises (about 5 skills of 10 questions) with no quicker way to show that a student already knows it. + +## What Changes + +- **A date per lesson**, one school-wide curriculum set by a manager (or a developer) in a new "תכנית לימודים" tab of the manager screen. A lesson without a date is never behind. +- **The path marks every lesson that should be finished by now**: a lesson whose date has passed and that is not completed carries a marker and a sentence ("היה אמור להסתיים ב־3.11"), and the path says how many lessons are behind. +- **A lesson is completed in either of two ways**: every one of its skills is mastered, or its quiz was passed with at least 13 right out of 15. Passing the quiz does not change the skills' own mastery. +- **The lesson quiz becomes 15 questions** (from 10), in 20 minutes (from 15). Its grade is still right answers out of the questions, so 13 of 15 is a grade of 87; the pass is a best grade of at least 87. + +## Capabilities + +### New Capabilities +- `curriculum`: the date of each lesson and who sets it. + +### Modified Capabilities +- `lesson-quizzes`: 15 questions in 20 minutes; the pass mark. +- `learning-path`: completed lessons and lessons behind the curriculum. + +## Impact + +- A migration (`m2_curriculum`), store methods, `GET/PUT /api/curriculum`, `quiz.py`/`attempts.py` (size, minutes, pass), `skills.py` (path), `manager.js`, `path.js`, the cache wrapper, the permissions matrix; PRD QZ-1..3, PTH, MGR. +- Quizzes already taken keep their grade (a 9 of 10 is 90, which passes): no quiz is re-graded. diff --git a/openspec/changes/add-lesson-schedule/specs/curriculum/spec.md b/openspec/changes/add-lesson-schedule/specs/curriculum/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..b84a47715fc5bf0987066a316e612a5e177f4c18 --- /dev/null +++ b/openspec/changes/add-lesson-schedule/specs/curriculum/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: A date per lesson +The course SHALL have one curriculum for the whole school: each lesson MAY have a date by which it should be completed. A manager (or a developer) SHALL set, change and clear these dates in a "תכנית לימודים" tab of the manager screen, which lists every lesson in the bank's order with its date. Nobody else SHALL be able to change them. A lesson with no date SHALL never count as behind. + +#### Scenario: Setting dates +- **WHEN** a manager enters 3.11 for the first lesson and saves +- **THEN** the curriculum tab shows 3.11 for that lesson, and students' paths use it + +#### Scenario: Clearing a date +- **WHEN** a manager clears a lesson's date and saves +- **THEN** the lesson has no date and no student sees it as behind + +#### Scenario: Not a manager +- **WHEN** a teacher or a student asks to change a date +- **THEN** the server refuses and nothing changes diff --git a/openspec/changes/add-lesson-schedule/specs/learning-path/spec.md b/openspec/changes/add-lesson-schedule/specs/learning-path/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..b99dba85fae696a2f861d53893d98ddf78dcbed6 --- /dev/null +++ b/openspec/changes/add-lesson-schedule/specs/learning-path/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: A completed lesson +A lesson SHALL be completed when every one of its skills is mastered, or when its quiz was passed. Passing the quiz MUST NOT change any skill's state. A completed lesson SHALL be marked on its hub with the word "הושלם". + +#### Scenario: By skills +- **WHEN** a student has mastered all five skills of a lesson +- **THEN** the lesson is marked completed + +#### Scenario: By the quiz +- **WHEN** a student passes a lesson's quiz while two of its skills are still in learning +- **THEN** the lesson is marked completed, and the two skills are still in learning + +#### Scenario: Neither +- **WHEN** a student has mastered four of five skills and has not passed the quiz +- **THEN** the lesson is not completed + +### Requirement: Lessons behind the curriculum +A lesson that has a date in the curriculum, whose date has passed and that is not completed SHALL be marked on the path as behind, with a marker and its date in words ("היה אמור להסתיים ב־3.11"). The path SHALL say how many lessons are behind. A lesson with no date, a date still ahead, or a completed lesson MUST NOT be marked. Nothing SHALL be locked. + +#### Scenario: Behind +- **WHEN** a lesson's date was yesterday and the student has not completed it +- **THEN** its hub shows the behind marker and the date, and the path says "1 שיעורים בפיגור" + +#### Scenario: Completed in time +- **WHEN** a lesson's date has passed and the student passed its quiz +- **THEN** the lesson is marked completed and not behind + +#### Scenario: Date still ahead or none +- **WHEN** a lesson's date is next week, or it has none +- **THEN** it is not marked behind diff --git a/openspec/changes/add-lesson-schedule/specs/lesson-quizzes/spec.md b/openspec/changes/add-lesson-schedule/specs/lesson-quizzes/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..1bcd4b35d0fa46fb183f2a914557bcafb33f687a --- /dev/null +++ b/openspec/changes/add-lesson-schedule/specs/lesson-quizzes/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Quiz format +A quiz SHALL be 15 multiple-choice questions drawn from the bank questions of the lesson's skills, at least one per skill and the rest spread evenly, in 20 minutes. Skill names MUST NOT be shown, and there SHALL be no feedback until the end. The board and calculator SHALL be available. + +#### Scenario: Lesson with four skills +- **WHEN** a quiz is drawn for a lesson with four skills +- **THEN** it has 15 questions and every skill has at least three + +## ADDED Requirements + +### Requirement: Passing a quiz +A quiz SHALL be passed when its grade is at least 87, which is 13 right answers out of 15. A lesson whose quiz was passed at least once SHALL stay passed whatever later retakes score. + +#### Scenario: Thirteen of fifteen +- **WHEN** a student gets 13 of 15 right +- **THEN** the quiz is passed + +#### Scenario: Twelve of fifteen +- **WHEN** a student gets 12 of 15 right +- **THEN** the quiz is not passed (grade 80) + +#### Scenario: A worse retake +- **WHEN** a student passed once and then scores 60 +- **THEN** the lesson is still passed diff --git a/openspec/changes/add-lesson-schedule/tasks.md b/openspec/changes/add-lesson-schedule/tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..1f8c064368ec0bee31201a99c8de175a70e80ca6 --- /dev/null +++ b/openspec/changes/add-lesson-schedule/tasks.md @@ -0,0 +1,19 @@ +# Tasks + +## 1. The quiz + +- [x] 1.1 Make the lesson quiz 15 questions in 20 minutes and add the pass mark (grade 87); verify unit tests: a quiz is drawn with 15 questions and at least three per skill for a four-skill lesson, 13 of 15 passes and 12 of 15 does not, a pass stays after a worse retake + +## 2. The curriculum + +- [x] 2.1 Add the migration and the store methods for the lesson dates (in memory and Supabase, remembered briefly by the cache wrapper and forgotten on change), and `GET /api/curriculum` and `PUT /api/curriculum` for managers and developers (in the permissions matrix); verify API tests: a manager sets, changes and clears a date, a teacher and a student are refused, an unknown lesson is refused, a bad date is refused, and the migration applies on a real PostgreSQL +- [x] 2.2 Add the "תכנית לימודים" tab to the manager screen; verify a browser test: dates are entered, saved and shown again, and the tab fits a phone without sideways scrolling + +## 3. The path + +- [x] 3.1 Give each lesson of `/api/path` its date, whether it is completed (all skills mastered or quiz passed) and whether it is behind, and the count of lessons behind; verify API tests for every scenario of "A completed lesson" and "Lessons behind the curriculum" +- [x] 3.2 Show "הושלם" and the behind marker with its date on the lesson hub, and the count at the top of the path; verify browser tests: a behind lesson, a completed lesson and a lesson with no date look right at 360px, nothing is locked, the marker is in words and not by colour alone + +## 4. Wrap-up + +- [x] 4.1 Update the PRD (QZ-1, QZ-2, QZ-3, the path and the manager's screens); run the full suite and `openspec validate add-lesson-schedule --strict`; verify both pass diff --git a/openspec/changes/add-written-work/.openspec.yaml b/openspec/changes/archive/2026-11-09-add-written-work/.openspec.yaml similarity index 100% rename from openspec/changes/add-written-work/.openspec.yaml rename to openspec/changes/archive/2026-11-09-add-written-work/.openspec.yaml diff --git a/openspec/changes/add-written-work/design.md b/openspec/changes/archive/2026-11-09-add-written-work/design.md similarity index 92% rename from openspec/changes/add-written-work/design.md rename to openspec/changes/archive/2026-11-09-add-written-work/design.md index a6bfc53bc785783bb1ec6dceede3b09864b467cc..14c655d687185d21e1346e60268d32e27171ee24 100644 --- a/openspec/changes/add-written-work/design.md +++ b/openspec/changes/archive/2026-11-09-add-written-work/design.md @@ -36,11 +36,11 @@ A written quiz is not copied into the assignment: the row names the lesson and t ### 2. Pictures: the server is the only door -`pictures.py` holds the logic and two stores of bytes behind one small interface (`put`, `get`, `delete`): `MemoryPictures` for tests and local development, and `SupabasePictures`, a **private** Storage bucket named by the setting `MATHLY_ANSWER_BUCKET` (default `m2-answers`, not mathly1's `mathly-answers`), read and written only with the secret key. The server creates the bucket on first use if it is missing (the Storage API, idempotent), so the migration needs no `storage` schema. *Alternative:* mathly1's Hugging Face bucket, chosen there to avoid Supabase egress. Rejected for now: a second token and library, and egress is not a problem at this size; the interface keeps it a later swap. +`pictures.py` holds the logic and two stores of bytes behind one small interface (`put`, `get`, `delete`): `MemoryPictures` for tests and local development, and `SupabasePictures`, a **private** Storage bucket named by the setting `MATHLY_M2_ANSWER_BUCKET` (default `m2-answers`; not `MATHLY_ANSWER_BUCKET`, which mathly1 reads from the shared `.env` for its own Hugging Face bucket, nor its `mathly-answers`), read and written only with the secret key. The server creates the bucket on first use if it is missing (the Storage API, idempotent), so the migration needs no `storage` schema. *Alternative:* mathly1's Hugging Face bucket, chosen there to avoid Supabase egress. Rejected for now: a second token and library, and egress is not a problem at this size; the interface keeps it a later swap. - **`clean_image(bytes, content_type)`** (ported): only JPEG, PNG or WebP up to 5 MB and 20 million pixels; the orientation is applied, the metadata dropped, the image re-encoded as JPEG. Anything else is refused (415 or 413). - **`render_pdf(bytes)`** (ported, teacher assignments only): up to 10 MB, 1600 px wide, 30 seconds for all pages, at most 20 pages; each page becomes a `page` picture, and the PDF itself is not kept. -- **Access:** `GET /api/pictures/{id}` streams the bytes, never a link into storage. Readable by: the uploader; a student of the class for a page of their class's assignment; the teacher of the class (or a developer, or a manager) for a picture an assignment or a submission of their class refers to. Anything else is 404. A deleted picture is 410. +- **Access:** `GET /api/pictures/{id}` streams the bytes, never a link into storage. Readable by: the uploader; a student of the class for a page of their class's assignment; the teacher of the class (or a developer) for a picture an assignment or a submission of their class refers to. A manager or an admin sees no learning data, so no pictures. Anything else is 404. A deleted picture is 410. ### 3. Handing in @@ -81,7 +81,7 @@ stateDiagram-v2 - **Orphans:** a picture no assignment, submission or photo request refers to and older than 24 hours is deleted by the cleanup. - **The school year:** `retention.expires_on(day)` is 31 August of the school year the picture was created in (1 Sep to 31 Aug); `answer` pictures are deleted on the first cleanup after it. Deleting removes the bytes and sets `deleted_at`; the submission keeps its grade and note, and the screens say "התמונות נמחקו". Assignment pages stay until their assignment is removed. -- **On request:** `DELETE /api/classes/{id}/students/{email}/pictures` by the class's teacher, a manager or a developer deletes that student's `answer` pictures for that class's assignments at once. +- **On request:** `DELETE /api/classes/{id}/pictures/{email}` (not under `/students/`, whose `{email}` route would swallow it) by the class's teacher, a manager or a developer deletes that student's `answer` pictures for that class's assignments at once. - **The job:** `cleanup.run(store, pictures, now)` returns what it removed, `python -m mathly2.cleanup` runs it once, and the server starts it every hour in the background when Supabase is on (the real deployment). ## Risks / Trade-offs diff --git a/openspec/changes/add-written-work/proposal.md b/openspec/changes/archive/2026-11-09-add-written-work/proposal.md similarity index 100% rename from openspec/changes/add-written-work/proposal.md rename to openspec/changes/archive/2026-11-09-add-written-work/proposal.md diff --git a/openspec/changes/add-written-work/specs/assignments/spec.md b/openspec/changes/archive/2026-11-09-add-written-work/specs/assignments/spec.md similarity index 70% rename from openspec/changes/add-written-work/specs/assignments/spec.md rename to openspec/changes/archive/2026-11-09-add-written-work/specs/assignments/spec.md index 8845bda8826e6a5eb8684a6d6c6600765cd1b7a3..ba6f03e8f7223afa3154b7801a5a4d86c841efde 100644 --- a/openspec/changes/add-written-work/specs/assignments/spec.md +++ b/openspec/changes/archive/2026-11-09-add-written-work/specs/assignments/spec.md @@ -29,12 +29,23 @@ A teacher SHALL be able to send one of their real test's two exercise tests to t - **THEN** the assignment shows as done with the test's grade, and nothing is added to the teacher's inbox ### Requirement: My assignments -A student SHALL see their assignments on home under "המשימות שלי" with the due date and state, open one, work on it on the board or on paper, and hand it in. +Home SHALL show under "המשימות שלי" only the new assignments, those the student has not handed in (or, for an exercise test, not taken), with the due date. The student SHALL open one, work on it on the board or on paper, and hand it in. #### Scenario: Hand-in - **WHEN** a student hands in an assignment from the board - **THEN** it shows as handed in, and the teacher's inbox lists it +### Requirement: Submissions tab +A tab "הגשות" SHALL list every assignment from the student's teachers, grouped by state: new (not handed in), sent back for correction, handed in and waiting for a check, and checked (with the grade and the teacher's note). Each assignment SHALL show its due date and open its page; an assignment past its due date and not handed in SHALL be marked "באיחור". + +#### Scenario: Grouped by state +- **WHEN** a student has one new, one sent-back, one waiting and one checked assignment +- **THEN** the tab shows four groups, each with its assignment, and the sent-back one shows the teacher's note + +#### Scenario: All of them +- **WHEN** a student has handed in everything +- **THEN** home shows no assignments, and the tab shows all of them under "הוגשו" and "נבדקו" + ### Requirement: Teacher inbox A teacher SHALL have one inbox of everything their students handed in that waits for them, oldest first: submissions not checked yet and requests to look again. A submission sent back waits for the student, not in the inbox. diff --git a/openspec/changes/add-written-work/specs/written-answers/spec.md b/openspec/changes/archive/2026-11-09-add-written-work/specs/written-answers/spec.md similarity index 100% rename from openspec/changes/add-written-work/specs/written-answers/spec.md rename to openspec/changes/archive/2026-11-09-add-written-work/specs/written-answers/spec.md diff --git a/openspec/changes/add-written-work/tasks.md b/openspec/changes/archive/2026-11-09-add-written-work/tasks.md similarity index 81% rename from openspec/changes/add-written-work/tasks.md rename to openspec/changes/archive/2026-11-09-add-written-work/tasks.md index 5d9c8e369b692fb9a4018bc591cccc0b70fa38b4..122a8e7b675b4a80e9ccf2af785b4b7969313268 100644 --- a/openspec/changes/add-written-work/tasks.md +++ b/openspec/changes/archive/2026-11-09-add-written-work/tasks.md @@ -2,39 +2,39 @@ ## 1. Data, pictures and retention -- [ ] 1.1 Add the migration `20261112090000_m2_written_work.sql` (decision 1) and the store methods for pictures, assignments and submissions (create, read, list, replace, check, soft-remove, references of a picture), in both stores; verify unit tests, request tests for the Supabase store, and migration tests on PostgreSQL (defaults, the state and grade checks, one submission per student and assignment, cascade with the class, the browser roles are refused) -- [ ] 1.2 Add the store methods for photo requests (create, supersede, read, list open, set state, expire) and push subscriptions (add, list, delete, drop other keys), in both stores; verify unit tests including supersede, expiry and that one student's rows never reach another -- [ ] 1.3 Add `pictures.py` (decision 2): `clean_image`, `render_pdf`, `MemoryPictures`, `SupabasePictures` that creates its private bucket on first use; verify unit tests: a JPEG with metadata comes back without it and upright, a non-image or oversized file is refused, an image over 20 million pixels is refused, a PDF gives one picture per page and too many pages are refused, and request tests for the Supabase storage calls -- [ ] 1.4 Add `retention.py` and `cleanup.py` (decision 8): the end of the school year, orphans older than 24 hours, `run`, and the CLI; verify unit tests: a picture from October expires on 1 September of the next year, one from May on 1 September of the same year, an unreferenced picture is removed after a day and a referenced one stays, a deleted picture's submission keeps its grade and note, and assignment pages are not removed by the year's end +- [x] 1.1 Add the migration `20261112090000_m2_written_work.sql` (decision 1) and the store methods for pictures, assignments and submissions (create, read, list, replace, check, soft-remove, references of a picture), in both stores; verify unit tests, request tests for the Supabase store, and migration tests on PostgreSQL (defaults, the state and grade checks, one submission per student and assignment, cascade with the class, the browser roles are refused) +- [x] 1.2 Add the store methods for photo requests (create, supersede, read, list open, set state, expire) and push subscriptions (add, list, delete, drop other keys), in both stores; verify unit tests including supersede, expiry and that one student's rows never reach another +- [x] 1.3 Add `pictures.py` (decision 2): `clean_image`, `render_pdf`, `MemoryPictures`, `SupabasePictures` that creates its private bucket on first use; verify unit tests: a JPEG with metadata comes back without it and upright, a non-image or oversized file is refused, an image over 20 million pixels is refused, a PDF gives one picture per page and too many pages are refused, and request tests for the Supabase storage calls +- [x] 1.4 Add `retention.py` and `cleanup.py` (decision 8): the end of the school year, orphans older than 24 hours, `run`, and the CLI; verify unit tests: a picture from October expires on 1 September of the next year, one from May on 1 September of the same year, an unreferenced picture is removed after a day and a referenced one stays, a deleted picture's submission keeps its grade and note, and assignment pages are not removed by the year's end ## 2. The API -- [ ] 2.1 Add `POST /api/pictures` and `GET /api/pictures/{id}` with the access rules (decisions 2 and 3); verify API tests: an upload is cleaned and stored, the uploader and the class's teacher read it, another student, another class's teacher and a signed-out request get 404, a deleted picture is 410, a page of an assignment is readable by the class's students, and the bytes are never reachable by a storage link -- [ ] 2.2 Add the teacher's assignment routes (decision 6): create from images or a PDF, a photo, the board's pages, a bank written quiz and an exercise test, list with counts, and remove; verify API tests: each of the five sources works and a bank quiz only for a lesson of the class that has one, a PDF becomes page pictures, a class the teacher does not teach is 404, an exercise test of another class is refused, removing an assignment hides it and keeps grades -- [ ] 2.3 Add the student's routes (decisions 3 and 7): `GET /api/assignments`, `GET /api/assignments/{id}`, and hand in or replace; verify API tests: only the student's classes' assignments, hand in 1 to 10 of their own pictures (none or 11 or someone else's refused), replace while handed in, refused once graded, hand in again after returned, a late hand-in is accepted and marked late, an exercise-test assignment is done after the student took it and shows the best grade, and a written quiz shows its questions with points -- [ ] 2.4 Add the inbox and checking (decision 6): `GET /api/teaching/inbox`, `POST /api/submissions/{id}/grade` and `/return`, and the assignments in `GET /api/grades`; verify API tests: Monday's submission before Tuesday's, a returned one is not listed, a grade is 0–100 whole, a return needs a note, a grade of 101 or -1 is refused, a stale `seen` gives 409, another class's teacher gets 404, the student then sees the grade and note or the request to correct, exercise-test assignments never reach the inbox, and the grades list shows graded assignments -- [ ] 2.5 Add the photo-request routes (decision 4); verify API tests: a request has a 4-digit code and expires after 10 minutes (the server's clock), a newer request for the same assignment supersedes the older, the phone's open list leaves out requests made by its own device, the states go waiting → uploading → received with the picture, a bad upload gives failed, and another student cannot read or answer a request -- [ ] 2.6 Add push (decision 5): `push.py`, `GET /api/push/key`, `POST /api/push/subscribe`, the notification on a new request, the service worker route and the manifest; verify API tests with a recording fake: a new request notifies the student's subscriptions with the request's address, a dead subscription (404 or 410) is deleted, changing the key drops every subscription, without a key push is off and says so, and everything else still works -- [ ] 2.7 Add early deletion `DELETE /api/classes/{id}/students/{email}/pictures` and start the cleanup every hour when Supabase is on; verify API tests: the class's teacher, a manager and a developer may, another class's teacher and a student may not, the student's submissions keep their grade and note, and a unit test that the hourly start is not used without Supabase +- [x] 2.1 Add `POST /api/pictures` and `GET /api/pictures/{id}` with the access rules (decisions 2 and 3); verify API tests: an upload is cleaned and stored, the uploader and the class's teacher read it, another student, another class's teacher and a signed-out request get 404, a deleted picture is 410, a page of an assignment is readable by the class's students, and the bytes are never reachable by a storage link +- [x] 2.2 Add the teacher's assignment routes (decision 6): create from images or a PDF, a photo, the board's pages, a bank written quiz and an exercise test, list with counts, and remove; verify API tests: each of the five sources works and a bank quiz only for a lesson of the class that has one, a PDF becomes page pictures, a class the teacher does not teach is 404, an exercise test of another class is refused, removing an assignment hides it and keeps grades +- [x] 2.3 Add the student's routes (decisions 3 and 7): `GET /api/assignments`, `GET /api/assignments/{id}`, and hand in or replace; verify API tests: only the student's classes' assignments, hand in 1 to 10 of their own pictures (none or 11 or someone else's refused), replace while handed in, refused once graded, hand in again after returned, a late hand-in is accepted and marked late, an exercise-test assignment is done after the student took it and shows the best grade, and a written quiz shows its questions with points +- [x] 2.4 Add the inbox and checking (decision 6): `GET /api/teaching/inbox`, `POST /api/submissions/{id}/grade` and `/return`, and the assignments in `GET /api/grades`; verify API tests: Monday's submission before Tuesday's, a returned one is not listed, a grade is 0–100 whole, a return needs a note, a grade of 101 or -1 is refused, a stale `seen` gives 409, another class's teacher gets 404, the student then sees the grade and note or the request to correct, exercise-test assignments never reach the inbox, and the grades list shows graded assignments +- [x] 2.5 Add the photo-request routes (decision 4); verify API tests: a request has a 4-digit code and expires after 10 minutes (the server's clock), a newer request for the same assignment supersedes the older, the phone's open list leaves out requests made by its own device, the states go waiting → uploading → received with the picture, a bad upload gives failed, and another student cannot read or answer a request +- [x] 2.6 Add push (decision 5): `push.py`, `GET /api/push/key`, `POST /api/push/subscribe`, the notification on a new request, the service worker route and the manifest; verify API tests with a recording fake: a new request notifies the student's subscriptions with the request's address, a dead subscription (404 or 410) is deleted, changing the key drops every subscription, without a key push is off and says so, and everything else still works +- [x] 2.7 Add early deletion `DELETE /api/classes/{id}/pictures/{email}` and start the cleanup every hour when Supabase is on; verify API tests: the class's teacher, a manager and a developer may, another class's teacher and a student may not, the student's submissions keep their grade and note, and a unit test that the hourly start is not used without Supabase ## 3. Student screens -- [ ] 3.1 Add "המשימות שלי" to home: open ones first by due date, then done ones, each with its due date and state in words (and "באיחור", "ציון N", "הוחזר לתיקון"); verify a Playwright test of every state at 360px -- [ ] 3.2 Build the assignment page `#/assignment/`: the note, pages (images inline) or the bank's questions or the exercise-test link, the student's pictures with removal, work on the board with a hand-in button, a camera input, an upload, and "הגשה"; after hand-in the state, and the grade and note or the request to correct; verify a Playwright test that a file upload and a board hand-in (a stroke drawn and the send button pressed) add pictures, hand-in shows "הוגש", a returned submission shows the note and can be handed in again, and a graded one cannot be changed -- [ ] 3.3 Add the photo request to the computer's screen (a "בקשת צילום מהטלפון" button, the code and the state in words, newer request marks the older replaced) and the phone's request window (the same code, a camera button, once per request, not on the device that asked); verify a Playwright test with two pages standing for the computer and the phone: the code matches, the phone uploads, the computer shows "התשובה התקבלה" and the picture joins the draft, and the computer never shows its own request window -- [ ] 3.4 Add notifications on the phone: the service worker and manifest linked, a toggle shown only on touch devices and only when the server has a key, and a tap that opens the request; verify a Playwright test that the toggle is absent without a key and on a desktop pointer, present with both, that subscribing sends the subscription to the server, and that a `notified` message from the worker makes the phone look for requests at once -- [ ] 3.5 Add assignments to the grades screen (graded assignments and done exercise-test assignments, with the teacher's note); verify a Playwright test +- [x] 3.1 Add "המשימות שלי" to home: open ones first by due date, then done ones, each with its due date and state in words (and "באיחור", "ציון N", "הוחזר לתיקון"); verify a Playwright test of every state at 360px +- [x] 3.2 Build the assignment page `#/assignment/`: the note, pages (images inline) or the bank's questions or the exercise-test link, the student's pictures with removal, work on the board with a hand-in button, a camera input, an upload, and "הגשה"; after hand-in the state, and the grade and note or the request to correct; verify a Playwright test that a file upload and a board hand-in (a stroke drawn and the send button pressed) add pictures, hand-in shows "הוגש", a returned submission shows the note and can be handed in again, and a graded one cannot be changed +- [x] 3.3 Add the photo request to the computer's screen (a "בקשת צילום מהטלפון" button, the code and the state in words, newer request marks the older replaced) and the phone's request window (the same code, a camera button, once per request, not on the device that asked); verify a Playwright test with two pages standing for the computer and the phone: the code matches, the phone uploads, the computer shows "התשובה התקבלה" and the picture joins the draft, and the computer never shows its own request window +- [x] 3.4 Add notifications on the phone: the service worker and manifest linked, a toggle shown only on touch devices and only when the server has a key, and a tap that opens the request; verify a Playwright test that the toggle is absent without a key and on a desktop pointer, present with both, that subscribing sends the subscription to the server, and that a `notified` message from the worker makes the phone look for requests at once +- [x] 3.5 Add assignments to the grades screen (graded assignments and done exercise-test assignments, with the teacher's note); verify a Playwright test ## 4. Teacher screens -- [ ] 4.1 Add the assignments tab to the teacher's screen: create (class, title, note, due date, and one source: file, camera photo, board, bank written quiz or an exercise test of a test of the class), the list with "N הגישו, M נבדקו", and removal; verify a Playwright test of each source at 360px and on a computer -- [ ] 4.2 Add the inbox tab: oldest first, open a submission to see its pictures, grade with a note, or send back with a required note, then the student's screen shows the result; verify a Playwright test that two submissions come oldest first, a grade empties its item, a return needs a note and leaves the inbox, and a stale submission shows "ההגשה השתנתה" +- [x] 4.1 Add the assignments tab to the teacher's screen: create (class, title, note, due date, and one source: file, camera photo, board, bank written quiz or an exercise test of a test of the class), the list with "N הגישו, M נבדקו", and removal; verify a Playwright test of each source at 360px and on a computer +- [x] 4.2 Add the inbox tab: oldest first, open a submission to see its pictures, grade with a note, or send back with a required note, then the student's screen shows the result; verify a Playwright test that two submissions come oldest first, a grade empties its item, a return needs a note and leaves the inbox, and a stale submission shows "ההגשה השתנתה" ## 5. Wrap-up -- [ ] 5.1 Walk the whole flow in a browser with two students' devices: a teacher gives an assignment from a file and from the bank, a student hands in from the board and a second student by a phone request, the teacher's inbox shows both in order, one is graded and one is sent back, the second student hands in again and is graded, the grades screen shows both, and the teacher deletes a student's pictures while the grade stays; verify the test passes -- [ ] 5.2 Update `docs/mockups/mathly2-screens.html` where the home assignments or the teacher's inbox no longer match what was built; verify by opening it beside screenshots -- [ ] 5.3 Run `venv/bin/python -m pytest mathly2/tests` and `openspec validate add-written-work --strict`; verify both pass +- [x] 5.1 Walk the whole flow in a browser with two students' devices: a teacher gives an assignment from a file and from the bank, a student hands in from the board and a second student by a phone request, the teacher's inbox shows both in order, one is graded and one is sent back, the second student hands in again and is graded, the grades screen shows both, and the teacher deletes a student's pictures while the grade stays; verify the test passes +- [x] 5.2 Update `docs/mockups/mathly2-screens.html` where the home assignments or the teacher's inbox no longer match what was built; verify by opening it beside screenshots +- [x] 5.3 Run `venv/bin/python -m pytest mathly2/tests` and `openspec validate add-written-work --strict`; verify both pass ## Workflow follow-up diff --git a/openspec/changes/archive/2026-11-09-cache-and-instant-screens/.openspec.yaml b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/.openspec.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca7e6273f6d99d2742600212cc8024ce08e30dfe --- /dev/null +++ b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-09 diff --git a/openspec/changes/archive/2026-11-09-cache-and-instant-screens/proposal.md b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/proposal.md new file mode 100644 index 0000000000000000000000000000000000000000..336f71980f0d006af9e8f68750c606e6e33bdafa --- /dev/null +++ b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/proposal.md @@ -0,0 +1,23 @@ +# Proposal + +## Why + +Each wait for the remote database costs 0.4 to 0.9 seconds from where the server runs, and the teacher's screens need 4 or 5 waits in a row (the tab numbers alone take 1.6 s). Most of what those waits read, the classes, their students, the roles, the tests and the assignments, changes rarely. And every screen starts blank until its data arrives. + +## What Changes + +- **The server remembers slow-changing facts for a short time** (30 seconds, set by `MATHLY_CACHE_SECONDS`, 0 turns it off): classes, class members, roles, tests and assignments. Any change made through the app to one of them forgets the remembered copy at once, so the person who made it never sees an old copy. +- **A screen opened again shows what it last knew, at once**, and refreshes it in the background: the tab numbers, the teacher's tabs, and the student's home, path, submissions and grades. A change the person makes clears what was remembered, so an old copy is never shown after their own action; and a refresh never redraws over something being typed. +- The teacher's counts ask for their tests and assignments together (one wait instead of two). + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `app-basics`: "Slow-changing facts are remembered briefly" and "A screen shows what it last knew". + +## Impact + +- A new `mathly2/cached.py` around the Supabase store, a setting, `mathly2/assignments.py` (counts), and in the browser `api.js` plus the screens' `show` functions. No database change. PRD 7.3. +- One server process is assumed (a Space is one): with several, a change made through another process could show up late by the remembering time. diff --git a/openspec/changes/archive/2026-11-09-cache-and-instant-screens/specs/app-basics/spec.md b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/specs/app-basics/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..e99c3eea4119da22c831b9f8dac062c674c404d2 --- /dev/null +++ b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/specs/app-basics/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Slow-changing facts are remembered briefly +The server SHALL remember the classes, their students, the roles, the tests and the assignments for at most 30 seconds, so a screen that needs them does not wait for the database again. Any change made through the app to one of them SHALL make the server forget the remembered copy at once. The time SHALL be a setting, and 0 SHALL turn it off. + +#### Scenario: Opened again +- **WHEN** a teacher opens the assignments tab twice within 30 seconds and every database call takes 0.1 seconds +- **THEN** the second time needs fewer waits in a row than the first + +#### Scenario: A change +- **WHEN** a teacher gives an assignment and then opens the assignments tab +- **THEN** the new assignment is there, not an old remembered list + +### Requirement: A screen shows what it last knew +A screen opened again SHALL show the data it last had at once and refresh it in the background; the tab numbers of a teacher's screen SHALL show their last values at once. After a change the person made, nothing remembered SHALL be shown: the screen waits for fresh data. A refresh MUST NOT redraw the screen while the person is typing in it or has something open. + +#### Scenario: Back to a tab +- **WHEN** a teacher goes to another tab and back, and the server takes two seconds to answer +- **THEN** the tab shows its last content and numbers at once, and shows the new ones when they arrive + +#### Scenario: After grading +- **WHEN** a teacher grades a submission and the inbox is shown again +- **THEN** the graded submission is not in it, not even for a moment + +#### Scenario: Typing +- **WHEN** fresh data arrives while a teacher is typing in a form on the screen +- **THEN** what they typed is still there diff --git a/openspec/changes/archive/2026-11-09-cache-and-instant-screens/tasks.md b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..507c27d7044fadffaf4fe51fa3f4f9b54ba747c1 --- /dev/null +++ b/openspec/changes/archive/2026-11-09-cache-and-instant-screens/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +## 1. The server remembers + +- [x] 1.1 Add `cached.py` (a wrapper around the Supabase store that remembers the reads of classes, members, roles, tests and assignments for `MATHLY_CACHE_SECONDS`, forgets everything at once on any change to them, never remembers an error, returns copies, and is not fooled by a read that was already running when a change happened) and use it for the real database; verify unit tests: a repeat read within the time is not asked again, the time passing asks again, each change forgets (create, update, remove for classes, members, roles, tests, assignments), a read that began before a change is not kept, answers and progress writes do not forget, 0 turns it off, and a returned row can be changed without changing the remembered one +- [x] 1.2 Make the teacher's counts ask for tests and assignments together, and measure the waits with the delay store wrapped in the cache; verify the timing test: the teacher's counts, assignments and tests tabs and the student's grades need fewer waits in a row the second time than the first, and a change shows at once + +## 2. Screens that show what they last knew + +- [x] 2.1 Add the remembering of GET answers to `api.js` (shown at once, refreshed in the background, forgotten on any change the person makes and on signing out, never redrawn over typing or an open detail) and use it for the teacher's tab numbers and tabs (classes, tests, assignments, inbox) and the student's home, path, submissions and grades; verify Playwright tests with a held request: going back to a tab shows its content and numbers at once, the new ones replace them when they arrive, grading shows no stale inbox, typing survives a refresh, and signing out clears it + +## 3. Wrap-up + +- [x] 3.1 Add the PRD line; run `venv/bin/python -m pytest mathly2/tests` and `openspec validate cache-and-instant-screens --strict`; verify both pass diff --git a/openspec/changes/fewer-database-calls/.openspec.yaml b/openspec/changes/archive/2026-11-09-fewer-database-calls/.openspec.yaml similarity index 100% rename from openspec/changes/fewer-database-calls/.openspec.yaml rename to openspec/changes/archive/2026-11-09-fewer-database-calls/.openspec.yaml diff --git a/openspec/changes/fewer-database-calls/proposal.md b/openspec/changes/archive/2026-11-09-fewer-database-calls/proposal.md similarity index 100% rename from openspec/changes/fewer-database-calls/proposal.md rename to openspec/changes/archive/2026-11-09-fewer-database-calls/proposal.md diff --git a/openspec/changes/fewer-database-calls/specs/app-basics/spec.md b/openspec/changes/archive/2026-11-09-fewer-database-calls/specs/app-basics/spec.md similarity index 100% rename from openspec/changes/fewer-database-calls/specs/app-basics/spec.md rename to openspec/changes/archive/2026-11-09-fewer-database-calls/specs/app-basics/spec.md diff --git a/openspec/changes/fewer-database-calls/tasks.md b/openspec/changes/archive/2026-11-09-fewer-database-calls/tasks.md similarity index 100% rename from openspec/changes/fewer-database-calls/tasks.md rename to openspec/changes/archive/2026-11-09-fewer-database-calls/tasks.md diff --git a/openspec/changes/mark-wrong-choice/.openspec.yaml b/openspec/changes/archive/2026-11-09-mark-wrong-choice/.openspec.yaml similarity index 100% rename from openspec/changes/mark-wrong-choice/.openspec.yaml rename to openspec/changes/archive/2026-11-09-mark-wrong-choice/.openspec.yaml diff --git a/openspec/changes/mark-wrong-choice/proposal.md b/openspec/changes/archive/2026-11-09-mark-wrong-choice/proposal.md similarity index 100% rename from openspec/changes/mark-wrong-choice/proposal.md rename to openspec/changes/archive/2026-11-09-mark-wrong-choice/proposal.md diff --git a/openspec/changes/mark-wrong-choice/specs/exercise-screen/spec.md b/openspec/changes/archive/2026-11-09-mark-wrong-choice/specs/exercise-screen/spec.md similarity index 100% rename from openspec/changes/mark-wrong-choice/specs/exercise-screen/spec.md rename to openspec/changes/archive/2026-11-09-mark-wrong-choice/specs/exercise-screen/spec.md diff --git a/openspec/changes/mark-wrong-choice/tasks.md b/openspec/changes/archive/2026-11-09-mark-wrong-choice/tasks.md similarity index 100% rename from openspec/changes/mark-wrong-choice/tasks.md rename to openspec/changes/archive/2026-11-09-mark-wrong-choice/tasks.md diff --git a/openspec/changes/practice-ten-questions/.openspec.yaml b/openspec/changes/archive/2026-11-09-practice-ten-questions/.openspec.yaml similarity index 100% rename from openspec/changes/practice-ten-questions/.openspec.yaml rename to openspec/changes/archive/2026-11-09-practice-ten-questions/.openspec.yaml diff --git a/openspec/changes/practice-ten-questions/proposal.md b/openspec/changes/archive/2026-11-09-practice-ten-questions/proposal.md similarity index 100% rename from openspec/changes/practice-ten-questions/proposal.md rename to openspec/changes/archive/2026-11-09-practice-ten-questions/proposal.md diff --git a/openspec/changes/practice-ten-questions/specs/exercise-screen/spec.md b/openspec/changes/archive/2026-11-09-practice-ten-questions/specs/exercise-screen/spec.md similarity index 100% rename from openspec/changes/practice-ten-questions/specs/exercise-screen/spec.md rename to openspec/changes/archive/2026-11-09-practice-ten-questions/specs/exercise-screen/spec.md diff --git a/openspec/changes/practice-ten-questions/tasks.md b/openspec/changes/archive/2026-11-09-practice-ten-questions/tasks.md similarity index 100% rename from openspec/changes/practice-ten-questions/tasks.md rename to openspec/changes/archive/2026-11-09-practice-ten-questions/tasks.md diff --git a/openspec/changes/show-answer-pending/.openspec.yaml b/openspec/changes/archive/2026-11-09-show-answer-pending/.openspec.yaml similarity index 100% rename from openspec/changes/show-answer-pending/.openspec.yaml rename to openspec/changes/archive/2026-11-09-show-answer-pending/.openspec.yaml diff --git a/openspec/changes/show-answer-pending/proposal.md b/openspec/changes/archive/2026-11-09-show-answer-pending/proposal.md similarity index 100% rename from openspec/changes/show-answer-pending/proposal.md rename to openspec/changes/archive/2026-11-09-show-answer-pending/proposal.md diff --git a/openspec/changes/show-answer-pending/specs/exercise-screen/spec.md b/openspec/changes/archive/2026-11-09-show-answer-pending/specs/exercise-screen/spec.md similarity index 100% rename from openspec/changes/show-answer-pending/specs/exercise-screen/spec.md rename to openspec/changes/archive/2026-11-09-show-answer-pending/specs/exercise-screen/spec.md diff --git a/openspec/changes/show-answer-pending/tasks.md b/openspec/changes/archive/2026-11-09-show-answer-pending/tasks.md similarity index 100% rename from openspec/changes/show-answer-pending/tasks.md rename to openspec/changes/archive/2026-11-09-show-answer-pending/tasks.md diff --git a/openspec/changes/ten-question-rounds/.openspec.yaml b/openspec/changes/archive/2026-11-09-ten-question-rounds/.openspec.yaml similarity index 100% rename from openspec/changes/ten-question-rounds/.openspec.yaml rename to openspec/changes/archive/2026-11-09-ten-question-rounds/.openspec.yaml diff --git a/openspec/changes/ten-question-rounds/proposal.md b/openspec/changes/archive/2026-11-09-ten-question-rounds/proposal.md similarity index 100% rename from openspec/changes/ten-question-rounds/proposal.md rename to openspec/changes/archive/2026-11-09-ten-question-rounds/proposal.md diff --git a/openspec/changes/ten-question-rounds/specs/exercise-screen/spec.md b/openspec/changes/archive/2026-11-09-ten-question-rounds/specs/exercise-screen/spec.md similarity index 100% rename from openspec/changes/ten-question-rounds/specs/exercise-screen/spec.md rename to openspec/changes/archive/2026-11-09-ten-question-rounds/specs/exercise-screen/spec.md diff --git a/openspec/changes/ten-question-rounds/tasks.md b/openspec/changes/archive/2026-11-09-ten-question-rounds/tasks.md similarity index 100% rename from openspec/changes/ten-question-rounds/tasks.md rename to openspec/changes/archive/2026-11-09-ten-question-rounds/tasks.md diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/.openspec.yaml b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/.openspec.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca7e6273f6d99d2742600212cc8024ce08e30dfe --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-09 diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/proposal.md b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/proposal.md new file mode 100644 index 0000000000000000000000000000000000000000..a8c4f65f54964644b7859085da649e90d67f91bc --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/proposal.md @@ -0,0 +1,29 @@ +# Proposal + +## Why + +Three things from using the app: +- The teacher's tabs give no sense of how much is in each: how many classes, tests, assignments, and how many submissions wait. +- The student's home has become crowded (other tests, all assignments, a notifications card). It should show only what is critical. +- A slow action (a hand-in, a grade, a save) gives no sign that it is working, so people press again or think it failed. + +## What Changes + +- **Teacher tabs show a count** next to each name: classes taught, tests, assignments, and submissions waiting in the inbox. +- **Student home shows only the critical things:** the nearest test with its readiness and today's task, a required simulation, the new assignments (not handed in yet), and "continue the path". The other tests are reached from the test's card; the notifications switch moves to the new tab below. +- **A new student tab "הגשות"** lists every assignment from the teachers, grouped by state: new, sent back for correction, handed in and waiting for a check, and checked. The tab bar becomes: בית, מסלול, הגשות, ציונים, האוסף. +- **Any button whose action takes time shows it**, everywhere in the app: after about a third of a second the button shows a spinner and ignores a second press until the action ends (with reduced motion: "…"). +- The assignments requirements ("My assignments", and the new "Submissions tab") are written into the stage-6 change `add-written-work`, whose delta is not archived yet; the tasks for them are here. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `student-home`: the tab bar gains "הגשות"; home shows only what is critical. +- `accounts-and-classes`: the teacher's tabs show counts. +- `app-basics`: a button that takes time shows it. + +## Impact + +- `mathly2/static` (the tab bars, home, a new submissions screen, a busy-button hook used by every request), `mathly2/teacher.js`, and one new teacher route for the counts. No database change. PRD STU-1, STU-5, a new STU-7, TCH-1 and the design notes. diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/accounts-and-classes/spec.md b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/accounts-and-classes/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..2e0a30997e6846e91bdf58d26fe0e7b6f6ac8281 --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/accounts-and-classes/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Teacher tabs show how many +Each tab of a teacher's screen SHALL show the number of items in it: the classes they teach, their classes' tests, their assignments, and the submissions waiting in the inbox. + +#### Scenario: Counts +- **WHEN** a teacher teaches 2 classes with 3 tests and 4 assignments, and 5 submissions wait +- **THEN** the tabs show 2, 3, 4 and 5 next to their names + +#### Scenario: Nothing waiting +- **WHEN** no submission waits +- **THEN** the inbox tab shows 0 diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/app-basics/spec.md b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/app-basics/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..232420ca751b1de344461a343c4f56e3bf91aaea --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/app-basics/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: A button that takes time shows it +When an action started by a button or a form's submit takes longer than about a third of a second, the button SHALL show that it is working (a spinner) and SHALL ignore another press, until the action ends. This applies to every such button in the app. The indicator MUST NOT depend on motion: with reduced motion the button shows "…" instead of a turning spinner. + +#### Scenario: A slow save +- **WHEN** a person presses a button and the server takes two seconds +- **THEN** after about a third of a second the button shows a spinner and a second press does nothing, and it returns to normal when the answer arrives + +#### Scenario: A quick action +- **WHEN** the server answers within a tenth of a second +- **THEN** the button never shows the spinner + +#### Scenario: Reduced motion +- **WHEN** the device asks for reduced motion and an action is slow +- **THEN** the button shows "…" and nothing turns diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/student-home/spec.md b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/student-home/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..67d66dca9f3f5b342e9a349d5ea9e97c9394414e --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/specs/student-home/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Tab bar +The student's screens (home, path, submissions "הגשות", grades, collection) SHALL be reachable from a tab bar at the bottom of the screen. + +#### Scenario: Switching +- **WHEN** a student taps "ציונים" in the tab bar +- **THEN** the grades screen opens + +#### Scenario: The new tab +- **WHEN** a student taps "הגשות" in the tab bar +- **THEN** the submissions screen opens + +## ADDED Requirements + +### Requirement: Home shows only what is critical +The home SHALL show only: the nearest test with its readiness and today's task, a required simulation, the new assignments (not handed in yet), and the button that continues the path. Other tests, assignments that were handed in or checked, and settings such as notifications SHALL NOT be on home; the other tests are reached from the test's card. + +#### Scenario: Nothing critical +- **WHEN** a student has no upcoming test and no new assignment +- **THEN** home shows only the button that continues the path + +#### Scenario: Two tests +- **WHEN** a student has two upcoming tests +- **THEN** home shows the nearest one, and the other is listed on that test's card diff --git a/openspec/changes/archive/2026-11-09-work-tabs-and-hints/tasks.md b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..6d8229f12b73c28cfb36541a230eeb3fa285fe4e --- /dev/null +++ b/openspec/changes/archive/2026-11-09-work-tabs-and-hints/tasks.md @@ -0,0 +1,22 @@ +# Tasks + +## 1. A button that takes time shows it + +- [x] 1.1 Add one hook that every request goes through: the button or form submitter that started it gets a spinner after about a third of a second, ignores a second press until the request ends, and shows "…" under reduced motion; covers `api()` and the picture uploads, so every button in the app (teacher, manager, admin, developer, student) is covered without editing each handler; verify a Playwright test with a held request for a button of each kind (a plain button, a form's submit, a picture upload), a quick request shows nothing, a second press during the wait sends nothing, and the state clears after success and after an error + +## 2. The teacher's counts + +- [x] 2.1 Add `GET /api/teaching/counts` (classes taught, tests, assignments, submissions waiting) and show the number on every tab of the teacher's screen, refreshed after each action; verify API tests (2 classes, 3 tests, 4 assignments, 5 waiting; a removed test or assignment is not counted; another teacher's items are not counted; the permission matrix) and a Playwright test that the numbers show on all four tabs and change after grading + +## 3. The student's home and the new tab + +- [x] 3.1 Make home show only what is critical: remove the other-tests list and the notifications card from home, list the other tests on the test's card, and show under "המשימות שלי" only the new assignments; verify a Playwright test with several tests and assignments in every state (home shows the nearest test, a required simulation, only the new assignments, and the continue button) +- [x] 3.2 Add the "הגשות" screen and tab (the tab bar becomes בית, מסלול, הגשות, ציונים, האוסף, still readable at 360px) with every assignment grouped by state, the sent-back note, the grade and note of checked ones, "באיחור", and the notifications switch at the bottom; verify a Playwright test of each group, that every assignment is listed, and that the switch moved from home + +## 4. Wrap-up + +- [x] 4.1 Run `venv/bin/python -m pytest mathly2/tests` and `openspec validate work-tabs-and-hints --strict` and `openspec validate add-written-work --strict`; verify all pass + +## Note + +- Archive `add-written-work` first: its assignments requirements ("My assignments", "Submissions tab") were changed there, because its delta is not archived yet. diff --git a/openspec/changes/launch-on-space/proposal.md b/openspec/changes/launch-on-space/proposal.md index 36e1637167dcd80de420710485ab43dbedfce41b..e3fd5680fbcb6600934c688be6fddd46b2bbf144 100644 --- a/openspec/changes/launch-on-space/proposal.md +++ b/openspec/changes/launch-on-space/proposal.md @@ -6,7 +6,8 @@ mathly2 must replace mathly1 on its Hugging Face Space without risking mathly1's ## What Changes -- Commit and tag mathly1's current code, including its uncommitted work, so the fallback is exactly what ran before. +- Tag mathly1's code exactly as it runs on the Space (`mathly1-final`, the Space's `main` before this change) so the fallback is exactly what ran before. +- Put mathly2 on the same Space: the `mathly2` branch (with mathly1's latest commits merged in, so the push only adds) becomes the Space's `main`, and the repository's root `Dockerfile` builds mathly2. mathly1's code stays in the repository, and its old Dockerfile is kept as `Dockerfile.mathly1`. - Apply the `m2_` migrations to the shared Supabase project; check that no mathly1 table, policy or bucket changed. - Deploy the `mathly2` image to the Space with its settings (Supabase, teacher, developer and admin emails, AI key, push keys, picture bucket). - Rehearse the fallback: switch the Space back to mathly1, confirm a mathly1 student's data, switch forward again. @@ -22,4 +23,4 @@ No behavior of the app changes here; the behavior is in the earlier stages' spec ## Impact -- The Hugging Face Space, the shared Supabase project, and the repository's tags. Tasks are written when this stage starts. +- The Hugging Face Space, the shared Supabase project, and the repository's tags. The Space's own settings (secrets) are set by its owner. diff --git a/openspec/changes/launch-on-space/tasks.md b/openspec/changes/launch-on-space/tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..f7b4411e21afd383e796dbd63a35d95de54ad5d3 --- /dev/null +++ b/openspec/changes/launch-on-space/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +## 1. Keep the way back + +- [x] 1.1 Tag the Space's current code as `mathly1-final` and push the tag; verify `git rev-parse mathly1-final^{commit}` equals what the Space's `main` was +- [ ] 1.2 Keep mathly1's Dockerfile as `Dockerfile.mathly1`; verify that building it from the tag's tree still works, by reading that its COPY lines name only `app` and `static` + +## 2. Build mathly2 on the Space + +- [ ] 2.1 Merge the Space's latest `main` (mathly1) into the `mathly2` branch so the push only adds; verify `git merge-base --is-ancestor origin/main HEAD` +- [ ] 2.2 Make the root `Dockerfile` build mathly2 (the content of `mathly2/Dockerfile`) and describe mathly2 in the README's text below the Space's front matter, which stays as it is; verify `docker build` of the root Dockerfile succeeds and the container answers `/api/me` with 401 and `/` with the app page +- [ ] 2.3 Verify the container with the Supabase settings from `.env` starts, serves `/`, and refuses a half-configured start; verify no migration or table of mathly1 is touched by reading the migrations for statements on tables without the `m2_` prefix + +## 3. Switch + +- [ ] 3.1 Run the full suite; push the `mathly2` branch to the Space's `main` (a push that only adds); verify the Space builds and `/` answers +- [ ] 3.2 Tell the Space's owner which secrets mathly2 reads (`SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`, `TEACHER_EMAILS`, `DEVELOPER_EMAILS`, `ADMIN_EMAILS`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`); verify the list against `config.py` + +## 4. The fallback + +- [ ] 4.1 Document how to switch back (`git push origin mathly1-final^{commit}:main --force`, noting what it does) in the README; the rehearsal on the live Space is done by the owner after the switch diff --git a/openspec/changes/one-course-for-all-classes/.openspec.yaml b/openspec/changes/one-course-for-all-classes/.openspec.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca7e6273f6d99d2742600212cc8024ce08e30dfe --- /dev/null +++ b/openspec/changes/one-course-for-all-classes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-09 diff --git a/openspec/changes/one-course-for-all-classes/proposal.md b/openspec/changes/one-course-for-all-classes/proposal.md new file mode 100644 index 0000000000000000000000000000000000000000..352f197c177973a081322e0482f5ddff8e19dfab --- /dev/null +++ b/openspec/changes/one-course-for-all-classes/proposal.md @@ -0,0 +1,23 @@ +# Proposal + +## Why + +Choosing the lessons of each class is a chore nobody needs: every class studies the whole bank, as one course. The course should have a name students recognise: "מתמטיקה כיתה ט - מצוינות". + +## What Changes + +- **A class no longer has chosen lessons.** Every class studies every lesson of the bank. The class form has no lesson list, and class lists do not show lessons. +- **A student in any class sees every lesson** (the path, the skills, the collection). +- **The subject card is named "מתמטיקה כיתה ט - מצוינות"** (the course name), instead of "מתמטיקה". +- A test still covers the lessons the teacher picks for it (unchanged). +- Classes saved earlier with some lessons are treated the same: the stored list is no longer used to limit anything. No database change. + +## Capabilities + +### Modified Capabilities +- `accounts-and-classes`: classes have no chosen lessons; a student's lessons are all of them. +- `learning-path`: the subject card carries the course name. + +## Impact + +- `classes.py` (no lessons in the body; the stored list is all lessons), `skills.py` and `main.py` (visibility), `manager.js`, `path.js`. PRD sections on classes and the subject card. diff --git a/openspec/changes/one-course-for-all-classes/specs/accounts-and-classes/spec.md b/openspec/changes/one-course-for-all-classes/specs/accounts-and-classes/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..5723c2c50289699fafbd1a9a88abd3889cdd3cb2 --- /dev/null +++ b/openspec/changes/one-course-for-all-classes/specs/accounts-and-classes/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: Manager creates classes +A manager (or a developer) SHALL create, edit and delete classes. A class SHALL have a name (1–80 characters, unique ignoring case), its students, at most one teacher (an email, never also a student of that class), an AI switch that starts off, and a daily AI budget (default 150). A class SHALL NOT have chosen lessons: every class studies every lesson of the bank. + +#### Scenario: New class +- **WHEN** a manager creates "ט'3" with a teacher and 25 students +- **THEN** "ט'3" appears in the class list with that teacher and 25 students, its AI switch is off, and the form never asked for lessons + +#### Scenario: Teacher listed as a student +- **WHEN** a manager names "t@school.org" as the teacher and also lists it as a student of the same class +- **THEN** the class is not saved and the manager is told why + +#### Scenario: A refused save keeps what was typed +- **WHEN** a manager changes a class's name and teacher, presses save at the bottom of the form, and the save is refused +- **THEN** the reason is shown next to the save button and in view, the typed name and teacher are still in the form, and nothing was saved + +### Requirement: Editing and deleting a class +A manager SHALL be able to change a class's name, students, teacher, AI switch and budget, and delete a class after confirming. Neither SHALL delete any student's progress. + +#### Scenario: Deleting a class +- **WHEN** a manager deletes a class and confirms +- **THEN** the class is gone, and its students' progress is still there + +### Requirement: Class and student lists +A manager SHALL see every class with its name, number of students and their emails and its teacher, and a list of all students with the classes each belongs to. + +#### Scenario: A student in two classes +- **WHEN** a manager opens the students list and a student belongs to "ט'3" and "ט'4" +- **THEN** that student's row shows both classes + +### Requirement: Teacher home +A teacher's home SHALL list the classes they teach, each with its students. A teacher MUST NOT be able to change a class's name, teacher, AI switch or budget. + +#### Scenario: Two classes +- **WHEN** a teacher who teaches "ט'3" and "ט'4" signs in +- **THEN** their home lists both classes with their students + +#### Scenario: No class yet +- **WHEN** a teacher who teaches no class signs in +- **THEN** their home says that a manager chooses who teaches each class + +### Requirement: A student's lessons +A student who belongs to at least one class SHALL have every lesson of the bank, whatever lessons were stored for their classes before. A student in no class has none. + +#### Scenario: Two classes, different lessons +- **WHEN** a student is in one class stored with lessons A and B and another stored with B and C +- **THEN** the student's lessons are every lesson of the bank + +#### Scenario: A class saved with some lessons +- **WHEN** a student is in a class that was saved earlier with only lessons A and B +- **THEN** the student's path has every lesson + +#### Scenario: No class +- **WHEN** a student who is in no class signs in +- **THEN** they see no lessons diff --git a/openspec/changes/one-course-for-all-classes/specs/learning-path/spec.md b/openspec/changes/one-course-for-all-classes/specs/learning-path/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..3139939106616a94d3c04a96c9fc2cc70dd1c6a1 --- /dev/null +++ b/openspec/changes/one-course-for-all-classes/specs/learning-path/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Choosing a subject +After sign-in a student SHALL first see the subjects of their classes as cards, each with its name and a bar "N מתוך M מיומנויות". Math is the only subject, and its card is named "מתמטיקה כיתה ט - מצוינות". Tapping a card SHALL open the subject's home, reached by the tab bar, and every screen of the subject SHALL have a way back to the subjects. + +#### Scenario: After signing in +- **WHEN** a student signs in +- **THEN** they see a card "מתמטיקה כיתה ט - מצוינות" with "N מתוך M מיומנויות", and no path yet + +#### Scenario: Opening the subject +- **WHEN** the student taps the math card +- **THEN** the math home opens, with the tab bar and a way back to the subjects + +#### Scenario: A link to a skill +- **WHEN** a student opens `#/skill/` directly or reloads the page on it +- **THEN** the math path opens with that skill's exercise screen over it diff --git a/openspec/changes/one-course-for-all-classes/tasks.md b/openspec/changes/one-course-for-all-classes/tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..1fd5b96a0446f7ec278775ac69bebfbf6e2316a1 --- /dev/null +++ b/openspec/changes/one-course-for-all-classes/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +## 1. Server + +- [x] 1.1 Make a class save without lessons (the stored list is every lesson of the bank, any `lessons` in a request is ignored) and give every student who is in a class every lesson (path, skills, collection, lessons list); verify API tests: a class created with no lessons works, a class stored with two lessons still gives its student the whole path, a student in no class sees none + +## 2. Screens + +- [x] 2.1 Take the lesson list out of the manager's class form and class cards, and name the subject card "מתמטיקה כיתה ט - מצוינות"; verify browser tests: the form has no lesson checkboxes and a class saves, the card shows the course name + +## 3. Wrap-up + +- [x] 3.1 Update the PRD (classes, subject card), run the full suite and `openspec validate one-course-for-all-classes --strict`; verify both pass diff --git a/openspec/specs/accounts-and-classes/spec.md b/openspec/specs/accounts-and-classes/spec.md index 05703647815d8386c4ffd7f9a3265ec759e7939b..9e9b5678b26275e6c65d8ecf52a9ea2b78595ddc 100644 --- a/openspec/specs/accounts-and-classes/spec.md +++ b/openspec/specs/accounts-and-classes/spec.md @@ -187,3 +187,14 @@ The server SHALL check the sign-in and the caller's permissions on every request #### Scenario: No sign-in - **WHEN** any request other than the health check arrives without a valid sign-in - **THEN** the server refuses it + +### Requirement: Teacher tabs show how many +Each tab of a teacher's screen SHALL show the number of items in it: the classes they teach, their classes' tests, their assignments, and the submissions waiting in the inbox. + +#### Scenario: Counts +- **WHEN** a teacher teaches 2 classes with 3 tests and 4 assignments, and 5 submissions wait +- **THEN** the tabs show 2, 3, 4 and 5 next to their names + +#### Scenario: Nothing waiting +- **WHEN** no submission waits +- **THEN** the inbox tab shows 0 diff --git a/openspec/specs/app-basics/spec.md b/openspec/specs/app-basics/spec.md index 83c98388d387acec2fec029ba611bce585cf4db6..7328abbbfd4157a35a3739060ba24ce2f20809fd 100644 --- a/openspec/specs/app-basics/spec.md +++ b/openspec/specs/app-basics/spec.md @@ -98,3 +98,59 @@ The app SHALL declare its icon (mathly1's, from `static/icons/`), so the browser #### Scenario: Opening the app - **WHEN** someone opens the app - **THEN** the tab shows the app's icon and no request for an icon fails + +### Requirement: A button that takes time shows it +When an action started by a button or a form's submit takes longer than about a third of a second, the button SHALL show that it is working (a spinner) and SHALL ignore another press, until the action ends. This applies to every such button in the app. The indicator MUST NOT depend on motion: with reduced motion the button shows "…" instead of a turning spinner. + +#### Scenario: A slow save +- **WHEN** a person presses a button and the server takes two seconds +- **THEN** after about a third of a second the button shows a spinner and a second press does nothing, and it returns to normal when the answer arrives + +#### Scenario: A quick action +- **WHEN** the server answers within a tenth of a second +- **THEN** the button never shows the spinner + +#### Scenario: Reduced motion +- **WHEN** the device asks for reduced motion and an action is slow +- **THEN** the button shows "…" and nothing turns + +### Requirement: Few database round trips +The server SHALL read the same fact once per request, make reads that do not depend on each other at the same time, and keep its connections to the database open for reuse. Opening a student's screen (home, path, skill, grades, collection) SHALL wait for the database at most 3 times in a row, and grading one practice answer at most 4 times in a row. + +#### Scenario: Opening a screen +- **WHEN** a student opens the path, home, a skill, the grades or the collection and every database call takes 0.1 seconds +- **THEN** the screen's data arrives in under 0.55 seconds + +#### Scenario: Answering +- **WHEN** a student answers a practice question and every database call takes 0.1 seconds +- **THEN** the answer is graded in under 0.75 seconds + +#### Scenario: Connections +- **WHEN** the server makes several calls to the database in a row +- **THEN** they reuse an open connection instead of opening a new one each time + +### Requirement: Slow-changing facts are remembered briefly +The server SHALL remember the classes, their students, the roles, the tests and the assignments for at most 30 seconds, so a screen that needs them does not wait for the database again. Any change made through the app to one of them SHALL make the server forget the remembered copy at once. The time SHALL be a setting, and 0 SHALL turn it off. + +#### Scenario: Opened again +- **WHEN** a teacher opens the assignments tab twice within 30 seconds and every database call takes 0.1 seconds +- **THEN** the second time needs fewer waits in a row than the first + +#### Scenario: A change +- **WHEN** a teacher gives an assignment and then opens the assignments tab +- **THEN** the new assignment is there, not an old remembered list + +### Requirement: A screen shows what it last knew +A screen opened again SHALL show the data it last had at once and refresh it in the background; the tab numbers of a teacher's screen SHALL show their last values at once. After a change the person made, nothing remembered SHALL be shown: the screen waits for fresh data. A refresh MUST NOT redraw the screen while the person is typing in it or has something open. + +#### Scenario: Back to a tab +- **WHEN** a teacher goes to another tab and back, and the server takes two seconds to answer +- **THEN** the tab shows its last content and numbers at once, and shows the new ones when they arrive + +#### Scenario: After grading +- **WHEN** a teacher grades a submission and the inbox is shown again +- **THEN** the graded submission is not in it, not even for a moment + +#### Scenario: Typing +- **WHEN** fresh data arrives while a teacher is typing in a form on the screen +- **THEN** what they typed is still there diff --git a/openspec/specs/assignments/spec.md b/openspec/specs/assignments/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..60c23b8ae6f5bd190680aa9f5b52b18e1521e2c0 --- /dev/null +++ b/openspec/specs/assignments/spec.md @@ -0,0 +1,60 @@ +# assignments Specification + +## Purpose +Lets a teacher give work from any material to a class, and check what students hand in, from one inbox. + +## Requirements + +### Requirement: Giving an assignment +A teacher SHALL create an assignment for a class they teach, with a title, a note and a due date, and its pages from: a file from the computer (PDF or images), a photo from the device camera, the board, or one of the written quizzes authored in the bank. + +#### Scenario: From the board +- **WHEN** a teacher writes an assignment on the board and saves it +- **THEN** the board's pages become the assignment's pages + +#### Scenario: From the bank +- **WHEN** a teacher picks the bank's written quiz of "טכניקה אלגברית 1" +- **THEN** its four questions become the assignment, with their points + +### Requirement: An exercise test as an assignment +A teacher SHALL be able to send one of their real test's two exercise tests to the whole class as an assignment with a due date. Students SHALL see it under "המשימות שלי"; it opens the exercise test, counts as done when the student has taken it, and its grade is the exercise test's best grade. It is not handed in as a picture and does not wait in the inbox. + +#### Scenario: Sent to the class +- **WHEN** a teacher sends exercise test 1 of a test to the class with a due date +- **THEN** every student sees it in "המשימות שלי", and it opens the exercise test + +#### Scenario: Not in the inbox +- **WHEN** a student finishes the exercise test +- **THEN** the assignment shows as done with the test's grade, and nothing is added to the teacher's inbox + +### Requirement: My assignments +Home SHALL show under "המשימות שלי" only the new assignments, those the student has not handed in (or, for an exercise test, not taken), with the due date. The student SHALL open one, work on it on the board or on paper, and hand it in. + +#### Scenario: Hand-in +- **WHEN** a student hands in an assignment from the board +- **THEN** it shows as handed in, and the teacher's inbox lists it + +### Requirement: Submissions tab +A tab "הגשות" SHALL list every assignment from the student's teachers, grouped by state: new (not handed in), sent back for correction, handed in and waiting for a check, and checked (with the grade and the teacher's note). Each assignment SHALL show its due date and open its page; an assignment past its due date and not handed in SHALL be marked "באיחור". + +#### Scenario: Grouped by state +- **WHEN** a student has one new, one sent-back, one waiting and one checked assignment +- **THEN** the tab shows four groups, each with its assignment, and the sent-back one shows the teacher's note + +#### Scenario: All of them +- **WHEN** a student has handed in everything +- **THEN** home shows no assignments, and the tab shows all of them under "הוגשו" and "נבדקו" + +### Requirement: Teacher inbox +A teacher SHALL have one inbox of everything their students handed in that waits for them, oldest first: submissions not checked yet and requests to look again. A submission sent back waits for the student, not in the inbox. + +#### Scenario: Oldest first +- **WHEN** two students hand in on Monday and Tuesday +- **THEN** Monday's submission is first in the inbox + +### Requirement: Checking a submission +The teacher SHALL open a submission, give a grade from 0 to 100 and a note, or send it back for correction with a note. The student SHALL see the grade and note, or the request to correct. + +#### Scenario: Sent back +- **WHEN** a teacher sends a submission back with "חסר נימוק בסעיף 2" +- **THEN** the student sees the note and can hand in again diff --git a/openspec/specs/exercise-screen/spec.md b/openspec/specs/exercise-screen/spec.md index 2f6e4967861f102a0c593e7e79534eb93d11cf37..c464285c19e4870aec9dce4570c0f427adc6d018 100644 --- a/openspec/specs/exercise-screen/spec.md +++ b/openspec/specs/exercise-screen/spec.md @@ -13,33 +13,53 @@ The exercise screen SHALL open as a sheet over the path. Its header SHALL show t - **THEN** the sheet shows the lesson's name, "פירוק טרינום", the board, "?" and close buttons, and the explanation ### Requirement: One question at a time -The sheet SHALL show one question: its text with formulas, its figure or graph if any, and its choices in a new random order each time it is shown, with "question n of N" and a row of dots for the skill's questions. +The sheet SHALL show one question: its text with formulas, its figure or graph if any, and its choices in a new random order each time it is shown, with "question n of N" and a row of N dots, one for each question of the round. A round SHALL be the skill's practice questions, at most 10, each shown once. A dot SHALL be filled for a right answer and marked "✗" for a wrong one, and empty for a question not yet answered. #### Scenario: Same question again - **WHEN** a question is shown a second time - **THEN** its choices may be in a different order, and the server still grades by the bank's choice +#### Scenario: A skill with 30 questions +- **WHEN** a student opens a skill that has 30 questions in the bank +- **THEN** the sheet says "question n of 10" and shows 10 dots, and only the first 10 questions come up in the round + +#### Scenario: Dots after a mistake +- **WHEN** a student answers the 1st question right and the 2nd wrongly +- **THEN** the first dot is filled, the second is marked wrong, and the sheet says "question 3 of 10" + ### Requirement: A right answer -A right answer SHALL get a short celebration and the count of right answers in a row. After a short countdown the next question SHALL appear; the student can skip or cancel the countdown. +A right answer SHALL get a short celebration and the count of right answers in a row. After a short countdown the next question SHALL appear; the student can skip or cancel the countdown. After the last question of the round there is no countdown: the sheet offers the round's result. #### Scenario: Countdown - **WHEN** a student answers correctly - **THEN** a countdown to the next question starts, and pressing "next" shows it at once ### Requirement: A wrong answer -A wrong answer SHALL be quiet and SHALL show the name and tip of the mistake attached to the chosen choice, about the mistake and not the student. It MUST NOT reveal the correct answer. When the mistake names a skill to go back to, the feedback SHALL link to it. +A wrong answer SHALL be quiet and SHALL show the name and tip of the mistake attached to the chosen choice, about the mistake and not the student. The chosen choice SHALL be marked as wrong with a symbol and a word on the choice itself ("✗ לא נכון"), not by colour alone, and no other choice SHALL be marked. It MUST NOT reveal the correct answer, and the question MUST NOT come back in the same round. The sheet SHALL offer "לשאלה הבאה" (after the last question: the round's result). When the mistake names a skill to go back to, the feedback SHALL link to it. #### Scenario: Sign mistake - **WHEN** a student picks a choice tagged with "טעות סימן באיבר האמצעי" - **THEN** the mistake's name and tip are shown and no choice is marked as correct +#### Scenario: The chosen choice is marked +- **WHEN** a student picks a wrong choice +- **THEN** that choice shows "✗ לא נכון" and a stronger border, and every other choice looks as it did + +#### Scenario: A wrong question does not return +- **WHEN** a student answers the 3rd question wrongly +- **THEN** it is not shown again in the round, and the round ends after the 10th question + ### Requirement: A different next question -After any answer the next question SHALL be another question of the same skill, picked at random among those this student has seen least, never the one just answered. A skill with one question repeats it. +After any answer the next question SHALL be a question of the round that was not answered yet, picked at random. When every question of the round was answered, there is no next question and the sheet shows the round's result. A skill with one question has a round of one question. #### Scenario: After a mistake - **WHEN** a student answers a question wrongly - **THEN** the next question is not the one just answered +#### Scenario: Never twice in a round +- **WHEN** a student answers all 10 questions of a skill +- **THEN** each question was shown exactly once, and none came back + ### Requirement: Help The "?" button SHALL show the skill's explanation and, where the bank has one, a solved example. Opening it SHALL make the answer to the question on screen not count for mastery; that answer still counts for the daily goal. @@ -67,3 +87,36 @@ A skill with no questions SHALL say there is no practice for it yet, and cannot #### Scenario: Empty skill - **WHEN** a student opens a skill with no questions - **THEN** the sheet says there is no practice yet + +### Requirement: A round's result +After the last answer of a round the sheet SHALL show how many questions were answered right out of the round's questions ("N מתוך 10") and, for each wrong answer, the question, the mistake's name and tip and a link to the skill to go back to when the bank names one. It MUST NOT show the correct answers. The result SHALL be shown again after leaving and returning, until a new round is started. + +#### Scenario: Result +- **WHEN** a student answers the 10th question of a round with 7 right +- **THEN** the sheet shows "7 מתוך 10" and the three mistakes with their names and tips + +#### Scenario: Leaving in the middle +- **WHEN** a student leaves after the 4th question and comes back +- **THEN** the round continues with the 5th question, and the dots show the first four answers + +### Requirement: A new round +The result SHALL offer "סבב חדש", which starts a new round with all the skill's practice questions again in a new random order, and a way back to the path. Mastery SHALL be counted across rounds as before. + +#### Scenario: Another round +- **WHEN** a student presses "סבב חדש" after a finished round +- **THEN** a round of the 10 questions starts again, with empty dots + +#### Scenario: Coming back after a finished round +- **WHEN** a student leaves after finishing a round and opens the skill again +- **THEN** the result is shown, and "סבב חדש" starts a round of the 10 questions again + +### Requirement: Thinking indicator +As soon as a choice is tapped, the sheet SHALL show that the answer is being checked: the tapped choice shows a spinner, a status line says "בודקים…", and the other choices are dimmed and cannot be tapped. The indicator SHALL go away when the answer arrives or fails, and it MUST NOT depend on motion: with reduced motion the words are shown and the spinner is still. + +#### Scenario: A slow answer +- **WHEN** a student taps a choice and the server takes two seconds +- **THEN** during those two seconds the tapped choice shows a spinner, "בודקים…" is shown and the other choices cannot be tapped, and the result replaces them when it arrives + +#### Scenario: An error +- **WHEN** the answer cannot be sent +- **THEN** the indicator goes away, the choices can be tapped again and the error is shown diff --git a/openspec/specs/student-home/spec.md b/openspec/specs/student-home/spec.md index 26065c2f60951c0cde39d352863a7b64add0b696..c4d041047c538961348585f4707744a4444bde06 100644 --- a/openspec/specs/student-home/spec.md +++ b/openspec/specs/student-home/spec.md @@ -27,8 +27,23 @@ Home SHALL have a button that opens the path's next skill. - **THEN** home shows the button to continue at the next skill ### Requirement: Tab bar -The student's screens (home, path, grades, collection) SHALL be reachable from a tab bar at the bottom of the screen. +The student's screens (home, path, submissions "הגשות", grades, collection) SHALL be reachable from a tab bar at the bottom of the screen. #### Scenario: Switching - **WHEN** a student taps "ציונים" in the tab bar - **THEN** the grades screen opens + +#### Scenario: The new tab +- **WHEN** a student taps "הגשות" in the tab bar +- **THEN** the submissions screen opens + +### Requirement: Home shows only what is critical +The home SHALL show only: the nearest test with its readiness and today's task, a required simulation, the new assignments (not handed in yet), and the button that continues the path. Other tests, assignments that were handed in or checked, and settings such as notifications SHALL NOT be on home; the other tests are reached from the test's card. + +#### Scenario: Nothing critical +- **WHEN** a student has no upcoming test and no new assignment +- **THEN** home shows only the button that continues the path + +#### Scenario: Two tests +- **WHEN** a student has two upcoming tests +- **THEN** home shows the nearest one, and the other is listed on that test's card diff --git a/openspec/specs/written-answers/spec.md b/openspec/specs/written-answers/spec.md new file mode 100644 index 0000000000000000000000000000000000000000..b5e4fb8232df55402f42e59836a365163304548d --- /dev/null +++ b/openspec/specs/written-answers/spec.md @@ -0,0 +1,45 @@ +# written-answers Specification + +## Purpose +Lets a student hand in a handwritten or drawn solution as a picture from whatever device they have, and keeps those pictures private and only as long as needed. + +## Requirements + +### Requirement: Handing in a written answer +A written answer SHALL be handed in as a picture: from the board, or by photographing paper. On a phone the camera opens; on a computer the student uploads an image or asks their own phone to take the photo. + +#### Scenario: Photo on a phone +- **WHEN** a student on a phone chooses to photograph their answer +- **THEN** the camera opens and the picture is handed in + +### Requirement: Photo request to the phone +A computer SHALL be able to create a photo request for one answer. The student's phone, signed in to the same account, SHALL show the request with a 4-digit code that matches the one on the computer. The computer SHALL show the state: waiting for the phone, uploading, received, failed, expired, or replaced by a newer request. + +#### Scenario: Matching code +- **WHEN** a student asks for a photo from the computer and opens the app on their phone +- **THEN** the phone shows the same 4-digit code as the computer and a camera button + +#### Scenario: Newer request +- **WHEN** a student makes a second photo request for the same answer +- **THEN** the first one shows as replaced + +### Requirement: Push notification to the phone +A phone SHALL be able to turn notifications on. When the computer creates a photo request, the phone SHALL be notified even if the app is closed; tapping the notification SHALL open the request. Only phones subscribe; a subscription the push service no longer knows SHALL be deleted. + +#### Scenario: App closed +- **WHEN** a student asks for a photo from the computer while the app is closed on their phone +- **THEN** the phone shows a notification that opens the request + +### Requirement: Private pictures +Answer pictures SHALL be kept in private storage tied to the account and read only by the server. Pictures no answer refers to SHALL be removed every hour. + +#### Scenario: Direct link +- **WHEN** someone tries to open an answer picture without going through the server +- **THEN** it is refused + +### Requirement: Keeping pictures for a school year +Answer pictures SHALL be removed after 31 August of the school year they were handed in; grades and teachers' comments stay. A teacher of the class, a manager or a developer SHALL be able to delete a student's pictures earlier on request. + +#### Scenario: New school year +- **WHEN** 1 September comes +- **THEN** last year's answer pictures are gone and their grades remain