mathly2: written work, rounds, caching, one course for all classes, lesson schedule
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- docs/tasks/prd-mathly.md +21 -15
- mathly2/assignments.py +390 -0
- mathly2/attempts.py +6 -2
- mathly2/cached.py +69 -0
- mathly2/classes.py +37 -12
- mathly2/cleanup.py +83 -0
- mathly2/config.py +8 -0
- mathly2/main.py +37 -7
- mathly2/pictures.py +193 -0
- mathly2/push.py +133 -0
- mathly2/quiz.py +4 -3
- mathly2/quizzes.py +2 -1
- mathly2/reads.py +1 -1
- mathly2/requirements.txt +3 -0
- mathly2/retention.py +17 -0
- mathly2/skills.py +12 -6
- mathly2/static/api.js +41 -3
- mathly2/static/app.css +67 -1
- mathly2/static/assignment.js +144 -0
- mathly2/static/busy.js +54 -0
- mathly2/static/grades.js +10 -4
- mathly2/static/home.js +32 -7
- mathly2/static/index.html +2 -0
- mathly2/static/manager.js +26 -12
- mathly2/static/manifest.webmanifest +18 -0
- mathly2/static/media.js +55 -0
- mathly2/static/notifications.js +40 -0
- mathly2/static/path.js +21 -8
- mathly2/static/phone.js +117 -0
- mathly2/static/service-worker.js +30 -0
- mathly2/static/session.js +5 -0
- mathly2/static/student.js +9 -0
- mathly2/static/submissions.js +33 -0
- mathly2/static/teacher.js +41 -9
- mathly2/static/teacher_work.js +167 -0
- mathly2/static/testcard.js +5 -2
- mathly2/static/ui.js +1 -0
- mathly2/store.py +368 -1
- mathly2/supabase/migrations/20261112090000_m2_written_work.sql +94 -0
- mathly2/supabase/migrations/20261113090000_m2_curriculum.sql +12 -0
- mathly2/tests/serve.py +7 -0
- mathly2/tests/test_assignments_api.py +502 -0
- mathly2/tests/test_busy_browser.py +110 -0
- mathly2/tests/test_cached_store.py +97 -0
- mathly2/tests/test_cached_waits.py +48 -0
- mathly2/tests/test_classes.py +9 -13
- mathly2/tests/test_curriculum_api.py +54 -0
- mathly2/tests/test_foundation_screens_browser.py +2 -4
- mathly2/tests/test_home_browser.py +10 -7
- 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,
|
| 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
|
| 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
|
| 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),
|
| 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,
|
| 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,
|
| 146 |
-
- **CLS-8 Lessons in the path.** A
|
| 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
|
| 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 **
|
| 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.
|
|
|
|
| 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
|
| 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 |
-
QUIZ_MINUTES =
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
| 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:
|
| 86 |
if set(user["roles"]) - {"student"}:
|
| 87 |
return None
|
| 88 |
-
|
|
|
|
|
|
|
| 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":
|
|
|
|
| 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 {
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 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 {
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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">
|
| 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">עוד אין כיתות. פתחו כיתה ראשונה
|
| 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
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}">מבחנים</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
function wireTabs() {
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
function report(result) {
|
|
@@ -22,15 +37,17 @@ function report(result) {
|
|
| 22 |
return lines;
|
| 23 |
}
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 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>
|
|
|
|
| 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 |
-
|
| 94 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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"] ==
|
| 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
|
| 73 |
client, _ = world
|
| 74 |
-
assert make(client, lessons=[]).
|
| 75 |
-
assert make(client, lessons=["nope"]).
|
| 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
|
| 96 |
client, _ = world
|
| 97 |
-
|
| 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",
|
| 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", ["
|
| 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 |
-
|
| 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"
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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") == "טיוטה"
|