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 `
${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 ? `
עוד אין ציונים. הבוחן של כל שיעור מחכה בסוף השיעור במסלול.
` : ""}`);
}
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) {