3v324v23 Claude Sonnet 5.5 commited on
Commit
0815e01
·
1 Parent(s): a7ffb22

mathly2: written work, rounds, caching, one course for all classes, lesson schedule

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. docs/tasks/prd-mathly.md +21 -15
  2. mathly2/assignments.py +390 -0
  3. mathly2/attempts.py +6 -2
  4. mathly2/cached.py +69 -0
  5. mathly2/classes.py +37 -12
  6. mathly2/cleanup.py +83 -0
  7. mathly2/config.py +8 -0
  8. mathly2/main.py +37 -7
  9. mathly2/pictures.py +193 -0
  10. mathly2/push.py +133 -0
  11. mathly2/quiz.py +4 -3
  12. mathly2/quizzes.py +2 -1
  13. mathly2/reads.py +1 -1
  14. mathly2/requirements.txt +3 -0
  15. mathly2/retention.py +17 -0
  16. mathly2/skills.py +12 -6
  17. mathly2/static/api.js +41 -3
  18. mathly2/static/app.css +67 -1
  19. mathly2/static/assignment.js +144 -0
  20. mathly2/static/busy.js +54 -0
  21. mathly2/static/grades.js +10 -4
  22. mathly2/static/home.js +32 -7
  23. mathly2/static/index.html +2 -0
  24. mathly2/static/manager.js +26 -12
  25. mathly2/static/manifest.webmanifest +18 -0
  26. mathly2/static/media.js +55 -0
  27. mathly2/static/notifications.js +40 -0
  28. mathly2/static/path.js +21 -8
  29. mathly2/static/phone.js +117 -0
  30. mathly2/static/service-worker.js +30 -0
  31. mathly2/static/session.js +5 -0
  32. mathly2/static/student.js +9 -0
  33. mathly2/static/submissions.js +33 -0
  34. mathly2/static/teacher.js +41 -9
  35. mathly2/static/teacher_work.js +167 -0
  36. mathly2/static/testcard.js +5 -2
  37. mathly2/static/ui.js +1 -0
  38. mathly2/store.py +368 -1
  39. mathly2/supabase/migrations/20261112090000_m2_written_work.sql +94 -0
  40. mathly2/supabase/migrations/20261113090000_m2_curriculum.sql +12 -0
  41. mathly2/tests/serve.py +7 -0
  42. mathly2/tests/test_assignments_api.py +502 -0
  43. mathly2/tests/test_busy_browser.py +110 -0
  44. mathly2/tests/test_cached_store.py +97 -0
  45. mathly2/tests/test_cached_waits.py +48 -0
  46. mathly2/tests/test_classes.py +9 -13
  47. mathly2/tests/test_curriculum_api.py +54 -0
  48. mathly2/tests/test_foundation_screens_browser.py +2 -4
  49. mathly2/tests/test_home_browser.py +10 -7
  50. mathly2/tests/test_instant_browser.py +67 -0
docs/tasks/prd-mathly.md CHANGED
@@ -18,7 +18,7 @@ The app has five roles (see 4.1):
18
  Students can also get help with a photographed problem and have handwritten answers checked, always through their teacher.
19
  - **Teachers** give and check assignments, set tests, review the AI's checks of their students' work, and see the
20
  progress of the classes they teach, per skill and per lesson.
21
- - **Managers** create the classes: name, lessons, teacher and students.
22
  - **Admins** give roles to accounts.
23
  - **Developers** (the app's operators) have every permission and the developer tools.
24
 
@@ -34,7 +34,7 @@ reused as files (see 7.1); everything else is rebuilt.
34
  | skill | מיומנות | A specific topic, e.g. פירוק טרינום. Shown on the path as a star |
35
  | exercise | תרגיל | A question on a certain topic. Can be either open-ended or multiple choice |
36
  | lesson | שיעור | A set of skills, e.g. טכניקה אלגברית 1. Shown on the path as a hub with its skills around it |
37
- | class | כיתה | A named group of students with one teacher and the lessons it studies |
38
  | 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 |
39
  | exercise test | מבחן תרגול | A timed test on a set of lessons. Can be retaken; best grade wins |
40
  | 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.
82
  - As a teacher, I want to see how much of each lesson each student has covered.
83
 
84
  **Manager**
85
- - As a manager, I want to create a class with its lessons, its teacher and its students, so a new group can start practicing.
86
  - 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.
87
 
88
  **Admin**
@@ -135,15 +135,14 @@ Requirements are grouped; each id (for example `ACC-3`) is stable and is what Op
135
 
136
  - **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).
137
  A class's teacher can also add and remove the students of that class, and nothing else about it (TCH-9).
138
- - **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),
139
- 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).
140
  - **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
141
  invalid ones are reported. A student may belong to more than one class.
142
  - **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.
143
- - **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.
144
  - **CLS-6 Deleting.** The manager can delete a class after confirming. Deleting a class does not delete its students' progress.
145
- - **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.
146
- - **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).
147
  Within those lessons everything is open (PTH-4).
148
 
149
  ### 4.3 The exercise bank (BNK)
@@ -179,8 +178,10 @@ The path is the student's main screen: a trail of stars.
179
  - **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.
180
  Animations stop when the device asks for reduced motion. The path is dark, like the rest of the app.
181
  - **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
182
- 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.
183
- 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.
 
 
184
  - **PTH-8 Phone first.** The path fits a 360px-wide screen without sideways scrolling, including the lesson with the most skills.
185
 
186
  ### 4.5 The exercise screen (EXR)
@@ -271,10 +272,11 @@ The exercise screen is where a student practices one skill.
271
 
272
  - **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;
273
  the best grade counts, and the quiz's best grade is the lesson's grade.
274
- - **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**.
275
  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.
276
  The smart board and the calculator are available.
277
- - **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.
 
278
  - **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).
279
  The correct answers are never shown, so a retake tests understanding and not memory.
280
  - **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.
382
 
383
  ### 4.16 Student screens (STU)
384
 
385
- A tab bar at the bottom of the screen leads between them.
386
 
387
- - **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).
388
  - **STU-2 Path:** see 4.4.
389
  - **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
390
  - **STU-4 Collection:** see MST-7.
391
  - **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.
 
392
  - **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
393
  (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.
394
  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.
399
 
400
  ### 4.17 Teacher screens (TCH)
401
 
402
- - **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.
403
  - **TCH-2 Give an assignment.** The teacher creates an assignment (title, note, due date) and loads its content from:
404
  - the computer (file upload: PDF or images);
405
  - a photo taken with the device camera;
@@ -424,6 +427,7 @@ A tab bar at the bottom of the screen leads between them.
424
  ### 4.18 Manager screens (MGR)
425
 
426
  - **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.
 
427
  - **MGR-2 Students.** A list of all students with the classes each belongs to.
428
 
429
  ### 4.19 Admin screens (ADM)
@@ -460,6 +464,7 @@ A tab bar at the bottom of the screen leads between them.
460
 
461
  - 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.
462
  - 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.
 
463
  - Math is written in `$…$` text in the exercise bank; the app must render it as formulas.
464
  - State and result never rely on color alone: every state has a symbol or a word (stars, heat map cells, feedback).
465
  - 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.
502
  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.
503
  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.
504
  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).
 
505
  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.
506
  - **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.
507
  Grades that matter cannot be changed from the device.
 
18
  Students can also get help with a photographed problem and have handwritten answers checked, always through their teacher.
19
  - **Teachers** give and check assignments, set tests, review the AI's checks of their students' work, and see the
20
  progress of the classes they teach, per skill and per lesson.
21
+ - **Managers** create the classes: name, teacher and students.
22
  - **Admins** give roles to accounts.
23
  - **Developers** (the app's operators) have every permission and the developer tools.
24
 
 
34
  | skill | מיומנות | A specific topic, e.g. פירוק טרינום. Shown on the path as a star |
35
  | exercise | תרגיל | A question on a certain topic. Can be either open-ended or multiple choice |
36
  | lesson | שיעור | A set of skills, e.g. טכניקה אלגברית 1. Shown on the path as a hub with its skills around it |
37
+ | class | כיתה | A named group of students with one teacher. Every class studies the whole course |
38
  | 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 |
39
  | exercise test | מבחן תרגול | A timed test on a set of lessons. Can be retaken; best grade wins |
40
  | simulation test | מבחן סימולציה | A timed test on a set of lessons. One time only |
 
82
  - As a teacher, I want to see how much of each lesson each student has covered.
83
 
84
  **Manager**
85
+ - As a manager, I want to create a class with its teacher and its students, so a new group can start practicing.
86
  - 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.
87
 
88
  **Admin**
 
135
 
136
  - **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).
137
  A class's teacher can also add and remove the students of that class, and nothing else about it (TCH-9).
138
+ - **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).
 
139
  - **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
140
  invalid ones are reported. A student may belong to more than one class.
141
  - **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.
142
+ - **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.
143
  - **CLS-6 Deleting.** The manager can delete a class after confirming. Deleting a class does not delete its students' progress.
144
+ - **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.
145
+ - **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.
146
  Within those lessons everything is open (PTH-4).
147
 
148
  ### 4.3 The exercise bank (BNK)
 
178
  - **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.
179
  Animations stop when the device asks for reduced motion. The path is dark, like the rest of the app.
180
  - **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
181
+ 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.
182
+ 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.
183
+ - **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 "הושלם".
184
+ - **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 "היה אמור להסתיים ב־<date>", 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).
185
  - **PTH-8 Phone first.** The path fits a 360px-wide screen without sideways scrolling, including the lesson with the most skills.
186
 
187
  ### 4.5 The exercise screen (EXR)
 
272
 
273
  - **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;
274
  the best grade counts, and the quiz's best grade is the lesson's grade.
275
+ - **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).
276
  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.
277
  The smart board and the calculator are available.
278
+ - **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.
279
+ - **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.
280
  - **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).
281
  The correct answers are never shown, so a retake tests understanding and not memory.
282
  - **QZ-5 Grades on the server.** Quiz grades and timers are computed and stored on the server.
 
384
 
385
  ### 4.16 Student screens (STU)
386
 
387
+ A tab bar at the bottom of the screen leads between them: home, path, submissions (STU-7), grades and collection.
388
 
389
+ - **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).
390
  - **STU-2 Path:** see 4.4.
391
  - **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
392
  - **STU-4 Collection:** see MST-7.
393
  - **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.
394
+ - **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.
395
  - **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
396
  (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.
397
  The button appears in two places, and only for students of a class with the AI on (AI-10):
 
402
 
403
  ### 4.17 Teacher screens (TCH)
404
 
405
+ - **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.
406
  - **TCH-2 Give an assignment.** The teacher creates an assignment (title, note, due date) and loads its content from:
407
  - the computer (file upload: PDF or images);
408
  - a photo taken with the device camera;
 
427
  ### 4.18 Manager screens (MGR)
428
 
429
  - **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.
430
+ - **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).
431
  - **MGR-2 Students.** A list of all students with the classes each belongs to.
432
 
433
  ### 4.19 Admin screens (ADM)
 
464
 
465
  - 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.
466
  - 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.
467
+ - 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 "…".
468
  - Math is written in `$…$` text in the exercise bank; the app must render it as formulas.
469
  - State and result never rely on color alone: every state has a symbol or a word (stars, heat map cells, feedback).
470
  - Mastered skills must look clearly different from "learning" and "not started" ones.
 
507
  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.
508
  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.
509
  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).
510
+ 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.
511
  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.
512
  - **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.
513
  Grades that matter cannot be changed from the device.
mathly2/assignments.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """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
2
+ of a real test), students hand in pictures, the teacher checks them from one inbox."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import date as Day
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Request
10
+ from pydantic import BaseModel, Field
11
+ from starlette.concurrency import run_in_threadpool
12
+
13
+ from . import attempts, cleanup, clock, pictures, reads
14
+ from .access import allowed, learner, need
15
+ from .pictures import PictureError
16
+ from .realtests import _my_class, lesson_names
17
+ from .written import picture_error
18
+
19
+ router = APIRouter(prefix="/api")
20
+ MAX_PAGES = 20
21
+
22
+
23
+ class NewAssignment(BaseModel):
24
+ title: str = Field(min_length=1, max_length=200)
25
+ note: str = Field(default="", max_length=1000)
26
+ due_date: Day
27
+ kind: Literal["pages", "quiz", "exercise_test"]
28
+ pages: list[str] = Field(default_factory=list, max_length=MAX_PAGES)
29
+ quiz_lesson: str | None = None
30
+ test_id: int | None = None
31
+ exercise_number: Literal[1, 2] | None = None
32
+
33
+
34
+ def written_quiz(bank, lesson_id: str) -> dict | None:
35
+ """The authored written quiz of a lesson (minutes and questions with points), or None."""
36
+ unit = next((u for u in bank.data["units"] if u["id"] == lesson_id), None)
37
+ return (unit or {}).get("quiz") or None
38
+
39
+
40
+ def quiz_view(bank, lesson_id: str) -> dict | None:
41
+ quiz = written_quiz(bank, lesson_id)
42
+ if not quiz:
43
+ return None
44
+ return {"minutes": quiz["minutes"], "points": sum(q["points"] for q in quiz["questions"]),
45
+ "questions": [{k: q[k] for k in ("question", "points", "figure", "graph") if k in q} for q in quiz["questions"]]}
46
+
47
+
48
+ # ---- the teacher: pages, giving, listing, removing ----
49
+
50
+ @router.post("/assignments/pages", status_code=201)
51
+ async def upload_pages(request: Request, user: dict = Depends(need("class.teach"))) -> dict:
52
+ """One file of an assignment: an image (one page) or a PDF (a page per sheet). Returns the page pictures' ids, in order."""
53
+ declared = request.headers.get("content-length")
54
+ if declared and declared.isdigit() and int(declared) > pictures.MAX_PDF + 1024:
55
+ raise picture_error(PictureError(413, "pdf_too_large"))
56
+ data = await request.body()
57
+ kind = request.headers.get("content-type", "").split(";")[0].strip().lower()
58
+
59
+ def work() -> list[str]:
60
+ sheets = pictures.render_pdf(data) if kind == "application/pdf" else [pictures.clean_image(data, kind)]
61
+ made = [pictures.store_picture(request.app.state.store, request.app.state.pictures, user["id"], "page", jpeg) for jpeg in sheets]
62
+ return [row["id"] for row in made]
63
+ try:
64
+ ids = await run_in_threadpool(work)
65
+ except PictureError as exc:
66
+ raise picture_error(exc) from exc
67
+ return {"pictures": ids}
68
+
69
+
70
+ def _checked_pages(request: Request, user: dict, ids: list[str]) -> list[str]:
71
+ store = request.app.state.store
72
+ found = list(dict.fromkeys(ids))
73
+ for pid in found:
74
+ pic = store.get_picture(pid)
75
+ if pic is None or pic["kind"] != "page" or pic["owner_id"] != user["id"] or pic["deleted_at"]:
76
+ raise HTTPException(422, "אחד העמודים לא תקין")
77
+ return found
78
+
79
+
80
+ @router.post("/classes/{class_id}/assignments", status_code=201)
81
+ def give_assignment(class_id: int, body: NewAssignment, request: Request, user: dict = Depends(need("class.teach"))) -> dict:
82
+ klass = _my_class(request, user, class_id)
83
+ store, bank, today = request.app.state.store, request.app.state.bank, clock.school_day()
84
+ if body.due_date < today:
85
+ raise HTTPException(422, "תאריך ההגשה כבר עבר")
86
+ fields: dict = {}
87
+ if body.kind == "pages":
88
+ fields["pages"] = _checked_pages(request, user, body.pages)
89
+ if not fields["pages"]:
90
+ raise HTTPException(422, "צריך לפחות עמוד אחד")
91
+ elif body.kind == "quiz":
92
+ if body.quiz_lesson not in klass["lessons"] or not written_quiz(bank, body.quiz_lesson):
93
+ raise HTTPException(422, "לשיעור הזה אין בוחן כתוב, או שהוא לא של הכיתה")
94
+ fields["quiz_lesson"] = body.quiz_lesson
95
+ else:
96
+ test = store.get_test(body.test_id) if body.test_id is not None else None
97
+ if test is None or test["removed_at"] or test["class_id"] != class_id or body.exercise_number is None:
98
+ raise HTTPException(422, "המבחן לא נמצא בכיתה הזו")
99
+ fields.update(test_id=body.test_id, exercise_number=body.exercise_number)
100
+ row = store.create_assignment(class_id, body.title.strip(), body.due_date, user["email"], body.kind, note=body.note.strip(),
101
+ created_at=clock.now(), **fields)
102
+ return teacher_assignment(request, row, klass, [], today)
103
+
104
+
105
+ def teacher_assignment(request: Request, row: dict, klass: dict, subs: list[dict], today: Day) -> dict:
106
+ """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."""
107
+ store, here = request.app.state.store, reads.of(request)
108
+ members = store.members(klass["id"])
109
+ out = {"id": row["id"], "class": {"id": klass["id"], "name": klass["name"]}, "title": row["title"], "note": row["note"], "kind": row["kind"],
110
+ "due_date": row["due_date"].isoformat(), "days_left": (row["due_date"] - today).days, "students": len(members), "pages": row["pages"]}
111
+ if row["kind"] == "quiz":
112
+ out["quiz_lesson"] = {"id": row["quiz_lesson"], "name": (lesson_names(request, [row["quiz_lesson"]]) or [""])[0]}
113
+ if row["kind"] == "exercise_test":
114
+ test = store.get_test(row["test_id"])
115
+ out["test"] = {"id": row["test_id"], "number": row["exercise_number"], "date": test["date"].isoformat() if test else None}
116
+ scope = f'{row["test_id"]}:{row["exercise_number"]}'
117
+ ids = [m["user_id"] for m in members if m["user_id"]]
118
+ here.prefetch(*[("attempts_of", uid, "exercise_test") for uid in ids])
119
+ 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")))
120
+ else:
121
+ 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"),
122
+ returned=sum(1 for s in subs if s["state"] == "returned"))
123
+ out["waiting"] = out["handed_in"]
124
+ return out
125
+
126
+
127
+ @router.get("/teaching/assignments")
128
+ def teaching_assignments(request: Request, user: dict = Depends(need("class.teach"))) -> list[dict]:
129
+ """The assignments of the classes I teach (a developer: every class), with the counts."""
130
+ store, today = request.app.state.store, clock.school_day()
131
+ classes = store.classes() if allowed(user["roles"], "*") else store.classes_taught(user["email"])
132
+ result = []
133
+ for klass in classes:
134
+ rows = store.assignments_of_classes([klass["id"]])
135
+ subs = store.submissions_of_assignments([r["id"] for r in rows])
136
+ bank = request.app.state.bank
137
+ result.append({"class": {"id": klass["id"], "name": klass["name"],
138
+ "lessons": [{"id": i, "name": n, "has_quiz": bool(written_quiz(bank, i))} for i, n in zip(klass["lessons"], lesson_names(request, klass["lessons"]))],
139
+ "tests": [{"id": t["id"], "date": t["date"].isoformat(), "lessons": lesson_names(request, t["lessons"])} for t in store.tests_of_classes([klass["id"]])]},
140
+ "assignments": [teacher_assignment(request, r, klass, [s for s in subs if s["assignment_id"] == r["id"]], today) for r in rows]})
141
+ return result
142
+
143
+
144
+ @router.delete("/assignments/{assignment_id}", status_code=204)
145
+ def remove_assignment(assignment_id: int, request: Request, user: dict = Depends(need("class.teach"))) -> None:
146
+ store = request.app.state.store
147
+ row = store.get_assignment(assignment_id)
148
+ if row is None or row["removed_at"]:
149
+ raise HTTPException(404, "המשימה לא נמצאה")
150
+ _my_class(request, user, row["class_id"])
151
+ store.remove_assignment(assignment_id, clock.now())
152
+
153
+
154
+ # ---- the student: my assignments and handing in ----
155
+
156
+ MAX_ANSWER_PICTURES = 10
157
+ OPEN_STATES = ("not_handed", "returned", "not_done")
158
+
159
+
160
+ def _my_assignment(request: Request, user: dict, assignment_id: int) -> dict:
161
+ """An assignment of one of the student's classes (404 for anything else, and for a removed one)."""
162
+ here = reads.of(request)
163
+ row = here.get_assignment(assignment_id)
164
+ mine = {c["id"] for c in here.classes_of_student(user["email"])}
165
+ if row is None or row["removed_at"] or row["class_id"] not in mine:
166
+ raise HTTPException(404, "המשימה לא נמצאה")
167
+ return row
168
+
169
+
170
+ def _exercise_result(request: Request, user: dict, row: dict) -> tuple[str, int | None]:
171
+ """The state of an exercise-test assignment: done once the student has a submitted attempt at it, and the best grade of those."""
172
+ scope = f'{row["test_id"]}:{row["exercise_number"]}'
173
+ 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)]
174
+ return ("done", max(grades)) if grades else ("not_done", None)
175
+
176
+
177
+ def student_assignment(request: Request, user: dict, row: dict, sub: dict | None, today: Day) -> dict:
178
+ """An assignment as its student sees it: its state, grade and the teacher's note."""
179
+ out = {"id": row["id"], "title": row["title"], "note": row["note"], "kind": row["kind"], "due_date": row["due_date"].isoformat(),
180
+ "days_left": (row["due_date"] - today).days, "grade": None, "teacher_note": "", "late": False}
181
+ klass = reads.of(request).get_class(row["class_id"])
182
+ out["class"] = {"id": row["class_id"], "name": klass["name"] if klass else ""}
183
+ if row["kind"] == "exercise_test":
184
+ out["state"], out["grade"] = _exercise_result(request, user, row)
185
+ out["test"] = {"id": row["test_id"], "number": row["exercise_number"]}
186
+ elif sub is None:
187
+ out["state"] = "not_handed"
188
+ else:
189
+ out.update(state=sub["state"], grade=sub["grade"], teacher_note=sub["teacher_note"], late=sub["late"], handed_at=sub["handed_at"].isoformat())
190
+ out["overdue"] = out["days_left"] < 0 and out["state"] in OPEN_STATES
191
+ return out
192
+
193
+
194
+ @router.get("/assignments")
195
+ def my_assignments(request: Request, user: dict = Depends(learner)) -> list[dict]:
196
+ """The student's assignments: the open ones first by due date, then the done ones."""
197
+ here, today = reads.of(request), clock.school_day()
198
+ class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
199
+ here.prefetch(("assignments_of_classes", class_ids), ("submissions_of_user", user["id"]), ("attempts_of", user["id"], "exercise_test"))
200
+ subs = {s["assignment_id"]: s for s in here.submissions_of_user(user["id"])}
201
+ shown = [student_assignment(request, user, r, subs.get(r["id"]), today) for r in here.assignments_of_classes(class_ids)]
202
+ return sorted(shown, key=lambda a: (a["state"] not in OPEN_STATES, a["due_date"], a["id"]))
203
+
204
+
205
+ @router.get("/assignments/{assignment_id}")
206
+ def my_assignment(assignment_id: int, request: Request, user: dict = Depends(learner)) -> dict:
207
+ row = _my_assignment(request, user, assignment_id)
208
+ here, bank, today = reads.of(request), request.app.state.bank, clock.school_day()
209
+ sub = here.get_submission(assignment_id, user["id"])
210
+ out = student_assignment(request, user, row, sub, today)
211
+ out["pages"] = row["pages"]
212
+ if row["kind"] == "quiz":
213
+ out["quiz"] = quiz_view(bank, row["quiz_lesson"])
214
+ pics = []
215
+ for pid in (sub["pictures"] if sub else []):
216
+ pic = request.app.state.store.get_picture(pid)
217
+ pics.append({"id": pid, "deleted": bool(pic and pic["deleted_at"])})
218
+ out["pictures"] = pics
219
+ return out
220
+
221
+
222
+ class Handing(BaseModel):
223
+ pictures: list[str] = Field(min_length=1, max_length=MAX_ANSWER_PICTURES)
224
+
225
+
226
+ @router.post("/assignments/{assignment_id}/submission")
227
+ def hand_in(assignment_id: int, body: Handing, request: Request, user: dict = Depends(learner)) -> dict:
228
+ """Hand in (or replace, or hand in again after a return) the student's picture answers. A graded submission cannot be changed."""
229
+ store, today = request.app.state.store, clock.school_day()
230
+ row = _my_assignment(request, user, assignment_id)
231
+ if row["kind"] == "exercise_test":
232
+ raise HTTPException(422, "מבחן תרגול לא מגישים בתמונה: עושים אותו באפליקציה")
233
+ existing = store.get_submission(assignment_id, user["id"])
234
+ if existing and existing["state"] == "graded":
235
+ raise HTTPException(409, "המשימה כבר נבדקה ולא אפשר לשנות אותה")
236
+ ids = list(dict.fromkeys(body.pictures))
237
+ for pid in ids:
238
+ pic = store.get_picture(pid)
239
+ if pic is None or pic["kind"] != "answer" or pic["owner_id"] != user["id"] or pic["deleted_at"]:
240
+ raise HTTPException(422, "אחת התמונות לא תקינה")
241
+ saved = store.save_submission(assignment_id, user["id"], pictures=ids, state="handed_in", late=today > row["due_date"], handed_at=clock.now(),
242
+ grade=None, teacher_note="" if not existing else existing["teacher_note"], checked_at=None, checked_by=None)
243
+ return student_assignment(request, user, row, saved, today)
244
+
245
+
246
+ # ---- the teacher: the inbox and checking ----
247
+
248
+ class Check(BaseModel):
249
+ grade: int = Field(ge=0, le=100)
250
+ note: str = Field(default="", max_length=2000)
251
+ seen: str # the handed_at the teacher was looking at
252
+
253
+
254
+ class SendBack(BaseModel):
255
+ note: str = Field(min_length=1, max_length=2000)
256
+ seen: str
257
+
258
+
259
+ def _teachable_classes(request: Request, user: dict) -> list[dict]:
260
+ store = request.app.state.store
261
+ return store.classes() if allowed(user["roles"], "*") else store.classes_taught(user["email"])
262
+
263
+
264
+ def _student_emails(request: Request, class_id: int) -> dict[str, str]:
265
+ return {m["user_id"]: m["email"] for m in request.app.state.store.members(class_id) if m["user_id"]}
266
+
267
+
268
+ def inbox_item(request: Request, sub: dict, assignment: dict, klass: dict, emails: dict[str, str]) -> dict:
269
+ return {"kind": "submission", "id": sub["id"], "assignment": {"id": assignment["id"], "title": assignment["title"], "kind": assignment["kind"],
270
+ "class": {"id": klass["id"], "name": klass["name"]}},
271
+ "student": emails.get(sub["user_id"], ""), "handed_at": sub["handed_at"].isoformat(), "late": sub["late"], "seen": sub["handed_at"].isoformat(),
272
+ "pictures": [{"id": p} for p in sub["pictures"]]}
273
+
274
+
275
+ @router.get("/teaching/inbox")
276
+ def inbox(request: Request, user: dict = Depends(need("class.teach"))) -> list[dict]:
277
+ """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."""
278
+ store, items = request.app.state.store, []
279
+ for klass in _teachable_classes(request, user):
280
+ rows = {r["id"]: r for r in store.assignments_of_classes([klass["id"]]) if r["kind"] != "exercise_test"}
281
+ emails = _student_emails(request, klass["id"])
282
+ 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"]
283
+ return sorted(items, key=lambda i: (i["handed_at"], i["id"]))
284
+
285
+
286
+ def _my_submission(request: Request, user: dict, submission_id: int) -> tuple[dict, dict, dict]:
287
+ store = request.app.state.store
288
+ sub = store.get_submission_by_id(submission_id)
289
+ row = store.get_assignment(sub["assignment_id"]) if sub else None
290
+ if sub is None or row is None or row["removed_at"]:
291
+ raise HTTPException(404, "ההגשה לא נמצאה")
292
+ return sub, row, _my_class(request, user, row["class_id"])
293
+
294
+
295
+ @router.get("/submissions/{submission_id}")
296
+ def open_submission(submission_id: int, request: Request, user: dict = Depends(need("class.teach"))) -> dict:
297
+ sub, row, klass = _my_submission(request, user, submission_id)
298
+ out = inbox_item(request, sub, row, klass, _student_emails(request, klass["id"]))
299
+ out.update(state=sub["state"], grade=sub["grade"], teacher_note=sub["teacher_note"], pages=row["pages"], note=row["note"],
300
+ quiz=quiz_view(request.app.state.bank, row["quiz_lesson"]) if row["kind"] == "quiz" else None)
301
+ out["pictures"] = [{"id": p, "deleted": bool((request.app.state.store.get_picture(p) or {}).get("deleted_at"))} for p in sub["pictures"]]
302
+ return out
303
+
304
+
305
+ def _check(request: Request, user: dict, submission_id: int, seen: str) -> tuple[dict, dict]:
306
+ sub, row, _ = _my_submission(request, user, submission_id)
307
+ try:
308
+ saw = datetime.fromisoformat(seen)
309
+ except ValueError:
310
+ raise HTTPException(422, "seen לא תקין") from None
311
+ if sub["state"] != "handed_in":
312
+ raise HTTPException(409, "ההגשה כבר נבדקה או הוחזרה")
313
+ if saw != sub["handed_at"]:
314
+ raise HTTPException(409, "ההגשה השתנתה. פתחו אותה שוב.")
315
+ return sub, row
316
+
317
+
318
+ @router.post("/submissions/{submission_id}/grade")
319
+ def grade_submission(submission_id: int, body: Check, request: Request, user: dict = Depends(need("class.teach"))) -> dict:
320
+ sub, _ = _check(request, user, submission_id, body.seen)
321
+ saved = request.app.state.store.save_submission(sub["assignment_id"], sub["user_id"], state="graded", grade=body.grade, teacher_note=body.note.strip(),
322
+ checked_at=clock.now(), checked_by=user["email"])
323
+ return {"id": saved["id"], "state": saved["state"], "grade": saved["grade"]}
324
+
325
+
326
+ @router.post("/submissions/{submission_id}/return")
327
+ def return_submission(submission_id: int, body: SendBack, request: Request, user: dict = Depends(need("class.teach"))) -> dict:
328
+ sub, _ = _check(request, user, submission_id, body.seen)
329
+ if not body.note.strip():
330
+ raise HTTPException(422, "צריך לכתוב מה לתקן")
331
+ saved = request.app.state.store.save_submission(sub["assignment_id"], sub["user_id"], state="returned", grade=None, teacher_note=body.note.strip(),
332
+ checked_at=clock.now(), checked_by=user["email"])
333
+ return {"id": saved["id"], "state": saved["state"]}
334
+
335
+
336
+ # ---- the grades screen's rows ----
337
+
338
+ def grade_rows(request: Request, user: dict) -> list[dict]:
339
+ """Graded assignments and done exercise-test assignments, with the teacher's note. A removed assignment keeps its grade."""
340
+ store, here = request.app.state.store, reads.of(request)
341
+ class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
342
+ rows = {r["id"]: r for r in store.assignments_of_classes(class_ids, include_removed=True)}
343
+ out = []
344
+ subs = {s["assignment_id"]: s for s in store.submissions_of_user(user["id"])}
345
+ here.prefetch(("attempts_of", user["id"], "exercise_test"))
346
+ for row in rows.values():
347
+ item = {"assignment": {"id": row["id"], "title": row["title"], "kind": row["kind"], "due_date": row["due_date"].isoformat(), "removed": bool(row["removed_at"])}}
348
+ if row["kind"] == "exercise_test":
349
+ state, grade = _exercise_result(request, user, row)
350
+ if state == "done":
351
+ out.append({**item, "grade": grade, "note": ""})
352
+ elif row["id"] in subs and subs[row["id"]]["state"] == "graded":
353
+ out.append({**item, "grade": subs[row["id"]]["grade"], "note": subs[row["id"]]["teacher_note"]})
354
+ return sorted(out, key=lambda r: (r["assignment"]["due_date"], r["assignment"]["id"]))
355
+
356
+
357
+ # ---- deleting a student's pictures on request ----
358
+
359
+ @router.delete("/classes/{class_id}/pictures/{email:path}")
360
+ def delete_student_pictures(class_id: int, email: str, request: Request, user: dict = Depends(need("class.roster"))) -> dict:
361
+ """Delete a student's answer pictures for this class's assignments now. The class's teacher, a manager or a developer may. The submissions
362
+ keep their grade and note."""
363
+ store = request.app.state.store
364
+ klass = store.get_class(class_id)
365
+ is_teacher = klass is not None and (klass["teacher_email"] or "").lower() == user["email"]
366
+ if klass is None or not (is_teacher or allowed(user["roles"], "classes.manage") or allowed(user["roles"], "*")):
367
+ raise HTTPException(404, "הכיתה לא נמצאה")
368
+ member = next((m for m in store.members(class_id) if m["email"] == email.strip().lower()), None)
369
+ if member is None:
370
+ raise HTTPException(404, "התלמיד לא נמצא בכיתה")
371
+ if not member["user_id"]:
372
+ return {"deleted": 0} # never signed in: nothing was handed in
373
+ ids = [a["id"] for a in store.assignments_of_classes([class_id], include_removed=True)]
374
+ return {"deleted": cleanup.delete_answer_pictures(store, request.app.state.pictures, ids, member["user_id"], clock.now())}
375
+
376
+
377
+ # ---- the numbers on the teacher's tabs ----
378
+
379
+ @router.get("/teaching/counts")
380
+ def teaching_counts(request: Request, user: dict = Depends(need("class.teach"))) -> dict:
381
+ """How many items each tab of the teacher's screen holds: the classes they teach, their tests, their assignments, and the submissions waiting."""
382
+ store = request.app.state.store
383
+ classes = _teachable_classes(request, user)
384
+ ids = [c["id"] for c in classes]
385
+ here = reads.of(request)
386
+ here.prefetch(("tests_of_classes", ids), ("assignments_of_classes", ids))
387
+ tests, assignments = here.tests_of_classes(ids), here.assignments_of_classes(ids)
388
+ graded_by_student = [a["id"] for a in assignments if a["kind"] != "exercise_test"]
389
+ waiting = sum(1 for s in store.submissions_of_assignments(graded_by_student) if s["state"] == "handed_in")
390
+ return {"classes": len(classes), "tests": len(tests), "assignments": len(assignments), "inbox": waiting}
mathly2/attempts.py CHANGED
@@ -6,8 +6,9 @@ from __future__ import annotations
6
 
7
  from datetime import datetime
8
 
9
- QUIZ_SIZE = 10
10
- QUIZ_MINUTES = 15
 
11
 
12
 
13
  def percent(right: int, total: int) -> int:
@@ -15,6 +16,9 @@ def percent(right: int, total: int) -> int:
15
  return 0 if total <= 0 else (200 * right + total) // (2 * total)
16
 
17
 
 
 
 
18
  def _right(bank, question: dict, answers: dict) -> bool:
19
  choice = answers.get(question["key"])
20
  graded = bank.grade(question["skill_id"], question["key"], choice) if isinstance(choice, int) else None
 
6
 
7
  from datetime import datetime
8
 
9
+ QUIZ_SIZE = 15
10
+ QUIZ_MINUTES = 20
11
+ PASS_RIGHT = 13 # right answers out of QUIZ_SIZE that pass a lesson quiz
12
 
13
 
14
  def percent(right: int, total: int) -> int:
 
16
  return 0 if total <= 0 else (200 * right + total) // (2 * total)
17
 
18
 
19
+ PASS_GRADE = percent(PASS_RIGHT, QUIZ_SIZE) # 87: a quiz is passed with this grade or better
20
+
21
+
22
  def _right(bank, question: dict, answers: dict) -> bool:
23
  choice = answers.get(question["key"])
24
  graded = bank.grade(question["skill_id"], question["key"], choice) if isinstance(choice, int) else None
mathly2/cached.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A store that remembers slow-changing reads (classes, members, roles, tests, assignments) for a short time.
2
+
3
+ Any change made through it to one of those forgets everything remembered at once. Rows come back as copies.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import threading
9
+ import time
10
+
11
+ READS = frozenset({
12
+ "classes", "classes_of_student", "classes_taught", "members", "get_class", "roles_of",
13
+ "tests_of_classes", "get_test", "assignments_of_classes", "get_assignment", "all_students", "accounts", "curriculum",
14
+ })
15
+ CHANGES = frozenset({
16
+ "create_class", "update_class", "delete_class", "add_member", "remove_member", "give_role", "take_role", "link_user",
17
+ "create_test", "update_test", "remove_test", "create_assignment", "remove_assignment", "set_lesson_dates",
18
+ })
19
+
20
+
21
+ def _key(name: str, args: tuple, kwargs: dict):
22
+ def freeze(v):
23
+ if isinstance(v, (set, frozenset)):
24
+ return tuple(sorted(v, key=repr))
25
+ if isinstance(v, (list, tuple)):
26
+ return tuple(freeze(x) for x in v)
27
+ return v
28
+ return name, freeze(args), tuple(sorted((k, freeze(v)) for k, v in kwargs.items()))
29
+
30
+
31
+ class CachedStore:
32
+ def __init__(self, store, seconds: float, clock=time.monotonic) -> None:
33
+ self._store, self._seconds, self._clock = store, seconds, clock
34
+ self._lock = threading.Lock()
35
+ self._kept: dict = {} # key -> (expires, value)
36
+ self._generation = 0 # bumped by every change, so a read that began before one is not kept
37
+
38
+ def forget(self) -> None:
39
+ with self._lock:
40
+ self._kept.clear()
41
+ self._generation += 1
42
+
43
+ def __getattr__(self, name: str):
44
+ target = getattr(self._store, name)
45
+ if self._seconds <= 0 or not callable(target):
46
+ return target
47
+ if name in CHANGES:
48
+ def change(*args, **kwargs):
49
+ try:
50
+ return target(*args, **kwargs)
51
+ finally:
52
+ self.forget()
53
+ return change
54
+ if name in READS:
55
+ def read(*args, **kwargs):
56
+ key = _key(name, args, kwargs)
57
+ now = self._clock()
58
+ with self._lock:
59
+ hit = self._kept.get(key)
60
+ if hit and hit[0] > now:
61
+ return copy.deepcopy(hit[1])
62
+ generation = self._generation
63
+ value = target(*args, **kwargs)
64
+ with self._lock:
65
+ if generation == self._generation:
66
+ self._kept[key] = (now + self._seconds, copy.deepcopy(value))
67
+ return value
68
+ return read
69
+ return target
mathly2/classes.py CHANGED
@@ -3,10 +3,12 @@ removes its students; a teacher sees the classes they teach; a manager sees ever
3
  from __future__ import annotations
4
 
5
  import re
 
6
 
7
  from fastapi import APIRouter, Depends, HTTPException, Request
8
  from pydantic import BaseModel, Field
9
 
 
10
  from .access import allowed, need
11
  from .auth import EMAIL
12
  from .store import ClassExists, MemberExists, TeacherIsStudent
@@ -19,7 +21,7 @@ teaching = APIRouter(prefix="/api")
19
 
20
  class ClassBody(BaseModel):
21
  name: str = Field(max_length=200)
22
- lessons: list[str] = Field(default_factory=list, max_length=200)
23
  teacher_email: str | None = Field(default=None, max_length=300)
24
  students: str | None = Field(default=None, max_length=40000) # pasted text: the whole roster of the class
25
  ai_enabled: bool = False
@@ -55,10 +57,7 @@ def checked(request: Request, body: ClassBody) -> dict:
55
  name = " ".join(body.name.split())
56
  if not 1 <= len(name) <= MAX_NAME:
57
  raise HTTPException(422, f"שם הכיתה צריך להיות בין תו אחד ל־{MAX_NAME} תווים")
58
- known = [lesson["id"] for lesson in request.app.state.bank.lessons]
59
- lessons = [lesson for lesson in dict.fromkeys(body.lessons) if lesson in known]
60
- if len(lessons) != len(set(body.lessons)) or not lessons:
61
- raise HTTPException(422, "יש לבחור לפחות שיעור אחד מהרשימה")
62
  teacher = (body.teacher_email or "").strip().lower() or None
63
  if teacher and not EMAIL.match(teacher):
64
  raise HTTPException(422, "כתובת המורה לא נראית כמו מייל")
@@ -100,13 +99,6 @@ def list_classes(request: Request, user: dict = Depends(need("classes.manage")))
100
  return [shown(request, c) for c in request.app.state.store.classes()]
101
 
102
 
103
- @router.get("/lessons")
104
- def lessons_to_choose(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
105
- """The bank's lessons, for choosing what a class studies (a manager has no student path, so no /api/lessons)."""
106
- return [{"id": lesson["id"], "name": lesson["name"], "skills": len(lesson["skills"])}
107
- for lesson in request.app.state.bank.lessons_with_skills()]
108
-
109
-
110
  @router.post("", status_code=201)
111
  def create_class(body: ClassBody, request: Request, user: dict = Depends(need("classes.manage"))) -> dict:
112
  fields = checked(request, body)
@@ -199,3 +191,36 @@ def my_classes(request: Request, user: dict = Depends(need("class.teach"))) -> l
199
  @teaching.get("/students")
200
  def all_students(request: Request, user: dict = Depends(need("students.list"))) -> list[dict]:
201
  return request.app.state.store.all_students()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from __future__ import annotations
4
 
5
  import re
6
+ from datetime import date
7
 
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
  from pydantic import BaseModel, Field
10
 
11
+ from . import reads
12
  from .access import allowed, need
13
  from .auth import EMAIL
14
  from .store import ClassExists, MemberExists, TeacherIsStudent
 
21
 
22
  class ClassBody(BaseModel):
23
  name: str = Field(max_length=200)
24
+ lessons: list[str] = Field(default_factory=list, max_length=200) # ignored: kept so older clients still work
25
  teacher_email: str | None = Field(default=None, max_length=300)
26
  students: str | None = Field(default=None, max_length=40000) # pasted text: the whole roster of the class
27
  ai_enabled: bool = False
 
57
  name = " ".join(body.name.split())
58
  if not 1 <= len(name) <= MAX_NAME:
59
  raise HTTPException(422, f"שם הכיתה צריך להיות בין תו אחד ל־{MAX_NAME} תווים")
60
+ lessons = [lesson["id"] for lesson in request.app.state.bank.lessons] # every class studies the whole course
 
 
 
61
  teacher = (body.teacher_email or "").strip().lower() or None
62
  if teacher and not EMAIL.match(teacher):
63
  raise HTTPException(422, "כתובת המורה לא נראית כמו מייל")
 
99
  return [shown(request, c) for c in request.app.state.store.classes()]
100
 
101
 
 
 
 
 
 
 
 
102
  @router.post("", status_code=201)
103
  def create_class(body: ClassBody, request: Request, user: dict = Depends(need("classes.manage"))) -> dict:
104
  fields = checked(request, body)
 
191
  @teaching.get("/students")
192
  def all_students(request: Request, user: dict = Depends(need("students.list"))) -> list[dict]:
193
  return request.app.state.store.all_students()
194
+
195
+
196
+ # ---- the curriculum: the date by which each lesson should be completed (one for the whole school) ----
197
+
198
+ curriculum = APIRouter(prefix="/api/curriculum")
199
+
200
+
201
+ class Dates(BaseModel):
202
+ dates: dict[str, str | None] = Field(max_length=200) # lesson id -> "YYYY-MM-DD", or null to clear
203
+
204
+
205
+ @curriculum.get("")
206
+ def read_curriculum(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
207
+ """Every lesson in the bank's order with its date (or null) and the bank's month as a hint."""
208
+ dates = reads.of(request).curriculum()
209
+ return [{"id": lesson["id"], "name": lesson["name"], "month": lesson.get("when", ""), "due_date": dates[lesson["id"]].isoformat() if lesson["id"] in dates else None}
210
+ for lesson in request.app.state.bank.lessons]
211
+
212
+
213
+ @curriculum.put("")
214
+ def change_curriculum(body: Dates, request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
215
+ known = {lesson["id"] for lesson in request.app.state.bank.lessons}
216
+ parsed: dict[str, date | None] = {}
217
+ for lesson, text in body.dates.items():
218
+ if lesson not in known:
219
+ raise HTTPException(422, "שיעור לא מוכר")
220
+ try:
221
+ parsed[lesson] = date.fromisoformat(text) if text else None
222
+ except ValueError:
223
+ raise HTTPException(422, "תאריך לא תקין") from None
224
+ request.app.state.store.set_lesson_dates(parsed)
225
+ request.state.reads = None # what this request read is out of date now
226
+ return read_curriculum(request, user)
mathly2/cleanup.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The hourly cleanup of pictures, and deleting a student's pictures on request. `python -m mathly2.cleanup` runs it once; the server runs it
2
+ every hour when it is connected to Supabase."""
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from datetime import datetime
8
+
9
+ from . import clock, pictures, retention
10
+
11
+ log = logging.getLogger("mathly.cleanup")
12
+
13
+
14
+ def run(store, backend, now: datetime | None = None) -> dict:
15
+ """Remove pictures nothing refers to (after a day), answer pictures past their school year, and the pages of removed assignments.
16
+ Returns how many of each were removed. A picture whose bytes cannot be deleted now is left for the next run."""
17
+ now = now or clock.now()
18
+ today = clock.school_day(now)
19
+ removed = {"orphans": 0, "expired": 0, "removed_assignments": 0}
20
+ for pic in store.pictures():
21
+ refs = store.picture_refs(pic["id"])
22
+ referenced = any(refs.values())
23
+ if pic["kind"] == "answer" and retention.is_expired(clock.school_day(pic["created_at"]), today):
24
+ reason = "expired"
25
+ elif not referenced and now - pic["created_at"] > retention.ORPHAN_AGE:
26
+ reason = "orphans"
27
+ elif pic["kind"] == "page" and refs["assignments"] and all((store.get_assignment(a) or {}).get("removed_at") for a in refs["assignments"]):
28
+ reason = "removed_assignments"
29
+ else:
30
+ continue
31
+ try:
32
+ pictures.remove_picture(store, backend, pic, now)
33
+ removed[reason] += 1
34
+ except pictures.PictureError as exc:
35
+ log.warning("could not delete a picture (%s); it stays for the next run", exc.code)
36
+ return removed
37
+
38
+
39
+ def delete_answer_pictures(store, backend, assignment_ids, user_id: str, now: datetime | None = None) -> int:
40
+ """Delete a student's answer pictures for these assignments now (a teacher of the class, a manager or a developer asked). The submissions
41
+ keep their grade and note. Returns how many pictures were deleted."""
42
+ count = 0
43
+ for sub in store.submissions_of_assignments(assignment_ids):
44
+ if sub["user_id"] != user_id:
45
+ continue
46
+ for pid in sub["pictures"]:
47
+ pic = store.get_picture(pid)
48
+ if pic and pic["kind"] == "answer" and pic["owner_id"] == user_id and not pic["deleted_at"]:
49
+ pictures.remove_picture(store, backend, pic, now)
50
+ count += 1
51
+ return count
52
+
53
+
54
+ def start_hourly(store, backend, every: float = 3600.0) -> threading.Event:
55
+ """Run the cleanup now and then every `every` seconds in a background thread. Returns an event that stops it when set."""
56
+ stop = threading.Event()
57
+
58
+ def loop() -> None:
59
+ while True:
60
+ try:
61
+ log.info("cleanup removed %s", run(store, backend))
62
+ except Exception: # a failed run must not stop the next one
63
+ log.exception("the picture cleanup failed")
64
+ if stop.wait(every):
65
+ return
66
+
67
+ threading.Thread(target=loop, name="picture-cleanup", daemon=True).start()
68
+ return stop
69
+
70
+
71
+ def main() -> None:
72
+ from .config import load_settings
73
+ from .store import SupabaseStore
74
+ settings = load_settings()
75
+ if not settings.supabase_on:
76
+ raise SystemExit("Set the Supabase settings: the cleanup works on the real database.")
77
+ store = SupabaseStore(settings.supabase_url, settings.supabase_secret)
78
+ backend = pictures.SupabasePictures(settings.supabase_url, settings.supabase_secret, settings.answer_bucket)
79
+ print("Removed", run(store, backend))
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
mathly2/config.py CHANGED
@@ -24,6 +24,10 @@ class Settings:
24
  teachers: frozenset[str] = field(default_factory=frozenset)
25
  developers: frozenset[str] = field(default_factory=frozenset) # the developer role comes only from here
26
  admins: frozenset[str] = field(default_factory=frozenset) # the first admins; others are given the role by an admin
 
 
 
 
27
 
28
  @property
29
  def supabase_on(self) -> bool:
@@ -51,4 +55,8 @@ def load_settings(env_file: Path | None = None) -> Settings:
51
  teachers=_emails("TEACHER_EMAILS"),
52
  developers=_emails("DEVELOPER_EMAILS"),
53
  admins=_emails("ADMIN_EMAILS"),
 
 
 
 
54
  ).check()
 
24
  teachers: frozenset[str] = field(default_factory=frozenset)
25
  developers: frozenset[str] = field(default_factory=frozenset) # the developer role comes only from here
26
  admins: frozenset[str] = field(default_factory=frozenset) # the first admins; others are given the role by an admin
27
+ answer_bucket: str = "m2-answers" # the private bucket for answer pictures (not mathly1's)
28
+ cache_seconds: float = 30 # how long slow-changing reads are remembered; 0 is off
29
+ vapid_private_key: str = "" # the server's push key; push notifications are off without it
30
+ vapid_subject: str = "" # a contact the push services may write to ("mailto:...")
31
 
32
  @property
33
  def supabase_on(self) -> bool:
 
55
  teachers=_emails("TEACHER_EMAILS"),
56
  developers=_emails("DEVELOPER_EMAILS"),
57
  admins=_emails("ADMIN_EMAILS"),
58
+ answer_bucket=env("MATHLY_M2_ANSWER_BUCKET") or "m2-answers",
59
+ cache_seconds=float(env("MATHLY_CACHE_SECONDS") or 30),
60
+ vapid_private_key=env("VAPID_PRIVATE_KEY"),
61
+ vapid_subject=env("VAPID_SUBJECT"),
62
  ).check()
mathly2/main.py CHANGED
@@ -3,19 +3,24 @@ from __future__ import annotations
3
 
4
  import json
5
  import logging
 
6
  from pathlib import Path
7
 
8
  from fastapi import Depends, FastAPI, HTTPException
9
  from fastapi import Request
10
- from fastapi.responses import HTMLResponse, JSONResponse
11
  from fastapi.staticfiles import StaticFiles
12
 
13
  from .access import RoleResolver, learner, permissions_of
14
  from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
15
  from .bank import Bank, load_bank
16
- from . import accounts, classes, quizzes, reads, realtests, skills
 
17
  from .config import Settings, load_settings
 
18
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
 
 
19
 
20
  STATIC = Path(__file__).parent / "static"
21
 
@@ -31,29 +36,49 @@ class FreshStaticFiles(StaticFiles):
31
  log = logging.getLogger("mathly2")
32
 
33
 
34
- def create_app(settings: Settings | None = None, bank: Bank | None = None, store=None, authenticator=None) -> FastAPI:
35
- app = FastAPI(title="Mathly 2")
 
 
 
 
 
 
 
 
 
36
  app.state.settings = settings = (settings or load_settings()).check()
37
  app.state.bank = bank or load_bank() # a broken bank stops the server from starting
38
  if settings.supabase_on:
39
- app.state.store = store or SupabaseStore(settings.supabase_url, settings.supabase_secret)
40
  app.state.authenticator = authenticator or SupabaseAuthenticator(settings.supabase_url, settings.supabase_key)
41
  else:
42
  app.state.store = store or InMemoryStore()
43
  app.state.authenticator = authenticator or DevAuthenticator()
44
 
 
 
 
 
45
  app.state.roles = RoleResolver(settings, app.state.store)
46
  @app.exception_handler(StoreUnavailable)
47
  async def store_unavailable(request: Request, exc: StoreUnavailable) -> JSONResponse:
48
  log.warning("database problem on %s %s: %s", request.method, request.url.path, exc)
49
  return JSONResponse({"detail": "מסד הנתונים לא ענה או סירב לבקשה. נסו שוב, ואם זה חוזר, בדקו שהמיגרציות הוחלו (ראו את יומן השרת)."}, status_code=503)
50
 
 
 
 
 
51
  app.include_router(classes.router)
52
  app.include_router(classes.teaching)
 
53
  app.include_router(accounts.router)
54
  app.include_router(skills.router)
55
  app.include_router(quizzes.router)
56
  app.include_router(realtests.router)
 
 
57
 
58
  @app.get("/api/health")
59
  def health() -> dict:
@@ -78,8 +103,7 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store
78
  everything = app.state.bank.lessons_with_skills()
79
  if set(user["roles"]) - {"student"}:
80
  return everything
81
- mine = {lesson for c in reads.of(request).classes_of_student(user["email"]) for lesson in c["lessons"]}
82
- return [lesson for lesson in everything if lesson["id"] in mine]
83
 
84
  @app.get("/", include_in_schema=False)
85
  def index() -> HTMLResponse:
@@ -90,6 +114,12 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store
90
  html = (STATIC / "index.html").read_text(encoding="utf-8").replace("__CONFIG__", text)
91
  return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
92
 
 
 
 
 
 
 
93
  app.mount("/static", FreshStaticFiles(directory=STATIC), name="static")
94
  return app
95
 
 
3
 
4
  import json
5
  import logging
6
+ from contextlib import asynccontextmanager
7
  from pathlib import Path
8
 
9
  from fastapi import Depends, FastAPI, HTTPException
10
  from fastapi import Request
11
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
12
  from fastapi.staticfiles import StaticFiles
13
 
14
  from .access import RoleResolver, learner, permissions_of
15
  from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
16
  from .bank import Bank, load_bank
17
+
18
+ from . import accounts, assignments, classes, cleanup, quizzes, reads, realtests, skills, written
19
  from .config import Settings, load_settings
20
+ from .cached import CachedStore
21
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
22
+ from .pictures import MemoryPictures, PictureError, SupabasePictures
23
+ from .push import Pusher, default_subject
24
 
25
  STATIC = Path(__file__).parent / "static"
26
 
 
36
  log = logging.getLogger("mathly2")
37
 
38
 
39
+ def create_app(settings: Settings | None = None, bank: Bank | None = None, store=None, authenticator=None, picture_store=None, pusher=None) -> FastAPI:
40
+ @asynccontextmanager
41
+ async def lifespan(application: FastAPI):
42
+ # Against the real database the pictures are tidied every hour (nothing runs without Supabase, so local runs and tests are quiet).
43
+ stop = cleanup.start_hourly(application.state.store, application.state.pictures) if application.state.settings.supabase_on else None
44
+ application.state.cleanup_stop = stop
45
+ yield
46
+ if stop:
47
+ stop.set()
48
+
49
+ app = FastAPI(title="Mathly 2", lifespan=lifespan)
50
  app.state.settings = settings = (settings or load_settings()).check()
51
  app.state.bank = bank or load_bank() # a broken bank stops the server from starting
52
  if settings.supabase_on:
53
+ app.state.store = store or CachedStore(SupabaseStore(settings.supabase_url, settings.supabase_secret), settings.cache_seconds)
54
  app.state.authenticator = authenticator or SupabaseAuthenticator(settings.supabase_url, settings.supabase_key)
55
  else:
56
  app.state.store = store or InMemoryStore()
57
  app.state.authenticator = authenticator or DevAuthenticator()
58
 
59
+ app.state.pictures = picture_store or (SupabasePictures(settings.supabase_url, settings.supabase_secret, settings.answer_bucket)
60
+ if settings.supabase_on else MemoryPictures())
61
+ app.state.pusher = pusher or Pusher(app.state.store, settings.vapid_private_key, default_subject(settings))
62
+ app.state.notify_request = written.notify_new_request
63
  app.state.roles = RoleResolver(settings, app.state.store)
64
  @app.exception_handler(StoreUnavailable)
65
  async def store_unavailable(request: Request, exc: StoreUnavailable) -> JSONResponse:
66
  log.warning("database problem on %s %s: %s", request.method, request.url.path, exc)
67
  return JSONResponse({"detail": "מסד הנתונים לא ענה או סירב לבקשה. נסו שוב, ואם זה חוזר, בדקו שהמיגרציות הוחלו (ראו את יומן השרת)."}, status_code=503)
68
 
69
+ @app.exception_handler(PictureError)
70
+ async def picture_trouble(request: Request, exc: PictureError) -> JSONResponse:
71
+ return JSONResponse({"detail": written.MESSAGES.get(exc.code, exc.code)}, status_code=exc.status)
72
+
73
  app.include_router(classes.router)
74
  app.include_router(classes.teaching)
75
+ app.include_router(classes.curriculum)
76
  app.include_router(accounts.router)
77
  app.include_router(skills.router)
78
  app.include_router(quizzes.router)
79
  app.include_router(realtests.router)
80
+ app.include_router(written.router)
81
+ app.include_router(assignments.router)
82
 
83
  @app.get("/api/health")
84
  def health() -> dict:
 
103
  everything = app.state.bank.lessons_with_skills()
104
  if set(user["roles"]) - {"student"}:
105
  return everything
106
+ return everything if reads.of(request).classes_of_student(user["email"]) else []
 
107
 
108
  @app.get("/", include_in_schema=False)
109
  def index() -> HTMLResponse:
 
114
  html = (STATIC / "index.html").read_text(encoding="utf-8").replace("__CONFIG__", text)
115
  return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
116
 
117
+ @app.get("/service-worker.js", include_in_schema=False)
118
+ def service_worker() -> FileResponse:
119
+ # served from the root so it covers the whole app; the browser must check it for changes each time
120
+ return FileResponse(STATIC / "service-worker.js", media_type="text/javascript",
121
+ headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"})
122
+
123
  app.mount("/static", FreshStaticFiles(directory=STATIC), name="static")
124
  return app
125
 
mathly2/pictures.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pictures: students' written answers and the pages of a teacher's assignment. The server is the only door to them.
2
+
3
+ An uploaded image is checked (JPEG, PNG or WebP, at most 5 MB and 20 million pixels), turned upright, stripped of its metadata and
4
+ 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
5
+ key (`SupabasePictures`; `MemoryPictures` for tests and local development); the rows that name them are in `m2_pictures`."""
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import json
10
+ import logging
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import uuid
15
+ import warnings
16
+ from datetime import datetime
17
+
18
+ from PIL import Image, ImageOps, UnidentifiedImageError
19
+
20
+ from . import pooled
21
+
22
+ log = logging.getLogger("mathly.pictures")
23
+
24
+ MAX_IMAGE = 5_000_000
25
+ MAX_PDF = 10_000_000
26
+ MAX_PAGES = 20
27
+ MAX_SIDE = 2400 # the longest side of a stored picture, in pixels
28
+ PAGE_WIDTH = 1600 # a rendered PDF page
29
+ RENDER_BUDGET = 30.0 # seconds for all the pages of one PDF
30
+ ALLOWED = {"image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WEBP"}
31
+ Image.MAX_IMAGE_PIXELS = 20_000_000
32
+
33
+
34
+ class PictureError(Exception):
35
+ """A picture that cannot be taken, with the HTTP status to answer and a short code."""
36
+
37
+ def __init__(self, status: int, code: str) -> None:
38
+ super().__init__(code)
39
+ self.status, self.code = status, code
40
+
41
+
42
+ def clean_image(data: bytes, content_type: str) -> bytes:
43
+ """A stored picture from an uploaded one: checked, upright, without metadata, as JPEG."""
44
+ if len(data) > MAX_IMAGE:
45
+ raise PictureError(413, "image_too_large")
46
+ expected = ALLOWED.get((content_type or "").split(";")[0].strip().lower())
47
+ if expected is None:
48
+ raise PictureError(415, "unsupported_image")
49
+ try:
50
+ with warnings.catch_warnings():
51
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
52
+ image = Image.open(io.BytesIO(data))
53
+ if image.format != expected:
54
+ raise PictureError(415, "unsupported_image")
55
+ image.load()
56
+ except PictureError:
57
+ raise
58
+ except (UnidentifiedImageError, Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, ValueError, SyntaxError) as exc:
59
+ raise PictureError(400, "bad_image") from exc
60
+ image = ImageOps.exif_transpose(image)
61
+ if image.mode in ("RGBA", "LA", "P"): # flatten transparency onto white
62
+ rgba = image.convert("RGBA")
63
+ flat = Image.new("RGB", rgba.size, "white")
64
+ flat.paste(rgba, mask=rgba.getchannel("A"))
65
+ image = flat
66
+ else:
67
+ image = image.convert("RGB")
68
+ image.thumbnail((MAX_SIDE, MAX_SIDE))
69
+ out = io.BytesIO()
70
+ image.save(out, "JPEG", quality=88) # a new file: none of the original's metadata comes along
71
+ return out.getvalue()
72
+
73
+
74
+ def render_pdf(data: bytes) -> list[bytes]:
75
+ """The pages of a teacher's PDF as stored pictures (JPEG); PictureError 413, 415 or 400 when it is not one we take."""
76
+ if len(data) > MAX_PDF:
77
+ raise PictureError(413, "pdf_too_large")
78
+ if not data.startswith(b"%PDF-"):
79
+ raise PictureError(415, "not_a_pdf")
80
+ import pypdfium2 as pdfium
81
+ try:
82
+ doc = pdfium.PdfDocument(data)
83
+ except pdfium.PdfiumError as exc:
84
+ raise PictureError(400, "bad_pdf") from exc
85
+ try:
86
+ if len(doc) == 0:
87
+ raise PictureError(400, "bad_pdf")
88
+ if len(doc) > MAX_PAGES:
89
+ raise PictureError(413, "pdf_too_many_pages")
90
+ started, pages = time.monotonic(), []
91
+ for index in range(len(doc)):
92
+ if time.monotonic() - started > RENDER_BUDGET:
93
+ raise PictureError(413, "pdf_too_slow")
94
+ page = doc[index]
95
+ width = page.get_width() or 1
96
+ image = page.render(scale=PAGE_WIDTH / width).to_pil().convert("RGB")
97
+ out = io.BytesIO()
98
+ image.save(out, "JPEG", quality=85)
99
+ pages.append(out.getvalue())
100
+ return pages
101
+ finally:
102
+ doc.close()
103
+
104
+
105
+ class MemoryPictures:
106
+ """The bytes of pictures in memory: tests and local development."""
107
+
108
+ def __init__(self) -> None:
109
+ self.files: dict[str, bytes] = {}
110
+
111
+ def put(self, path: str, data: bytes, content_type: str = "image/jpeg") -> None:
112
+ self.files[path] = data
113
+
114
+ def get(self, path: str) -> bytes:
115
+ try:
116
+ return self.files[path]
117
+ except KeyError:
118
+ raise PictureError(404, "picture_missing") from None
119
+
120
+ def delete(self, path: str) -> None:
121
+ self.files.pop(path, None)
122
+
123
+
124
+ class SupabasePictures:
125
+ """The bytes of pictures in a private Supabase Storage bucket, read and written with the secret key. The bucket is made when first needed."""
126
+
127
+ def __init__(self, url: str, secret: str, bucket: str = "m2-answers") -> None:
128
+ self.url, self.secret, self.bucket = url.rstrip("/"), secret, bucket
129
+ self._ready = False
130
+
131
+ def _headers(self, content_type: str | None = None) -> dict:
132
+ headers = {"apikey": self.secret, "Authorization": f"Bearer {self.secret}", "User-Agent": "mathly2/1.0"}
133
+ if content_type:
134
+ headers["Content-Type"] = content_type
135
+ return headers
136
+
137
+ def _object(self, path: str) -> str:
138
+ return f"{self.url}/storage/v1/object/{self.bucket}/{urllib.parse.quote(path)}"
139
+
140
+ def ensure_bucket(self) -> None:
141
+ """Create the private bucket if it is not there (idempotent)."""
142
+ if self._ready:
143
+ return
144
+ body = json.dumps({"id": self.bucket, "name": self.bucket, "public": False}).encode()
145
+ try:
146
+ pooled.request("POST", f"{self.url}/storage/v1/bucket", self._headers("application/json"), body, 15)
147
+ except urllib.error.HTTPError as exc:
148
+ if exc.code not in (400, 409): # 400/409: it already exists
149
+ log.error("cannot create the picture bucket: %s", exc.code)
150
+ raise PictureError(503, "answer_storage_unavailable") from exc
151
+ except (urllib.error.URLError, TimeoutError) as exc:
152
+ raise PictureError(503, "answer_storage_unavailable") from exc
153
+ self._ready = True
154
+
155
+ def put(self, path: str, data: bytes, content_type: str = "image/jpeg") -> None:
156
+ self.ensure_bucket()
157
+ try:
158
+ pooled.request("POST", self._object(path), {**self._headers(content_type), "x-upsert": "true"}, data, 30)
159
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
160
+ log.error("cannot store a picture: %s", exc)
161
+ raise PictureError(503, "answer_storage_unavailable") from exc
162
+
163
+ def get(self, path: str) -> bytes:
164
+ try:
165
+ return pooled.request("GET", self._object(path), self._headers(), None, 30)[1]
166
+ except urllib.error.HTTPError as exc:
167
+ if exc.code in (400, 404):
168
+ raise PictureError(404, "picture_missing") from exc
169
+ raise PictureError(503, "answer_storage_unavailable") from exc
170
+ except (urllib.error.URLError, TimeoutError) as exc:
171
+ raise PictureError(503, "answer_storage_unavailable") from exc
172
+
173
+ def delete(self, path: str) -> None:
174
+ try:
175
+ pooled.request("DELETE", self._object(path), self._headers(), None, 30)
176
+ except urllib.error.HTTPError as exc:
177
+ if exc.code not in (400, 404): # already gone is fine
178
+ raise PictureError(503, "answer_storage_unavailable") from exc
179
+ except (urllib.error.URLError, TimeoutError) as exc:
180
+ raise PictureError(503, "answer_storage_unavailable") from exc
181
+
182
+
183
+ def store_picture(store, backend, owner_id: str, kind: str, jpeg: bytes, created_at: datetime | None = None) -> dict:
184
+ """Keep one cleaned picture: its bytes in the bucket, its row in the database. Returns the row."""
185
+ path = f"{owner_id}/{uuid.uuid4()}.jpg"
186
+ backend.put(path, jpeg, "image/jpeg")
187
+ return store.create_picture(owner_id, kind, path, "image/jpeg", len(jpeg), created_at)
188
+
189
+
190
+ def remove_picture(store, backend, picture: dict, at: datetime | None = None) -> None:
191
+ """Delete a picture's bytes and mark its row deleted (the row stays, so what referred to it can say "the pictures were deleted")."""
192
+ backend.delete(picture["path"])
193
+ store.mark_picture_deleted(picture["id"], at)
mathly2/push.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Push notifications: the student's phone is told when a computer asks for a photo of an answer (PRD PSH-1, PSH-2).
2
+
3
+ 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
4
+ subscribe, so the computer that asked gets none. A subscription the push service no longer knows (404 or 410) is deleted, and changing the
5
+ server's key (VAPID_PRIVATE_KEY) drops every subscription: each phone turns notifications on again. Without a key, notifications are off and
6
+ everything else works. On an iPhone they work only from the app added to the home screen (iOS 16.4 and later).
7
+
8
+ Make a key with `python -m mathly2.push` and keep it as a secret."""
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import logging
15
+ from datetime import datetime
16
+
17
+ log = logging.getLogger("mathly.push")
18
+ MAX_ENDPOINT = 1000
19
+
20
+
21
+ class Gone(Exception):
22
+ """The push service no longer knows this subscription (404 or 410)."""
23
+
24
+
25
+ def _b64(raw: bytes) -> str:
26
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
27
+
28
+
29
+ def new_private_key() -> str:
30
+ """A new VAPID private key as VAPID_PRIVATE_KEY keeps it: the raw 32 bytes, base64url."""
31
+ from cryptography.hazmat.primitives.asymmetric import ec
32
+ return _b64(ec.generate_private_key(ec.SECP256R1()).private_numbers().private_value.to_bytes(32, "big"))
33
+
34
+
35
+ def public_key(private: str) -> str:
36
+ """The browser's applicationServerKey for a private key: the uncompressed point, base64url. ValueError for a key that is not one."""
37
+ from cryptography.hazmat.primitives import serialization
38
+ from cryptography.hazmat.primitives.asymmetric import ec
39
+ try:
40
+ raw = base64.urlsafe_b64decode(private + "=" * (-len(private) % 4))
41
+ except ValueError as exc:
42
+ raise ValueError("not a 32-byte base64url key") from exc
43
+ if len(raw) != 32 or _b64(raw) != private:
44
+ raise ValueError("not a 32-byte base64url key")
45
+ key = ec.derive_private_key(int.from_bytes(raw, "big"), ec.SECP256R1())
46
+ return _b64(key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint))
47
+
48
+
49
+ def request_payload(request: dict, assignment: dict | None) -> dict:
50
+ """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,
51
+ so a newer request for the same answer replaces the older notification."""
52
+ return {"title": "📷 בקשת צילום מהמחשב", "body": f"קוד {request['code']} · צלמו את התשובה ושלחו למחשב.",
53
+ "url": f"/#/assignment/{request['assignment_id']}", "tag": f"photo-{request['assignment_id']}"}
54
+
55
+
56
+ def seconds_left(request: dict, now: datetime) -> int:
57
+ """How long the push service should keep trying: until the request expires (a dead request is not worth a ring)."""
58
+ return max(0, min(900, int((request["expires_at"] - now).total_seconds())))
59
+
60
+
61
+ def web_push(subscription: dict, payload: dict, private: str, subject: str, ttl: int) -> None:
62
+ """The real sender (pywebpush). Raises Gone for a subscription the push service no longer knows."""
63
+ from pywebpush import WebPushException, webpush
64
+ try:
65
+ webpush({"endpoint": subscription["endpoint"], "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}},
66
+ json.dumps(payload, ensure_ascii=False), vapid_private_key=private, vapid_claims={"sub": subject}, ttl=ttl, timeout=10,
67
+ headers={"Urgency": "high"}) # the student is waiting at the computer
68
+ except WebPushException as exc:
69
+ if getattr(exc.response, "status_code", None) in (404, 410):
70
+ raise Gone() from exc
71
+ raise
72
+
73
+
74
+ class Pusher:
75
+ """Subscriptions and sending, on the store's table. `sender(subscription, payload, ttl)` is injectable so tests do not touch the network."""
76
+
77
+ def __init__(self, store, private: str = "", subject: str = "", sender=None) -> None:
78
+ self.store, self.private, self.subject = store, private.strip(), subject
79
+ self.public, self.why, self.key_id = "", "", ""
80
+ if not self.private:
81
+ self.why = "VAPID_PRIVATE_KEY is not set"
82
+ else:
83
+ try:
84
+ self.public = public_key(self.private)
85
+ self.key_id = hashlib.sha256(self.public.encode()).hexdigest()[:16]
86
+ except ValueError as exc:
87
+ log.error("VAPID_PRIVATE_KEY is not a valid key: push notifications are off (%s)", exc)
88
+ self.why = "VAPID_PRIVATE_KEY is not a valid key"
89
+ self.sender = sender or (lambda sub, payload, ttl: web_push(sub, payload, self.private, self.subject, ttl))
90
+
91
+ @property
92
+ def on(self) -> bool:
93
+ return not self.why
94
+
95
+ def subscribe(self, user_id: str, body: dict) -> None:
96
+ """Keep a browser's subscription for this account (ValueError when it is not one). Subscriptions made under another key are dropped first."""
97
+ endpoint, keys = str(body.get("endpoint") or ""), body.get("keys") or {}
98
+ if not endpoint.startswith("https://") or len(endpoint) > MAX_ENDPOINT or not isinstance(keys, dict):
99
+ raise ValueError("not a push subscription")
100
+ p256dh, auth = str(keys.get("p256dh") or ""), str(keys.get("auth") or "")
101
+ if not (0 < len(p256dh) <= 200 and 0 < len(auth) <= 100):
102
+ raise ValueError("not a push subscription")
103
+ self.store.drop_push_subscriptions_not_of(self.key_id)
104
+ self.store.add_push_subscription(user_id, endpoint, p256dh, auth, self.key_id)
105
+
106
+ def notify(self, user_id: str, payload: dict, ttl: int) -> int:
107
+ """Send to the account's phones; how many went. A dead subscription is deleted, other failures are logged (the phone still finds the
108
+ request when the app opens)."""
109
+ if not self.on or ttl <= 0:
110
+ return 0
111
+ self.store.drop_push_subscriptions_not_of(self.key_id)
112
+ sent = 0
113
+ for sub in self.store.push_subscriptions_of(user_id):
114
+ try:
115
+ self.sender(sub, payload, ttl)
116
+ sent += 1
117
+ except Gone:
118
+ self.store.delete_push_subscription(sub["endpoint"])
119
+ except Exception as exc:
120
+ log.warning("push failed: %s", exc)
121
+ return sent
122
+
123
+
124
+ def default_subject(settings) -> str:
125
+ if settings.vapid_subject:
126
+ return settings.vapid_subject
127
+ first = sorted(settings.developers)
128
+ return f"mailto:{first[0]}" if first else "mailto:admin@example.com"
129
+
130
+
131
+ if __name__ == "__main__":
132
+ key = new_private_key()
133
+ print(f"VAPID_PRIVATE_KEY={key}\n# its public key (the page reads it from the server): {public_key(key)}")
mathly2/quiz.py CHANGED
@@ -1,8 +1,9 @@
1
- """Drawing a lesson quiz: 10 multiple-choice questions from the lesson's skills, spread evenly, new questions first on a retake."""
2
  from __future__ import annotations
3
 
4
  import random
5
 
 
6
  from .attempts import QUIZ_SIZE
7
 
8
 
@@ -35,15 +36,15 @@ def draw(bank, lesson_id: str, used: dict[str, int] | None = None, rng: random.R
35
  def facts(store, bank, user_id: str, now, reads=None) -> dict[str, dict]:
36
  """Per lesson the student has attempted: {best, attempts, open, last}. Overdue open attempts are settled first, so a quiz abandoned
37
  past its deadline is graded from the answers given. `best` is None until an attempt is submitted."""
38
- from . import attempts
39
  found: dict[str, dict] = {}
40
  for row in (reads or store).attempts_of(user_id, "quiz"):
41
  row = attempts.settle(store, bank, row, now)
42
- item = found.setdefault(row["scope"], {"best": None, "attempts": 0, "open": None, "last": None})
43
  if attempts.is_open(row):
44
  item["open"] = {"id": row["id"], "due_at": row["due_at"]}
45
  continue
46
  item["attempts"] += 1
 
47
  item["best"] = row["grade"] if item["best"] is None else max(item["best"], row["grade"])
48
  item["last"] = row["submitted_at"]
49
  return found
 
1
+ """Drawing a lesson quiz: 15 multiple-choice questions from the lesson's skills, spread evenly, new questions first on a retake."""
2
  from __future__ import annotations
3
 
4
  import random
5
 
6
+ from . import attempts
7
  from .attempts import QUIZ_SIZE
8
 
9
 
 
36
  def facts(store, bank, user_id: str, now, reads=None) -> dict[str, dict]:
37
  """Per lesson the student has attempted: {best, attempts, open, last}. Overdue open attempts are settled first, so a quiz abandoned
38
  past its deadline is graded from the answers given. `best` is None until an attempt is submitted."""
 
39
  found: dict[str, dict] = {}
40
  for row in (reads or store).attempts_of(user_id, "quiz"):
41
  row = attempts.settle(store, bank, row, now)
42
+ item = found.setdefault(row["scope"], {"best": None, "attempts": 0, "open": None, "last": None, "passed": False})
43
  if attempts.is_open(row):
44
  item["open"] = {"id": row["id"], "due_at": row["due_at"]}
45
  continue
46
  item["attempts"] += 1
47
+ item["passed"] = item["passed"] or row["grade"] >= attempts.PASS_GRADE
48
  item["best"] = row["grade"] if item["best"] is None else max(item["best"], row["grade"])
49
  item["last"] = row["submitted_at"]
50
  return found
mathly2/quizzes.py CHANGED
@@ -163,4 +163,5 @@ def grades(request: Request, user: dict = Depends(learner)) -> dict:
163
  simulations.append(item)
164
  exercise.sort(key=lambda g: (g["test"]["date"], g["test"]["id"], g["number"]))
165
  simulations.sort(key=lambda g: (g["test"]["date"], g["test"]["id"]))
166
- return {"quizzes": lessons, "exercise_tests": exercise, "simulations": simulations}
 
 
163
  simulations.append(item)
164
  exercise.sort(key=lambda g: (g["test"]["date"], g["test"]["id"], g["number"]))
165
  simulations.sort(key=lambda g: (g["test"]["date"], g["test"]["id"]))
166
+ from .assignments import grade_rows
167
+ return {"quizzes": lessons, "exercise_tests": exercise, "simulations": simulations, "assignments": grade_rows(request, user)}
mathly2/reads.py CHANGED
@@ -11,7 +11,7 @@ from fastapi import Request
11
  POOL = ThreadPoolExecutor(max_workers=16, thread_name_prefix="db")
12
 
13
  READS = frozenset({"classes_of_student", "classes_taught", "progress_of", "last_answered", "daily_correct", "recent_skills", "attempts_of",
14
- "tests_of_classes", "get_progress", "answers_of", "reward_state", "comets_of", "members", "get_class", "get_test", "roles_of"})
15
 
16
 
17
  def _key(name: str, args: tuple) -> tuple:
 
11
  POOL = ThreadPoolExecutor(max_workers=16, thread_name_prefix="db")
12
 
13
  READS = frozenset({"classes_of_student", "classes_taught", "progress_of", "last_answered", "daily_correct", "recent_skills", "attempts_of",
14
+ "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"})
15
 
16
 
17
  def _key(name: str, args: tuple) -> tuple:
mathly2/requirements.txt CHANGED
@@ -3,3 +3,6 @@ uvicorn[standard]>=0.29
3
  anyio>=4.10,<4.11 # Python 3.10: newer releases can hang Starlette's worker-thread bridge
4
  python-dotenv>=1.0
5
  tzdata>=2024.1
 
 
 
 
3
  anyio>=4.10,<4.11 # Python 3.10: newer releases can hang Starlette's worker-thread bridge
4
  python-dotenv>=1.0
5
  tzdata>=2024.1
6
+ Pillow==12.3.0 # checks and cleans answer pictures (mathly2/pictures.py)
7
+ pypdfium2>=4.30,<6 # a teacher's PDF to page images
8
+ pywebpush>=2.0,<3 # notifications to a student's phone for a photo request
mathly2/retention.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """How long pictures are kept (PRD ANS-3): answer pictures until the end of the school year (31 August), unreferenced pictures for a day,
2
+ assignment pages until their assignment is removed. Grades and teachers' notes are never touched: only the pictures go."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+
7
+ ORPHAN_AGE = timedelta(hours=24)
8
+
9
+
10
+ def expires_on(day: date) -> date:
11
+ """The last day an answer picture made on `day` is kept: 31 August of its school year (1 September to 31 August)."""
12
+ return date(day.year + 1 if day.month >= 9 else day.year, 8, 31)
13
+
14
+
15
+ def is_expired(created_day: date, today: date) -> bool:
16
+ """An answer picture is removed from 1 September on."""
17
+ return today > expires_on(created_day)
mathly2/skills.py CHANGED
@@ -82,10 +82,12 @@ def _round_view(request: Request, skill_id: str, rows: list[dict], round_start:
82
 
83
 
84
  def visible_lesson_ids(request: Request, user: dict) -> set[str] | None:
85
- """The lessons the student can see: those of their classes. None means all (a teacher or a developer)."""
86
  if set(user["roles"]) - {"student"}:
87
  return None
88
- return {lesson for c in reads.of(request).classes_of_student(user["email"]) for lesson in c["lessons"]}
 
 
89
 
90
 
91
  @router.get("/skills/{skill_id}")
@@ -199,11 +201,11 @@ def _comet_due(request: Request, user_id: str, today_now: dict, state: dict | No
199
  @router.get("/path")
200
  def path(request: Request, user: dict = Depends(learner)) -> dict:
201
  store, here, uid = request.app.state.store, reads.of(request), user["id"]
202
- here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"))
203
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
204
  last, today = here.last_answered(uid), clock.school_day()
205
  quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
206
- shown = visible_lesson_ids(request, user)
207
  lessons, order = [], []
208
  for lesson in request.app.state.bank.lessons_with_skills():
209
  if shown is not None and lesson["id"] not in shown:
@@ -214,11 +216,15 @@ def path(request: Request, user: dict = Depends(learner)) -> dict:
214
  order += [s["id"] for s in skills]
215
  mastered = sum(1 for s in skills if s["state"] == MASTERED)
216
  fact = quizzes_facts.get(lesson["id"], {})
 
 
 
217
  lessons.append({"id": lesson["id"], "name": lesson["name"], "skills": skills, "mastered": mastered,
218
- "completed": bool(skills) and mastered == len(skills),
 
219
  "quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") else None}})
220
  daily = here.daily_correct(uid)
221
- return {"lessons": lessons, "next": next_skill(order, states, here.recent_skills(uid, RECENT)),
222
  "today": today_progress(daily, today), "streak": streak(daily, today)}
223
 
224
 
 
82
 
83
 
84
  def visible_lesson_ids(request: Request, user: dict) -> set[str] | None:
85
+ """The lessons the student can see: every lesson once they are in a class, none before. None means all (a teacher or a developer)."""
86
  if set(user["roles"]) - {"student"}:
87
  return None
88
+ if not reads.of(request).classes_of_student(user["email"]):
89
+ return set()
90
+ return {lesson["id"] for lesson in request.app.state.bank.lessons}
91
 
92
 
93
  @router.get("/skills/{skill_id}")
 
201
  @router.get("/path")
202
  def path(request: Request, user: dict = Depends(learner)) -> dict:
203
  store, here, uid = request.app.state.store, reads.of(request), user["id"]
204
+ here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("curriculum",))
205
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
206
  last, today = here.last_answered(uid), clock.school_day()
207
  quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
208
+ shown, dates = visible_lesson_ids(request, user), here.curriculum()
209
  lessons, order = [], []
210
  for lesson in request.app.state.bank.lessons_with_skills():
211
  if shown is not None and lesson["id"] not in shown:
 
216
  order += [s["id"] for s in skills]
217
  mastered = sum(1 for s in skills if s["state"] == MASTERED)
218
  fact = quizzes_facts.get(lesson["id"], {})
219
+ passed = bool(fact.get("passed"))
220
+ completed = passed or (bool(skills) and mastered == len(skills))
221
+ due = dates.get(lesson["id"])
222
  lessons.append({"id": lesson["id"], "name": lesson["name"], "skills": skills, "mastered": mastered,
223
+ "completed": completed, "passed": passed, "due_date": due.isoformat() if due else None,
224
+ "behind": bool(due and due < today and not completed),
225
  "quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") else None}})
226
  daily = here.daily_correct(uid)
227
+ return {"lessons": lessons, "behind": sum(1 for l in lessons if l["behind"]), "next": next_skill(order, states, here.recent_skills(uid, RECENT)),
228
  "today": today_progress(daily, today), "streak": streak(daily, today)}
229
 
230
 
mathly2/static/api.js CHANGED
@@ -1,4 +1,5 @@
1
- import { token } from "./session.js";
 
2
 
3
  export class ApiError extends Error {
4
  constructor(status, message) { super(message); this.status = status; }
@@ -6,12 +7,49 @@ export class ApiError extends Error {
6
 
7
  const MESSAGES = { 503: "שירות ההתחברות לא זמין כרגע, נסו שוב בעוד רגע", 403: "אין הרשאה לפעולה הזו" };
8
 
9
- export async function api(path, { method = "GET", body } = {}) {
 
 
 
 
 
 
10
  const headers = { Authorization: "Bearer " + await token() };
11
  if (body !== undefined) headers["Content-Type"] = "application/json";
12
  const response = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
13
- if (response.ok) return response.status === 204 ? null : response.json();
 
 
 
 
 
14
  let detail = "";
15
  try { detail = (await response.json()).detail; } catch { /* no body */ }
16
  throw new ApiError(response.status, typeof detail === "string" && detail ? detail : (MESSAGES[response.status] || "משהו השתבש, נסו שוב"));
17
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { track } from "./busy.js";
2
+ import { onSignOut, token } from "./session.js";
3
 
4
  export class ApiError extends Error {
5
  constructor(status, message) { super(message); this.status = status; }
 
7
 
8
  const MESSAGES = { 503: "שירות ההתחברות לא זמין כרגע, נסו שוב בעוד רגע", 403: "אין הרשאה לפעולה הזו" };
9
 
10
+ const remembered = new Map(); // GET path -> the last answer
11
+ let changes = 0; // counts changes, so an answer to a read that began before one is not kept
12
+ onSignOut(() => { remembered.clear(); changes++; });
13
+
14
+ async function request(path, { method = "GET", body } = {}) {
15
+ const started = changes;
16
+ if (method !== "GET") { remembered.clear(); changes++; } // after a change of theirs, no old answer is ever shown
17
  const headers = { Authorization: "Bearer " + await token() };
18
  if (body !== undefined) headers["Content-Type"] = "application/json";
19
  const response = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
20
+ if (response.ok) {
21
+ const data = response.status === 204 ? null : await response.json();
22
+ if (method === "GET") { if (started === changes) remembered.set(path, data); }
23
+ else { remembered.clear(); changes++; }
24
+ return data;
25
+ }
26
  let detail = "";
27
  try { detail = (await response.json()).detail; } catch { /* no body */ }
28
  throw new ApiError(response.status, typeof detail === "string" && detail ? detail : (MESSAGES[response.status] || "משהו השתבש, נסו שוב"));
29
  }
30
+
31
+ /** A request to the server. The button that was just pressed shows it is working if the answer takes a moment (busy.js). */
32
+ export const api = (path, options) => track(request(path, options));
33
+
34
+ /** The last answer for this GET path, or undefined (nothing yet, or something was changed since). */
35
+ export const lastAnswer = path => remembered.get(path);
36
+
37
+ let screen = 0;
38
+ const busyNow = () => {
39
+ const here = document.activeElement;
40
+ return !!(here && here.matches?.("input, textarea, select, [contenteditable]")) || !!document.querySelector("main details[open], .sheet, dialog[open]");
41
+ };
42
+
43
+ /** Shows a screen: draws `render(data)` at once from the last answer if there is one, then draws again when the fresh answer
44
+ * differs, unless the person has moved on, is typing, or has something open. Without a last answer it waits, as before.
45
+ * `path` may be a list: then `render` gets the answers as a list. */
46
+ export async function swr(path, render) {
47
+ const paths = Array.isArray(path) ? path : [path], one = !Array.isArray(path);
48
+ const give = list => render(one ? list[0] : list);
49
+ const mine = ++screen, hash = location.hash, before = paths.map(p => remembered.get(p));
50
+ if (before.some(x => x === undefined)) return give(await Promise.all(paths.map(p => api(p))));
51
+ give(before);
52
+ Promise.all(paths.map(p => api(p))).then(fresh => {
53
+ if (mine === screen && hash === location.hash && JSON.stringify(fresh) !== JSON.stringify(before) && !busyNow()) give(fresh);
54
+ }).catch(() => { /* the screen already shows what it knew */ });
55
+ }
mathly2/static/app.css CHANGED
@@ -168,6 +168,11 @@ button.danger { color: var(--danger); border-color: var(--danger); }
168
  .orbit .hub h3 { margin: 0; font-size: 13px; line-height: 1.25; }
169
  .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); }
170
  .orbit.completed .hub { box-shadow: 0 0 0 3px var(--sky-gold); }
 
 
 
 
 
171
  .star { position: absolute; left: calc(50% + var(--dx) * var(--R)); top: calc(50% + var(--dy) * var(--R) * 1.1); transform: translate(-50%, -26px);
172
  width: 84px; display: flex; flex-direction: column; align-items: center; gap: 3px; text-decoration: none; color: var(--text); border-radius: 12px; }
173
  .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; }
309
  /* The student's tab bar sits at the bottom of the screen, above the page and below the sheets */
310
  .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));
311
  background: var(--surface); border-top: 1px solid var(--line); }
312
- .tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; }
313
  body:has(.tabs) main { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }
314
 
315
  /* Home */
@@ -370,3 +375,64 @@ ol.plan li.done .mark { color: var(--sky-aurora); }
370
  .ex-score { margin: 0; font-size: 18px; }
371
  .ex-score b { font-size: 44px; line-height: 1; color: var(--sky-gold); }
372
  .ex-missed { padding-inline-start: 22px; display: grid; gap: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  .orbit .hub h3 { margin: 0; font-size: 13px; line-height: 1.25; }
169
  .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); }
170
  .orbit.completed .hub { box-shadow: 0 0 0 3px var(--sky-gold); }
171
+ .orbit.behind .hub { box-shadow: 0 0 0 3px var(--sky-warn, #ff9f6b); }
172
+ .lesson-status { margin: 0 auto 6px; text-align: center; font-size: 13px; font-weight: 600; }
173
+ .lesson-status.late, .behind-note { color: var(--sky-warn, #ff9f6b); }
174
+ .lesson-status.done { color: var(--sky-gold); }
175
+ .behind-note { margin: 8px 0 0; font-weight: 600; }
176
  .star { position: absolute; left: calc(50% + var(--dx) * var(--R)); top: calc(50% + var(--dy) * var(--R) * 1.1); transform: translate(-50%, -26px);
177
  width: 84px; display: flex; flex-direction: column; align-items: center; gap: 3px; text-decoration: none; color: var(--text); border-radius: 12px; }
178
  .star:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
 
314
  /* The student's tab bar sits at the bottom of the screen, above the page and below the sheets */
315
  .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));
316
  background: var(--surface); border-top: 1px solid var(--line); }
317
+ .tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; font-size: 15px; padding-inline: 2px; }
318
  body:has(.tabs) main { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }
319
 
320
  /* Home */
 
375
  .ex-score { margin: 0; font-size: 18px; }
376
  .ex-score b { font-size: 44px; line-height: 1; color: var(--sky-gold); }
377
  .ex-missed { padding-inline-start: 22px; display: grid; gap: 12px; }
378
+
379
+ /* My assignments */
380
+ ul.assignments li { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; }
381
+ ul.assignments li a { color: var(--text); text-decoration: none; }
382
+ ul.assignments li .tag { grid-column: 2; grid-row: 1 / span 2; white-space: nowrap; }
383
+ ul.assignments li[data-state="returned"] .tag, ul.assignments li[data-state="not_handed"] .tag { border-color: var(--sky-gold); color: var(--text); }
384
+
385
+ /* An assignment */
386
+ .pages { display: grid; gap: 10px; }
387
+ .pages img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid var(--line); background: #fff; }
388
+ .qz-written { padding-inline-start: 22px; display: grid; gap: 14px; }
389
+ .drafts { display: flex; flex-wrap: wrap; gap: 10px; }
390
+ .drafts li { position: relative; list-style: none; border: 1px solid var(--line); border-radius: 10px; padding: 4px; max-width: 46%; }
391
+ .drafts img { display: block; max-width: 100%; max-height: 160px; border-radius: 8px; background: #fff; }
392
+ .drafts .remove { position: absolute; inset-block-start: -8px; inset-inline-end: -8px; width: 28px; height: 28px; min-height: 0; padding: 0; border-radius: 50%; }
393
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; }
394
+ .button.file { cursor: pointer; }
395
+ .returned { border: 2px solid var(--sky-gold); border-radius: 12px; padding: 10px 14px; background: color-mix(in srgb, var(--sky-gold) 12%, var(--surface)); }
396
+ .grade-big b { font-size: 40px; color: var(--sky-gold); }
397
+ .teacher-note { margin: 6px 0; }
398
+
399
+ /* The photo request */
400
+ .photo-request { border: 2px dashed var(--sky-accent); border-radius: 14px; padding: 10px 14px; margin-top: 8px; }
401
+ .code { font-size: 34px; letter-spacing: 6px; color: var(--sky-gold); }
402
+ .old-requests { margin-top: 6px; font-size: 14px; color: var(--muted); }
403
+ .photo-window { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 16px; background: rgb(0 0 0 / .7); }
404
+ .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; }
405
+ .photo-window h3 { margin: 0; }
406
+
407
+ /* The teacher's work tab */
408
+ .assignment-form { display: grid; gap: 8px; margin-top: 8px; }
409
+ .assignment-form label { display: grid; gap: 4px; }
410
+ .assignment-form label.check, .assignment-form label.file { display: inline-flex; gap: 6px; align-items: center; }
411
+ .assignment-form fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; }
412
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0; }
413
+ .tag.waiting { border-color: var(--sky-gold); font-weight: 700; }
414
+
415
+ .open-submission { width: 100%; min-height: 56px; text-align: start; }
416
+
417
+ /* A button whose action takes time: a spinner, and a second press does nothing (busy.js). With reduced motion: "…" */
418
+ .busy { pointer-events: none; opacity: .85; }
419
+ .busy::after { content: ""; display: inline-block; width: 14px; height: 14px; margin-inline-start: 8px; vertical-align: -2px; border-radius: 50%;
420
+ border: 2px solid currentColor; border-top-color: transparent; animation: ex-spin .8s linear infinite; }
421
+ @media (prefers-reduced-motion: reduce) { .busy::after { content: "…"; width: auto; height: auto; border: 0; border-radius: 0; animation: none; vertical-align: baseline; } }
422
+
423
+ /* The number of items in a teacher's tab */
424
+ .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;
425
+ background: color-mix(in srgb, var(--text) 16%, transparent); font-weight: 800; }
426
+ .tab-count:empty { display: none; }
427
+ [aria-pressed="true"] > .tab-count { background: color-mix(in srgb, var(--accent-text) 22%, transparent); }
428
+
429
+ /* The submissions tab */
430
+ .group { margin-top: 12px; }
431
+ .group h3 { margin: 0 0 6px; font-size: 16px; }
432
+
433
+ /* A student's row on the teacher's class card: the address, its state and two buttons must wrap on a phone */
434
+ ul.students li { flex-wrap: wrap; }
435
+ ul.students li [dir="ltr"] { overflow-wrap: anywhere; min-width: 0; }
436
+ .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); }
437
+ .curriculum label { flex: 1 1 200px; }
438
+ .curriculum input[type=date] { flex: 0 0 auto; }
mathly2/static/assignment.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 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
2
+ // the phone) and handing it in; afterwards its state, and the grade and note or the request to correct.
3
+ import { api } from "./api.js";
4
+ import { loadBoard } from "./exercise.js";
5
+ import { figures } from "./figures.js";
6
+ import { assignmentState } from "./home.js";
7
+ import { withBusy } from "./busy.js";
8
+ import { esc, fmt } from "./math.js";
9
+ import { isTouch, pictureUrl, prepare, sendPicture } from "./media.js";
10
+ import * as phone from "./phone.js";
11
+ import { shortDate, tabs } from "./ui.js";
12
+
13
+ let draw = null, current = null, draft = [], message = "";
14
+
15
+ const draftKey = id => `assignment-draft:${id}`;
16
+ const saveDraft = () => { try { sessionStorage.setItem(draftKey(current.id), JSON.stringify(draft)); } catch { /* private window */ } };
17
+ const loadDraft = id => { try { return JSON.parse(sessionStorage.getItem(draftKey(id)) || "null"); } catch { return null; } };
18
+ const q = selector => document.querySelector(selector);
19
+ const editable = a => ["not_handed", "handed_in", "returned"].includes(a.state);
20
+
21
+ /** Fill an <img> with a private picture (fetched with the sign-in). */
22
+ async function fill(img, id) {
23
+ try { img.src = await pictureUrl(id); img.hidden = false; } catch (error) { img.replaceWith(Object.assign(document.createElement("span"), { className: "muted", textContent: error.message })); }
24
+ }
25
+
26
+ function material(a) {
27
+ if (a.kind === "exercise_test") return `<p><a class="button primary" href="#/exam/${a.test.id}/${a.test.number}">למבחן התרגול</a></p>`;
28
+ if (a.kind === "quiz" && a.quiz) {
29
+ return `<h3>הבוחן הכתוב <span class="muted">(${a.quiz.minutes} דקות · ${a.quiz.points} נקודות)</span></h3><ol class="qz-written" id="written-quiz">${a.quiz.questions.map(x =>
30
+ `<li><p class="ex-text" dir="auto">${fmt(x.question)}</p>${figures(x)}<span class="muted">${x.points} נק׳</span></li>`).join("")}</ol>`;
31
+ }
32
+ return a.pages.length ? `<h3>הדף</h3><div class="pages" id="pages">${a.pages.map((id, i) => `<img data-picture="${esc(id)}" alt="עמוד ${i + 1}" hidden>`).join("")}</div>` : "";
33
+ }
34
+
35
+ function stateBox(a) {
36
+ const s = assignmentState(a);
37
+ const note = a.teacher_note ? `<p class="teacher-note" id="teacher-note"><b>הערת המורה:</b> ${fmt(a.teacher_note)}</p>` : "";
38
+ const head = `<p id="state" role="status"><span class="tag st-${esc(a.state)}"><span aria-hidden="true">${s.symbol}</span> ${s.word}</span></p>`;
39
+ if (a.state === "returned") return `${head}<div class="returned" id="returned"><b>המורה החזיר/ה את המשימה לתיקון.</b>${note}<p>תקנו והגישו שוב.</p></div>`;
40
+ if (a.state === "graded") return `${head}<p class="grade-big" id="grade">הציון: <b>${a.grade}</b></p>${note}`;
41
+ return head;
42
+ }
43
+
44
+ function draftHtml() {
45
+ return draft.length ? draft.map(id => `<li data-picture-id="${esc(id)}"><img data-picture="${esc(id)}" alt="תמונה של התשובה" hidden>
46
+ <button type="button" class="remove" data-remove="${esc(id)}" aria-label="הסרת התמונה">✕</button></li>`).join("") : `<li class="muted" id="draft-empty">עוד לא הוספתם תמונה.</li>`;
47
+ }
48
+
49
+ function renderDraft() {
50
+ q("#draft").innerHTML = draftHtml();
51
+ q("#draft").querySelectorAll("img[data-picture]").forEach(img => fill(img, img.dataset.picture));
52
+ q("#hand-in").disabled = !draft.length;
53
+ saveDraft();
54
+ }
55
+
56
+ function answerBox(a) {
57
+ if (a.kind === "exercise_test") return "";
58
+ if (!editable(a)) {
59
+ return a.pictures.length ? `<section class="card" id="answer"><h3>התשובה שהגשתי</h3><div class="pages" id="submitted">${a.pictures.map(p => p.deleted
60
+ ? `<p class="muted" data-deleted>התמונות נמחקו</p>` : `<img data-picture="${esc(p.id)}" alt="התשובה שהגשתי" hidden>`).join("")}</div></section>` : "";
61
+ }
62
+ return `<section class="card" id="answer"><h3>${a.state === "handed_in" ? "התשובה שהגשתי (אפשר להחליף עד שהמורה בודק/ת)" : "התשובה שלי"}</h3>
63
+ <ul class="plain drafts" id="draft">${draftHtml()}</ul>
64
+ <p class="actions" id="add-actions"><button type="button" id="board-btn">✎ כתיבה על הלוח</button>
65
+ ${isTouch() ? `<label class="button file">📷 צילום<input type="file" accept="image/*" capture="environment" hidden data-file="camera"></label>` : ""}
66
+ <label class="button file">🖼 העלאת תמונה<input type="file" accept="image/jpeg,image/png,image/webp" multiple hidden data-file="upload"></label>
67
+ ${!isTouch() ? `<button type="button" id="ask-phone">📱 בקשת צילום מהטלפון</button>` : ""}</p>
68
+ <div id="phone-panel"></div>
69
+ <p class="error" id="add-error" role="alert" hidden></p>
70
+ <p><button type="button" class="primary" id="hand-in" disabled>${a.state === "handed_in" ? "החלפת ההגשה" : "הגשה"}</button></p>
71
+ <p id="hand-msg" role="status" class="muted">${esc(message)}</p></section>`;
72
+ }
73
+
74
+ function add(id) {
75
+ if (!q("#draft")) return; // the page was left while a photo was on its way
76
+ if (!draft.includes(id)) draft.push(id);
77
+ renderDraft();
78
+ }
79
+
80
+ async function addFiles(files) {
81
+ const error = q("#add-error");
82
+ error.hidden = true;
83
+ for (const file of files) {
84
+ if (draft.length >= 10) { error.textContent = "אפשר להגיש עד 10 תמונות"; error.hidden = false; break; }
85
+ try {
86
+ add((await sendPicture("/api/pictures", await prepare(file))).id);
87
+ } catch (e) { error.textContent = e.message; error.hidden = false; }
88
+ }
89
+ }
90
+
91
+ async function openBoard() {
92
+ await loadBoard();
93
+ if (!window.MathlyBoard?.openFull) return;
94
+ const host = Object.assign(document.createElement("div"), { className: "mx-layer" });
95
+ document.body.append(host);
96
+ window.MathlyBoard.openFull({
97
+ host, key: `assignment:${current.id}`, title: "כתיבה על הלוח", sendLabel: "הוספה להגשה",
98
+ submit: async (page, blob) => {
99
+ if (draft.length >= 10) return { error: "אפשר להגיש עד 10 תמונות" };
100
+ try { add((await sendPicture("/api/pictures", blob)).id); return {}; } catch (e) { return { error: e.message }; }
101
+ },
102
+ done: () => host.remove(),
103
+ });
104
+ }
105
+
106
+ async function handIn() {
107
+ const button = q("#hand-in");
108
+ button.disabled = true;
109
+ q("#hand-msg").textContent = "שולחים…";
110
+ try {
111
+ await api(`/api/assignments/${current.id}/submission`, { method: "POST", body: { pictures: draft } });
112
+ try { sessionStorage.removeItem(draftKey(current.id)); } catch { /* nothing */ }
113
+ message = "ההגשה נשלחה. המורה יבדוק/תבדוק.";
114
+ await show(draw, current.id);
115
+ } catch (error) {
116
+ button.disabled = false;
117
+ q("#hand-msg").textContent = "";
118
+ q("#add-error").textContent = error.message || "משהו השתבש";
119
+ q("#add-error").hidden = false;
120
+ }
121
+ }
122
+
123
+ export async function show(view, id) {
124
+ draw = view;
125
+ current = await api(`/api/assignments/${encodeURIComponent(id)}`);
126
+ const saved = loadDraft(current.id);
127
+ draft = editable(current) ? (saved ?? current.pictures.filter(p => !p.deleted).map(p => p.id)) : [];
128
+ view(`${tabs("submissions")}<p><a class="back" href="#/submissions">‹ ההגשות</a></p>
129
+ <section class="card" id="assignment" data-id="${current.id}" data-state="${esc(current.state)}"><h2>${fmt(current.title)}</h2>
130
+ <p class="muted">${esc(current.class.name)} · עד ${shortDate(current.due_date)}${current.overdue ? " · באיחור" : ""}</p>
131
+ ${current.note ? `<p id="assignment-note">${fmt(current.note)}</p>` : ""}${stateBox(current)}</section>
132
+ <section class="card" id="material">${material(current)}</section>${answerBox(current)}`);
133
+ document.querySelectorAll("img[data-picture]:not(#draft img)").forEach(img => fill(img, img.dataset.picture));
134
+ if (!editable(current) || current.kind === "exercise_test") return;
135
+ renderDraft();
136
+ q("#board-btn").addEventListener("click", openBoard);
137
+ q("#hand-in").addEventListener("click", handIn);
138
+ q("#draft").addEventListener("click", event => {
139
+ const id = event.target.closest("[data-remove]")?.dataset.remove;
140
+ if (id) { draft = draft.filter(x => x !== id); renderDraft(); }
141
+ });
142
+ document.querySelectorAll("[data-file]").forEach(input => input.addEventListener("change", async () => { const files = [...input.files]; input.value = ""; await withBusy(input.closest("label"), addFiles(files)); }));
143
+ q("#ask-phone")?.addEventListener("click", () => phone.askFromComputer(current, id => add(id), q("#phone-panel")));
144
+ }
mathly2/static/busy.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 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
2
+ // 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
3
+ // a second press does nothing, until the request ends. Quick actions never flash it. Uploads (a file chooser's label) use `withBusy` directly.
4
+ const DELAY = 300; // milliseconds before the button shows it is working
5
+ const WINDOW = 600; // a request counts as the button's when it starts within this time of the press
6
+ const SKIP = ".ex-choice, .qz-choice, .mx-board, [data-board-toggle]"; // these show their own state
7
+
8
+ let pressed = null, pressedAt = 0;
9
+ const counts = new WeakMap();
10
+
11
+ function target(event) {
12
+ const el = event.target?.closest?.("button, .button, [type=submit]");
13
+ return el && !el.closest(SKIP) ? el : null;
14
+ }
15
+
16
+ function mark(el) {
17
+ counts.set(el, (counts.get(el) || 0) + 1);
18
+ el.classList.add("busy");
19
+ el.setAttribute("aria-busy", "true");
20
+ }
21
+
22
+ function unmark(el) {
23
+ const left = (counts.get(el) || 1) - 1;
24
+ counts.set(el, left);
25
+ if (left <= 0) { el.classList.remove("busy"); el.removeAttribute("aria-busy"); }
26
+ }
27
+
28
+ document.addEventListener("click", event => {
29
+ const el = target(event);
30
+ if (!el) return;
31
+ if (el.classList.contains("busy")) { event.preventDefault(); event.stopImmediatePropagation(); return; } // a second press while it works
32
+ pressed = el;
33
+ pressedAt = performance.now();
34
+ }, true);
35
+
36
+ document.addEventListener("submit", event => {
37
+ if (event.submitter?.classList.contains("busy")) { event.preventDefault(); event.stopImmediatePropagation(); return; }
38
+ if (event.submitter && !event.submitter.closest(SKIP)) { pressed = event.submitter; pressedAt = performance.now(); }
39
+ }, true);
40
+
41
+ /** Follow a promise for a button: after a third of a second the button shows it is working. Returns the promise. */
42
+ export function withBusy(el, promise) {
43
+ if (!el) return promise;
44
+ const timer = setTimeout(() => { if (el.isConnected) { el.dataset.busyOn = "1"; mark(el); } }, DELAY);
45
+ const done = () => { clearTimeout(timer); if (el.dataset.busyOn) { delete el.dataset.busyOn; unmark(el); } };
46
+ promise.then(done, done);
47
+ return promise;
48
+ }
49
+
50
+ /** Follow a request for the button that was just pressed (if one was pressed a moment ago). Used by `api()`. */
51
+ export function track(promise) {
52
+ const el = performance.now() - pressedAt < WINDOW && pressed?.isConnected ? pressed : null;
53
+ return withBusy(el, promise);
54
+ }
mathly2/static/grades.js CHANGED
@@ -1,6 +1,6 @@
1
  // The student's grades: each lesson's best quiz grade, and each real test's exercise tests and simulation (best grade, tries, last date).
2
  // Assignments will add their rows later.
3
- import { api } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { shortDate, tabs } from "./ui.js";
6
 
@@ -16,15 +16,21 @@ function row({ attrs, href, title, best, attempts, last }) {
16
 
17
  const testTitle = (t, suffix) => `מבחן ב־${shortDate(t.date)}: ${suffix}${t.removed ? " (הוסר)" : ""}`;
18
 
19
- export async function show(view) {
20
- const { quizzes, exercise_tests: exercises = [], simulations = [] } = await api("/api/grades");
21
- const none = !quizzes.length && !exercises.length && !simulations.length;
 
22
  view(`${tabs("grades")}<section class="card" id="grades"><h2>הציונים שלי</h2>
23
  ${none ? `<p class="muted" id="no-grades">עוד אין ציונים. הבוחן של כל שיעור מחכה בסוף השיעור במסלול.</p>` : ""}
24
  ${quizzes.length ? `<h3>בחנים</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
25
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
26
  ${exercises.length ? `<h3>מבחני תרגול</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
27
  href: `#/test/${g.test.id}`, title: testTitle(g.test, `מבחן תרגול ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
 
 
 
 
 
28
  ${simulations.length ? `<h3>מבחני סימולציה</h3><ul class="plain grades" id="simulation-grades">${simulations.map(g => row({ attrs: `data-test="${g.test.id}"`,
29
  href: `#/test/${g.test.id}`, title: testTitle(g.test, "מבחן סימולציה"), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}</section>`);
30
  }
 
1
  // The student's grades: each lesson's best quiz grade, and each real test's exercise tests and simulation (best grade, tries, last date).
2
  // Assignments will add their rows later.
3
+ import { swr } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { shortDate, tabs } from "./ui.js";
6
 
 
16
 
17
  const testTitle = (t, suffix) => `מבחן ב־${shortDate(t.date)}: ${suffix}${t.removed ? " (הוסר)" : ""}`;
18
 
19
+ export const show = view => swr("/api/grades", data => draw(view, data));
20
+
21
+ function draw(view, { quizzes, exercise_tests: exercises = [], simulations = [], assignments = [] }) {
22
+ const none = !quizzes.length && !exercises.length && !simulations.length && !assignments.length;
23
  view(`${tabs("grades")}<section class="card" id="grades"><h2>הציונים שלי</h2>
24
  ${none ? `<p class="muted" id="no-grades">עוד אין ציונים. הבוחן של כל שיעור מחכה בסוף השיעור במסלול.</p>` : ""}
25
  ${quizzes.length ? `<h3>בחנים</h3><ul class="plain grades" id="quiz-grades">${quizzes.map(g => row({ attrs: `data-lesson="${esc(g.lesson.id)}"`, href: `#/quiz/${esc(g.lesson.id)}`,
26
  title: fmt(g.lesson.name), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
27
  ${exercises.length ? `<h3>מבחני תרגול</h3><ul class="plain grades" id="exercise-grades">${exercises.map(g => row({ attrs: `data-test="${g.test.id}" data-number="${g.number}"`,
28
  href: `#/test/${g.test.id}`, title: testTitle(g.test, `מבחן תרגול ${g.number}`), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}
29
+ ${assignments.length ? `<h3>משימות</h3><ul class="plain grades" id="assignment-grades">${assignments.map(g => `<li data-assignment="${g.assignment.id}">
30
+ <a href="#/assignment/${g.assignment.id}"><b>${fmt(g.assignment.title)}</b>${g.assignment.removed ? " (הוסרה)" : ""}</a>
31
+ <span class="gr-num" aria-label="הציון ${g.grade}">${g.grade}</span>
32
+ <span class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${g.grade}"><i style="width:${g.grade}%"></i></span>
33
+ ${g.note ? `<span class="muted gr-note">הערת המורה: ${fmt(g.note)}</span>` : `<span class="muted">${g.assignment.kind === "exercise_test" ? "מבחן תרגול" : "משימה"}</span>`}</li>`).join("")}</ul>` : ""}
34
  ${simulations.length ? `<h3>מבחני סימולציה</h3><ul class="plain grades" id="simulation-grades">${simulations.map(g => row({ attrs: `data-test="${g.test.id}"`,
35
  href: `#/test/${g.test.id}`, title: testTitle(g.test, "מבחן סימולציה"), best: g.best, attempts: g.attempts, last: g.last })).join("")}</ul>` : ""}</section>`);
36
  }
mathly2/static/home.js CHANGED
@@ -1,6 +1,6 @@
1
  // The student's home for a subject: the nearest test with its readiness and one task for today, the required simulation,
2
  // the other tests, and a button that continues the path.
3
- import { api } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { PATH, backToSubjects, daysText, shortDate, tabs } from "./ui.js";
6
 
@@ -40,16 +40,41 @@ function nearest(test, task, next) {
40
  <p><a href="#/test/${test.id}">כל התוכנית והמבחנים</a></p></section>`;
41
  }
42
 
43
- export async function show(view) {
44
- const data = await api("/api/home");
45
- const [first, ...others] = data.tests;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  const required = data.tests.filter(t => t.simulation.required);
47
  view(`${tabs("home")}${backToSubjects()}
48
  ${first ? nearest(first, data.task, data.next_skill) : `<section class="card" id="no-test"><h2>הבית</h2>
49
  <p class="muted">המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.</p></section>`}
 
50
  ${required.map(t => `<a class="required" href="#/sim/${t.id}" data-required="${t.id}"><b>חובה</b> מבחן סימולציה למבחן ב־${shortDate(t.date)}<span aria-hidden="true">‹</span></a>`).join("")}
51
  <p>${data.next_skill ? `<a class="primary-link" id="continue-path" href="#/skill/${encodeURIComponent(data.next_skill.id)}">להמשיך במסלול: ${fmt(data.next_skill.name)} <span aria-hidden="true">‹</span></a>`
52
- : `<a class="primary-link" id="continue-path" href="${PATH}">למסלול: כל הכוכבים פתוחים לתרגול <span aria-hidden="true">‹</span></a>`}</p>
53
- ${others.length ? `<h3>מבחנים נוספים</h3><ul class="plain" id="other-tests">${others.map(t => `<li><a href="#/test/${t.id}">${shortDate(t.date)} · ${lessonsOf(t)}</a>
54
- <span class="muted">${daysText(t.days_left)} · מוכנות ${t.readiness.number}</span></li>`).join("")}</ul>` : ""}`);
55
  }
 
1
  // The student's home for a subject: the nearest test with its readiness and one task for today, the required simulation,
2
  // the other tests, and a button that continues the path.
3
+ import { swr } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { PATH, backToSubjects, daysText, shortDate, tabs } from "./ui.js";
6
 
 
40
  <p><a href="#/test/${test.id}">כל התוכנית והמבחנים</a></p></section>`;
41
  }
42
 
43
+ /** An assignment's state in a symbol and a word (never colour alone). */
44
+ export function assignmentState(a) {
45
+ if (a.kind === "exercise_test") return a.state === "done" ? { symbol: "✓", word: `ציון ${a.grade}` } : a.overdue ? { symbol: "⚠", word: "באיחור" } : { symbol: "○", word: "עוד לא נעשה" };
46
+ switch (a.state) {
47
+ case "handed_in": return { symbol: "◐", word: a.late ? "הוגש באיחור" : "הוגש" };
48
+ case "graded": return { symbol: "✓", word: `ציון ${a.grade}` };
49
+ case "returned": return { symbol: "↩", word: "הוחזר לתיקון" };
50
+ default: return a.overdue ? { symbol: "⚠", word: "באיחור" } : { symbol: "○", word: "לא הוגש" };
51
+ }
52
+ }
53
+ export const assignmentHref = a => a.kind === "exercise_test" ? `#/exam/${a.test.id}/${a.test.number}` : `#/assignment/${a.id}`;
54
+
55
+ /** Home shows only the new assignments: those not handed in yet (or an exercise test not taken). The rest are in the "הגשות" tab. */
56
+ const isNew = a => a.state === "not_handed" || a.state === "not_done";
57
+
58
+ function assignmentsCard(all) {
59
+ const list = all.filter(isNew);
60
+ if (!list.length) return "";
61
+ return `<section class="card" id="assignments"><h3>המשימות שלי</h3><ul class="plain assignments">${list.map(a => {
62
+ const s = assignmentState(a);
63
+ return `<li data-assignment="${a.id}" data-state="${esc(a.state)}"><a href="${assignmentHref(a)}"><b>${fmt(a.title)}</b></a>
64
+ <span class="muted">עד ${shortDate(a.due_date)}</span><span class="tag st-${esc(a.state)}"><span aria-hidden="true">${s.symbol}</span> ${s.word}</span></li>`;
65
+ }).join("")}</ul></section>`;
66
+ }
67
+
68
+ export const show = view => swr(["/api/home", "/api/assignments"], ([data, assignments]) => draw(view, data, assignments));
69
+
70
+ function draw(view, data, assignments) {
71
+ const [first] = data.tests;
72
  const required = data.tests.filter(t => t.simulation.required);
73
  view(`${tabs("home")}${backToSubjects()}
74
  ${first ? nearest(first, data.task, data.next_skill) : `<section class="card" id="no-test"><h2>הבית</h2>
75
  <p class="muted">המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.</p></section>`}
76
+ ${assignmentsCard(assignments)}
77
  ${required.map(t => `<a class="required" href="#/sim/${t.id}" data-required="${t.id}"><b>חובה</b> מבחן סימולציה למבחן ב־${shortDate(t.date)}<span aria-hidden="true">‹</span></a>`).join("")}
78
  <p>${data.next_skill ? `<a class="primary-link" id="continue-path" href="#/skill/${encodeURIComponent(data.next_skill.id)}">להמשיך במסלול: ${fmt(data.next_skill.name)} <span aria-hidden="true">‹</span></a>`
79
+ : `<a class="primary-link" id="continue-path" href="${PATH}">למסלול: כל הכוכבים פתוחים לתרגול <span aria-hidden="true">‹</span></a>`}</p>`);
 
 
80
  }
mathly2/static/index.html CHANGED
@@ -6,6 +6,8 @@
6
  <meta name="color-scheme" content="dark">
7
  <title>Mathly</title>
8
  <link rel="icon" href="/static/icons/icon-192.png">
 
 
9
  <link rel="stylesheet" href="/static/app.css">
10
  </head>
11
  <body>
 
6
  <meta name="color-scheme" content="dark">
7
  <title>Mathly</title>
8
  <link rel="icon" href="/static/icons/icon-192.png">
9
+ <link rel="manifest" href="/static/manifest.webmanifest">
10
+ <meta name="theme-color" content="#060A19">
11
  <link rel="stylesheet" href="/static/app.css">
12
  </head>
13
  <body>
mathly2/static/manager.js CHANGED
@@ -2,16 +2,16 @@
2
  import { api } from "./api.js";
3
  import { esc } from "./math.js";
4
 
5
- let draw, lessons = [], tab = "classes", editing = null, draft = null, flash = { message: "", error: "" };
6
  // draft: what was typed in the editor when a save was refused, so the form keeps it and the person can fix one thing
7
 
8
  const note = () => `${flash.error && tab !== "edit" ? `<p class="error" role="alert">${esc(flash.error)}</p>` : ""}${flash.message ? `<p role="status" id="msg">${esc(flash.message)}</p>` : ""}`;
9
  const tabs = () => `<div class="subtabs" id="mgr-tabs" role="group" aria-label="מנהל כיתות">
10
- ${[["classes", "כיתות"], ["edit", editing ? "עריכת כיתה" : "כיתה חדשה"], ["students", "תלמידים"]].map(([id, label]) =>
11
  `<button type="button" data-tab="${id}" aria-pressed="${tab === id}">${label}</button>`).join("")}</div>`;
12
 
13
  async function render() {
14
- const body = tab === "classes" ? await classesView() : tab === "students" ? await studentsView() : editorView();
15
  draw(`<section id="mgr"><h2>מנהל כיתות</h2>${tabs()}${note()}${body}</section>`);
16
  document.getElementById("mgr-tabs").addEventListener("click", event => {
17
  const next = event.target.closest("[data-tab]")?.dataset.tab;
@@ -21,6 +21,7 @@ async function render() {
21
  go(next);
22
  });
23
  if (tab === "classes") wireClasses();
 
24
  if (tab === "edit") wireEditor();
25
  if (tab === "edit" && flash.error) { // the form is long: bring the reason into view, next to the buttons that were pressed
26
  const reason = document.getElementById("form-error");
@@ -30,19 +31,37 @@ async function render() {
30
  flash = { message: "", error: "" };
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  const go = next => { tab = next; return render(); };
34
 
35
  async function classesView() {
36
  const classes = await api("/api/classes");
37
- const names = Object.fromEntries(lessons.map(l => [l.id, l.name]));
38
  return `<div class="hrow" style="margin-bottom:10px"><button class="primary" type="button" id="new-class">+ כיתה חדשה</button>
39
  <span class="muted">${classes.length} כיתות</span></div>
40
  ${classes.length ? `<div class="cards" id="classes">${classes.map(c => `<article class="card" data-id="${c.id}">
41
  <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><button type="button" data-edit="${c.id}">עריכה</button><button type="button" class="danger" data-delete="${c.id}">מחיקה</button></div>
42
- <p style="margin:0;font-size:14px">שיעורים: ${esc(c.lessons.map(id => names[id] || id).join(", "))}<br>
43
- מורה: <span dir="ltr">${esc(c.teacher_email || "עוד לא נבחר")}</span><br>
44
  ${c.students} תלמידים · AI: <b>${c.ai_enabled ? "פועל" : "כבוי"}</b> · ${c.ai_daily_checks} בדיקות ביום</p></article>`).join("")}</div>`
45
- : `<section class="card"><p class="muted">עוד אין כיתות. פתחו כיתה ראשונה, בחרו את השיעורים והוסיפו את התלמידים.</p></section>`}`;
46
  }
47
 
48
  function wireClasses() {
@@ -62,12 +81,9 @@ function wireClasses() {
62
 
63
  function editorView() {
64
  const c = editing, d = draft; // a refused save keeps what was typed
65
- const chosen = new Set(d ? d.lessons : c?.lessons || []);
66
  const value = (typed, saved, fallback = "") => d ? (typed ?? fallback) : (saved ?? fallback);
67
  return `<form class="card" id="class-form"><h3>${c ? `עריכת ${esc(c.name)}` : "כיתה חדשה"}</h3>
68
  <label for="c-name">שם הכיתה</label><input id="c-name" required maxlength="80" autocomplete="off" value="${esc(value(d?.name, c?.name))}">
69
- <label>השיעורים שהכיתה לומדת</label>
70
- <div class="checks" id="c-lessons">${lessons.map(l => `<label><input type="checkbox" name="lesson" value="${esc(l.id)}" ${chosen.has(l.id) ? "checked" : ""}>${esc(l.name)}</label>`).join("")}</div>
71
  <label for="c-teacher">מורה הכיתה (כתובת אחת; לא אחד התלמידים של הכיתה)</label>
72
  <input id="c-teacher" type="text" dir="ltr" autocomplete="off" value="${esc(value(d?.teacher_email, c?.teacher_email))}">
73
  <label for="c-students">תלמידים (כתובת בכל שורה; תלמיד יכול להיות בכמה כיתות)</label>
@@ -85,7 +101,6 @@ function wireEditor() {
85
  event.preventDefault();
86
  const body = {
87
  name: document.getElementById("c-name").value,
88
- lessons: [...document.querySelectorAll("#c-lessons input:checked")].map(i => i.value),
89
  teacher_email: document.getElementById("c-teacher").value.trim() || null,
90
  students: document.getElementById("c-students").value,
91
  ai_enabled: document.getElementById("c-ai").checked,
@@ -110,6 +125,5 @@ async function studentsView() {
110
  export async function show(me, view) {
111
  draw = view;
112
  tab = "classes"; editing = null;
113
- lessons = await api("/api/classes/lessons");
114
  await render();
115
  }
 
2
  import { api } from "./api.js";
3
  import { esc } from "./math.js";
4
 
5
+ let draw, tab = "classes", editing = null, draft = null, flash = { message: "", error: "" };
6
  // draft: what was typed in the editor when a save was refused, so the form keeps it and the person can fix one thing
7
 
8
  const note = () => `${flash.error && tab !== "edit" ? `<p class="error" role="alert">${esc(flash.error)}</p>` : ""}${flash.message ? `<p role="status" id="msg">${esc(flash.message)}</p>` : ""}`;
9
  const tabs = () => `<div class="subtabs" id="mgr-tabs" role="group" aria-label="מנהל כיתות">
10
+ ${[["classes", "כיתות"], ["edit", editing ? "עריכת כיתה" : "כיתה חדשה"], ["students", "תלמידים"], ["curriculum", "תכנית לימודים"]].map(([id, label]) =>
11
  `<button type="button" data-tab="${id}" aria-pressed="${tab === id}">${label}</button>`).join("")}</div>`;
12
 
13
  async function render() {
14
+ const body = tab === "classes" ? await classesView() : tab === "students" ? await studentsView() : tab === "curriculum" ? await curriculumView() : editorView();
15
  draw(`<section id="mgr"><h2>מנהל כיתות</h2>${tabs()}${note()}${body}</section>`);
16
  document.getElementById("mgr-tabs").addEventListener("click", event => {
17
  const next = event.target.closest("[data-tab]")?.dataset.tab;
 
21
  go(next);
22
  });
23
  if (tab === "classes") wireClasses();
24
+ if (tab === "curriculum") wireCurriculum();
25
  if (tab === "edit") wireEditor();
26
  if (tab === "edit" && flash.error) { // the form is long: bring the reason into view, next to the buttons that were pressed
27
  const reason = document.getElementById("form-error");
 
31
  flash = { message: "", error: "" };
32
  }
33
 
34
+ /** The curriculum: the date by which each lesson should be completed, one for the whole school. */
35
+ async function curriculumView() {
36
+ const lessons = await api("/api/curriculum");
37
+ return `<form class="card" id="curriculum-form"><h3>תכנית לימודים</h3>
38
+ <p class="muted">התאריך שעד אליו כל שיעור אמור להסתיים. תלמיד שלא סיים שיעור אחרי התאריך רואה אותו מסומן בפיגור. אפשר להשאיר תאריך ריק.</p>
39
+ <ul class="plain curriculum">${lessons.map(l => `<li><label for="due-${esc(l.id)}">${esc(l.name)} <span class="muted">${esc(l.month)}</span></label>
40
+ <input id="due-${esc(l.id)}" type="date" data-lesson="${esc(l.id)}" value="${esc(l.due_date || "")}"></li>`).join("")}</ul>
41
+ <p><button class="primary" type="submit" id="save-curriculum">שמירה</button></p></form>`;
42
+ }
43
+
44
+ function wireCurriculum() {
45
+ document.getElementById("curriculum-form").addEventListener("submit", async event => {
46
+ event.preventDefault();
47
+ const dates = Object.fromEntries([...document.querySelectorAll("#curriculum-form [data-lesson]")].map(i => [i.dataset.lesson, i.value || null]));
48
+ try { await api("/api/curriculum", { method: "PUT", body: { dates } }); flash = { message: "התכנית נשמרה.", error: "" }; }
49
+ catch (e) { flash = { message: "", error: e.message }; }
50
+ go("curriculum");
51
+ });
52
+ }
53
+
54
  const go = next => { tab = next; return render(); };
55
 
56
  async function classesView() {
57
  const classes = await api("/api/classes");
 
58
  return `<div class="hrow" style="margin-bottom:10px"><button class="primary" type="button" id="new-class">+ כיתה חדשה</button>
59
  <span class="muted">${classes.length} כיתות</span></div>
60
  ${classes.length ? `<div class="cards" id="classes">${classes.map(c => `<article class="card" data-id="${c.id}">
61
  <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><button type="button" data-edit="${c.id}">עריכה</button><button type="button" class="danger" data-delete="${c.id}">מחיקה</button></div>
62
+ <p style="margin:0;font-size:14px">מורה: <span dir="ltr">${esc(c.teacher_email || "עוד לא נבחר")}</span><br>
 
63
  ${c.students} תלמידים · AI: <b>${c.ai_enabled ? "פועל" : "כבוי"}</b> · ${c.ai_daily_checks} בדיקות ביום</p></article>`).join("")}</div>`
64
+ : `<section class="card"><p class="muted">עוד אין כיתות. פתחו כיתה ראשונה והוסיפו את התלמידים.</p></section>`}`;
65
  }
66
 
67
  function wireClasses() {
 
81
 
82
  function editorView() {
83
  const c = editing, d = draft; // a refused save keeps what was typed
 
84
  const value = (typed, saved, fallback = "") => d ? (typed ?? fallback) : (saved ?? fallback);
85
  return `<form class="card" id="class-form"><h3>${c ? `עריכת ${esc(c.name)}` : "כיתה חדשה"}</h3>
86
  <label for="c-name">שם הכיתה</label><input id="c-name" required maxlength="80" autocomplete="off" value="${esc(value(d?.name, c?.name))}">
 
 
87
  <label for="c-teacher">מורה הכיתה (כתובת אחת; לא אחד התלמידים של הכיתה)</label>
88
  <input id="c-teacher" type="text" dir="ltr" autocomplete="off" value="${esc(value(d?.teacher_email, c?.teacher_email))}">
89
  <label for="c-students">תלמידים (כתובת בכל שורה; תלמיד יכול להיות בכמה כיתות)</label>
 
101
  event.preventDefault();
102
  const body = {
103
  name: document.getElementById("c-name").value,
 
104
  teacher_email: document.getElementById("c-teacher").value.trim() || null,
105
  students: document.getElementById("c-students").value,
106
  ai_enabled: document.getElementById("c-ai").checked,
 
125
  export async function show(me, view) {
126
  draw = view;
127
  tab = "classes"; editing = null;
 
128
  await render();
129
  }
mathly2/static/manifest.webmanifest ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "/",
3
+ "name": "לומדים בכיף",
4
+ "short_name": "לומדים בכיף",
5
+ "description": "תרגול ולמידה לכיתה ט׳",
6
+ "lang": "he",
7
+ "dir": "rtl",
8
+ "start_url": "/",
9
+ "scope": "/",
10
+ "display": "standalone",
11
+ "background_color": "#060A19",
12
+ "theme_color": "#060A19",
13
+ "icons": [
14
+ {"src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
15
+ {"src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
16
+ {"src": "/static/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
17
+ ]
18
+ }
mathly2/static/media.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Pictures in the browser: reading a private picture through the server (a plain <img src> cannot send the sign-in), shrinking a photo before
2
+ // it is sent, uploading it, and telling a phone from a computer.
3
+ import { token } from "./session.js";
4
+ import { ApiError } from "./api.js";
5
+
6
+ const cache = new Map();
7
+
8
+ /** A picture's bytes as a blob URL, fetched with the sign-in (the server is the only door to pictures). Rejects with ApiError (410: deleted). */
9
+ export function pictureUrl(id) {
10
+ if (!cache.has(id)) {
11
+ cache.set(id, (async () => {
12
+ const response = await fetch(`/api/pictures/${encodeURIComponent(id)}`, { headers: { Authorization: "Bearer " + await token() } });
13
+ if (!response.ok) { cache.delete(id); throw new ApiError(response.status, response.status === 410 ? "התמונה נמחקה" : "לא הצלחנו לפתוח את התמונה"); }
14
+ return URL.createObjectURL(await response.blob());
15
+ })());
16
+ }
17
+ return cache.get(id);
18
+ }
19
+
20
+ /** A photo shrunk to at most `side` pixels and re-encoded as JPEG (this also drops its metadata). Falls back to the file itself when the browser
21
+ * cannot decode it, and the server then decides. */
22
+ export async function prepare(file, side = 2000) {
23
+ try {
24
+ const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
25
+ const scale = Math.min(1, side / Math.max(bitmap.width, bitmap.height));
26
+ const canvas = Object.assign(document.createElement("canvas"), { width: Math.round(bitmap.width * scale), height: Math.round(bitmap.height * scale) });
27
+ const context = canvas.getContext("2d");
28
+ context.fillStyle = "#fff";
29
+ context.fillRect(0, 0, canvas.width, canvas.height);
30
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
31
+ const blob = await new Promise(done => canvas.toBlob(done, "image/jpeg", 0.88));
32
+ return blob || file;
33
+ } catch { return file; }
34
+ }
35
+
36
+ /** Send picture bytes (a Blob or File) to `url`; resolves to the JSON answer. */
37
+ export async function sendPicture(url, blob) {
38
+ const response = await fetch(url, { method: "POST", headers: { Authorization: "Bearer " + await token(), "Content-Type": blob.type || "image/jpeg" }, body: blob });
39
+ let data = null;
40
+ try { data = await response.json(); } catch { /* no body */ }
41
+ if (!response.ok) throw new ApiError(response.status, (data && typeof data.detail === "string" && data.detail) || "משהו השתבש, נסו שוב");
42
+ return data;
43
+ }
44
+
45
+ /** A phone or tablet (a touch screen): where the camera opens directly and notifications are offered. */
46
+ export const isTouch = () => !!window.matchMedia?.("(pointer: coarse)").matches;
47
+
48
+ /** This browser's own id, so a photo request knows which device asked and the phone does not ask itself. */
49
+ export function deviceId() {
50
+ try {
51
+ let id = localStorage.getItem("mathly2.device");
52
+ if (!id) { id = crypto.randomUUID(); localStorage.setItem("mathly2.device", id); }
53
+ return id;
54
+ } catch { return "device-" + Math.random().toString(36).slice(2); }
55
+ }
mathly2/static/notifications.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Notifications for photo requests, on a phone only: a switch on home. The service worker shows the notification when the app is closed and, on a
2
+ // tap, tells the open app to look for requests. Without a push key on the server the switch is not offered.
3
+ import { api } from "./api.js";
4
+ import { isTouch } from "./media.js";
5
+
6
+ const bytes = base64 => Uint8Array.from(atob(base64.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - base64.length % 4) % 4)), c => c.charCodeAt(0));
7
+
8
+ /** Draw the switch into `box` (nothing at all on a computer, without notification support, or when the server has no key). */
9
+ export async function setup(box) {
10
+ if (!isTouch() || !("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) return;
11
+ let server;
12
+ try { server = await api("/api/push/key"); } catch { return; }
13
+ if (!server.enabled) return;
14
+ let registration;
15
+ try { registration = await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }); } catch { return; }
16
+ let subscription = await registration.pushManager.getSubscription();
17
+ const draw = (note = "") => {
18
+ box.hidden = false;
19
+ box.innerHTML = `<h3>התראות</h3><p class="muted">כשהמחשב מבקש תמונה של תשובה, הטלפון יקבל התראה גם כשהאפליקציה סגורה. באייפון זה עובד רק מהאפליקציה שהוספתם למסך הבית.</p>
20
+ <p>${subscription ? `<span class="tag" id="notif-state"><span aria-hidden="true">✓</span> התראות פועלות</span> <button type="button" id="notif-off">כיבוי</button>`
21
+ : `<button type="button" class="primary" id="notif-on">הפעלת התראות</button>`}</p><p class="muted" id="notif-note" role="status">${note}</p>`;
22
+ box.querySelector("#notif-on")?.addEventListener("click", enable);
23
+ box.querySelector("#notif-off")?.addEventListener("click", disable);
24
+ };
25
+ async function enable() {
26
+ try {
27
+ if (await Notification.requestPermission() !== "granted") return draw("ההתראות חסומות בדפדפן. אפשר לאשר אותן בהגדרות.");
28
+ subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes(server.key) });
29
+ await api("/api/push/subscribe", { method: "POST", body: subscription.toJSON() });
30
+ draw();
31
+ } catch (error) { subscription = null; draw(error.message || "לא הצלחנו להפעיל התראות."); }
32
+ }
33
+ async function disable() {
34
+ const endpoint = subscription?.endpoint;
35
+ try { await subscription?.unsubscribe(); if (endpoint) await api("/api/push/unsubscribe", { method: "POST", body: { endpoint } }); } catch { /* it is off here either way */ }
36
+ subscription = null;
37
+ draw();
38
+ }
39
+ draw();
40
+ }
mathly2/static/path.js CHANGED
@@ -1,9 +1,9 @@
1
  // The path: a trail of lessons. Each lesson is a hub with its skills as numbered stars in a ring around it; the owl perches on the
2
  // suggested next skill. Every star is a link to its exercise screen (#/skill/<id>) and says its state in a symbol and a word.
3
- import { api } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { owl, ringSvg, spark } from "./sky.js";
6
- import { HOME, backToSubjects, goalBar, tabs } from "./ui.js";
7
 
8
  export const STATES = {
9
  not_started: { symbol: "○", word: "לא התחלת" },
@@ -32,18 +32,27 @@ function quizNode(l) {
32
  aria-label="בוחן ${esc(plain(l.name))}: ${state}"><span class="qz-icon" aria-hidden="true">${spark()}</span><span><b>בוחן</b><em>${state}</em></span></a>`;
33
  }
34
 
 
 
 
 
 
 
 
 
 
35
  function lesson(l, next) {
36
  const n = l.skills.length;
37
- return `<section class="orbit${l.completed ? " completed" : ""}" id="lesson-${esc(l.id)}" aria-label="${esc(plain(l.name))}">
38
  <div class="stage">${ringSvg(l.skills.map(s => s.state))}
39
  <div class="hub"><h3>${fmt(l.name)}</h3><span class="count">${l.mastered} מתוך ${n} נשלטו${l.completed ? " ✓" : ""}</span></div>
40
- ${l.skills.map((s, i) => star(s, i, n, s.id === next)).join("")}</div></section>${quizNode(l)}`;
41
  }
42
 
43
  export function pathHtml(path) {
44
  return `${tabs("path")}${backToSubjects()}
45
  <section class="card path-top" id="path"><h2>המסלול שלי</h2>
46
- <p class="muted">כל השיעורים פתוחים, בכל סדר. הינשוף מציע איפה להמשיך, אבל אפשר ללחוץ על כל כוכב.</p>${goalBar(path.today, path.streak)}</section>
47
  <div class="trail">
48
  <div class="mark start">${owl(44)}<b>התחלה</b></div>
49
  ${path.lessons.map(l => lesson(l, path.next)).join("")}
@@ -51,8 +60,12 @@ export function pathHtml(path) {
51
  }
52
 
53
  /** Draw the path. `scroll`: bring the next skill to the middle of the screen (when the path opens); a refresh keeps the place. */
54
- export async function show(view, { scroll = true } = {}) {
55
- const path = await api("/api/path");
 
 
 
 
56
  const place = window.scrollY;
57
  view(pathHtml(path));
58
  if (scroll) document.querySelector(".star.next")?.scrollIntoView({ block: "center" });
@@ -67,7 +80,7 @@ export async function showSubjects(view) {
67
  view(`<section class="subjects" aria-label="מקצועות">
68
  <h2>במה נתרגל היום?</h2>
69
  <a class="subject-card" href="${HOME}" data-subject="מתמטיקה"><span class="subject-icon" aria-hidden="true">∑</span>
70
- <span class="grow"><b>מתמטיקה</b>
71
  <span class="bar" role="progressbar" aria-label="מיומנויות שהושלמו" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${lit}"><i style="width:${total ? Math.round(100 * lit / total) : 0}%"></i></span>
72
  <small>${lit} מתוך ${total} מיומנויות</small></span><i aria-hidden="true">‹</i></a></section>`);
73
  return path;
 
1
  // The path: a trail of lessons. Each lesson is a hub with its skills as numbered stars in a ring around it; the owl perches on the
2
  // suggested next skill. Every star is a link to its exercise screen (#/skill/<id>) and says its state in a symbol and a word.
3
+ import { api, swr } from "./api.js";
4
  import { esc, fmt } from "./math.js";
5
  import { owl, ringSvg, spark } from "./sky.js";
6
+ import { HOME, backToSubjects, goalBar, shortDate, tabs } from "./ui.js";
7
 
8
  export const STATES = {
9
  not_started: { symbol: "○", word: "לא התחלת" },
 
32
  aria-label="בוחן ${esc(plain(l.name))}: ${state}"><span class="qz-icon" aria-hidden="true">${spark()}</span><span><b>בוחן</b><em>${state}</em></span></a>`;
33
  }
34
 
35
+ /** "הושלם", or, for a lesson whose date has passed without it being completed, the marker with the date in words. */
36
+ function lessonStatus(l) {
37
+ if (l.completed) return `<p class="lesson-status done" data-status="completed"><span aria-hidden="true">✓</span> הושלם${l.passed ? " (עברת את הבוחן)" : ""}</p>`;
38
+ if (l.behind) return `<p class="lesson-status late" data-status="behind"><span aria-hidden="true">⚠</span> היה אמור להסתיים ב־${shortDate(l.due_date)}</p>`;
39
+ return "";
40
+ }
41
+
42
+ const behindNote = n => n ? `<p class="behind-note" id="behind-note" role="status"><span aria-hidden="true">⚠</span> ${n === 1 ? "שיעור אחד בפיגור" : `${n} שיעורים בפיגור`} לפי התכנית</p>` : "";
43
+
44
  function lesson(l, next) {
45
  const n = l.skills.length;
46
+ return `<section class="orbit${l.completed ? " completed" : ""}${l.behind ? " behind" : ""}" id="lesson-${esc(l.id)}" aria-label="${esc(plain(l.name))}">
47
  <div class="stage">${ringSvg(l.skills.map(s => s.state))}
48
  <div class="hub"><h3>${fmt(l.name)}</h3><span class="count">${l.mastered} מתוך ${n} נשלטו${l.completed ? " ✓" : ""}</span></div>
49
+ ${l.skills.map((s, i) => star(s, i, n, s.id === next)).join("")}</div>${lessonStatus(l)}</section>${quizNode(l)}`;
50
  }
51
 
52
  export function pathHtml(path) {
53
  return `${tabs("path")}${backToSubjects()}
54
  <section class="card path-top" id="path"><h2>המסלול שלי</h2>
55
+ <p class="muted">כל השיעורים פתוחים, בכל סדר. הינשוף מציע איפה להמשיך, אבל אפשר ללחוץ על כל כוכב.</p>${goalBar(path.today, path.streak)}${behindNote(path.behind)}</section>
56
  <div class="trail">
57
  <div class="mark start">${owl(44)}<b>התחלה</b></div>
58
  ${path.lessons.map(l => lesson(l, path.next)).join("")}
 
60
  }
61
 
62
  /** Draw the path. `scroll`: bring the next skill to the middle of the screen (when the path opens); a refresh keeps the place. */
63
+ export const show = (view, { scroll = true } = {}) => {
64
+ let first = true; // a refresh of the same screen keeps the place
65
+ return swr("/api/path", path => { const result = drawPath(view, path, scroll && first); first = false; return result; });
66
+ };
67
+
68
+ function drawPath(view, path, scroll) {
69
  const place = window.scrollY;
70
  view(pathHtml(path));
71
  if (scroll) document.querySelector(".star.next")?.scrollIntoView({ block: "center" });
 
80
  view(`<section class="subjects" aria-label="מקצועות">
81
  <h2>במה נתרגל היום?</h2>
82
  <a class="subject-card" href="${HOME}" data-subject="מתמטיקה"><span class="subject-icon" aria-hidden="true">∑</span>
83
+ <span class="grow"><b>מתמטיקה כיתה ט - מצוינות</b>
84
  <span class="bar" role="progressbar" aria-label="מיומנויות שהושלמו" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${lit}"><i style="width:${total ? Math.round(100 * lit / total) : 0}%"></i></span>
85
  <small>${lit} מתוך ${total} מיומנויות</small></span><i aria-hidden="true">‹</i></a></section>`);
86
  return path;
mathly2/static/phone.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The photo request, both ends. The computer asks for a photo of an answer: it shows a 4-digit code and the request's state in words.
2
+ // The student's phone (any other device signed in to the same account) shows a window with the same code and a camera button.
3
+ import { api, ApiError } from "./api.js";
4
+ import { withBusy } from "./busy.js";
5
+ import { esc, fmt } from "./math.js";
6
+ import { deviceId, prepare, sendPicture } from "./media.js";
7
+
8
+ export const WORDS = { waiting: "ממתינים לטלפון", uploading: "מעלים את התשובה…", received: "התשובה התקבלה", failed: "ההעלאה נכשלה", expired: "הבקשה פגה. אפשר לבקש שוב.",
9
+ superseded: "הוחלפה בבקשה חדשה" };
10
+ const DONE = new Set(["received", "failed", "expired", "superseded"]);
11
+ const everyComputer = () => window.__photoPollComputer ?? 2000; // the computer looks for the phone's answer this often (milliseconds)
12
+ const everyPhone = () => window.__photoPollPhone ?? 5000; // the phone looks for new requests this often
13
+
14
+ // ---------- the computer ----------
15
+
16
+ let runs = 0; // a newer request (or leaving the page) stops the older one's looking
17
+
18
+ /** Ask the student's phone for a photo of this assignment's answer. `onPicture(id)` is called when the picture arrives. */
19
+ export async function askFromComputer(assignment, onPicture, panel) {
20
+ const mine = ++runs;
21
+ const old = [...panel.querySelectorAll(".photo-request[data-code]")].map(el => `<li data-state="superseded">קוד ${esc(el.dataset.code)}: ${WORDS.superseded}</li>`).join("")
22
+ + (panel.querySelector(".old-requests")?.innerHTML || "");
23
+ let request;
24
+ try {
25
+ request = await api("/api/photo-requests", { method: "POST", body: { assignment_id: assignment.id, device: deviceId() } });
26
+ } catch (error) {
27
+ panel.innerHTML = `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`;
28
+ return;
29
+ }
30
+ const draw = state => {
31
+ panel.innerHTML = `<div class="photo-request" id="photo-request" data-code="${esc(request.code)}" data-state="${esc(state)}">
32
+ <p>הקוד של הבקשה: <b class="code" id="request-code" dir="ltr">${esc(request.code)}</b></p>
33
+ <p class="muted">פתחו את האפליקציה בטלפון. אם הקוד שם זהה, צלמו את התשובה.</p>
34
+ <p class="status" id="request-state" role="status"><b>${WORDS[state] || state}</b></p></div>${old ? `<ul class="plain old-requests">${old}</ul>` : ""}`;
35
+ };
36
+ draw(request.state);
37
+ while (mine === runs && !DONE.has(request.state)) {
38
+ await new Promise(done => setTimeout(done, everyComputer()));
39
+ if (mine !== runs) return;
40
+ try { request = await api(`/api/photo-requests/${request.id}`); } catch { return; }
41
+ draw(request.state);
42
+ }
43
+ if (mine === runs && request.state === "received" && request.picture_id) onPicture(request.picture_id);
44
+ }
45
+
46
+ /** Stop looking for an answer (the page was left). */
47
+ export const stopComputer = () => { runs += 1; };
48
+
49
+ // ---------- the phone ----------
50
+
51
+ let watching = null, handled = new Set(), shown = null;
52
+
53
+ function closeWindow() {
54
+ document.getElementById("photo-window")?.remove();
55
+ shown = null;
56
+ }
57
+
58
+ function windowHtml(request) {
59
+ return `<div class="photo-window" id="photo-window" role="dialog" aria-modal="true" aria-labelledby="pw-title" data-request="${esc(request.id)}">
60
+ <div class="sheet"><h3 id="pw-title">📷 בקשת צילום מהמחשב</h3>
61
+ <p>הקוד שבמחשב: <b class="code" id="phone-code" dir="ltr">${esc(request.code)}</b></p>
62
+ <p class="muted">אם הקוד זהה למה שרואים במחשב, צלמו את התשובה: ${fmt(request.assignment.title)}</p>
63
+ <p class="status" id="phone-state" role="status"></p>
64
+ <p><label class="button primary file" id="phone-camera">📷 צילום<input type="file" accept="image/*" capture="environment" hidden data-photo></label>
65
+ <button type="button" id="phone-later">לא עכשיו</button></p></div></div>`;
66
+ }
67
+
68
+ async function takePhoto(request, file) {
69
+ const status = document.getElementById("phone-state");
70
+ status.textContent = "מעלים את התשובה…";
71
+ try {
72
+ await api(`/api/photo-requests/${request.id}/start`, { method: "POST" });
73
+ await sendPicture(`/api/photo-requests/${request.id}/picture`, await prepare(file));
74
+ handled.add(request.id);
75
+ status.textContent = "התמונה נשלחה למחשב ✓";
76
+ setTimeout(closeWindow, 1500);
77
+ } catch (error) {
78
+ status.textContent = (error instanceof ApiError && error.message) || "ההעלאה נכשלה. נסו שוב.";
79
+ }
80
+ }
81
+
82
+ async function look() {
83
+ let open;
84
+ try { open = await api(`/api/photo-requests/open?device=${encodeURIComponent(deviceId())}`); } catch { return; }
85
+ const wanted = open.find(r => !handled.has(r.id));
86
+ if (!wanted) { if (shown && !document.getElementById("phone-state")?.textContent.includes("נשלחה")) closeWindow(); return; }
87
+ if (shown === wanted.id) return;
88
+ closeWindow();
89
+ shown = wanted.id;
90
+ document.body.insertAdjacentHTML("beforeend", windowHtml(wanted));
91
+ const box = document.getElementById("photo-window");
92
+ box.querySelector("[data-photo]").addEventListener("change", event => { const file = event.target.files[0]; event.target.value = ""; if (file) withBusy(box.querySelector("#phone-camera"), takePhoto(wanted, file)); });
93
+ box.querySelector("#phone-later").addEventListener("click", () => { handled.add(wanted.id); closeWindow(); });
94
+ box.querySelector("#phone-camera").focus?.();
95
+ }
96
+
97
+ /** Start looking for photo requests made by the student's other devices: now, on a timer, when the page comes back, and when a notification was tapped. */
98
+ export function startWatching() {
99
+ if (watching) return;
100
+ handled = new Set();
101
+ const again = () => { look(); };
102
+ const timer = () => { watching = setTimeout(async () => { await look(); if (watching) timer(); }, everyPhone()); };
103
+ look();
104
+ timer();
105
+ const visible = () => { if (document.visibilityState === "visible") again(); };
106
+ const message = event => { if (event.data?.type === "notified") again(); };
107
+ document.addEventListener("visibilitychange", visible);
108
+ navigator.serviceWorker?.addEventListener("message", message);
109
+ startWatching.stop = () => {
110
+ clearTimeout(watching); watching = null; closeWindow();
111
+ document.removeEventListener("visibilitychange", visible);
112
+ navigator.serviceWorker?.removeEventListener("message", message);
113
+ };
114
+ }
115
+
116
+ export const stopWatching = () => { startWatching.stop?.(); };
117
+ window.addEventListener("mathly:leave", () => { stopComputer(); stopWatching(); });
mathly2/static/service-worker.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Mathly's service worker: push notifications only (app/push.py). It has no fetch handler and keeps no cache.
2
+ An older version served /static/ from its own cache forever; activating this one deletes those caches. */
3
+ self.addEventListener("install", () => self.skipWaiting());
4
+ self.addEventListener("activate", event => event.waitUntil((async () => {
5
+ await Promise.all((await caches.keys()).map(key => caches.delete(key)));
6
+ await self.clients.claim();
7
+ })()));
8
+
9
+ // a photo request from the computer (or any later message): shown even when the app is closed
10
+ self.addEventListener("push", event => {
11
+ let msg = {};
12
+ try { msg = event.data ? event.data.json() : {}; } catch { msg = { body: event.data && event.data.text() }; }
13
+ event.waitUntil(self.registration.showNotification(msg.title || "לומדים בכיף", {
14
+ body: msg.body || "", tag: msg.tag || "mathly", renotify: true, requireInteraction: false,
15
+ lang: "he", dir: "rtl",
16
+ icon: "/static/icons/icon-192.png", badge: "/static/icons/icon-192.png",
17
+ data: { url: msg.url || "/" }
18
+ }));
19
+ });
20
+
21
+ // a tap: the app already open is brought forward and told to look for requests now; otherwise it is opened
22
+ self.addEventListener("notificationclick", event => {
23
+ event.notification.close();
24
+ const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
25
+ event.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(wins => {
26
+ const app = wins.find(w => new URL(w.url).origin === self.location.origin && !new URL(w.url).pathname.startsWith("/static/"));
27
+ if (app) { app.postMessage({ type: "notified", url }); return app.focus(); }
28
+ return self.clients.openWindow(url);
29
+ }));
30
+ });
mathly2/static/session.js CHANGED
@@ -30,7 +30,12 @@ export function signInDev(email) {
30
  try { localStorage.setItem(DEV_KEY, email.trim().toLowerCase()); } catch { /* private window: stays signed out */ }
31
  }
32
 
 
 
 
 
33
  export async function signOut() {
 
34
  if (client) await client.auth.signOut();
35
  try { localStorage.removeItem(DEV_KEY); } catch { /* nothing stored */ }
36
  }
 
30
  try { localStorage.setItem(DEV_KEY, email.trim().toLowerCase()); } catch { /* private window: stays signed out */ }
31
  }
32
 
33
+ const outListeners = [];
34
+ /** Calls `fn` when the person signs out (so nothing remembered about them is shown to the next one). */
35
+ export const onSignOut = fn => outListeners.push(fn);
36
+
37
  export async function signOut() {
38
+ outListeners.forEach(fn => fn());
39
  if (client) await client.auth.signOut();
40
  try { localStorage.removeItem(DEV_KEY); } catch { /* nothing stored */ }
41
  }
mathly2/static/student.js CHANGED
@@ -2,6 +2,7 @@
2
  import { esc } from "./math.js";
3
  import * as exercise from "./exercise.js";
4
  import * as home from "./home.js";
 
5
  import * as path from "./path.js";
6
  import * as quiz from "./quiz.js";
7
  import { route as onRoute } from "./router.js";
@@ -15,6 +16,7 @@ export async function show(me, view) {
15
  }
16
  let lastSkill = "";
17
  async function route() {
 
18
  const raw = location.hash.replace(/^#/, "");
19
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
20
  try {
@@ -28,6 +30,12 @@ export async function show(me, view) {
28
  const [lesson, attempt] = where.slice(6).split("/").map(decodeURIComponent);
29
  if (!document.getElementById("path")) await path.show(view); // a link or a reload: the path first, then the sheet
30
  if (attempt) await quiz.open(attempt); else await quiz.start(lesson); // the node starts or resumes; the address then names the attempt
 
 
 
 
 
 
31
  } else if (where.startsWith("/exam/") || where.startsWith("/sim/")) {
32
  exercise.close();
33
  const [kind, ...rest] = where.slice(1).split("/").map(decodeURIComponent);
@@ -65,5 +73,6 @@ export async function show(me, view) {
65
  if (!where.startsWith("/skill/") && !exercise.isOpen()) { /* the path scrolls itself */ }
66
  }
67
  onRoute(route);
 
68
  await route();
69
  }
 
2
  import { esc } from "./math.js";
3
  import * as exercise from "./exercise.js";
4
  import * as home from "./home.js";
5
+ import * as phone from "./phone.js";
6
  import * as path from "./path.js";
7
  import * as quiz from "./quiz.js";
8
  import { route as onRoute } from "./router.js";
 
16
  }
17
  let lastSkill = "";
18
  async function route() {
19
+ phone.stopComputer(); // a page that was waiting for the phone's photo is left
20
  const raw = location.hash.replace(/^#/, "");
21
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
22
  try {
 
30
  const [lesson, attempt] = where.slice(6).split("/").map(decodeURIComponent);
31
  if (!document.getElementById("path")) await path.show(view); // a link or a reload: the path first, then the sheet
32
  if (attempt) await quiz.open(attempt); else await quiz.start(lesson); // the node starts or resumes; the address then names the attempt
33
+ } else if (where === "/submissions") {
34
+ exercise.close(); quiz.close();
35
+ await (await import("./submissions.js")).show(view);
36
+ } else if (where.startsWith("/assignment/")) {
37
+ exercise.close(); quiz.close();
38
+ await (await import("./assignment.js")).show(view, decodeURIComponent(where.slice(12)));
39
  } else if (where.startsWith("/exam/") || where.startsWith("/sim/")) {
40
  exercise.close();
41
  const [kind, ...rest] = where.slice(1).split("/").map(decodeURIComponent);
 
73
  if (!where.startsWith("/skill/") && !exercise.isOpen()) { /* the path scrolls itself */ }
74
  }
75
  onRoute(route);
76
+ phone.startWatching(); // a photo request from the student's computer shows here when this is the phone
77
  await route();
78
  }
mathly2/static/submissions.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // "הגשות": every assignment from the student's teachers, grouped by state, and (on a phone) the switch for notifications.
2
+ import { swr } from "./api.js";
3
+ import { assignmentHref, assignmentState } from "./home.js";
4
+ import { esc, fmt } from "./math.js";
5
+ import * as notifications from "./notifications.js";
6
+ import { shortDate, tabs } from "./ui.js";
7
+
8
+ /** The groups, in the order a student needs them: what to do first, then what waits, then what is done. */
9
+ const GROUPS = [
10
+ { id: "new", title: "חדשות: עוד לא הוגשו", states: ["not_handed", "not_done"] },
11
+ { id: "returned", title: "הוחזרו לתיקון", states: ["returned"] },
12
+ { id: "waiting", title: "הוגשו וממתינות לבדיקה", states: ["handed_in"] },
13
+ { id: "checked", title: "נבדקו", states: ["graded", "done"] },
14
+ ];
15
+
16
+ function item(a) {
17
+ const s = assignmentState(a);
18
+ const note = a.teacher_note ? `<span class="muted gr-note">הערת המורה: ${fmt(a.teacher_note)}</span>` : "";
19
+ return `<li data-assignment="${a.id}" data-state="${esc(a.state)}"><a href="${assignmentHref(a)}"><b>${fmt(a.title)}</b></a>
20
+ <span class="muted">${esc(a.class.name)} · עד ${shortDate(a.due_date)}</span><span class="tag st-${esc(a.state)}"><span aria-hidden="true">${s.symbol}</span> ${s.word}</span>${note}</li>`;
21
+ }
22
+
23
+ export const show = view => swr("/api/assignments", list => draw(view, list));
24
+
25
+ function draw(view, list) {
26
+ const groups = GROUPS.map(g => ({ ...g, items: list.filter(a => g.states.includes(a.state)) })).filter(g => g.items.length);
27
+ view(`${tabs("submissions")}<section class="card" id="submissions"><h2>הגשות</h2>
28
+ ${groups.length ? groups.map(g => `<section class="group" id="group-${g.id}" data-group="${g.id}"><h3>${g.title} <span class="tab-count">${g.items.length}</span></h3>
29
+ <ul class="plain assignments">${g.items.map(item).join("")}</ul></section>`).join("")
30
+ : `<p class="muted" id="no-assignments">המורים עוד לא נתנו משימות.</p>`}</section>
31
+ <section class="card" id="notifications" hidden></section>`);
32
+ notifications.setup(document.getElementById("notifications"));
33
+ }
mathly2/static/teacher.js CHANGED
@@ -1,16 +1,31 @@
1
  // The teacher's home: the classes I teach, each with its students; I add (a pasted list) and remove students of my own classes.
2
- import { api } from "./api.js";
3
  import { esc, fmt } from "./math.js";
4
  import { daysText, shortDate } from "./ui.js";
5
 
6
  let draw; // the app's view() function
7
 
8
  const tabs = active => `<div class="subtabs" id="teacher-tabs" role="group" aria-label="המורה">
9
- <button type="button" data-tab="classes" aria-pressed="${active === "classes"}">הכיתות שלי</button>
10
- <button type="button" data-tab="tests" aria-pressed="${active === "tests"}">מבחנים</button></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  function wireTabs() {
13
- document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => (b.dataset.tab === "tests" ? showTests() : showClasses())));
 
 
14
  }
15
 
16
  function report(result) {
@@ -22,15 +37,17 @@ function report(result) {
22
  return lines;
23
  }
24
 
25
- async function showClasses(notes = {}) {
26
- const classes = await api("/api/teaching");
 
27
  draw(`${tabs("classes")}<section id="teaching"><h2>הכיתות שלי</h2>
28
  ${classes.length ? `<div class="cards">${classes.map(c => `<article class="card class-card" data-id="${c.id}">
29
  <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><span class="tag">${c.students.length} תלמידים</span></div>
30
  <p class="muted" style="margin:0 0 8px">${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}</p>
31
  ${c.students.length ? `<ul class="plain students">${c.students.map(s => `<li><span dir="ltr">${esc(s.email)}</span>
32
  <span class="muted">${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"}</span>
33
- <button type="button" data-remove="${esc(s.email)}" aria-label="הסרת ${esc(s.email)}">הסרה</button></li>`).join("")}</ul>`
 
34
  : `<p class="muted">עוד אין תלמידים בכיתה.</p>`}
35
  <form class="add-students"><label for="emails-${c.id}">הוספת תלמידים (כתובת בכל שורה)</label>
36
  <textarea id="emails-${c.id}" class="emails" dir="ltr" required></textarea>
@@ -49,6 +66,20 @@ async function showClasses(notes = {}) {
49
  } catch (e) { await showClasses({ [id]: { error: e.message } }); }
50
  });
51
  card.addEventListener("click", async event => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const email = event.target.closest("[data-remove]")?.dataset.remove;
53
  if (!email) return;
54
  try {
@@ -90,8 +121,9 @@ function testCard(c, t) {
90
  <div class="edit-slot" data-slot="${t.id}"></div></article>`;
91
  }
92
 
93
- async function showTests() {
94
- const classes = await api("/api/teaching/tests");
 
95
  draw(`${tabs("tests")}<section id="tests"><h2>מבחנים</h2>
96
  ${classes.length ? classes.map(({ class: c, tests }) => `<section class="card class-tests" data-class="${c.id}"><h3>${esc(c.name)}</h3>
97
  ${tests.length ? tests.map(t => testCard(c, t)).join("") : `<p class="muted">עוד לא נקבע מבחן לכיתה.</p>`}
 
1
  // The teacher's home: the classes I teach, each with its students; I add (a pasted list) and remove students of my own classes.
2
+ import { api, lastAnswer, swr } from "./api.js";
3
  import { esc, fmt } from "./math.js";
4
  import { daysText, shortDate } from "./ui.js";
5
 
6
  let draw; // the app's view() function
7
 
8
  const tabs = active => `<div class="subtabs" id="teacher-tabs" role="group" aria-label="המורה">
9
+ <button type="button" data-tab="classes" aria-pressed="${active === "classes"}">הכיתות שלי <span class="tab-count" data-count="classes"></span></button>
10
+ <button type="button" data-tab="tests" aria-pressed="${active === "tests"}">מבחנים <span class="tab-count" data-count="tests"></span></button>
11
+ <button type="button" data-tab="assignments" aria-pressed="${active === "assignments"}">משימות <span class="tab-count" data-count="assignments"></span></button>
12
+ <button type="button" data-tab="inbox" aria-pressed="${active === "inbox"}">תיבת בדיקה <span class="tab-count" data-count="inbox"></span></button></div>`;
13
+
14
+ const work = () => import("./teacher_work.js");
15
+ const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() };
16
+
17
+ /** The number of items in each tab, from the server, filled in after the screen is drawn (and so refreshed after every action). */
18
+ async function fillCounts() {
19
+ const show = counts => document.querySelectorAll("#teacher-tabs [data-count]").forEach(el => { el.textContent = counts[el.dataset.count]; });
20
+ const known = lastAnswer("/api/teaching/counts");
21
+ if (known) show(known); // the last numbers at once, the fresh ones when they arrive
22
+ try { show(await api("/api/teaching/counts")); } catch { /* the tabs work without the numbers */ }
23
+ }
24
 
25
  function wireTabs() {
26
+ fillCounts();
27
+ const screens = { classes: showClasses, tests: showTests, assignments: () => work().then(m => m.showAssignments(context)), inbox: () => work().then(m => m.showInbox(context)) };
28
+ document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => screens[b.dataset.tab]()));
29
  }
30
 
31
  function report(result) {
 
37
  return lines;
38
  }
39
 
40
+ const showClasses = (notes = {}) => swr("/api/teaching", classes => drawClasses(classes, notes));
41
+
42
+ async function drawClasses(classes, notes) {
43
  draw(`${tabs("classes")}<section id="teaching"><h2>הכיתות שלי</h2>
44
  ${classes.length ? `<div class="cards">${classes.map(c => `<article class="card class-card" data-id="${c.id}">
45
  <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><span class="tag">${c.students.length} תלמידים</span></div>
46
  <p class="muted" style="margin:0 0 8px">${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}</p>
47
  ${c.students.length ? `<ul class="plain students">${c.students.map(s => `<li><span dir="ltr">${esc(s.email)}</span>
48
  <span class="muted">${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"}</span>
49
+ <button type="button" data-remove="${esc(s.email)}" aria-label="הסרת ${esc(s.email)}">הסרה</button>
50
+ <button type="button" data-wipe="${esc(s.email)}" aria-label="מחיקת התמונות של ${esc(s.email)}">מחיקת תמונות</button></li>`).join("")}</ul>`
51
  : `<p class="muted">עוד אין תלמידים בכיתה.</p>`}
52
  <form class="add-students"><label for="emails-${c.id}">הוספת תלמידים (כתובת בכל שורה)</label>
53
  <textarea id="emails-${c.id}" class="emails" dir="ltr" required></textarea>
 
66
  } catch (e) { await showClasses({ [id]: { error: e.message } }); }
67
  });
68
  card.addEventListener("click", async event => {
69
+ const wipe = event.target.closest("[data-wipe]");
70
+ if (wipe) { // the student's answer pictures, on request: two steps (the grades stay)
71
+ wipe.outerHTML = `<span class="confirm"><button type="button" class="danger" data-confirm-wipe="${wipe.dataset.wipe}">אישור מחיקת התמונות</button> <button type="button" data-cancel-wipe>ביטול</button></span>`;
72
+ return;
73
+ }
74
+ if (event.target.closest("[data-cancel-wipe]")) { await showClasses(); return; }
75
+ const sure = event.target.closest("[data-confirm-wipe]")?.dataset.confirmWipe;
76
+ if (sure) {
77
+ try {
78
+ const done = await api(`/api/classes/${id}/pictures/${encodeURIComponent(sure)}`, { method: "DELETE" });
79
+ await showClasses({ [id]: { lines: [`נמחקו ${done.deleted} תמונות של ${esc(sure)}. הציונים וההערות נשארו.`] } });
80
+ } catch (e) { await showClasses({ [id]: { error: e.message } }); }
81
+ return;
82
+ }
83
  const email = event.target.closest("[data-remove]")?.dataset.remove;
84
  if (!email) return;
85
  try {
 
121
  <div class="edit-slot" data-slot="${t.id}"></div></article>`;
122
  }
123
 
124
+ const showTests = () => swr("/api/teaching/tests", drawTests);
125
+
126
+ async function drawTests(classes) {
127
  draw(`${tabs("tests")}<section id="tests"><h2>מבחנים</h2>
128
  ${classes.length ? classes.map(({ class: c, tests }) => `<section class="card class-tests" data-class="${c.id}"><h3>${esc(c.name)}</h3>
129
  ${tests.length ? tests.map(t => testCard(c, t)).join("") : `<p class="muted">עוד לא נקבע מבחן לכיתה.</p>`}
mathly2/static/teacher_work.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The teacher's work tab: give an assignment (a file, a photo, the board, one of the bank's written quizzes, or an exercise test) and see
2
+ // how many students handed in. `ctx` is the teacher's screen: { draw(html), tabs(active), wire() }.
3
+ import { api, swr } from "./api.js";
4
+ import { loadBoard } from "./exercise.js";
5
+ import { withBusy } from "./busy.js";
6
+ import { esc, fmt } from "./math.js";
7
+ import { isTouch, pictureUrl, prepare, sendPicture } from "./media.js";
8
+ import { daysText, shortDate } from "./ui.js";
9
+
10
+ const today = () => new Date().toISOString().slice(0, 10);
11
+ const SOURCES = [["pages", "קובץ, צילום או כתיבה על הלוח"], ["quiz", "בוחן כתוב מהמאגר"], ["exercise_test", "מבחן תרגול"]];
12
+
13
+ function sourceFields(c) {
14
+ const quizzes = c.lessons.filter(l => l.has_quiz);
15
+ return `<div class="source" data-source="pages"><p class="muted">הוסיפו עמודים: קובץ (תמונה או PDF), צילום או כתיבה על הלוח.</p>
16
+ <ul class="plain drafts" data-pages><li class="muted" data-pages-empty>עוד לא נוספו עמודים.</li></ul>
17
+ <p class="actions"><label class="button file">🖼 קובץ<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" multiple hidden data-pages-file></label>
18
+ ${isTouch() ? `<label class="button file">📷 צילום<input type="file" accept="image/*" capture="environment" hidden data-pages-camera></label>` : ""}
19
+ <button type="button" data-pages-board>✎ כתיבה על הלוח</button></p></div>
20
+ <div class="source" data-source="quiz" hidden>${quizzes.length ? `<label>הבוחן הכתוב של השיעור<select name="quiz_lesson">${quizzes.map(l => `<option value="${esc(l.id)}">${fmt(l.name)}</option>`).join("")}</select></label>
21
+ <p class="muted">ארבע שאלות כתובות, 100 נקודות.</p>` : `<p class="muted">לשיעורים של הכיתה אין בוחן כתוב במאגר.</p>`}</div>
22
+ <div class="source" data-source="exercise_test" hidden>${c.tests.length ? `<label>המבחן<select name="test">${c.tests.map(t => `<option value="${t.id}">מבחן ב־${shortDate(t.date)}: ${t.lessons.map(fmt).join(", ")}</option>`).join("")}</select></label>
23
+ <label>איזה מבחן תרגול<select name="exercise_number"><option value="1">מבחן תרגול 1</option><option value="2">מבחן תרגול 2</option></select></label>` : `<p class="muted">עוד לא נקבע מבחן לכיתה (בלשונית מבחנים).</p>`}</div>`;
24
+ }
25
+
26
+ function newForm(c) {
27
+ return `<form class="assignment-form" data-class="${c.id}"><label>כותרת<input name="title" required maxlength="200"></label>
28
+ <label>הערה לתלמידים (לא חובה)<textarea name="note" rows="2" maxlength="1000"></textarea></label>
29
+ <label>להגיש עד<input name="due_date" type="date" required min="${today()}"></label>
30
+ <fieldset><legend>התוכן</legend>${SOURCES.map(([v, label], i) => `<label class="check"><input type="radio" name="source" value="${v}"${i === 0 ? " checked" : ""}> ${label}</label>`).join("")}</fieldset>
31
+ ${sourceFields(c)}<p class="error form-error" role="alert" hidden></p><p><button class="primary" type="submit">שליחה לכיתה</button></p></form>`;
32
+ }
33
+
34
+ function row(a) {
35
+ const counts = a.kind === "exercise_test" ? `<span class="tag">${a.done} מתוך ${a.students} עשו</span>`
36
+ : `<span class="tag counts">${a.handed_in + a.graded + a.returned} מתוך ${a.students} הגישו</span><span class="tag">${a.graded} נבדקו</span>${a.waiting ? `<span class="tag waiting">${a.waiting} מחכות לבדיקה</span>` : ""}`;
37
+ const what = a.kind === "quiz" ? `בוחן כתוב: ${fmt(a.quiz_lesson.name)}` : a.kind === "exercise_test" ? `מבחן תרגול ${a.test.number}` : `${a.pages.length} עמודים`;
38
+ return `<article class="card assignment-row" data-assignment="${a.id}"><div class="hrow"><h4 class="grow">${fmt(a.title)}</h4><span class="muted">עד ${shortDate(a.due_date)} (${daysText(a.days_left)})</span></div>
39
+ <p class="muted">${what}${a.note ? ` · ${fmt(a.note)}` : ""}</p><p class="tags">${counts}</p>
40
+ <p><button type="button" data-remove-assignment="${a.id}">הסרה</button></p></article>`;
41
+ }
42
+
43
+ export const showAssignments = ctx => swr("/api/teaching/assignments", classes => drawAssignments(ctx, classes));
44
+
45
+ async function drawAssignments(ctx, classes) {
46
+ ctx.draw(`${ctx.tabs("assignments")}<section id="work"><h2>משימות</h2>
47
+ ${classes.length ? classes.map(({ class: c, assignments }) => `<section class="card class-work" data-class="${c.id}"><h3>${esc(c.name)}</h3>
48
+ ${assignments.length ? assignments.map(row).join("") : `<p class="muted">עוד לא נתנו משימה לכיתה.</p>`}
49
+ <details class="new-assignment"><summary>משימה חדשה</summary>${newForm(c)}</details></section>`).join("")
50
+ : `<section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מל��ד/ת כל כיתה.</p></section>`}</section>`);
51
+ ctx.wire();
52
+ const root = document.getElementById("work");
53
+ root.querySelectorAll(".assignment-form").forEach(form => wireForm(form, ctx));
54
+ root.addEventListener("click", async event => {
55
+ const button = event.target.closest("[data-remove-assignment]");
56
+ if (button) { // two steps, so a slip does not remove it
57
+ button.outerHTML = `<span class="confirm"><button type="button" class="danger" data-confirm-remove-assignment="${button.dataset.removeAssignment}">אישור הסרה</button> <button type="button" data-cancel-remove>ביטול</button></span>`;
58
+ return;
59
+ }
60
+ if (event.target.closest("[data-cancel-remove]")) return showAssignments(ctx);
61
+ const confirm = event.target.closest("[data-confirm-remove-assignment]")?.dataset.confirmRemoveAssignment;
62
+ if (confirm) { await api(`/api/assignments/${confirm}`, { method: "DELETE" }); await showAssignments(ctx); }
63
+ });
64
+ }
65
+
66
+ function wireForm(form, ctx) {
67
+ let pages = [];
68
+ const error = form.querySelector(".form-error");
69
+ const fail = text => { error.textContent = text; error.hidden = false; error.scrollIntoView({ block: "nearest" }); };
70
+ const list = form.querySelector("[data-pages]");
71
+ const drawPages = () => {
72
+ list.innerHTML = pages.length ? pages.map(id => `<li data-page="${esc(id)}"><img data-picture="${esc(id)}" alt="עמוד של המשימה" hidden>
73
+ <button type="button" class="remove" data-remove-page="${esc(id)}" aria-label="הסרת העמוד">✕</button></li>`).join("") : `<li class="muted" data-pages-empty>עוד לא נוספו עמודים.</li>`;
74
+ list.querySelectorAll("img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.remove(); } });
75
+ };
76
+ const upload = async (blob, type) => {
77
+ const got = await fetch("/api/assignments/pages", { method: "POST", headers: { Authorization: "Bearer " + await (await import("./session.js")).token(), "Content-Type": type || blob.type }, body: blob });
78
+ const data = await got.json().catch(() => ({}));
79
+ if (!got.ok) throw new Error(typeof data.detail === "string" ? data.detail : "משהו השתבש");
80
+ pages.push(...data.pictures);
81
+ drawPages();
82
+ };
83
+ const addFiles = async files => {
84
+ error.hidden = true;
85
+ for (const file of files) {
86
+ try { await upload(file.type === "application/pdf" ? file : await prepare(file), file.type === "application/pdf" ? file.type : "image/jpeg"); } catch (e) { fail(e.message); }
87
+ }
88
+ };
89
+ form.querySelectorAll("[data-pages-file], [data-pages-camera]").forEach(input => input.addEventListener("change", async () => { const files = [...input.files]; input.value = ""; await withBusy(input.closest("label"), addFiles(files)); }));
90
+ list.addEventListener("click", event => { const id = event.target.closest("[data-remove-page]")?.dataset.removePage; if (id) { pages = pages.filter(p => p !== id); drawPages(); } });
91
+ form.querySelector("[data-pages-board]").addEventListener("click", async () => {
92
+ await loadBoard();
93
+ if (!window.MathlyBoard?.openFull) return;
94
+ const host = Object.assign(document.createElement("div"), { className: "mx-layer" });
95
+ document.body.append(host);
96
+ window.MathlyBoard.openFull({ host, key: `assignment-new:${form.dataset.class}`, title: "כתיבת משימה על הלוח", sendLabel: "הוספה למשימה",
97
+ submit: async (page, blob) => { try { await upload(blob, "image/png"); return {}; } catch (e) { return { error: e.message }; } }, done: () => host.remove() });
98
+ });
99
+ form.querySelectorAll("[name=source]").forEach(radio => radio.addEventListener("change", () => {
100
+ form.querySelectorAll(".source").forEach(box => { box.hidden = box.dataset.source !== form.querySelector("[name=source]:checked").value; });
101
+ }));
102
+ form.addEventListener("submit", async event => {
103
+ event.preventDefault();
104
+ error.hidden = true;
105
+ const data = new FormData(form), kind = data.get("source");
106
+ const body = { title: data.get("title"), note: data.get("note") || "", due_date: data.get("due_date"), kind };
107
+ if (kind === "pages") body.pages = pages;
108
+ if (kind === "quiz") body.quiz_lesson = data.get("quiz_lesson");
109
+ if (kind === "exercise_test") { body.test_id = Number(data.get("test")); body.exercise_number = Number(data.get("exercise_number")); }
110
+ try {
111
+ await api(`/api/classes/${form.dataset.class}/assignments`, { method: "POST", body });
112
+ await showAssignments(ctx);
113
+ } catch (e) { fail(e.message); } // the form keeps what was typed
114
+ });
115
+ }
116
+
117
+ // ---------- the inbox: everything that waits for the teacher, oldest first ----------
118
+
119
+ const when = iso => new Date(iso).toLocaleString("he-IL", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" });
120
+
121
+ export const showInbox = (ctx, notice = "") => swr("/api/teaching/inbox", items => drawInbox(ctx, items, notice));
122
+
123
+ async function drawInbox(ctx, items, notice) {
124
+ ctx.draw(`${ctx.tabs("inbox")}<section id="inbox"><h2>תיבת בדיקה</h2>${notice ? `<p class="ex-note" role="status" id="inbox-notice">${esc(notice)}</p>` : ""}
125
+ ${items.length ? `<ul class="plain inbox-list" id="inbox-list">${items.map(i => `<li data-submission="${i.id}"><button type="button" class="open-submission" data-open="${i.id}">
126
+ <b>${esc(i.student || "תלמיד/ה")}</b> · ${fmt(i.assignment.title)}<br><span class="muted">${esc(i.assignment.class.name)} · הוגש ${when(i.handed_at)}${i.late ? " · באיחור" : ""}</span></button></li>`).join("")}</ul>`
127
+ : `<p class="muted" id="inbox-empty">אין מה לבדוק. כל ההגשות נבדקו.</p>`}</section>`);
128
+ ctx.wire();
129
+ document.getElementById("inbox").addEventListener("click", event => {
130
+ const id = event.target.closest("[data-open]")?.dataset.open;
131
+ if (id) openSubmission(ctx, id);
132
+ });
133
+ }
134
+
135
+ async function openSubmission(ctx, id) {
136
+ const s = await api(`/api/submissions/${id}`);
137
+ const material = s.quiz ? `<h4>הבוחן הכתוב</h4><ol class="qz-written">${s.quiz.questions.map(x => `<li><p class="ex-text" dir="auto">${fmt(x.question)}</p><span class="muted">${x.points} נק׳</span></li>`).join("")}</ol>`
138
+ : s.pages.length ? `<h4>הדף שניתן</h4><div class="pages">${s.pages.map(p => `<img data-picture="${esc(p)}" alt="עמוד של המשימה" hidden>`).join("")}</div>` : "";
139
+ ctx.draw(`${ctx.tabs("inbox")}<section id="check" class="card" data-submission="${s.id}"><p><button type="button" id="back-to-inbox">‹ חזרה לתיבה</button></p>
140
+ <h2>${esc(s.student)}: ${fmt(s.assignment.title)}</h2><p class="muted">${esc(s.assignment.class.name)} · הוגש ${when(s.handed_at)}${s.late ? " · באיחור" : ""}</p>
141
+ <h4>התשובה</h4><div class="pages" id="answer-pictures">${s.pictures.map(p => p.deleted ? `<p class="muted">התמונה נמחקה</p>` : `<img data-picture="${esc(p.id)}" alt="תמונה של התשובה" hidden>`).join("")}</div>
142
+ <details><summary>מה ביקשנו</summary>${material || `<p class="muted">אין תוכן להצגה.</p>`}</details>
143
+ <form id="check-form"><label>ציון (0 עד 100)<input name="grade" type="number" min="0" max="100" step="1" inputmode="numeric"></label>
144
+ <label>הערה לתלמיד/ה<textarea name="note" rows="3" maxlength="2000"></textarea></label>
145
+ <p class="error" id="check-error" role="alert" hidden></p>
146
+ <p><button type="submit" class="primary" id="give-grade">שמירת הציון</button> <button type="button" id="send-back">החזרה לתיקון</button></p></form></section>`);
147
+ ctx.wire();
148
+ document.querySelectorAll("#check img[data-picture]").forEach(async img => { try { img.src = await pictureUrl(img.dataset.picture); img.hidden = false; } catch { img.remove(); } });
149
+ const form = document.getElementById("check-form"), error = document.getElementById("check-error");
150
+ const fail = text => { error.textContent = text; error.hidden = false; };
151
+ document.getElementById("back-to-inbox").addEventListener("click", () => showInbox(ctx));
152
+ const send = async (path, body, done) => {
153
+ error.hidden = true;
154
+ try { await api(`/api/submissions/${id}/${path}`, { method: "POST", body: { ...body, seen: s.seen } }); await showInbox(ctx, done); }
155
+ catch (e) { fail(e.status === 409 ? `${e.message} ` : e.message); if (e.status === 409) error.insertAdjacentHTML("beforeend", `<button type="button" id="reopen">פתיחה מחדש</button>`); document.getElementById("reopen")?.addEventListener("click", () => showInbox(ctx)); }
156
+ };
157
+ form.addEventListener("submit", event => {
158
+ event.preventDefault();
159
+ const grade = form.elements.grade.value;
160
+ if (grade === "" || !Number.isInteger(Number(grade)) || Number(grade) < 0 || Number(grade) > 100) return fail("הציון הוא מספר שלם בין 0 ל־100.");
161
+ send("grade", { grade: Number(grade), note: form.elements.note.value }, "הציון נשמר.");
162
+ });
163
+ document.getElementById("send-back").addEventListener("click", () => {
164
+ if (!form.elements.note.value.trim()) return fail("צריך לכתוב מה לתקן.");
165
+ send("return", { note: form.elements.note.value }, "המשימה הוחזרה לתלמיד/ה לתיקון.");
166
+ });
167
+ }
mathly2/static/testcard.js CHANGED
@@ -32,7 +32,8 @@ function simulation(test) {
32
  }
33
 
34
  export async function show(view, id) {
35
- const test = await api(`/api/tests/${encodeURIComponent(id)}`);
 
36
  view(`${tabs("home")}<p><a class="back" href="${HOME}">‹ הבית</a></p>
37
  <section class="card" id="test-card" data-test="${test.id}"><h2>מבחן ב־${shortDate(test.date)} <span class="muted">(${daysText(test.days_left)})</span></h2>
38
  <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` · ${esc(test.note)}` : ""}<br>${test.questions} שאלות · ${test.minutes} דקות</p>
@@ -41,5 +42,7 @@ export async function show(view, id) {
41
  <section class="card"><h3>מבחני תרגול</h3>${exercises(test)}
42
  ${test.practice_first.length ? `<p id="practice-first"><b>כדאי לתרגל קודם:</b> ${test.practice_first.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
43
  <p class="muted">אלה מיומנויות שעוד לא התחלתם, ולכן אין להן שאלות במבחני התרגול.</p>` : ""}</section>
44
- <section class="card"><h3>מבחן סימולציה</h3>${simulation(test)}</section>`);
 
 
45
  }
 
32
  }
33
 
34
  export async function show(view, id) {
35
+ const [test, home] = await Promise.all([api(`/api/tests/${encodeURIComponent(id)}`), api("/api/home")]);
36
+ const others = home.tests.filter(t => t.id !== test.id);
37
  view(`${tabs("home")}<p><a class="back" href="${HOME}">‹ הבית</a></p>
38
  <section class="card" id="test-card" data-test="${test.id}"><h2>מבחן ב־${shortDate(test.date)} <span class="muted">(${daysText(test.days_left)})</span></h2>
39
  <p class="muted">${test.lessons.map(l => fmt(l.name)).join(", ")}${test.note ? ` · ${esc(test.note)}` : ""}<br>${test.questions} שאלות · ${test.minutes} דקות</p>
 
42
  <section class="card"><h3>מבחני תרגול</h3>${exercises(test)}
43
  ${test.practice_first.length ? `<p id="practice-first"><b>כדאי לתרגל קודם:</b> ${test.practice_first.map(s => `<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>
44
  <p class="muted">אלה מיומנויות שעוד לא התחלתם, ולכן אין להן שאלות במבחני התרגול.</p>` : ""}</section>
45
+ <section class="card"><h3>מבחן סימולציה</h3>${simulation(test)}</section>
46
+ ${others.length ? `<section class="card"><h3>מבחנים נוספים</h3><ul class="plain" id="other-tests">${others.map(t => `<li><a href="#/test/${t.id}">${shortDate(t.date)} · ${t.lessons.map(l => fmt(l.name)).join(", ")}</a>
47
+ <span class="muted">${daysText(t.days_left)} · מוכנות ${t.readiness.number}</span></li>`).join("")}</ul></section>` : ""}`);
48
  }
mathly2/static/ui.js CHANGED
@@ -17,6 +17,7 @@ export const PATH = "#/subject/math/path";
17
  export const tabs = active => `<nav class="tabs" aria-label="תפריט התלמיד">
18
  <a href="${HOME}" ${active === "home" ? 'aria-current="page"' : ""}>בית</a>
19
  <a href="${PATH}" ${active === "path" ? 'aria-current="page"' : ""}>מסלול</a>
 
20
  <a href="#/grades" ${active === "grades" ? 'aria-current="page"' : ""}>ציונים</a>
21
  <a href="#/collection" ${active === "collection" ? 'aria-current="page"' : ""}>האוסף</a></nav>`;
22
 
 
17
  export const tabs = active => `<nav class="tabs" aria-label="תפריט התלמיד">
18
  <a href="${HOME}" ${active === "home" ? 'aria-current="page"' : ""}>בית</a>
19
  <a href="${PATH}" ${active === "path" ? 'aria-current="page"' : ""}>מסלול</a>
20
+ <a href="#/submissions" ${active === "submissions" ? 'aria-current="page"' : ""}>הגשות</a>
21
  <a href="#/grades" ${active === "grades" ? 'aria-current="page"' : ""}>ציונים</a>
22
  <a href="#/collection" ${active === "collection" ? 'aria-current="page"' : ""}>האוסף</a></nav>`;
23
 
mathly2/store.py CHANGED
@@ -2,6 +2,7 @@
2
  InMemoryStore (tests and local development, lost on restart) and SupabaseStore (PostgREST with the secret key)."""
3
  from __future__ import annotations
4
 
 
5
  import itertools
6
  import json
7
  import threading
@@ -50,7 +51,9 @@ def _copy_attempt(row: dict) -> dict:
50
  def _when(value):
51
  if value is None or isinstance(value, datetime):
52
  return value
53
- return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
 
 
54
 
55
 
56
  def _attempt_row(row: dict) -> dict:
@@ -78,6 +81,25 @@ def _test_row(row: dict) -> dict:
78
  return {**row, "date": day, "lessons": list(row.get("lessons") or []), "created_at": _when(row["created_at"]), "removed_at": _when(row.get("removed_at"))}
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  class InMemoryStore:
82
  def __init__(self) -> None:
83
  self._lock = threading.Lock()
@@ -91,6 +113,12 @@ class InMemoryStore:
91
  self._attempts: dict[str, dict] = {} # attempt id -> row
92
  self._reward: dict[str, dict] = {} # user_id -> {last_comet_day, last_catch_day}
93
  self._tests: dict[int, dict] = {} # test id -> row
 
 
 
 
 
 
94
 
95
  # --- classes ---
96
 
@@ -332,6 +360,177 @@ class InMemoryStore:
332
  rows = [r for r in self._tests.values() if r["class_id"] in wanted and (include_removed or not r["removed_at"])]
333
  return [dict(r, lessons=list(r["lessons"])) for r in sorted(rows, key=lambda r: (r["date"], r["id"]))]
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  # --- timed attempts (quizzes; later exercise tests and the simulation) ---
336
 
337
  def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict:
@@ -384,6 +583,21 @@ class InMemoryStore:
384
  row.update(fields)
385
  return dict(row)
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  def last_answered(self, user_id: str) -> dict[str, date]:
388
  """The day of the student's newest answer on each skill (helped and wrong answers count): what fading reads."""
389
  with self._lock:
@@ -633,6 +847,146 @@ class SupabaseStore:
633
  params["removed_at"] = "is.null"
634
  return [_test_row(r) for r in self._request("GET", "m2_tests", params)]
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  # --- timed attempts ---
637
 
638
  def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict:
@@ -687,6 +1041,19 @@ class SupabaseStore:
687
  def reward_state_row(row: dict) -> dict:
688
  return {**row, **{k: date.fromisoformat(row[k]) if row.get(k) else None for k in ("last_comet_day", "last_catch_day")}}
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  def last_answered(self, user_id: str) -> dict[str, date]:
691
  rows = self._request("GET", "m2_last_answered", {"user_id": self._eq(user_id), "select": "skill_id,day"})
692
  return {r["skill_id"]: date.fromisoformat(r["day"]) for r in rows}
 
2
  InMemoryStore (tests and local development, lost on restart) and SupabaseStore (PostgREST with the secret key)."""
3
  from __future__ import annotations
4
 
5
+ import re
6
  import itertools
7
  import json
8
  import threading
 
51
  def _when(value):
52
  if value is None or isinstance(value, datetime):
53
  return value
54
+ # Python 3.10 reads a fraction of exactly 3 or 6 digits only, and Postgres drops trailing zeros ("...57.63003+00:00")
55
+ text = re.sub(r"\.(\d+)", lambda m: "." + m.group(1)[:6].ljust(6, "0"), str(value).replace("Z", "+00:00"), count=1)
56
+ return datetime.fromisoformat(text)
57
 
58
 
59
  def _attempt_row(row: dict) -> dict:
 
81
  return {**row, "date": day, "lessons": list(row.get("lessons") or []), "created_at": _when(row["created_at"]), "removed_at": _when(row.get("removed_at"))}
82
 
83
 
84
+ PICTURE_KINDS = ("page", "answer")
85
+ SUBMISSION_FIELDS = {"pictures", "state", "late", "handed_at", "grade", "teacher_note", "checked_at", "checked_by"}
86
+ REQUEST_FIELDS = {"state", "picture_id"}
87
+
88
+
89
+ def _stamped(row: dict, *names: str) -> dict:
90
+ """A database row with the named timestamp columns as datetimes (the in-memory store keeps datetimes already)."""
91
+ return {**row, **{n: _when(row.get(n)) for n in names}}
92
+
93
+
94
+ def _assignment_row(row: dict) -> dict:
95
+ day = row["due_date"] if isinstance(row["due_date"], date) else date.fromisoformat(str(row["due_date"])[:10])
96
+ return {**_stamped(row, "created_at", "removed_at"), "due_date": day, "pages": [str(x) for x in (row.get("pages") or [])]}
97
+
98
+
99
+ def _submission_row(row: dict) -> dict:
100
+ return {**_stamped(row, "handed_at", "checked_at"), "pictures": [str(x) for x in (row.get("pictures") or [])]}
101
+
102
+
103
  class InMemoryStore:
104
  def __init__(self) -> None:
105
  self._lock = threading.Lock()
 
113
  self._attempts: dict[str, dict] = {} # attempt id -> row
114
  self._reward: dict[str, dict] = {} # user_id -> {last_comet_day, last_catch_day}
115
  self._tests: dict[int, dict] = {} # test id -> row
116
+ self._pictures: dict[str, dict] = {}
117
+ self._curriculum: dict[str, date] = {} # lesson id -> the date it should be completed by
118
+ self._assignments: dict[int, dict] = {}
119
+ self._submissions: dict[int, dict] = {}
120
+ self._requests: dict[str, dict] = {}
121
+ self._subs: dict[int, dict] = {} # push subscriptions
122
 
123
  # --- classes ---
124
 
 
360
  rows = [r for r in self._tests.values() if r["class_id"] in wanted and (include_removed or not r["removed_at"])]
361
  return [dict(r, lessons=list(r["lessons"])) for r in sorted(rows, key=lambda r: (r["date"], r["id"]))]
362
 
363
+ # --- pictures, assignments and submissions (written work) ---
364
+
365
+ def create_picture(self, owner_id: str, kind: str, path: str, content_type: str, size: int, created_at: datetime | None = None) -> dict:
366
+ if kind not in PICTURE_KINDS:
367
+ raise ValueError(f"unknown picture kind {kind!r}")
368
+ row = {"id": str(uuid.uuid4()), "owner_id": owner_id, "kind": kind, "path": path, "content_type": content_type, "size": size,
369
+ "created_at": created_at or datetime.now(timezone.utc), "deleted_at": None}
370
+ with self._lock:
371
+ self._pictures[row["id"]] = row
372
+ return dict(row)
373
+
374
+ def get_picture(self, picture_id: str) -> dict | None:
375
+ with self._lock:
376
+ row = self._pictures.get(str(picture_id))
377
+ return dict(row) if row else None
378
+
379
+ def mark_picture_deleted(self, picture_id: str, at: datetime | None = None) -> bool:
380
+ with self._lock:
381
+ row = self._pictures.get(str(picture_id))
382
+ if row is None or row["deleted_at"]:
383
+ return False
384
+ row["deleted_at"] = at or datetime.now(timezone.utc)
385
+ return True
386
+
387
+ def pictures(self, kind: str | None = None, owner_id: str | None = None, include_deleted: bool = False) -> list[dict]:
388
+ with self._lock:
389
+ return [dict(r) for r in sorted(self._pictures.values(), key=lambda r: r["created_at"])
390
+ if (kind is None or r["kind"] == kind) and (owner_id is None or r["owner_id"] == owner_id) and (include_deleted or not r["deleted_at"])]
391
+
392
+ def picture_refs(self, picture_id: str) -> dict:
393
+ """What refers to a picture: assignments (as a page), submissions (as an answer) and photo requests."""
394
+ pid = str(picture_id)
395
+ with self._lock:
396
+ return {"assignments": sorted(a["id"] for a in self._assignments.values() if pid in a["pages"]),
397
+ "submissions": sorted(s["id"] for s in self._submissions.values() if pid in s["pictures"]),
398
+ "requests": sorted(r["id"] for r in self._requests.values() if r["picture_id"] == pid)}
399
+
400
+ def create_assignment(self, class_id: int, title: str, due_date: date, created_by: str, kind: str, *, note: str = "", pages=(),
401
+ quiz_lesson: str | None = None, test_id: int | None = None, exercise_number: int | None = None,
402
+ created_at: datetime | None = None) -> dict:
403
+ ok = {"pages": bool(pages), "quiz": bool(quiz_lesson), "exercise_test": test_id is not None and exercise_number in (1, 2)}
404
+ if not ok.get(kind) or not 1 <= len(title) <= 200:
405
+ raise ValueError("an assignment needs a title and its content")
406
+ with self._lock:
407
+ row = {"id": next(self._ids), "class_id": class_id, "title": title, "note": note, "due_date": due_date, "kind": kind,
408
+ "pages": [str(p) for p in pages], "quiz_lesson": quiz_lesson, "test_id": test_id, "exercise_number": exercise_number,
409
+ "created_by": created_by, "created_at": created_at or datetime.now(timezone.utc), "removed_at": None}
410
+ self._assignments[row["id"]] = row
411
+ return dict(row, pages=list(row["pages"]))
412
+
413
+ def get_assignment(self, assignment_id: int) -> dict | None:
414
+ with self._lock:
415
+ row = self._assignments.get(assignment_id)
416
+ return dict(row, pages=list(row["pages"])) if row else None
417
+
418
+ def assignments_of_classes(self, class_ids, include_removed: bool = False) -> list[dict]:
419
+ """The assignments of these classes, the nearest due date first."""
420
+ wanted = set(class_ids)
421
+ with self._lock:
422
+ rows = [r for r in self._assignments.values() if r["class_id"] in wanted and (include_removed or not r["removed_at"])]
423
+ return [dict(r, pages=list(r["pages"])) for r in sorted(rows, key=lambda r: (r["due_date"], r["id"]))]
424
+
425
+ def remove_assignment(self, assignment_id: int, at: datetime | None = None) -> bool:
426
+ with self._lock:
427
+ row = self._assignments.get(assignment_id)
428
+ if row is None or row["removed_at"]:
429
+ return False
430
+ row["removed_at"] = at or datetime.now(timezone.utc)
431
+ return True
432
+
433
+ def get_submission(self, assignment_id: int, user_id: str) -> dict | None:
434
+ with self._lock:
435
+ row = next((r for r in self._submissions.values() if r["assignment_id"] == assignment_id and r["user_id"] == user_id), None)
436
+ return dict(row, pictures=list(row["pictures"])) if row else None
437
+
438
+ def get_submission_by_id(self, submission_id: int) -> dict | None:
439
+ with self._lock:
440
+ row = self._submissions.get(submission_id)
441
+ return dict(row, pictures=list(row["pictures"])) if row else None
442
+
443
+ def save_submission(self, assignment_id: int, user_id: str, **fields) -> dict:
444
+ """Create the student's submission or change the given fields of it."""
445
+ unknown = set(fields) - SUBMISSION_FIELDS
446
+ if unknown:
447
+ raise ValueError(f"unknown submission fields {sorted(unknown)}")
448
+ with self._lock:
449
+ row = next((r for r in self._submissions.values() if r["assignment_id"] == assignment_id and r["user_id"] == user_id), None)
450
+ candidate = dict(row) if row else {"id": next(self._ids), "assignment_id": assignment_id, "user_id": user_id, "pictures": [], "state": "handed_in",
451
+ "late": False, "handed_at": datetime.now(timezone.utc), "grade": None, "teacher_note": "", "checked_at": None,
452
+ "checked_by": None}
453
+ candidate.update({k: ([str(x) for x in v] if k == "pictures" else v) for k, v in fields.items()})
454
+ if candidate["state"] == "graded" and candidate["grade"] is None:
455
+ raise ValueError("a graded submission has a grade") # refused before anything is stored
456
+ if candidate["grade"] is not None and not 0 <= candidate["grade"] <= 100:
457
+ raise ValueError("a grade is 0 to 100")
458
+ self._submissions[candidate["id"]] = candidate
459
+ return dict(candidate, pictures=list(candidate["pictures"]))
460
+
461
+ def submissions_of_assignments(self, assignment_ids) -> list[dict]:
462
+ wanted = set(assignment_ids)
463
+ with self._lock:
464
+ return [dict(r, pictures=list(r["pictures"])) for r in sorted(self._submissions.values(), key=lambda r: (r["handed_at"], r["id"]))
465
+ if r["assignment_id"] in wanted]
466
+
467
+ def submissions_of_user(self, user_id: str) -> list[dict]:
468
+ with self._lock:
469
+ return [dict(r, pictures=list(r["pictures"])) for r in self._submissions.values() if r["user_id"] == user_id]
470
+
471
+ # --- photo requests and push subscriptions ---
472
+
473
+ def create_photo_request(self, user_id: str, assignment_id: int, device: str, code: str, created_at: datetime, expires_at: datetime) -> dict:
474
+ """A new request; the student's earlier waiting request for the same assignment becomes superseded."""
475
+ with self._lock:
476
+ for r in self._requests.values():
477
+ if r["user_id"] == user_id and r["assignment_id"] == assignment_id and r["state"] == "waiting":
478
+ r["state"] = "superseded"
479
+ row = {"id": str(uuid.uuid4()), "user_id": user_id, "assignment_id": assignment_id, "device": device, "code": code, "state": "waiting",
480
+ "created_at": created_at, "expires_at": expires_at, "picture_id": None}
481
+ self._requests[row["id"]] = row
482
+ return dict(row)
483
+
484
+ def get_photo_request(self, request_id: str) -> dict | None:
485
+ with self._lock:
486
+ row = self._requests.get(str(request_id))
487
+ return dict(row) if row else None
488
+
489
+ def save_photo_request(self, request_id: str, **fields) -> dict:
490
+ unknown = set(fields) - REQUEST_FIELDS
491
+ if unknown:
492
+ raise ValueError(f"unknown photo request fields {sorted(unknown)}")
493
+ with self._lock:
494
+ row = self._requests.get(str(request_id))
495
+ if row is None:
496
+ raise KeyError(request_id)
497
+ row.update(fields)
498
+ return dict(row)
499
+
500
+ def open_photo_requests(self, user_id: str) -> list[dict]:
501
+ """The student's requests still waiting for or receiving a picture, the newest first (expiry is decided by the caller)."""
502
+ with self._lock:
503
+ return [dict(r) for r in sorted(self._requests.values(), key=lambda r: r["created_at"], reverse=True)
504
+ if r["user_id"] == user_id and r["state"] in ("waiting", "uploading")]
505
+
506
+ def add_push_subscription(self, user_id: str, endpoint: str, p256dh: str, auth: str, key_id: str) -> dict:
507
+ with self._lock:
508
+ row = next((r for r in self._subs.values() if r["endpoint"] == endpoint), None)
509
+ if row is None:
510
+ row = {"id": next(self._ids), "endpoint": endpoint, "created_at": datetime.now(timezone.utc)}
511
+ self._subs[row["id"]] = row
512
+ row.update({"user_id": user_id, "p256dh": p256dh, "auth": auth, "key_id": key_id})
513
+ return dict(row)
514
+
515
+ def push_subscriptions_of(self, user_id: str) -> list[dict]:
516
+ with self._lock:
517
+ return [dict(r) for r in self._subs.values() if r["user_id"] == user_id]
518
+
519
+ def delete_push_subscription(self, endpoint: str) -> bool:
520
+ with self._lock:
521
+ found = [i for i, r in self._subs.items() if r["endpoint"] == endpoint]
522
+ for i in found:
523
+ del self._subs[i]
524
+ return bool(found)
525
+
526
+ def drop_push_subscriptions_not_of(self, key_id: str) -> int:
527
+ """The server's push key changed: subscriptions made under another key are dropped, and each phone turns notifications on again."""
528
+ with self._lock:
529
+ gone = [i for i, r in self._subs.items() if r["key_id"] != key_id]
530
+ for i in gone:
531
+ del self._subs[i]
532
+ return len(gone)
533
+
534
  # --- timed attempts (quizzes; later exercise tests and the simulation) ---
535
 
536
  def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict:
 
583
  row.update(fields)
584
  return dict(row)
585
 
586
+ # --- the curriculum: the date by which each lesson should be completed ---
587
+
588
+ def curriculum(self) -> dict[str, date]:
589
+ with self._lock:
590
+ return dict(self._curriculum)
591
+
592
+ def set_lesson_dates(self, dates: dict[str, date | None]) -> None:
593
+ """Set each lesson's date; None clears it."""
594
+ with self._lock:
595
+ for lesson, day in dates.items():
596
+ if day is None:
597
+ self._curriculum.pop(lesson, None)
598
+ else:
599
+ self._curriculum[lesson] = day
600
+
601
  def last_answered(self, user_id: str) -> dict[str, date]:
602
  """The day of the student's newest answer on each skill (helped and wrong answers count): what fading reads."""
603
  with self._lock:
 
847
  params["removed_at"] = "is.null"
848
  return [_test_row(r) for r in self._request("GET", "m2_tests", params)]
849
 
850
+ # --- pictures, assignments and submissions (written work) ---
851
+
852
+ def create_picture(self, owner_id: str, kind: str, path: str, content_type: str, size: int, created_at: datetime | None = None) -> dict:
853
+ if kind not in PICTURE_KINDS:
854
+ raise ValueError(f"unknown picture kind {kind!r}")
855
+ body = {"owner_id": owner_id, "kind": kind, "path": path, "content_type": content_type, "size": size}
856
+ if created_at:
857
+ body["created_at"] = created_at.isoformat()
858
+ return _stamped(self._request("POST", "m2_pictures", body=body, returning=True)[0], "created_at", "deleted_at")
859
+
860
+ def get_picture(self, picture_id: str) -> dict | None:
861
+ try:
862
+ rows = self._request("GET", "m2_pictures", {"id": self._eq(picture_id), "select": "*"})
863
+ except urllib.error.HTTPError as exc:
864
+ if exc.code == 400: # not a valid id
865
+ return None
866
+ raise
867
+ return _stamped(rows[0], "created_at", "deleted_at") if rows else None
868
+
869
+ def mark_picture_deleted(self, picture_id: str, at: datetime | None = None) -> bool:
870
+ stamp = (at or datetime.now(timezone.utc)).isoformat()
871
+ return bool(self._request("PATCH", "m2_pictures", {"id": self._eq(picture_id), "deleted_at": "is.null"}, {"deleted_at": stamp}, returning=True))
872
+
873
+ def pictures(self, kind: str | None = None, owner_id: str | None = None, include_deleted: bool = False) -> list[dict]:
874
+ params = {"select": "*", "order": "created_at"}
875
+ if kind:
876
+ params["kind"] = self._eq(kind)
877
+ if owner_id:
878
+ params["owner_id"] = self._eq(owner_id)
879
+ if not include_deleted:
880
+ params["deleted_at"] = "is.null"
881
+ return [_stamped(r, "created_at", "deleted_at") for r in self._request("GET", "m2_pictures", params)]
882
+
883
+ def picture_refs(self, picture_id: str) -> dict:
884
+ pid = str(picture_id)
885
+ return {"assignments": sorted(r["id"] for r in self._request("GET", "m2_assignments", {"pages": f"cs.{{{pid}}}", "select": "id"})),
886
+ "submissions": sorted(r["id"] for r in self._request("GET", "m2_submissions", {"pictures": f"cs.{{{pid}}}", "select": "id"})),
887
+ "requests": sorted(r["id"] for r in self._request("GET", "m2_photo_requests", {"picture_id": self._eq(pid), "select": "id"}))}
888
+
889
+ def create_assignment(self, class_id: int, title: str, due_date: date, created_by: str, kind: str, *, note: str = "", pages=(),
890
+ quiz_lesson: str | None = None, test_id: int | None = None, exercise_number: int | None = None,
891
+ created_at: datetime | None = None) -> dict:
892
+ ok = {"pages": bool(pages), "quiz": bool(quiz_lesson), "exercise_test": test_id is not None and exercise_number in (1, 2)}
893
+ if not ok.get(kind) or not 1 <= len(title) <= 200:
894
+ raise ValueError("an assignment needs a title and its content")
895
+ body = {"class_id": class_id, "title": title, "note": note, "due_date": due_date.isoformat(), "kind": kind, "pages": [str(p) for p in pages],
896
+ "quiz_lesson": quiz_lesson, "test_id": test_id, "exercise_number": exercise_number, "created_by": created_by}
897
+ if created_at:
898
+ body["created_at"] = created_at.isoformat()
899
+ return _assignment_row(self._request("POST", "m2_assignments", body=body, returning=True)[0])
900
+
901
+ def get_assignment(self, assignment_id: int) -> dict | None:
902
+ rows = self._request("GET", "m2_assignments", {"id": self._eq(assignment_id), "select": "*"})
903
+ return _assignment_row(rows[0]) if rows else None
904
+
905
+ def assignments_of_classes(self, class_ids, include_removed: bool = False) -> list[dict]:
906
+ ids = sorted(set(class_ids))
907
+ if not ids:
908
+ return []
909
+ params = {"class_id": f"in.({','.join(str(i) for i in ids)})", "select": "*", "order": "due_date,id"}
910
+ if not include_removed:
911
+ params["removed_at"] = "is.null"
912
+ return [_assignment_row(r) for r in self._request("GET", "m2_assignments", params)]
913
+
914
+ def remove_assignment(self, assignment_id: int, at: datetime | None = None) -> bool:
915
+ stamp = (at or datetime.now(timezone.utc)).isoformat()
916
+ return bool(self._request("PATCH", "m2_assignments", {"id": self._eq(assignment_id), "removed_at": "is.null"}, {"removed_at": stamp}, returning=True))
917
+
918
+ def get_submission(self, assignment_id: int, user_id: str) -> dict | None:
919
+ rows = self._request("GET", "m2_submissions", {"assignment_id": self._eq(assignment_id), "user_id": self._eq(user_id), "select": "*"})
920
+ return _submission_row(rows[0]) if rows else None
921
+
922
+ def get_submission_by_id(self, submission_id: int) -> dict | None:
923
+ rows = self._request("GET", "m2_submissions", {"id": self._eq(submission_id), "select": "*"})
924
+ return _submission_row(rows[0]) if rows else None
925
+
926
+ def save_submission(self, assignment_id: int, user_id: str, **fields) -> dict:
927
+ unknown = set(fields) - SUBMISSION_FIELDS
928
+ if unknown:
929
+ raise ValueError(f"unknown submission fields {sorted(unknown)}")
930
+ body = {k: (v.isoformat() if isinstance(v, datetime) else ([str(x) for x in v] if k == "pictures" else v)) for k, v in fields.items()}
931
+ rows = self._request("POST", "m2_submissions", {"on_conflict": "assignment_id,user_id"},
932
+ {"assignment_id": assignment_id, "user_id": user_id, **body}, returning=True, upsert=True)
933
+ return _submission_row(rows[0])
934
+
935
+ def submissions_of_assignments(self, assignment_ids) -> list[dict]:
936
+ ids = sorted(set(assignment_ids))
937
+ if not ids:
938
+ return []
939
+ rows = self._request("GET", "m2_submissions", {"assignment_id": f"in.({','.join(str(i) for i in ids)})", "select": "*", "order": "handed_at,id"})
940
+ return [_submission_row(r) for r in rows]
941
+
942
+ def submissions_of_user(self, user_id: str) -> list[dict]:
943
+ return [_submission_row(r) for r in self._request("GET", "m2_submissions", {"user_id": self._eq(user_id), "select": "*"})]
944
+
945
+ # --- photo requests and push subscriptions ---
946
+
947
+ def create_photo_request(self, user_id: str, assignment_id: int, device: str, code: str, created_at: datetime, expires_at: datetime) -> dict:
948
+ self._request("PATCH", "m2_photo_requests", {"user_id": self._eq(user_id), "assignment_id": self._eq(assignment_id), "state": "eq.waiting"},
949
+ {"state": "superseded"})
950
+ rows = self._request("POST", "m2_photo_requests", body={"user_id": user_id, "assignment_id": assignment_id, "device": device, "code": code,
951
+ "created_at": created_at.isoformat(), "expires_at": expires_at.isoformat()}, returning=True)
952
+ return _stamped(rows[0], "created_at", "expires_at")
953
+
954
+ def get_photo_request(self, request_id: str) -> dict | None:
955
+ try:
956
+ rows = self._request("GET", "m2_photo_requests", {"id": self._eq(request_id), "select": "*"})
957
+ except urllib.error.HTTPError as exc:
958
+ if exc.code == 400:
959
+ return None
960
+ raise
961
+ return _stamped(rows[0], "created_at", "expires_at") if rows else None
962
+
963
+ def save_photo_request(self, request_id: str, **fields) -> dict:
964
+ unknown = set(fields) - REQUEST_FIELDS
965
+ if unknown:
966
+ raise ValueError(f"unknown photo request fields {sorted(unknown)}")
967
+ rows = self._request("PATCH", "m2_photo_requests", {"id": self._eq(request_id)}, fields, returning=True)
968
+ if not rows:
969
+ raise KeyError(request_id)
970
+ return _stamped(rows[0], "created_at", "expires_at")
971
+
972
+ def open_photo_requests(self, user_id: str) -> list[dict]:
973
+ rows = self._request("GET", "m2_photo_requests", {"user_id": self._eq(user_id), "state": "in.(waiting,uploading)", "select": "*", "order": "created_at.desc"})
974
+ return [_stamped(r, "created_at", "expires_at") for r in rows]
975
+
976
+ def add_push_subscription(self, user_id: str, endpoint: str, p256dh: str, auth: str, key_id: str) -> dict:
977
+ rows = self._request("POST", "m2_push_subscriptions", {"on_conflict": "endpoint"},
978
+ {"user_id": user_id, "endpoint": endpoint, "p256dh": p256dh, "auth": auth, "key_id": key_id}, returning=True, upsert=True)
979
+ return _stamped(rows[0], "created_at")
980
+
981
+ def push_subscriptions_of(self, user_id: str) -> list[dict]:
982
+ return [_stamped(r, "created_at") for r in self._request("GET", "m2_push_subscriptions", {"user_id": self._eq(user_id), "select": "*"})]
983
+
984
+ def delete_push_subscription(self, endpoint: str) -> bool:
985
+ return bool(self._request("DELETE", "m2_push_subscriptions", {"endpoint": self._eq(endpoint)}, returning=True))
986
+
987
+ def drop_push_subscriptions_not_of(self, key_id: str) -> int:
988
+ return len(self._request("DELETE", "m2_push_subscriptions", {"key_id": f"neq.{key_id}"}, returning=True) or [])
989
+
990
  # --- timed attempts ---
991
 
992
  def create_attempt(self, user_id: str, kind: str, scope: str, started_at: datetime, due_at: datetime, questions: list[dict]) -> dict:
 
1041
  def reward_state_row(row: dict) -> dict:
1042
  return {**row, **{k: date.fromisoformat(row[k]) if row.get(k) else None for k in ("last_comet_day", "last_catch_day")}}
1043
 
1044
+ # --- the curriculum ---
1045
+
1046
+ def curriculum(self) -> dict[str, date]:
1047
+ return {r["lesson_id"]: date.fromisoformat(str(r["due_date"])[:10]) for r in self._request("GET", "m2_curriculum", {"select": "lesson_id,due_date"})}
1048
+
1049
+ def set_lesson_dates(self, dates: dict[str, date | None]) -> None:
1050
+ cleared = [lesson for lesson, day in dates.items() if day is None]
1051
+ if cleared:
1052
+ self._request("DELETE", "m2_curriculum", {"lesson_id": "in.(" + ",".join(f'"{x}"' for x in cleared) + ")"})
1053
+ rows = [{"lesson_id": lesson, "due_date": day.isoformat()} for lesson, day in dates.items() if day is not None]
1054
+ if rows:
1055
+ self._request("POST", "m2_curriculum", {"on_conflict": "lesson_id"}, rows, upsert=True)
1056
+
1057
  def last_answered(self, user_id: str) -> dict[str, date]:
1058
  rows = self._request("GET", "m2_last_answered", {"user_id": self._eq(user_id), "select": "skill_id,day"})
1059
  return {r["skill_id"]: date.fromisoformat(r["day"]) for r in rows}
mathly2/supabase/migrations/20261112090000_m2_written_work.sql ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Mathly 2: written work. Additive (new objects only). Read and written only by the server with the secret key: row-level security is on,
2
+ -- there are no policies, and the browser roles get no grants (as in m2_classes).
3
+ -- Pictures are not stored here: m2_pictures holds their rows, the bytes are in the private Storage bucket (MATHLY_M2_ANSWER_BUCKET, default m2-answers),
4
+ -- which the server creates on first use.
5
+ -- Rollback: drop table m2_push_subscriptions, m2_photo_requests, m2_submissions, m2_assignments, m2_pictures;
6
+ begin;
7
+
8
+ -- A picture: a page of an assignment (kind 'page') or a student's answer ('answer'). `deleted_at` is set when its bytes are removed.
9
+ create table public.m2_pictures (
10
+ id uuid primary key default gen_random_uuid(),
11
+ owner_id uuid not null,
12
+ kind text not null check (kind in ('page', 'answer')),
13
+ path text not null unique,
14
+ content_type text not null,
15
+ size integer not null check (size between 1 and 10000000),
16
+ created_at timestamptz not null default now(),
17
+ deleted_at timestamptz
18
+ );
19
+ create index m2_pictures_owner on public.m2_pictures (owner_id);
20
+
21
+ -- Work a teacher gives a class: pages (pictures), one of the bank's written quizzes, or an exercise test of a real test.
22
+ create table public.m2_assignments (
23
+ id bigint generated always as identity primary key,
24
+ class_id bigint not null references public.m2_classes(id) on delete cascade,
25
+ title text not null check (char_length(title) between 1 and 200),
26
+ note text not null default '',
27
+ due_date date not null,
28
+ kind text not null check (kind in ('pages', 'quiz', 'exercise_test')),
29
+ pages uuid[] not null default '{}',
30
+ quiz_lesson text,
31
+ test_id bigint references public.m2_tests(id) on delete cascade,
32
+ exercise_number integer check (exercise_number in (1, 2)),
33
+ created_by text not null,
34
+ created_at timestamptz not null default now(),
35
+ removed_at timestamptz,
36
+ check (case kind when 'pages' then cardinality(pages) > 0
37
+ when 'quiz' then quiz_lesson is not null
38
+ else test_id is not null and exercise_number is not null end)
39
+ );
40
+ create index m2_assignments_class on public.m2_assignments (class_id) where removed_at is null;
41
+
42
+ -- What a student handed in: one row per student and assignment, rewritten when they hand in again after a return.
43
+ create table public.m2_submissions (
44
+ id bigint generated always as identity primary key,
45
+ assignment_id bigint not null references public.m2_assignments(id) on delete cascade,
46
+ user_id uuid not null,
47
+ pictures uuid[] not null default '{}',
48
+ state text not null default 'handed_in' check (state in ('handed_in', 'graded', 'returned')),
49
+ late boolean not null default false,
50
+ handed_at timestamptz not null default now(),
51
+ grade integer check (grade between 0 and 100),
52
+ teacher_note text not null default '',
53
+ checked_at timestamptz,
54
+ checked_by text,
55
+ unique (assignment_id, user_id),
56
+ check (state <> 'graded' or grade is not null)
57
+ );
58
+
59
+ -- A computer asking the student's phone for a photo of one answer.
60
+ create table public.m2_photo_requests (
61
+ id uuid primary key default gen_random_uuid(),
62
+ user_id uuid not null,
63
+ assignment_id bigint not null references public.m2_assignments(id) on delete cascade,
64
+ device text not null,
65
+ code text not null check (code ~ '^[0-9]{4}$'),
66
+ state text not null default 'waiting' check (state in ('waiting', 'uploading', 'received', 'failed', 'expired', 'superseded')),
67
+ created_at timestamptz not null default now(),
68
+ expires_at timestamptz not null,
69
+ picture_id uuid references public.m2_pictures(id) on delete set null,
70
+ check (expires_at > created_at)
71
+ );
72
+ create index m2_photo_requests_user on public.m2_photo_requests (user_id, created_at);
73
+
74
+ -- A phone that turned notifications on. `key_id` is a fingerprint of the server's push key: a subscription made under another key is dropped.
75
+ create table public.m2_push_subscriptions (
76
+ id bigint generated always as identity primary key,
77
+ user_id uuid not null,
78
+ endpoint text not null unique,
79
+ p256dh text not null,
80
+ auth text not null,
81
+ key_id text not null,
82
+ created_at timestamptz not null default now()
83
+ );
84
+ create index m2_push_subscriptions_user on public.m2_push_subscriptions (user_id);
85
+
86
+ alter table public.m2_pictures enable row level security;
87
+ alter table public.m2_assignments enable row level security;
88
+ alter table public.m2_submissions enable row level security;
89
+ alter table public.m2_photo_requests enable row level security;
90
+ alter table public.m2_push_subscriptions enable row level security;
91
+ revoke all on public.m2_pictures, public.m2_assignments, public.m2_submissions, public.m2_photo_requests, public.m2_push_subscriptions from public, anon, authenticated;
92
+ grant all on public.m2_pictures, public.m2_assignments, public.m2_submissions, public.m2_photo_requests, public.m2_push_subscriptions to service_role;
93
+ grant usage, select on sequence public.m2_assignments_id_seq, public.m2_submissions_id_seq, public.m2_push_subscriptions_id_seq to service_role;
94
+ commit;
mathly2/supabase/migrations/20261113090000_m2_curriculum.sql ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Mathly 2: the curriculum. One date per lesson for the whole school: the day by which the lesson should be completed.
2
+ -- A lesson without a row has no date. Lesson ids are the exercise bank's ids (text), as in m2_classes.lessons.
3
+ -- Additive (one new table). Rollback: drop table public.m2_curriculum;
4
+ begin;
5
+ create table public.m2_curriculum (
6
+ lesson_id text primary key,
7
+ due_date date not null
8
+ );
9
+ alter table public.m2_curriculum enable row level security;
10
+ revoke all on public.m2_curriculum from public, anon, authenticated;
11
+ grant all on public.m2_curriculum to service_role;
12
+ commit;
mathly2/tests/serve.py CHANGED
@@ -32,3 +32,10 @@ def offline_formulas(page):
32
  """For tests that show no formulas: do not wait for the formula library from the network (a slow CDN made them time out)."""
33
  page.route("**/mathjax@*/**", lambda route: route.abort())
34
  return page
 
 
 
 
 
 
 
 
32
  """For tests that show no formulas: do not wait for the formula library from the network (a slow CDN made them time out)."""
33
  page.route("**/mathjax@*/**", lambda route: route.abort())
34
  return page
35
+
36
+
37
+ def only(bank, lessons):
38
+ """A bank with just these lessons (and their skills), for tests that want a short path: every class now studies the whole bank."""
39
+ from mathly2.bank import Bank
40
+ keep = set(lessons)
41
+ return Bank({**bank.data, "units": [u for u in bank.lessons if u["id"] in keep], "skills": [s for s in bank.skills if s["unit"] in keep]})
mathly2/tests/test_assignments_api.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assignments through the API: the teacher's side (add-written-work 2.2); the student's and the inbox are below."""
2
+ import io
3
+ import uuid
4
+ from datetime import date, datetime, timedelta, timezone
5
+
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+ from PIL import Image
9
+
10
+ from mathly2 import clock
11
+ from mathly2.bank import load_bank
12
+ from mathly2.config import Settings
13
+ from mathly2.main import create_app
14
+ from mathly2.store import InMemoryStore
15
+
16
+ TEACHER, OTHER_TEACHER, KID, KID2, OUTSIDER, MGR, DEV = ("t@school.test", "t2@school.test", "kid@school.test", "kid2@school.test", "out@school.test",
17
+ "mgr@school.test", "dev@school.test")
18
+ MON = datetime(2026, 11, 9, 9, 0, tzinfo=timezone.utc)
19
+ DAY = timedelta(days=1)
20
+
21
+
22
+ def uid(email):
23
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, email))
24
+
25
+
26
+ def auth(email):
27
+ return {"Authorization": f"Bearer dev:{email}"}
28
+
29
+
30
+ def image(fmt="JPEG", color="red"):
31
+ out = io.BytesIO()
32
+ Image.new("RGB", (80, 50), color).save(out, fmt)
33
+ return out.getvalue()
34
+
35
+
36
+ def make_pdf(pages):
37
+ import pypdfium2 as pdfium
38
+ doc = pdfium.PdfDocument.new()
39
+ for _ in range(pages):
40
+ doc.new_page(200, 300)
41
+ out = io.BytesIO()
42
+ doc.save(out)
43
+ return out.getvalue()
44
+
45
+
46
+ @pytest.fixture(scope="module")
47
+ def bank():
48
+ return load_bank()
49
+
50
+
51
+ @pytest.fixture
52
+ def world(bank, monkeypatch):
53
+ """Class A (teacher T; students KID, KID2) studies a lesson with an authored written quiz and one without; class B (teacher T2; OUTSIDER)."""
54
+ with_quiz = [u["id"] for u in bank.data["units"] if u.get("quiz")]
55
+ without = next(u["id"] for u in bank.data["units"] if not u.get("quiz"))
56
+ store = InMemoryStore()
57
+ store.give_role(MGR, "manager", "setup")
58
+ a = store.create_class("setup", "A", lessons=[with_quiz[0], without], teacher_email=TEACHER)
59
+ b = store.create_class("setup", "B", lessons=[with_quiz[1]], teacher_email=OTHER_TEACHER)
60
+ for email in (KID, KID2):
61
+ store.add_member(a["id"], email)
62
+ store.link_user(email, uid(email))
63
+ store.add_member(b["id"], OUTSIDER)
64
+ client = TestClient(create_app(Settings(developers=frozenset({DEV})), bank=bank, store=store))
65
+ moment = {"now": MON}
66
+ monkeypatch.setattr(clock, "now", lambda: moment["now"])
67
+ client.set_time = lambda value: moment.update(now=value)
68
+ client.store, client.a, client.b, client.quiz_lesson, client.plain_lesson, client.other_quiz = store, a, b, with_quiz[0], without, with_quiz[1]
69
+ return client
70
+
71
+
72
+ def upload_pages(client, data, kind="image/jpeg", who=TEACHER):
73
+ return client.post("/api/assignments/pages", content=data, headers={**auth(who), "Content-Type": kind})
74
+
75
+
76
+ def give(client, who=TEACHER, class_id=None, **body):
77
+ due = (clock.now() + 7 * DAY).date().isoformat()
78
+ return client.post(f"/api/classes/{class_id or client.a['id']}/assignments", json={"title": "משימה", "due_date": due, **body}, headers=auth(who))
79
+
80
+
81
+ # --- 2.2 giving: the five sources ---
82
+
83
+ def test_from_an_uploaded_image_the_board_or_a_photo_the_pages_become_the_assignments_pages(world):
84
+ first = upload_pages(world, image("JPEG")).json()["pictures"]
85
+ second = upload_pages(world, image("PNG", "blue"), "image/png").json()["pictures"] # what the board sends: a PNG page
86
+ assert len(first) == 1 and len(second) == 1
87
+ got = give(world, kind="pages", pages=first + second, note="לפתור עד יום ד׳")
88
+ assert got.status_code == 201
89
+ body = got.json()
90
+ assert body["kind"] == "pages" and body["pages"] == first + second and body["note"] == "לפתור עד יום ד׳" and body["days_left"] == 7
91
+ assert body["students"] == 2 and body["handed_in"] == 0 and body["graded"] == 0 and body["class"]["name"] == "A"
92
+ assert all(world.store.get_picture(p)["kind"] == "page" and world.store.get_picture(p)["owner_id"] == uid(TEACHER) for p in first + second)
93
+
94
+
95
+ def test_a_pdf_becomes_one_page_picture_per_sheet(world):
96
+ ids = upload_pages(world, make_pdf(3), "application/pdf").json()["pictures"]
97
+ assert len(ids) == 3 and len(set(ids)) == 3
98
+ assert upload_pages(world, b"hello", "application/pdf").status_code == 415
99
+ assert upload_pages(world, b"nonsense", "image/jpeg").status_code == 400
100
+ assert give(world, kind="pages", pages=ids).json()["pages"] == ids
101
+
102
+
103
+ def test_from_the_banks_written_quiz_only_for_a_class_lesson_that_has_one(world, bank):
104
+ got = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="בוחן כתוב")
105
+ assert got.status_code == 201 and got.json()["quiz_lesson"]["id"] == world.quiz_lesson and got.json()["quiz_lesson"]["name"]
106
+ assert give(world, kind="quiz", quiz_lesson=world.plain_lesson).status_code == 422 # a class lesson with no written quiz
107
+ assert give(world, kind="quiz", quiz_lesson=world.other_quiz).status_code == 422 # a quiz of a lesson the class does not study
108
+ assert give(world, kind="quiz").status_code == 422
109
+
110
+
111
+ def test_an_exercise_test_of_a_test_of_the_class_can_be_sent_as_an_assignment(world):
112
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
113
+ other = world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
114
+ got = give(world, kind="exercise_test", test_id=test["id"], exercise_number=2)
115
+ assert got.status_code == 201 and got.json()["test"] == {"id": test["id"], "number": 2, "date": test["date"].isoformat()} and got.json()["done"] == 0
116
+ assert give(world, kind="exercise_test", test_id=other["id"], exercise_number=1).status_code == 422 # another class's test
117
+ assert give(world, kind="exercise_test", test_id=test["id"], exercise_number=3).status_code == 422
118
+ assert give(world, kind="exercise_test", test_id=999, exercise_number=1).status_code == 422
119
+ world.store.remove_test(test["id"])
120
+ assert give(world, kind="exercise_test", test_id=test["id"], exercise_number=1).status_code == 422
121
+
122
+
123
+ def test_giving_needs_a_class_the_teacher_teaches_a_future_date_and_pages_that_are_their_own(world):
124
+ assert give(world, who=OTHER_TEACHER, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 404
125
+ assert give(world, who=KID, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 403
126
+ assert give(world, who=MGR, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 403
127
+ past = world.post(f"/api/classes/{world.a['id']}/assignments", json={"title": "x", "due_date": (clock.now() - DAY).date().isoformat(), "kind": "quiz",
128
+ "quiz_lesson": world.quiz_lesson}, headers=auth(TEACHER))
129
+ assert past.status_code == 422
130
+ assert give(world, kind="pages", pages=[]).status_code == 422
131
+ mine = upload_pages(world, image()).json()["pictures"]
132
+ theirs = upload_pages(world, image(), who=OTHER_TEACHER).json()["pictures"]
133
+ assert give(world, kind="pages", pages=theirs).status_code == 422 # a page someone else uploaded
134
+ answer = world.post("/api/pictures", content=image(), headers={**auth(KID), "Content-Type": "image/jpeg"}).json()["id"]
135
+ assert give(world, kind="pages", pages=[answer]).status_code == 422 # an answer picture is not a page
136
+ assert give(world, kind="pages", pages=[str(uuid.uuid4())]).status_code == 422
137
+ assert give(world, kind="pages", pages=mine, title="").status_code == 422
138
+ assert give(world, who=DEV, kind="quiz", quiz_lesson=world.quiz_lesson).status_code == 201 # a developer may give to any class
139
+
140
+
141
+ def test_the_list_has_counts_and_a_developer_sees_every_class(world):
142
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
143
+ world.store.save_submission(quiz["id"], uid(KID), pictures=["p1"])
144
+ world.store.save_submission(quiz["id"], uid(KID2), pictures=["p2"], state="graded", grade=80)
145
+ listed = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()
146
+ assert [c["class"]["name"] for c in listed] == ["A"]
147
+ one = listed[0]["assignments"][0]
148
+ assert (one["handed_in"], one["graded"], one["returned"], one["waiting"], one["students"]) == (1, 1, 0, 1, 2)
149
+ assert listed[0]["class"]["lessons"]
150
+ assert [c["class"]["name"] for c in world.get("/api/teaching/assignments", headers=auth(DEV)).json()] == ["A", "B"]
151
+ assert world.get("/api/teaching/assignments", headers=auth(OTHER_TEACHER)).json()[0]["assignments"] == []
152
+
153
+
154
+ def test_removing_an_assignment_hides_it_and_keeps_the_grades_already_given(world):
155
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
156
+ world.store.save_submission(quiz["id"], uid(KID), pictures=["p1"], state="graded", grade=90, teacher_note="יפה")
157
+ assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(OTHER_TEACHER)).status_code == 404
158
+ assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(KID)).status_code == 403
159
+ assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER)).status_code == 204
160
+ assert world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["assignments"] == []
161
+ assert world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER)).status_code == 404
162
+ kept = world.store.get_submission(quiz["id"], uid(KID))
163
+ assert (kept["grade"], kept["teacher_note"]) == (90, "יפה") and world.store.get_assignment(quiz["id"])["removed_at"] is not None
164
+
165
+
166
+ # --- 2.3 the student's side ---
167
+
168
+ def answer_picture(client, who=KID):
169
+ got = client.post("/api/pictures", content=image(), headers={**auth(who), "Content-Type": "image/jpeg"})
170
+ assert got.status_code == 201, got.text
171
+ return got.json()["id"]
172
+
173
+
174
+ def hand(client, assignment_id, pictures, who=KID):
175
+ return client.post(f"/api/assignments/{assignment_id}/submission", json={"pictures": pictures}, headers=auth(who))
176
+
177
+
178
+ def mine(client, who=KID):
179
+ return client.get("/api/assignments", headers=auth(who)).json()
180
+
181
+
182
+ def test_a_student_sees_only_their_classes_assignments_with_state_and_due_date(world):
183
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="בוחן כתוב").json()
184
+ listed = mine(world)
185
+ assert [x["id"] for x in listed] == [a["id"]] and listed[0]["state"] == "not_handed" and listed[0]["days_left"] == 7 and listed[0]["class"]["name"] == "A"
186
+ assert mine(world, KID2)[0]["id"] == a["id"] and mine(world, OUTSIDER) == [] # another class sees nothing
187
+ assert world.get(f"/api/assignments/{a['id']}", headers=auth(OUTSIDER)).status_code == 404
188
+
189
+
190
+ def test_a_written_quiz_shows_its_questions_with_points_and_a_pages_assignment_its_pages(world, bank):
191
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
192
+ detail = world.get(f"/api/assignments/{quiz['id']}", headers=auth(KID)).json()
193
+ questions = detail["quiz"]["questions"]
194
+ assert len(questions) == 4 and sum(q["points"] for q in questions) == 100 == detail["quiz"]["points"] and detail["quiz"]["minutes"] == 45
195
+ assert all(q["question"] for q in questions)
196
+ pages = upload_pages(world, image()).json()["pictures"]
197
+ sheet = give(world, kind="pages", pages=pages).json()
198
+ assert world.get(f"/api/assignments/{sheet['id']}", headers=auth(KID)).json()["pages"] == pages
199
+ assert world.get(f"/api/pictures/{pages[0]}", headers=auth(KID)).status_code == 200 # the class's students read the pages
200
+
201
+
202
+ def test_handing_in_one_to_ten_of_the_students_own_answer_pictures(world):
203
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
204
+ pics = [answer_picture(world) for _ in range(3)]
205
+ got = hand(world, a["id"], pics)
206
+ assert got.status_code == 200 and got.json()["state"] == "handed_in" and got.json()["late"] is False
207
+ detail = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
208
+ assert [p["id"] for p in detail["pictures"]] == pics and detail["state"] == "handed_in"
209
+ assert hand(world, a["id"], []).status_code == 422 and hand(world, a["id"], [answer_picture(world) for _ in range(11)]).status_code == 422
210
+ assert hand(world, a["id"], [answer_picture(world, KID2)]).status_code == 422 # someone else's picture
211
+ page = upload_pages(world, image()).json()["pictures"][0]
212
+ assert hand(world, a["id"], [page]).status_code == 422 # a page is not an answer
213
+ assert hand(world, a["id"], [str(uuid.uuid4())]).status_code == 422
214
+ gone = answer_picture(world)
215
+ world.store.mark_picture_deleted(gone)
216
+ assert hand(world, a["id"], [gone]).status_code == 422
217
+ assert world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()["pictures"] == detail["pictures"] # refused tries changed nothing
218
+ assert hand(world, a["id"], pics, who=OUTSIDER).status_code == 404
219
+
220
+
221
+ def test_a_handed_in_submission_can_be_replaced_a_returned_one_handed_in_again_and_a_graded_one_not_changed(world):
222
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
223
+ first = answer_picture(world)
224
+ hand(world, a["id"], [first])
225
+ second = answer_picture(world)
226
+ assert hand(world, a["id"], [second]).json()["state"] == "handed_in" # replaced while handed in
227
+ assert [p["id"] for p in world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()["pictures"]] == [second]
228
+ sub = world.store.get_submission(a["id"], uid(KID))
229
+ world.store.save_submission(a["id"], uid(KID), state="returned", teacher_note="חסר נימוק בסעיף 2", checked_by=TEACHER)
230
+ shown = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
231
+ assert shown["state"] == "returned" and shown["teacher_note"] == "חסר נימוק בסעיף 2" and mine(world)[0]["state"] == "returned"
232
+ third = answer_picture(world)
233
+ assert hand(world, a["id"], [third]).json()["state"] == "handed_in" # handed in again
234
+ world.store.save_submission(a["id"], uid(KID), state="graded", grade=88, teacher_note="יפה")
235
+ assert hand(world, a["id"], [answer_picture(world)]).status_code == 409 # graded: no more changes
236
+ done = world.get(f"/api/assignments/{a['id']}", headers=auth(KID)).json()
237
+ assert (done["state"], done["grade"], done["teacher_note"]) == ("graded", 88, "יפה") and sub["id"] == world.store.get_submission(a["id"], uid(KID))["id"]
238
+
239
+
240
+ def test_a_late_hand_in_is_accepted_and_marked_late(world):
241
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
242
+ world.set_time(MON + 9 * DAY) # two days after the due date
243
+ assert mine(world)[0]["overdue"] is True and mine(world)[0]["days_left"] == -2
244
+ got = hand(world, a["id"], [answer_picture(world)])
245
+ assert got.status_code == 200 and got.json()["late"] is True and got.json()["overdue"] is False
246
+
247
+
248
+ def test_an_exercise_test_assignment_is_done_after_the_student_took_it_with_the_best_grade_and_cannot_be_handed_in(world):
249
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
250
+ a = give(world, kind="exercise_test", test_id=test["id"], exercise_number=1).json()
251
+ assert mine(world)[0]["state"] == "not_done" and mine(world)[0]["test"] == {"id": test["id"], "number": 1}
252
+ assert hand(world, a["id"], [answer_picture(world)]).status_code == 422
253
+ for grade in (60, 85):
254
+ row = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:1", MON, MON + timedelta(minutes=45), [])
255
+ world.store.save_attempt(row["id"], submitted_at=MON + timedelta(minutes=5), grade=grade)
256
+ other = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:2", MON, MON + timedelta(minutes=45), [])
257
+ world.store.save_attempt(other["id"], submitted_at=MON, grade=99) # a different exercise test
258
+ done = mine(world)[0]
259
+ assert done["state"] == "done" and done["grade"] == 85
260
+ assert mine(world, KID2)[0]["state"] == "not_done"
261
+ teacher_view = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["assignments"][0]
262
+ assert teacher_view["done"] == 1 and "waiting" not in teacher_view # nothing waits in an inbox for it
263
+
264
+
265
+ def test_open_assignments_come_first_by_due_date_and_removed_ones_are_gone(world):
266
+ far = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="רחוק").json()
267
+ near = world.post(f"/api/classes/{world.a['id']}/assignments", json={"title": "קרוב", "due_date": (clock.now() + 2 * DAY).date().isoformat(), "kind": "quiz",
268
+ "quiz_lesson": world.quiz_lesson}, headers=auth(TEACHER)).json()
269
+ done = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="הוגש").json()
270
+ hand(world, done["id"], [answer_picture(world)])
271
+ world.store.save_submission(done["id"], uid(KID), state="graded", grade=70)
272
+ assert [x["title"] for x in mine(world)] == ["קרוב", "רחוק", "הוגש"]
273
+ world.delete(f"/api/assignments/{near['id']}", headers=auth(TEACHER))
274
+ assert [x["title"] for x in mine(world)] == ["רחוק", "הוגש"] and world.get(f"/api/assignments/{near['id']}", headers=auth(KID)).status_code == 404
275
+ assert far["id"] != done["id"]
276
+
277
+
278
+ # --- 2.4 the inbox and checking ---
279
+
280
+ def inbox(client, who=TEACHER):
281
+ return client.get("/api/teaching/inbox", headers=auth(who)).json()
282
+
283
+
284
+ def submit(client, assignment, who, day_offset=0):
285
+ """A student hands in at a given time; returns the submission row."""
286
+ client.set_time(MON + day_offset * DAY)
287
+ got = hand(client, assignment, [answer_picture(client, who)], who)
288
+ assert got.status_code == 200
289
+ return client.store.get_submission(assignment, uid(who))
290
+
291
+
292
+ def test_the_inbox_lists_what_waits_oldest_first_and_a_returned_one_is_not_in_it(world):
293
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
294
+ tuesday = submit(world, quiz["id"], KID2, 1) # KID2 hands in on Tuesday
295
+ monday = submit(world, quiz["id"], KID, 0) # KID's was Monday: first in the inbox even though made second
296
+ items = inbox(world)
297
+ assert [i["id"] for i in items] == [monday["id"], tuesday["id"]] and [i["student"] for i in items] == [KID, KID2]
298
+ assert items[0]["kind"] == "submission" and items[0]["assignment"]["title"] == "משימה" and items[0]["assignment"]["class"]["name"] == "A" and items[0]["pictures"]
299
+ world.store.save_submission(quiz["id"], uid(KID), state="returned", teacher_note="תקנו")
300
+ assert [i["id"] for i in inbox(world)] == [tuesday["id"]]
301
+ assert inbox(world, OTHER_TEACHER) == [] and [i["id"] for i in inbox(world, DEV)] == [tuesday["id"]]
302
+
303
+
304
+ def test_the_teacher_opens_a_submission_and_grades_it_and_the_student_sees_grade_and_note(world):
305
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
306
+ sub = submit(world, quiz["id"], KID)
307
+ opened = world.get(f"/api/submissions/{sub['id']}", headers=auth(TEACHER)).json()
308
+ assert opened["state"] == "handed_in" and len(opened["quiz"]["questions"]) == 4 and opened["pictures"] == [{"id": sub["pictures"][0], "deleted": False}]
309
+ assert world.get(f"/api/pictures/{sub['pictures'][0]}", headers=auth(TEACHER)).status_code == 200 # the teacher reads the answer
310
+ graded = world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 85, "note": "נימוק יפה", "seen": opened["seen"]}, headers=auth(TEACHER))
311
+ assert graded.status_code == 200 and graded.json() == {"id": sub["id"], "state": "graded", "grade": 85}
312
+ assert inbox(world) == []
313
+ mine_now = mine(world)[0]
314
+ assert (mine_now["state"], mine_now["grade"], mine_now["teacher_note"]) == ("graded", 85, "נימוק יפה")
315
+ row = world.store.get_submission(quiz["id"], uid(KID))
316
+ assert row["checked_by"] == TEACHER and row["checked_at"] is not None
317
+
318
+
319
+ def test_a_grade_is_a_whole_number_from_0_to_100(world):
320
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
321
+ sub = submit(world, quiz["id"], KID)
322
+ seen = sub["handed_at"].isoformat()
323
+ for bad in (101, -1, 85.5, "גבוה"):
324
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": bad, "seen": seen}, headers=auth(TEACHER)).status_code == 422
325
+ assert world.store.get_submission(quiz["id"], uid(KID))["state"] == "handed_in"
326
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 0, "seen": seen}, headers=auth(TEACHER)).status_code == 200 # the edges
327
+ other = submit(world, quiz["id"], KID2)
328
+ assert world.post(f"/api/submissions/{other['id']}/grade", json={"grade": 100, "seen": other["handed_at"].isoformat()}, headers=auth(TEACHER)).status_code == 200
329
+
330
+
331
+ def test_sending_back_needs_a_note_and_the_student_then_hands_in_again(world):
332
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
333
+ sub = submit(world, quiz["id"], KID)
334
+ seen = sub["handed_at"].isoformat()
335
+ assert world.post(f"/api/submissions/{sub['id']}/return", json={"note": "", "seen": seen}, headers=auth(TEACHER)).status_code == 422
336
+ assert world.post(f"/api/submissions/{sub['id']}/return", json={"note": " ", "seen": seen}, headers=auth(TEACHER)).status_code == 422
337
+ sent = world.post(f"/api/submissions/{sub['id']}/return", json={"note": "חסר נימוק בסעיף 2", "seen": seen}, headers=auth(TEACHER))
338
+ assert sent.status_code == 200 and sent.json()["state"] == "returned" and inbox(world) == []
339
+ shown = mine(world)[0]
340
+ assert shown["state"] == "returned" and shown["teacher_note"] == "חסר נימוק בסעיף 2"
341
+ submit(world, quiz["id"], KID, 1) # handed in again: back in the inbox
342
+ assert [i["id"] for i in inbox(world)] == [sub["id"]]
343
+
344
+
345
+ def test_a_stale_view_a_second_check_and_another_classs_teacher_are_refused(world):
346
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
347
+ sub = submit(world, quiz["id"], KID)
348
+ seen = sub["handed_at"].isoformat()
349
+ submit(world, quiz["id"], KID, 1) # the student replaced it after the teacher opened it
350
+ stale = world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": seen}, headers=auth(TEACHER))
351
+ assert stale.status_code == 409 and "השתנתה" in stale.json()["detail"]
352
+ fresh = world.store.get_submission(quiz["id"], uid(KID))["handed_at"].isoformat()
353
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(OTHER_TEACHER)).status_code == 404
354
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(KID)).status_code == 403
355
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": "not a time"}, headers=auth(TEACHER)).status_code == 422
356
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 70, "seen": fresh}, headers=auth(TEACHER)).status_code == 200
357
+ assert world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 90, "seen": fresh}, headers=auth(TEACHER)).status_code == 409 # already checked
358
+ assert world.get("/api/submissions/999", headers=auth(TEACHER)).status_code == 404
359
+
360
+
361
+ def test_exercise_test_assignments_never_reach_the_inbox_and_removed_assignments_leave_it(world):
362
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
363
+ give(world, kind="exercise_test", test_id=test["id"], exercise_number=1)
364
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
365
+ sub = submit(world, quiz["id"], KID)
366
+ assert [i["id"] for i in inbox(world)] == [sub["id"]]
367
+ world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))
368
+ assert inbox(world) == []
369
+
370
+
371
+ def test_the_grades_list_has_graded_assignments_and_done_exercise_tests_and_a_removed_one_keeps_its_grade(world):
372
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="בוחן כתוב").json()
373
+ waiting = give(world, kind="quiz", quiz_lesson=world.quiz_lesson, title="עוד לא נבדק").json()
374
+ sub = submit(world, quiz["id"], KID)
375
+ submit(world, waiting["id"], KID)
376
+ assert world.get("/api/grades", headers=auth(KID)).json()["assignments"] == [] # nothing graded yet
377
+ world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 92, "note": "יפה מאוד", "seen": sub["handed_at"].isoformat()}, headers=auth(TEACHER))
378
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
379
+ exam = give(world, kind="exercise_test", test_id=test["id"], exercise_number=1, title="תרגול").json()
380
+ row = world.store.create_attempt(uid(KID), "exercise_test", f"{test['id']}:1", MON, MON + timedelta(minutes=45), [])
381
+ world.store.save_attempt(row["id"], submitted_at=MON, grade=77)
382
+ got = world.get("/api/grades", headers=auth(KID)).json()["assignments"]
383
+ assert {(g["assignment"]["title"], g["grade"], g["note"]) for g in got} == {("בוחן כתוב", 92, "יפה מאוד"), ("תרגול", 77, "")}
384
+ world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))
385
+ kept = [g for g in world.get("/api/grades", headers=auth(KID)).json()["assignments"] if g["assignment"]["title"] == "בוחן כתוב"]
386
+ assert kept[0]["grade"] == 92 and kept[0]["assignment"]["removed"] is True and exam["id"]
387
+ assert world.get("/api/grades", headers=auth(KID2)).json()["assignments"] == []
388
+
389
+
390
+ # --- 2.7 deleting a student's pictures on request, and the hourly cleanup ---
391
+
392
+ def handed_in_with_pictures(world, who=KID):
393
+ quiz = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
394
+ pics = [answer_picture(world, who), answer_picture(world, who)]
395
+ hand(world, quiz["id"], pics, who)
396
+ world.store.save_submission(quiz["id"], uid(who), state="graded", grade=88, teacher_note="יפה")
397
+ return quiz, pics
398
+
399
+
400
+ def delete_pictures(world, who, email=KID, class_id=None):
401
+ return world.delete(f"/api/classes/{class_id or world.a['id']}/pictures/{email}", headers=auth(who))
402
+
403
+
404
+ def test_the_classs_teacher_a_manager_and_a_developer_can_delete_a_students_pictures_and_the_grade_stays(world):
405
+ for who in (TEACHER, MGR, DEV):
406
+ quiz, pics = handed_in_with_pictures(world)
407
+ got = delete_pictures(world, who)
408
+ assert got.status_code == 200 and got.json() == {"deleted": 2}, who
409
+ assert all(world.store.get_picture(p)["deleted_at"] for p in pics)
410
+ assert world.get(f"/api/pictures/{pics[0]}", headers=auth(KID)).status_code == 410 # gone for the student too
411
+ sub = world.store.get_submission(quiz["id"], uid(KID))
412
+ assert (sub["state"], sub["grade"], sub["teacher_note"]) == ("graded", 88, "יפה") # the grade and note stay
413
+ shown = world.get(f"/api/assignments/{quiz['id']}", headers=auth(KID)).json()
414
+ assert shown["grade"] == 88 and all(p["deleted"] for p in shown["pictures"])
415
+ world.delete(f"/api/assignments/{quiz['id']}", headers=auth(TEACHER))
416
+
417
+
418
+ def test_another_classs_teacher_a_student_and_a_missing_student_cannot(world):
419
+ handed_in_with_pictures(world)
420
+ assert delete_pictures(world, OTHER_TEACHER).status_code == 404
421
+ assert delete_pictures(world, KID2).status_code == 403
422
+ assert delete_pictures(world, KID).status_code == 403
423
+ assert delete_pictures(world, TEACHER, email="nobody@school.test").status_code == 404
424
+ assert delete_pictures(world, TEACHER, class_id=999).status_code == 404
425
+ assert len(world.store.pictures()) >= 2 and all(not p["deleted_at"] for p in world.store.pictures())
426
+
427
+
428
+ def test_only_that_students_pictures_for_that_class_go(world):
429
+ quiz, mine_pics = handed_in_with_pictures(world, KID)
430
+ other_quiz, theirs = handed_in_with_pictures(world, KID2)
431
+ assert delete_pictures(world, TEACHER, KID).json() == {"deleted": 2}
432
+ assert all(not world.store.get_picture(p)["deleted_at"] for p in theirs) and other_quiz["id"] != quiz["id"]
433
+ assert delete_pictures(world, TEACHER, KID).json() == {"deleted": 0} # twice is fine
434
+ never = world.store.add_member(world.a["id"], "newkid@school.test")
435
+ assert delete_pictures(world, TEACHER, "newkid@school.test").json() == {"deleted": 0} and never is None # never signed in: nothing handed in
436
+
437
+
438
+ def test_the_hourly_cleanup_starts_with_the_real_database_and_not_without(world, monkeypatch):
439
+ from mathly2 import cleanup
440
+ started = []
441
+
442
+ def fake_start(store, backend, every=3600.0):
443
+ import threading
444
+ started.append((store, backend))
445
+ return threading.Event()
446
+ monkeypatch.setattr(cleanup, "start_hourly", fake_start)
447
+ with TestClient(world.app): # no Supabase: nothing starts
448
+ pass
449
+ assert started == []
450
+ live = create_app(Settings(supabase_url="https://p.test", supabase_key="pub", supabase_secret="sec"), store=InMemoryStore(), authenticator=object())
451
+ with TestClient(live):
452
+ pass
453
+ assert len(started) == 1 and started[0][0] is live.state.store and started[0][1] is live.state.pictures
454
+
455
+
456
+ def test_the_teachers_list_says_which_lessons_have_a_written_quiz_and_lists_the_classs_tests(world):
457
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
458
+ got = world.get("/api/teaching/assignments", headers=auth(TEACHER)).json()[0]["class"]
459
+ assert {l["id"]: l["has_quiz"] for l in got["lessons"]} == {world.quiz_lesson: True, world.plain_lesson: False}
460
+ assert got["tests"] == [{"id": test["id"], "date": test["date"].isoformat(), "lessons": got["tests"][0]["lessons"]}] and got["tests"][0]["lessons"]
461
+
462
+
463
+ # --- work-tabs-and-hints 2.1: the numbers on the teacher's tabs ---
464
+
465
+ def counts(client, who=TEACHER):
466
+ return client.get("/api/teaching/counts", headers=auth(who)).json()
467
+
468
+
469
+ def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
470
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0}
471
+ for _ in range(3):
472
+ world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
473
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
474
+ b = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
475
+ give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
476
+ exam = world.store.create_test(world.a["id"], clock.school_day() + 5 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
477
+ give(world, kind="exercise_test", test_id=exam["id"], exercise_number=1)
478
+ submit(world, a["id"], KID)
479
+ submit(world, a["id"], KID2)
480
+ submit(world, b["id"], KID)
481
+ assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3}
482
+
483
+
484
+ def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
485
+ a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
486
+ test = world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
487
+ sub = submit(world, a["id"], KID)
488
+ assert counts(world)["inbox"] == 1
489
+ world.post(f"/api/submissions/{sub['id']}/grade", json={"grade": 80, "seen": sub["handed_at"].isoformat()}, headers=auth(TEACHER))
490
+ assert counts(world)["inbox"] == 0
491
+ world.store.remove_test(test["id"])
492
+ world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
493
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0}
494
+
495
+
496
+ def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
497
+ give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
498
+ world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
499
+ assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0}
500
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0}
501
+ assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0}
502
+ assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
mathly2/tests/test_busy_browser.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A button whose action takes time shows it (work-tabs-and-hints 1.1), in a real browser."""
2
+ import pytest
3
+
4
+ playwright = pytest.importorskip("playwright.sync_api")
5
+
6
+ from mathly2.tests.serve import offline_formulas, running
7
+ from mathly2.tests.test_written_browser import (DESKTOP, KID, PHONE, TEACHER, bank, browser, day, jpeg, one_assignment, sign_in, teacher_work, world) # noqa: F401
8
+
9
+ OBSERVER = """
10
+ window.__everBusy = false;
11
+ new MutationObserver(() => { if (document.querySelector('.busy')) window.__everBusy = true; }).observe(document, { subtree: true, attributes: true, attributeFilter: ['class'] });
12
+ """
13
+
14
+
15
+ def hold(page, pattern):
16
+ """Hold every request that matches until `release()`; returns (held list, release function)."""
17
+ held = []
18
+ page.route(pattern, lambda route: held.append(route))
19
+ return held, lambda: [r.continue_() for r in list(held)] and held.clear()
20
+
21
+
22
+ def test_a_plain_button_shows_a_spinner_after_a_moment_ignores_a_second_press_and_clears_after_success(browser, bank):
23
+ app, store, row = one_assignment(bank)
24
+ with running(app) as url:
25
+ page = sign_in(browser, url, where=f"#/assignment/{row['id']}")
26
+ page.wait_for_selector("#answer")
27
+ page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}])
28
+ page.wait_for_selector("#draft li[data-picture-id]")
29
+ held, release = hold(page, "**/api/assignments/*/submission")
30
+ page.click("#hand-in")
31
+ page.wait_for_selector("#hand-in.busy") # after about a third of a second
32
+ assert page.get_attribute("#hand-in", "aria-busy") == "true"
33
+ page.click("#hand-in", force=True) # a second press does nothing
34
+ page.wait_for_timeout(300)
35
+ assert len(held) == 1
36
+ release()
37
+ page.wait_for_selector("#assignment[data-state='handed_in']")
38
+ assert page.locator(".busy").count() == 0
39
+
40
+
41
+ def test_a_forms_submit_button_shows_it_too_and_a_quick_action_never_does(browser, bank):
42
+ app, store, klass, lessons = world(bank)
43
+ with running(app) as url:
44
+ page = offline_formulas(browser.new_page(viewport=DESKTOP))
45
+ page.add_init_script(OBSERVER)
46
+ page.goto(url)
47
+ page.fill("#email", TEACHER)
48
+ page.click("#dev button")
49
+ page.wait_for_selector("#teaching")
50
+ page.click("#teacher-tabs [data-tab='tests']")
51
+ page.wait_for_selector("#tests")
52
+ page.click(".set-test summary")
53
+ page.fill("[name=date]", day(10).isoformat())
54
+ held, release = hold(page, "**/api/classes/*/tests")
55
+ page.click(".set-test button[type=submit]")
56
+ page.wait_for_selector(".set-test button[type=submit].busy")
57
+ release()
58
+ page.wait_for_selector(".test-row")
59
+ assert page.locator(".busy").count() == 0
60
+ page.evaluate("window.__everBusy = false") # a quick action: the button never turns busy
61
+ page.click("#teacher-tabs [data-tab='classes']")
62
+ page.wait_for_selector("#teaching")
63
+ page.click("#teacher-tabs [data-tab='tests']")
64
+ page.wait_for_selector("#tests")
65
+ assert page.evaluate("window.__everBusy") is False
66
+
67
+
68
+ def test_a_picture_upload_from_a_file_chooser_marks_its_button(browser, bank):
69
+ app, store, row = one_assignment(bank)
70
+ with running(app) as url:
71
+ page = sign_in(browser, url, where=f"#/assignment/{row['id']}")
72
+ page.wait_for_selector("#answer")
73
+ held, release = hold(page, "**/api/pictures")
74
+ page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}])
75
+ page.wait_for_selector("label.file.busy")
76
+ release()
77
+ page.wait_for_selector("#draft li[data-picture-id]")
78
+ assert page.locator("label.file.busy").count() == 0
79
+
80
+
81
+ def test_with_reduced_motion_the_button_shows_dots_and_nothing_turns(browser, bank):
82
+ app, store, row = one_assignment(bank)
83
+ with running(app) as url:
84
+ page = sign_in(browser, url, where=f"#/assignment/{row['id']}", reduced_motion="reduce")
85
+ page.wait_for_selector("#answer")
86
+ page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}])
87
+ page.wait_for_selector("#draft li[data-picture-id]")
88
+ held, release = hold(page, "**/api/assignments/*/submission")
89
+ page.click("#hand-in")
90
+ page.wait_for_selector("#hand-in.busy")
91
+ assert page.evaluate("getComputedStyle(document.querySelector('#hand-in'), '::after').content") == '"…"'
92
+ assert page.evaluate("document.getAnimations().filter(a => a.animationName === 'ex-spin').length") == 0
93
+ release()
94
+ page.wait_for_selector("#assignment[data-state='handed_in']")
95
+
96
+
97
+ def test_the_state_clears_after_an_error_and_the_exercise_choices_keep_their_own_indicator(browser, bank):
98
+ app, store, row = one_assignment(bank)
99
+ with running(app) as url:
100
+ page = sign_in(browser, url, where=f"#/assignment/{row['id']}")
101
+ page.wait_for_selector("#answer")
102
+ page.set_input_files("[data-file=upload]", files=[{"name": "a.jpg", "mimeType": "image/jpeg", "buffer": jpeg()}])
103
+ page.wait_for_selector("#draft li[data-picture-id]")
104
+ held = []
105
+ page.route("**/api/assignments/*/submission", lambda route: held.append(route))
106
+ page.click("#hand-in")
107
+ page.wait_for_selector("#hand-in.busy")
108
+ held[0].fulfill(status=500, body='{"detail": "x"}', content_type="application/json")
109
+ page.wait_for_selector("#add-error:not([hidden])")
110
+ assert page.locator(".busy").count() == 0 and not page.is_disabled("#hand-in")
mathly2/tests/test_cached_store.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ from mathly2.cached import CachedStore
4
+ from mathly2.store import InMemoryStore
5
+
6
+
7
+ class Counting(InMemoryStore):
8
+ def __init__(self):
9
+ super().__init__()
10
+ self.calls = 0
11
+
12
+ def classes_taught(self, email):
13
+ self.calls += 1
14
+ return super().classes_taught(email)
15
+
16
+
17
+ def make(seconds=30, now=None):
18
+ base = Counting()
19
+ clock = now or (lambda: 0.0)
20
+ return base, CachedStore(base, seconds, clock=clock)
21
+
22
+
23
+ def test_repeat_read_is_remembered():
24
+ base, store = make()
25
+ store.classes_taught("t@x.com"); store.classes_taught("t@x.com")
26
+ assert base.calls == 1
27
+
28
+
29
+ def test_time_passing_asks_again():
30
+ t = [0.0]
31
+ base, store = make(now=lambda: t[0])
32
+ store.classes_taught("t@x.com"); t[0] = 31
33
+ store.classes_taught("t@x.com")
34
+ assert base.calls == 2
35
+
36
+
37
+ def test_changes_forget():
38
+ base, store = make()
39
+ assert store.classes_taught("t@x.com") == []
40
+ store.create_class("t@x.com", "A", teacher_email="t@x.com")
41
+ assert len(store.classes_taught("t@x.com")) == 1
42
+ cid = store.classes_taught("t@x.com")[0]["id"]
43
+ store.add_member(cid, "s@x.com")
44
+ assert [m["email"] for m in store.members(cid)] == ["s@x.com"]
45
+ store.remove_member(cid, "s@x.com")
46
+ assert store.members(cid) == []
47
+ store.give_role("a@x.com", "teacher", "root")
48
+ assert "teacher" in store.roles_of("a@x.com")
49
+ a = store.create_assignment(cid, "T", date(2030, 1, 1), "t@x.com", "pages", pages=["p1"])
50
+ assert len(store.assignments_of_classes([cid])) == 1
51
+ store.remove_assignment(a["id"])
52
+ assert store.assignments_of_classes([cid]) == []
53
+ store.delete_class(cid)
54
+ assert store.classes_taught("t@x.com") == []
55
+
56
+
57
+ def test_read_started_before_a_change_is_not_kept():
58
+ base, store = make()
59
+ real = base.classes_taught
60
+
61
+ def slow(email):
62
+ rows = real(email)
63
+ store.create_class("t@x.com", "B", teacher_email="t@x.com") # a change lands while this read is running
64
+ return rows
65
+ base.classes_taught = slow
66
+ store.classes_taught("t@x.com")
67
+ base.classes_taught = real
68
+ assert len(store.classes_taught("t@x.com")) == 1
69
+
70
+
71
+ def test_returned_rows_are_copies():
72
+ base, store = make()
73
+ store.create_class("t@x.com", "A", teacher_email="t@x.com")
74
+ store.classes_taught("t@x.com")[0]["name"] = "changed"
75
+ assert store.classes_taught("t@x.com")[0]["name"] == "A"
76
+
77
+
78
+ def test_zero_turns_it_off():
79
+ base, store = make(seconds=0)
80
+ store.classes_taught("t@x.com"); store.classes_taught("t@x.com")
81
+ assert base.calls == 2
82
+
83
+
84
+ def test_progress_writes_do_not_forget():
85
+ base, store = make()
86
+ store.classes_taught("t@x.com")
87
+ store.add_comet("u", "c")
88
+ store.classes_taught("t@x.com")
89
+ assert base.calls == 1
90
+
91
+
92
+ def test_database_times_with_any_number_of_fraction_digits_are_read():
93
+ from datetime import datetime, timezone
94
+ from mathly2.store import _when
95
+ for text in ("2026-11-09T10:10:57.63003+00:00", "2026-11-09T10:10:57.6+00:00", "2026-11-09T10:10:57+00:00", "2026-11-09T10:10:57.630031Z", "2026-11-09T10:10:57.1234567+00:00"):
96
+ assert _when(text).tzinfo is not None
97
+ assert _when("2026-11-09T10:10:57.63003+00:00") == datetime(2026, 11, 9, 10, 10, 57, 630030, tzinfo=timezone.utc)
mathly2/tests/test_cached_waits.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slow-changing facts are remembered (cache-and-instant-screens 1.2): with every store call made to wait, the second opening is quicker,
2
+ and a change shows at once."""
3
+ import time
4
+ from datetime import timedelta
5
+
6
+ from fastapi.testclient import TestClient
7
+
8
+ from mathly2 import clock
9
+ from mathly2.bank import load_bank
10
+ from mathly2.cached import CachedStore
11
+ from mathly2.config import Settings
12
+ from mathly2.main import create_app
13
+ from mathly2.tests.test_database_waits import DELAY, SlowStore
14
+
15
+ TEACHER = "t@school.test"
16
+ H = {"Authorization": f"Bearer dev:{TEACHER}"}
17
+
18
+
19
+ def test_second_opening_is_quicker_and_a_change_shows_at_once():
20
+ bank = load_bank()
21
+ slow = SlowStore()
22
+ lessons = [l["id"] for l in bank.lessons][:3]
23
+ klass = slow.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
24
+ slow.add_member(klass["id"], "kid@school.test")
25
+ cached = CachedStore(slow, 30)
26
+ client = TestClient(create_app(Settings(), bank=bank, store=cached))
27
+ client.get("/api/me", headers=H)
28
+ slow.slow = True
29
+
30
+ def took(path):
31
+ start = time.perf_counter()
32
+ r = client.get(path, headers=H)
33
+ assert r.status_code == 200, r.text
34
+ return time.perf_counter() - start, r.json()
35
+
36
+ for path in ("/api/teaching/counts", "/api/teaching/assignments", "/api/teaching/tests"):
37
+ cached.forget()
38
+ first, _ = took(path)
39
+ second, _ = took(path)
40
+ assert second < first - DELAY / 2, (path, first, second)
41
+
42
+ _, before = took("/api/teaching/assignments")
43
+ assert before[0]["assignments"] == []
44
+ r = client.post(f"/api/classes/{klass['id']}/assignments", headers=H,
45
+ json={"title": "חדש", "kind": "quiz", "quiz_lesson": lessons[0], "due_date": str(clock.school_day() + timedelta(days=5))})
46
+ assert r.status_code in (200, 201), r.text
47
+ _, after = took("/api/teaching/assignments")
48
+ assert len(after[0]["assignments"]) == 1
mathly2/tests/test_classes.py CHANGED
@@ -36,12 +36,12 @@ def test_parse_emails_handles_pasted_lists():
36
 
37
  # ---- the manager makes classes (spec: Manager creates classes)
38
 
39
- def test_a_manager_creates_a_class_with_lessons_teacher_and_students(world):
40
  client, _ = world
41
  created = make(client, teacher_email=T1, students="s1@school.test\ns2@school.test not-an-email")
42
  assert created.status_code == 201
43
  row = created.json()
44
- assert row["name"] == "ט'3" and row["lessons"] == ["t", "t2"] and row["teacher_email"] == T1
45
  assert row["ai_enabled"] is False and row["ai_daily_checks"] == 150
46
  assert row["students"] == 2 and row["student_emails"] == ["s1@school.test", "s2@school.test"] and row["invalid"] == ["not-an-email"]
47
  assert [c["name"] for c in client.get("/api/classes", headers=auth(MGR)).json()] == ["ט'3"]
@@ -69,12 +69,10 @@ def test_class_name_is_trimmed_checked_and_unique_ignoring_case(world):
69
  assert make(client, name="x" * 80).status_code == 201
70
 
71
 
72
- def test_a_class_needs_at_least_one_known_lesson(world):
73
  client, _ = world
74
- assert make(client, lessons=[]).status_code == 422
75
- assert make(client, lessons=["nope"]).status_code == 422
76
- assert make(client, lessons=["t", "nope"]).status_code == 422
77
- assert make(client, name="B", lessons=["t", "t"]).json()["lessons"] == ["t"] # repeats collapse
78
 
79
 
80
  def test_the_teacher_must_be_an_email_and_not_also_a_student(world):
@@ -92,11 +90,9 @@ def test_the_ai_budget_is_limited(world):
92
  assert make(client, name="C", ai_daily_checks=40, ai_enabled=True).json()["ai_enabled"] is True
93
 
94
 
95
- def test_the_manager_can_list_the_lessons_to_choose_from(world):
96
  client, _ = world
97
- lessons = client.get("/api/classes/lessons", headers=auth(MGR)).json()
98
- assert len(lessons) == 24 and set(lessons[0]) == {"id", "name", "skills"} and lessons[0]["skills"] > 0
99
- assert client.get("/api/classes/lessons", headers=auth(T1)).status_code == 403
100
 
101
 
102
  # ---- editing and deleting (spec: Editing and deleting a class)
@@ -104,9 +100,9 @@ def test_the_manager_can_list_the_lessons_to_choose_from(world):
104
  def test_a_manager_edits_everything_and_the_roster_follows(world):
105
  client, store = world
106
  cid = make(client, teacher_email=T1, students="a@school.test b@school.test").json()["id"]
107
- edited = client.put(f"/api/classes/{cid}", json=body("ט'4", lessons=["k"], teacher_email=T2, students="b@school.test c@school.test",
108
  ai_enabled=True, ai_daily_checks=60), headers=auth(MGR)).json()
109
- assert (edited["name"], edited["lessons"], edited["teacher_email"], edited["ai_enabled"], edited["ai_daily_checks"]) == ("ט'4", ["k"], T2, True, 60)
110
  assert edited["student_emails"] == ["b@school.test", "c@school.test"]
111
 
112
 
 
36
 
37
  # ---- the manager makes classes (spec: Manager creates classes)
38
 
39
+ def test_a_manager_creates_a_class_with_teacher_and_students_and_it_studies_every_lesson(world):
40
  client, _ = world
41
  created = make(client, teacher_email=T1, students="s1@school.test\ns2@school.test not-an-email")
42
  assert created.status_code == 201
43
  row = created.json()
44
+ assert row["name"] == "ט'3" and len(row["lessons"]) == 24 and row["teacher_email"] == T1
45
  assert row["ai_enabled"] is False and row["ai_daily_checks"] == 150
46
  assert row["students"] == 2 and row["student_emails"] == ["s1@school.test", "s2@school.test"] and row["invalid"] == ["not-an-email"]
47
  assert [c["name"] for c in client.get("/api/classes", headers=auth(MGR)).json()] == ["ט'3"]
 
69
  assert make(client, name="x" * 80).status_code == 201
70
 
71
 
72
+ def test_lessons_in_a_request_are_ignored_every_class_studies_every_lesson(world):
73
  client, _ = world
74
+ assert len(make(client, lessons=[]).json()["lessons"]) == 24
75
+ assert len(make(client, name="B", lessons=["nope"]).json()["lessons"]) == 24
 
 
76
 
77
 
78
  def test_the_teacher_must_be_an_email_and_not_also_a_student(world):
 
90
  assert make(client, name="C", ai_daily_checks=40, ai_enabled=True).json()["ai_enabled"] is True
91
 
92
 
93
+ def test_there_is_no_lesson_list_to_choose_from(world):
94
  client, _ = world
95
+ assert client.get("/api/classes/lessons", headers=auth(MGR)).status_code in (404, 405, 422)
 
 
96
 
97
 
98
  # ---- editing and deleting (spec: Editing and deleting a class)
 
100
  def test_a_manager_edits_everything_and_the_roster_follows(world):
101
  client, store = world
102
  cid = make(client, teacher_email=T1, students="a@school.test b@school.test").json()["id"]
103
+ edited = client.put(f"/api/classes/{cid}", json=body("ט'4", teacher_email=T2, students="b@school.test c@school.test",
104
  ai_enabled=True, ai_daily_checks=60), headers=auth(MGR)).json()
105
+ assert (edited["name"], edited["lessons"], edited["teacher_email"], edited["ai_enabled"], edited["ai_daily_checks"]) == ("ט'4", edited["lessons"], T2, True, 60)
106
  assert edited["student_emails"] == ["b@school.test", "c@school.test"]
107
 
108
 
mathly2/tests/test_curriculum_api.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The curriculum (add-lesson-schedule 2.1, 3.1): a date per lesson, who sets it, and what the path says about it."""
2
+ from datetime import timedelta
3
+
4
+ from mathly2 import clock
5
+ from mathly2.tests.test_skills_api import KID, KID_ID, MGR, MON, OTHER, TEACHER, auth, raw, world # noqa: F401
6
+
7
+
8
+ def rows(client, who=KID):
9
+ return {l["id"]: l for l in client.get("/api/path", headers=auth(who)).json()["lessons"]}
10
+
11
+
12
+ def put(client, dates, who=MGR):
13
+ return client.put("/api/curriculum", json={"dates": dates}, headers=auth(who))
14
+
15
+
16
+ def test_a_manager_sets_changes_and_clears_a_date(world):
17
+ client, _, bank = world
18
+ first, second = bank.lessons[0]["id"], bank.lessons[1]["id"]
19
+ got = client.get("/api/curriculum", headers=auth(MGR)).json()
20
+ assert [r["id"] for r in got] == [l["id"] for l in bank.lessons] and all(r["due_date"] is None for r in got) and got[0]["month"]
21
+ assert put(client, {first: "2026-11-03", second: "2026-12-01"}).json()[0]["due_date"] == "2026-11-03"
22
+ assert put(client, {first: "2026-11-05", second: None}).status_code == 200
23
+ now = {r["id"]: r["due_date"] for r in client.get("/api/curriculum", headers=auth(MGR)).json()}
24
+ assert now[first] == "2026-11-05" and now[second] is None
25
+
26
+
27
+ def test_only_managers_change_it_and_bad_input_is_refused(world):
28
+ client, _, bank = world
29
+ first = bank.lessons[0]["id"]
30
+ for who in (TEACHER, KID, OTHER):
31
+ assert put(client, {first: "2026-11-03"}, who).status_code in (401, 403)
32
+ assert client.get("/api/curriculum", headers=auth(who)).status_code in (401, 403)
33
+ assert put(client, {"no-such-lesson": "2026-11-03"}).status_code == 422
34
+ assert put(client, {first: "3.11"}).status_code == 422
35
+ assert client.get("/api/curriculum", headers=auth(MGR)).json()[0]["due_date"] is None # nothing changed
36
+
37
+
38
+ def test_a_lesson_is_behind_only_when_dated_past_and_not_completed(world):
39
+ client, store, bank = world
40
+ ids = [l["id"] for l in bank.lessons_with_skills()][:5]
41
+ today = clock.school_day()
42
+ yesterday, tomorrow = (today - timedelta(days=1)).isoformat(), (today + timedelta(days=7)).isoformat()
43
+ put(client, {ids[0]: yesterday, ids[1]: tomorrow, ids[2]: yesterday, ids[3]: yesterday}) # ids[4]: no date
44
+ path = client.get("/api/path", headers=auth()).json()
45
+ by = {l["id"]: l for l in path["lessons"]}
46
+ assert by[ids[0]]["behind"] is True and by[ids[0]]["due_date"] == yesterday
47
+ assert by[ids[1]]["behind"] is False and by[ids[4]]["behind"] is False and by[ids[4]]["due_date"] is None
48
+ # a completed lesson (all skills mastered) is not behind, and neither is one whose quiz was passed
49
+ for skill in by[ids[2]]["skills"]:
50
+ store.save_progress(KID_ID, skill["id"], started_at=MON, mastered_at=MON)
51
+ assert rows(client)[ids[2]]["completed"] is True and rows(client)[ids[2]]["behind"] is False
52
+ assert path["behind"] == 3 # before the third was completed
53
+ after = client.get("/api/path", headers=auth()).json()
54
+ assert after["behind"] == 2 and {l["id"] for l in after["lessons"] if l["behind"]} == {ids[0], ids[3]}
mathly2/tests/test_foundation_screens_browser.py CHANGED
@@ -158,8 +158,7 @@ def test_a_person_with_one_role_sees_no_chips_and_a_developer_sees_all_five(brow
158
 
159
  def fill_class(page, name, lessons, teacher, students):
160
  page.fill("#c-name", name)
161
- for lesson in lessons:
162
- page.check(f"#c-lessons input[value='{lesson}']")
163
  page.fill("#c-teacher", teacher)
164
  page.fill("#c-students", students)
165
 
@@ -189,9 +188,8 @@ def test_a_manager_creates_edits_and_deletes_classes_without_scrolling_sideways(
189
  assert columns >= 2 # the wide screen is used
190
  page.click("#classes article >> nth=0 >> [data-edit]")
191
  page.wait_for_selector("#class-form")
192
- assert page.input_value("#c-name") == "ט'3" and page.is_checked("#c-lessons input[value='t2']")
193
  page.fill("#c-name", "ט'3 חדשה")
194
- page.uncheck("#c-lessons input[value='t2']")
195
  page.check("#c-ai")
196
  page.click("#c-save")
197
  page.wait_for_selector("#classes article")
 
158
 
159
  def fill_class(page, name, lessons, teacher, students):
160
  page.fill("#c-name", name)
161
+ assert page.locator("#c-lessons, input[name=lesson]").count() == 0 # a class has no chosen lessons
 
162
  page.fill("#c-teacher", teacher)
163
  page.fill("#c-students", students)
164
 
 
188
  assert columns >= 2 # the wide screen is used
189
  page.click("#classes article >> nth=0 >> [data-edit]")
190
  page.wait_for_selector("#class-form")
191
+ assert page.input_value("#c-name") == "ט'3"
192
  page.fill("#c-name", "ט'3 חדשה")
 
193
  page.check("#c-ai")
194
  page.click("#c-save")
195
  page.wait_for_selector("#classes article")
mathly2/tests/test_home_browser.py CHANGED
@@ -12,7 +12,7 @@ from mathly2.bank import load_bank
12
  from mathly2.config import Settings
13
  from mathly2.main import create_app
14
  from mathly2.store import InMemoryStore
15
- from mathly2.tests.serve import offline_formulas, running
16
 
17
  KID, TEACHER = "kid@school.test", "t@school.test"
18
  KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
@@ -39,7 +39,7 @@ def world(bank):
39
  store = InMemoryStore()
40
  klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
41
  store.add_member(klass["id"], KID)
42
- return create_app(Settings(), bank=bank, store=store), store, klass, lessons
43
 
44
 
45
  def add_test(store, klass, lessons, days_ahead, set_days_ago=20, **kw):
@@ -81,8 +81,8 @@ def test_the_card_opens_home_and_each_tab_opens_its_screen(browser, bank):
81
  page = sign_in(browser, url)
82
  to_home(page)
83
  assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "בית"
84
- assert page.locator(".tabs a").all_inner_texts() == ["בית", "מסלול", "ציונים", "האוסף"]
85
- for href, marker in (("#/subject/math/path", ".orbit"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/subject/math", "#no-test")):
86
  page.click(f".tabs a[href='{href}']")
87
  page.wait_for_selector(marker)
88
  assert page.errors == []
@@ -178,15 +178,18 @@ def test_the_readiness_bar_shows_every_part_and_loses_the_cap_after_the_simulati
178
  assert page.locator(".required").count() == 0 # the simulation is taken
179
 
180
 
181
- def test_other_tests_are_listed_small_and_the_closest_one_gives_the_task(browser, bank):
182
  app, store, klass, lessons = world(bank)
183
  far = add_test(store, klass, lessons, 20)
184
  near = add_test(store, klass, lessons, 9)
185
  with running(app) as url:
186
  page = sign_in(browser, url)
187
  to_home(page)
188
- assert page.get_attribute("#nearest-test", "data-test") == str(near["id"])
189
- assert page.locator("#other-tests li").count() == 1 and page.get_attribute("#other-tests a", "href") == f"#/test/{far['id']}"
 
 
 
190
 
191
 
192
  # --- 3.3 a test's card ---
 
12
  from mathly2.config import Settings
13
  from mathly2.main import create_app
14
  from mathly2.store import InMemoryStore
15
+ from mathly2.tests.serve import offline_formulas, only, running
16
 
17
  KID, TEACHER = "kid@school.test", "t@school.test"
18
  KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
 
39
  store = InMemoryStore()
40
  klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
41
  store.add_member(klass["id"], KID)
42
+ return create_app(Settings(), bank=only(bank, lessons), store=store), store, klass, lessons
43
 
44
 
45
  def add_test(store, klass, lessons, days_ahead, set_days_ago=20, **kw):
 
81
  page = sign_in(browser, url)
82
  to_home(page)
83
  assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "בית"
84
+ assert page.locator(".tabs a").all_inner_texts() == ["בית", "מסלול", "הגשות", "ציונים", "האוסף"]
85
+ for href, marker in (("#/subject/math/path", ".orbit"), ("#/submissions", "#submissions"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/subject/math", "#no-test")):
86
  page.click(f".tabs a[href='{href}']")
87
  page.wait_for_selector(marker)
88
  assert page.errors == []
 
178
  assert page.locator(".required").count() == 0 # the simulation is taken
179
 
180
 
181
+ def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(browser, bank):
182
  app, store, klass, lessons = world(bank)
183
  far = add_test(store, klass, lessons, 20)
184
  near = add_test(store, klass, lessons, 9)
185
  with running(app) as url:
186
  page = sign_in(browser, url)
187
  to_home(page)
188
+ assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) and page.get_attribute("#today-task", "href") == f"#/exam/{near['id']}/1"
189
+ assert page.locator("#other-tests").count() == 0 and page.locator("#notifications").count() == 0 # only what is critical
190
+ page.click("#nearest-test a[href^='#/test/']")
191
+ page.wait_for_selector("#other-tests")
192
+ assert page.get_attribute("#other-tests a", "href") == f"#/test/{far['id']}"
193
 
194
 
195
  # --- 3.3 a test's card ---
mathly2/tests/test_instant_browser.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A screen opened again shows what it last knew (cache-and-instant-screens 2.1), in a real browser."""
2
+ import pytest
3
+
4
+ playwright = pytest.importorskip("playwright.sync_api")
5
+
6
+ from mathly2.tests.serve import running
7
+ from mathly2.tests.test_busy_browser import hold
8
+ from mathly2.tests.test_written_browser import (DESKTOP, KID, PHONE, TEACHER, bank, browser, day, jpeg, one_assignment, sign_in, teacher_work, world) # noqa: F401
9
+
10
+
11
+ def counts(page):
12
+ return page.eval_on_selector_all("#teacher-tabs [data-count]", "els => els.map(e => e.textContent)")
13
+
14
+
15
+ def test_going_back_to_a_tab_shows_its_content_and_numbers_at_once_then_the_fresh_ones(browser, bank):
16
+ app, store, row = one_assignment(bank)
17
+ with running(app) as url:
18
+ page = teacher_work(browser, url, tab="assignments")
19
+ page.wait_for_function("[...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.textContent)")
20
+ known = counts(page)
21
+ page.click("#teacher-tabs [data-tab='classes']")
22
+ page.wait_for_selector("#teaching")
23
+ held, release = hold(page, "**/api/teaching/**")
24
+ page.click("#teacher-tabs [data-tab='assignments']")
25
+ page.wait_for_selector("#work") # drawn from memory while the server is still being asked
26
+ assert held and counts(page) == known
27
+ # something changes on the server meanwhile; once the answer arrives the fresh content replaces the remembered one
28
+ other = store.create_assignment(row["class_id"], "משימה אחרת", day(7), TEACHER, "quiz", quiz_lesson=row["quiz_lesson"])
29
+ release()
30
+ page.wait_for_function("document.querySelectorAll('#work .assignment, #work article').length >= 2")
31
+ assert not page.errors
32
+
33
+
34
+ def test_after_the_teachers_own_change_no_old_inbox_is_shown(browser, bank):
35
+ app, store, row = one_assignment(bank)
36
+ with running(app) as url:
37
+ page = teacher_work(browser, url, tab="assignments")
38
+ page.click("#work [data-remove-assignment]")
39
+ page.click("[data-confirm-remove-assignment]")
40
+ page.wait_for_selector("#work")
41
+ held, release = hold(page, "**/api/teaching/assignments")
42
+ page.click("#teacher-tabs [data-tab='inbox']")
43
+ page.click("#teacher-tabs [data-tab='assignments']")
44
+ page.wait_for_timeout(300)
45
+ assert not page.query_selector("#work .assignment[data-id], #work [data-remove-assignment]") # nothing remembered of the removed one
46
+ release()
47
+
48
+
49
+ def test_a_refresh_does_not_redraw_over_what_is_being_typed(browser, bank):
50
+ app, store, row = one_assignment(bank)
51
+ with running(app) as url:
52
+ page = teacher_work(browser, url, tab="assignments")
53
+ page.click("#teacher-tabs [data-tab='classes']")
54
+ page.wait_for_selector("#teaching")
55
+ page.click("#teacher-tabs [data-tab='assignments']")
56
+ page.wait_for_selector("#work")
57
+ page.wait_for_timeout(500)
58
+ held, release = hold(page, "**/api/teaching/**")
59
+ page.click("#teacher-tabs [data-tab='classes']")
60
+ page.click("#teacher-tabs [data-tab='assignments']")
61
+ page.wait_for_selector("#work")
62
+ store.create_assignment(row["class_id"], "משימה אחרת", day(7), TEACHER, "quiz", quiz_lesson=row["quiz_lesson"])
63
+ page.click("#work summary")
64
+ page.fill("#work .assignment-form input", "טיוטה")
65
+ release()
66
+ page.wait_for_timeout(500)
67
+ assert page.input_value("#work .assignment-form input") == "טיוטה"