feat(progress): add class progress views for teachers
Browse files- Introduced a new "התקדמות" tab for teachers to view class progress by lesson, including readiness for upcoming tests.
- Implemented API endpoint `GET /api/classes/{id}/progress` to fetch progress data, including skill states, coverage, and readiness metrics.
- Updated permissions to restrict access to class progress data to the class's teacher and developers only.
- Enhanced UI with period tabs and a class picker, ensuring a responsive design that prevents sideways scrolling.
- Added comprehensive tests for API and browser interactions to validate functionality and permissions.
- docs/tasks/prd-mathly.md +14 -14
- mathly2/assignments.py +3 -1
- mathly2/main.py +2 -1
- mathly2/progress.py +126 -0
- mathly2/reads.py +5 -0
- mathly2/realtests.py +19 -1
- mathly2/static/app.css +22 -0
- mathly2/static/teacher.js +5 -3
- mathly2/static/teacher_progress.js +66 -0
- mathly2/store.py +60 -0
- mathly2/tests/test_assignments_api.py +6 -6
- mathly2/tests/test_permissions.py +1 -0
- mathly2/tests/test_progress_api.py +142 -0
- mathly2/tests/test_progress_browser.py +86 -0
- mathly2/tests/test_written_browser.py +2 -2
- openspec/changes/add-class-progress-views/proposal.md +10 -10
- openspec/changes/add-class-progress-views/specs/class-progress-views/spec.md +53 -34
- openspec/changes/add-class-progress-views/tasks.md +14 -0
docs/tasks/prd-mathly.md
CHANGED
|
@@ -78,7 +78,7 @@ reused as files (see 7.1); everything else is rebuilt.
|
|
| 78 |
- As a teacher, I want to add and remove the students of my own class, so a new student can start without waiting for a manager.
|
| 79 |
- As a teacher, I want to set a test date and the lessons it covers, so the app prepares my students for it.
|
| 80 |
- As a teacher, I want to see who has taken the simulation test.
|
| 81 |
-
- As a teacher, I want
|
| 82 |
- As a teacher, I want to see how much of each lesson each student has covered.
|
| 83 |
|
| 84 |
**Manager**
|
|
@@ -246,14 +246,14 @@ The exercise screen is where a student practices one skill.
|
|
| 246 |
- **MST-3 Help does not break the run.** An answer given after opening help is skipped for mastery: it neither counts toward the 3 nor breaks the run.
|
| 247 |
A wrong answer restarts the run.
|
| 248 |
- **MST-4 Mastery does not decay.** A mastered skill stays mastered. It does not need re-mastering over time, even after later wrong answers.
|
| 249 |
-
The readiness meter, the exercise tests
|
| 250 |
- **MST-5 The server decides.** The server stores the date of every answer (the calendar day in Israel, decided by the server, never by the device) and decides mastery from it.
|
| 251 |
- **MST-6 Daily goal and streak.** 5 correct answers in a day make a streak day. The path shows today's count and the number of days in a row.
|
| 252 |
A streak is kept through the morning of a new day and counted up to yesterday until the new day's 5 are reached.
|
| 253 |
- **MST-7 Collection.** A collection screen shows what the student achieved: each mastered skill as a star, each completed lesson (all of its skills mastered)
|
| 254 |
as a constellation drawn from its stars, and the comets caught (CMT-3).
|
| 255 |
- **MST-8 Fading stars.** A mastered skill the student has not answered for 14 days has a **faded** star: it dims and says "דועך". One answer on the skill (correct or not) relights it.
|
| 256 |
-
Fading is only a review cue: the skill is still mastered (MST-4), so the readiness meter,
|
| 257 |
|
| 258 |
### 4.8 Comets (CMT)
|
| 259 |
|
|
@@ -413,14 +413,14 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
|
|
| 413 |
and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0–100) and a note, or sends it back for correction.
|
| 414 |
- **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7).
|
| 415 |
- **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken the simulation test and which have not.
|
| 416 |
-
- **TCH-7 Class
|
| 417 |
-
- Rows: students. Columns: skills (grouped by lesson).
|
| 418 |
-
- Each cell shows the skill state: not started / learning / mastered, by color **and** by a symbol or word.
|
| 419 |
-
- Tabs for the period: **Sep–Dec** and **Jan–Jun**. A period shows the skills of the lessons taught in it, and states as they were at the end of the period (or today, for the current period).
|
| 420 |
-
- **TCH-8 Student lesson coverage.**
|
| 421 |
- Rows: students. Columns: lessons.
|
| 422 |
-
- Each cell shows the
|
| 423 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
- **TCH-9 Roster of my class.** For each class they teach, the teacher can paste email addresses to add students and can remove a student (CLS-3, CLS-5). They cannot change the class's name, lessons, teacher or AI budget,
|
| 425 |
and a teacher cannot add themselves as a student. Removing a student does not delete their progress.
|
| 426 |
|
|
@@ -441,7 +441,7 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
|
|
| 441 |
|
| 442 |
*Reference: `app/prompter.py`, `static/prompter.js`, and the management sheet in `static/math.js`.*
|
| 443 |
|
| 444 |
-
- **DEV-1 Every permission.** A developer can open any class's progress,
|
| 445 |
- **DEV-2 Prompter (פרומפטר).** A developer writes or pastes a text and shows it full screen as a scrolling teleprompter. Saved texts (a title up to 120 characters; up to 200 texts) belong to the account and are on every device; what is being edited is also kept in the browser, so nothing is lost before it is saved.
|
| 446 |
Keys: space starts and stops, ↑/↓ change speed, +/− change size, M mirrors, F is full screen, Esc leaves; the mouse and a presentation remote work too.
|
| 447 |
A saved text can have an **outline (ראשי פרקים)**: on request the AI rewrites the text as the same slides with two to four very short topic reminders each, once; it is kept with the text and can be edited. Changing the text without sending an outline drops the outline, so a stale one is never shown.
|
|
@@ -462,11 +462,11 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
|
|
| 462 |
|
| 463 |
## 6. Design Considerations
|
| 464 |
|
| 465 |
-
- Hebrew, right-to-left. Works on phones first; the teacher, manager, admin and developer views (
|
| 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,
|
| 470 |
- Mastered skills must look clearly different from "learning" and "not started" ones.
|
| 471 |
- Touch targets are at least 44px. Animations (stars, celebrations, comets) respect the device's reduced-motion setting.
|
| 472 |
- The "one task for today" on home is a single, obvious button.
|
|
@@ -530,7 +530,7 @@ Rebuilt, not copied as a whole; read the places named in each requirement group.
|
|
| 530 |
- ≥ 90% of students take the simulation test before each real test.
|
| 531 |
- On test day, students have mastered on average ≥ 80% of the covered skills.
|
| 532 |
- ≥ 75% of students take both exercise tests before each real test.
|
| 533 |
-
- Teachers open the
|
| 534 |
- The AI's estimated cost stays within each class's daily budget on every day.
|
| 535 |
|
| 536 |
## 9. Open Questions
|
|
|
|
| 78 |
- As a teacher, I want to add and remove the students of my own class, so a new student can start without waiting for a manager.
|
| 79 |
- As a teacher, I want to set a test date and the lessons it covers, so the app prepares my students for it.
|
| 80 |
- As a teacher, I want to see who has taken the simulation test.
|
| 81 |
+
- As a teacher, I want each student's progress per lesson, so I can see at a glance which lessons the class struggles with.
|
| 82 |
- As a teacher, I want to see how much of each lesson each student has covered.
|
| 83 |
|
| 84 |
**Manager**
|
|
|
|
| 246 |
- **MST-3 Help does not break the run.** An answer given after opening help is skipped for mastery: it neither counts toward the 3 nor breaks the run.
|
| 247 |
A wrong answer restarts the run.
|
| 248 |
- **MST-4 Mastery does not decay.** A mastered skill stays mastered. It does not need re-mastering over time, even after later wrong answers.
|
| 249 |
+
The readiness meter, the exercise tests and the teacher's progress by lesson all use this state.
|
| 250 |
- **MST-5 The server decides.** The server stores the date of every answer (the calendar day in Israel, decided by the server, never by the device) and decides mastery from it.
|
| 251 |
- **MST-6 Daily goal and streak.** 5 correct answers in a day make a streak day. The path shows today's count and the number of days in a row.
|
| 252 |
A streak is kept through the morning of a new day and counted up to yesterday until the new day's 5 are reached.
|
| 253 |
- **MST-7 Collection.** A collection screen shows what the student achieved: each mastered skill as a star, each completed lesson (all of its skills mastered)
|
| 254 |
as a constellation drawn from its stars, and the comets caught (CMT-3).
|
| 255 |
- **MST-8 Fading stars.** A mastered skill the student has not answered for 14 days has a **faded** star: it dims and says "דועך". One answer on the skill (correct or not) relights it.
|
| 256 |
+
Fading is only a review cue: the skill is still mastered (MST-4), so the readiness meter, the teacher's progress and exercise tests are unchanged.
|
| 257 |
|
| 258 |
### 4.8 Comets (CMT)
|
| 259 |
|
|
|
|
| 413 |
and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0–100) and a note, or sends it back for correction.
|
| 414 |
- **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7).
|
| 415 |
- **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken the simulation test and which have not.
|
| 416 |
+
- **TCH-7 Class progress, by lesson.** The teacher sees progress **per lesson, never per skill**.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
- Rows: students. Columns: lessons.
|
| 418 |
+
- Each cell is short and shows three things only: a **progress bar** with the percent of the lesson's skills mastered written on it; the student's best quiz (בוחן) grade as a **bare number** (a dash for none, without the word); and, in the current period, a **warning mark** (⚠) when the lesson is behind the curriculum (PTH-11) and not completed. The lesson's state in words (הושלם PTH-10, בלמידה, לא התחילו, בפיגור) is the cell's accessible name, and a completed lesson's bar is gold.
|
| 419 |
+
- Tabs for the period: **Sep–Dec** and **Jan–Jun**. A period shows the lessons of that period (by the lesson's date in the curriculum, PTH-11, else the last month of the bank's month for it), and states as they were at the end of the period (or today, for the current period). The current period opens first.
|
| 420 |
+
- Where: a "התקדמות" tab on the teacher's screen (with its number: the students of the teacher's classes), also reached from a shortcut on each class card; one class at a time, with a picker when the teacher has several. Only the class's teacher and a developer can read it (never a manager or an admin).
|
| 421 |
+
- The table scrolls inside its own frame with the student column fixed; the page does not scroll sideways. A student who has not signed in yet is all "not started".
|
| 422 |
+
- **TCH-8 (merged into TCH-7).** The coverage view is the progress by lesson above; there is no separate per-skill heat map.
|
| 423 |
+
- **TCH-10 Readiness per student.** For the class's nearest coming test: each student's readiness number and word (RDY), mastery percent, practice-test average and simulation grade, a dash for what is missing; a class with no coming test says so.
|
| 424 |
- **TCH-9 Roster of my class.** For each class they teach, the teacher can paste email addresses to add students and can remove a student (CLS-3, CLS-5). They cannot change the class's name, lessons, teacher or AI budget,
|
| 425 |
and a teacher cannot add themselves as a student. Removing a student does not delete their progress.
|
| 426 |
|
|
|
|
| 441 |
|
| 442 |
*Reference: `app/prompter.py`, `static/prompter.js`, and the management sheet in `static/math.js`.*
|
| 443 |
|
| 444 |
+
- **DEV-1 Every permission.** A developer can open any class's progress, tests, assignments, inbox and AI queue, as the class's teacher can, and everything managers and admins can do.
|
| 445 |
- **DEV-2 Prompter (פרומפטר).** A developer writes or pastes a text and shows it full screen as a scrolling teleprompter. Saved texts (a title up to 120 characters; up to 200 texts) belong to the account and are on every device; what is being edited is also kept in the browser, so nothing is lost before it is saved.
|
| 446 |
Keys: space starts and stops, ↑/↓ change speed, +/− change size, M mirrors, F is full screen, Esc leaves; the mouse and a presentation remote work too.
|
| 447 |
A saved text can have an **outline (ראשי פרקים)**: on request the AI rewrites the text as the same slides with two to four very short topic reminders each, once; it is kept with the text and can be edited. Changing the text without sending an outline drops the outline, so a stale one is never shown.
|
|
|
|
| 462 |
|
| 463 |
## 6. Design Considerations
|
| 464 |
|
| 465 |
+
- Hebrew, right-to-left. Works on phones first; the teacher, manager, admin and developer views (progress by lesson, 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, progress cells, feedback).
|
| 470 |
- Mastered skills must look clearly different from "learning" and "not started" ones.
|
| 471 |
- Touch targets are at least 44px. Animations (stars, celebrations, comets) respect the device's reduced-motion setting.
|
| 472 |
- The "one task for today" on home is a single, obvious button.
|
|
|
|
| 530 |
- ≥ 90% of students take the simulation test before each real test.
|
| 531 |
- On test day, students have mastered on average ≥ 80% of the covered skills.
|
| 532 |
- ≥ 75% of students take both exercise tests before each real test.
|
| 533 |
+
- Teachers open the progress view at least once a week.
|
| 534 |
- The AI's estimated cost stays within each class's daily budget on every day.
|
| 535 |
|
| 536 |
## 9. Open Questions
|
mathly2/assignments.py
CHANGED
|
@@ -387,4 +387,6 @@ def teaching_counts(request: Request, user: dict = Depends(need("class.teach")))
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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 |
+
here.prefetch(*[("members", i) for i in ids])
|
| 391 |
+
students = len({m["email"] for i in ids for m in here.members(i)})
|
| 392 |
+
return {"classes": len(classes), "tests": len(tests), "assignments": len(assignments), "inbox": waiting, "progress": students}
|
mathly2/main.py
CHANGED
|
@@ -15,7 +15,7 @@ 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
|
|
@@ -77,6 +77,7 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store
|
|
| 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 |
|
|
|
|
| 15 |
from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
|
| 16 |
from .bank import Bank, load_bank
|
| 17 |
|
| 18 |
+
from . import accounts, assignments, classes, cleanup, progress, quizzes, reads, realtests, skills, written
|
| 19 |
from .config import Settings, load_settings
|
| 20 |
from .cached import CachedStore
|
| 21 |
from .store import InMemoryStore, StoreUnavailable, SupabaseStore
|
|
|
|
| 77 |
app.include_router(skills.router)
|
| 78 |
app.include_router(quizzes.router)
|
| 79 |
app.include_router(realtests.router)
|
| 80 |
+
app.include_router(progress.router)
|
| 81 |
app.include_router(written.router)
|
| 82 |
app.include_router(assignments.router)
|
| 83 |
|
mathly2/progress.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A class's progress for its teacher (PRD TCH-7, TCH-10): per lesson, never per skill, with the periods and the readiness per
|
| 2 |
+
student. Nothing is stored: it is all read from the students' progress, quizzes and tests, the lessons' dates and the bank."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import date, datetime
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 8 |
+
|
| 9 |
+
from . import attempts, clock, readiness, reads
|
| 10 |
+
from .access import need
|
| 11 |
+
from .learning import LEARNING, MASTERED, NOT_STARTED, skill_state
|
| 12 |
+
from .realtests import _my_class, facts_for, prime_students, readiness_for
|
| 13 |
+
from .store import _when
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/classes")
|
| 16 |
+
|
| 17 |
+
MONTHS = {"ספטמבר": 9, "אוקטובר": 10, "נובמבר": 11, "דצמבר": 12, "ינואר": 1, "פברואר": 2, "מרץ": 3, "אפריל": 4, "מאי": 5, "יוני": 6}
|
| 18 |
+
PERIODS = ({"id": 1, "label": "ספט׳–דצמ׳"}, {"id": 2, "label": "ינו׳–יוני"})
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def period_of_month(month: int) -> int:
|
| 22 |
+
return 1 if 9 <= month <= 12 else 2
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def school_year(today: date) -> int:
|
| 26 |
+
"""The calendar year in which this school year began (it starts in September)."""
|
| 27 |
+
return today.year if today.month >= 9 else today.year - 1
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def period_end(period: int, today: date) -> date:
|
| 31 |
+
year = school_year(today)
|
| 32 |
+
return date(year, 12, 31) if period == 1 else date(year + 1, 6, 30)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def lesson_period(lesson: dict, due: date | None) -> int:
|
| 36 |
+
"""By the lesson's date in the curriculum or, without one, by the last month of the bank's month for it."""
|
| 37 |
+
if due:
|
| 38 |
+
return period_of_month(due.month)
|
| 39 |
+
month = MONTHS.get(str(lesson.get("when", "")).split("–")[-1].strip())
|
| 40 |
+
return period_of_month(month) if month else 2
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _day(value) -> date | None:
|
| 44 |
+
if not value:
|
| 45 |
+
return None
|
| 46 |
+
try:
|
| 47 |
+
moment = value if isinstance(value, datetime) else _when(value)
|
| 48 |
+
except ValueError:
|
| 49 |
+
return None
|
| 50 |
+
return clock.school_day(moment) if moment.tzinfo else moment.date()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def state_on(progress: dict | None, end: date, today: date) -> str:
|
| 54 |
+
"""The skill's state at the end of a period (today's state while the period has not ended). Mastery never goes away, so the stored times say."""
|
| 55 |
+
if end >= today:
|
| 56 |
+
return skill_state(progress)
|
| 57 |
+
if progress:
|
| 58 |
+
mastered, started = _day(progress.get("mastered_at")), _day(progress.get("started_at"))
|
| 59 |
+
if mastered and mastered <= end:
|
| 60 |
+
return MASTERED
|
| 61 |
+
if (started and started <= end) or (mastered and mastered <= end):
|
| 62 |
+
return LEARNING
|
| 63 |
+
return NOT_STARTED
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def best_quiz(rows: list[dict], end: date, today: date) -> int | None:
|
| 67 |
+
"""The best grade of the quizzes handed in by the end of the period."""
|
| 68 |
+
grades = [r["grade"] for r in rows if r.get("submitted_at") and r.get("grade") is not None and (end >= today or _day(r["submitted_at"]) <= end)]
|
| 69 |
+
return max(grades) if grades else None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.get("/{class_id}/progress")
|
| 73 |
+
def class_progress(class_id: int, request: Request, period: int | None = None, user: dict = Depends(need("class.teach"))) -> dict:
|
| 74 |
+
klass = _my_class(request, user, class_id)
|
| 75 |
+
store, bank, here = request.app.state.store, request.app.state.bank, reads.of(request)
|
| 76 |
+
today = clock.school_day()
|
| 77 |
+
current = period_of_month(today.month)
|
| 78 |
+
if period is None:
|
| 79 |
+
period = current
|
| 80 |
+
if period not in (1, 2):
|
| 81 |
+
raise HTTPException(422, "תקופה לא מוכרת")
|
| 82 |
+
end = period_end(period, today)
|
| 83 |
+
members = store.members(klass["id"])
|
| 84 |
+
ids = [m["user_id"] for m in members if m["user_id"]]
|
| 85 |
+
here.prefetch(("curriculum",), ("tests_of_classes", [klass["id"]]))
|
| 86 |
+
prime_students(request, ids, kinds=("exercise_test", "simulation", "quiz"))
|
| 87 |
+
dates = here.curriculum()
|
| 88 |
+
lessons = [l for l in bank.lessons_with_skills() if lesson_period(l, dates.get(l["id"])) == period]
|
| 89 |
+
now_view = end >= today
|
| 90 |
+
students = []
|
| 91 |
+
for m in members:
|
| 92 |
+
uid = m["user_id"]
|
| 93 |
+
states = {p["skill_id"]: p for p in here.progress_of(uid)} if uid else {}
|
| 94 |
+
quizzes: dict[str, list[dict]] = {}
|
| 95 |
+
for row in (here.attempts_of(uid, "quiz") if uid else []):
|
| 96 |
+
quizzes.setdefault(row["scope"], []).append(row)
|
| 97 |
+
coverage = {}
|
| 98 |
+
for lesson in lessons:
|
| 99 |
+
mine = {s["id"]: state_on(states.get(s["id"]), end, today) for s in lesson["skills"]}
|
| 100 |
+
best = best_quiz(quizzes.get(lesson["id"], []), end, today)
|
| 101 |
+
mastered = sum(1 for v in mine.values() if v == MASTERED)
|
| 102 |
+
passed = any(r.get("submitted_at") and r["grade"] is not None and r["grade"] >= attempts.PASS_GRADE and (now_view or _day(r["submitted_at"]) <= end)
|
| 103 |
+
for r in quizzes.get(lesson["id"], []))
|
| 104 |
+
completed = passed or (bool(mine) and mastered == len(mine))
|
| 105 |
+
due = dates.get(lesson["id"])
|
| 106 |
+
started = best is not None or any(v != NOT_STARTED for v in mine.values())
|
| 107 |
+
coverage[lesson["id"]] = {"state": "completed" if completed else "learning" if started else "not_started",
|
| 108 |
+
"percent": round(100 * mastered / len(mine)) if mine else 0, "best": best, "completed": completed,
|
| 109 |
+
"behind": bool(now_view and due and due < today and not completed)}
|
| 110 |
+
students.append({"email": m["email"], "signed_in": bool(uid), "lessons": coverage})
|
| 111 |
+
nearest = next((t for t in sorted(store.tests_of_classes([klass["id"]]), key=lambda t: t["date"]) if t["date"] >= today), None)
|
| 112 |
+
test = None
|
| 113 |
+
if nearest:
|
| 114 |
+
rows = []
|
| 115 |
+
for m in members:
|
| 116 |
+
facts = facts_for(request, m["user_id"], nearest, today)
|
| 117 |
+
ready = readiness_for(facts)
|
| 118 |
+
rows.append({"email": m["email"], "signed_in": bool(m["user_id"]), "readiness": ready, "mastery": ready["parts"]["M"],
|
| 119 |
+
"practice": ready["parts"]["E"], "simulation": ready["parts"]["S"]})
|
| 120 |
+
names = {l["id"]: l["name"] for l in bank.lessons}
|
| 121 |
+
test = {"id": nearest["id"], "date": nearest["date"].isoformat(), "days_left": (nearest["date"] - today).days,
|
| 122 |
+
"lessons": [names.get(i, i) for i in nearest["lessons"]], "students": rows}
|
| 123 |
+
return {"class": {"id": klass["id"], "name": klass["name"]}, "period": period, "current_period": current, "as_of": min(end, today).isoformat(),
|
| 124 |
+
"periods": [{**p, "current": p["id"] == current} for p in PERIODS],
|
| 125 |
+
"lessons": [{"id": l["id"], "name": l["name"], "due_date": dates[l["id"]].isoformat() if l["id"] in dates else None} for l in lessons],
|
| 126 |
+
"students": students, "test": test}
|
mathly2/reads.py
CHANGED
|
@@ -40,6 +40,11 @@ class Reads:
|
|
| 40 |
return lambda *args: self._read(name, *args)
|
| 41 |
raise AttributeError(name)
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def prefetch(self, *calls: tuple) -> None:
|
| 44 |
"""Start the reads (each `(name, *args)`) that are not done yet, together, and wait for all of them. The first error is raised."""
|
| 45 |
missing = []
|
|
|
|
| 40 |
return lambda *args: self._read(name, *args)
|
| 41 |
raise AttributeError(name)
|
| 42 |
|
| 43 |
+
def prime(self, name: str, args: tuple, value) -> None:
|
| 44 |
+
"""Record an answer that was fetched another way (for many students at once), so a later `name(*args)` does not ask again."""
|
| 45 |
+
with self._lock:
|
| 46 |
+
self._done.setdefault(_key(name, args), value)
|
| 47 |
+
|
| 48 |
def prefetch(self, *calls: tuple) -> None:
|
| 49 |
"""Start the reads (each `(name, *args)`) that are not done yet, together, and wait for all of them. The first error is raised."""
|
| 50 |
missing = []
|
mathly2/realtests.py
CHANGED
|
@@ -45,6 +45,24 @@ def _covered(bank, test: dict) -> list[str]:
|
|
| 45 |
return [s["id"] for s in bank.skills if s["unit"] in test["lessons"] and bank.question_keys(s["id"])]
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def _student_reads(user_id: str) -> list[tuple]:
|
| 49 |
return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
|
| 50 |
|
|
@@ -155,7 +173,7 @@ def _checked_lessons(klass: dict, lessons: list[str]) -> list[str]:
|
|
| 155 |
def teacher_view(request: Request, test: dict, klass: dict, today: date) -> dict:
|
| 156 |
store, here = request.app.state.store, reads.of(request)
|
| 157 |
members = store.members(klass["id"])
|
| 158 |
-
|
| 159 |
students = []
|
| 160 |
for m in members:
|
| 161 |
facts = facts_for(request, m["user_id"], test, today)
|
|
|
|
| 45 |
return [s["id"] for s in bank.skills if s["unit"] in test["lessons"] and bank.question_keys(s["id"])]
|
| 46 |
|
| 47 |
|
| 48 |
+
def prime_students(request: Request, user_ids: list[str], kinds: tuple[str, ...] = ("exercise_test", "simulation")) -> None:
|
| 49 |
+
"""Read what a class's students need (progress, last answers and attempts of each kind) with one call each for all of them, together,
|
| 50 |
+
and hand it to the request's reads, so asking for one student's later does not touch the database."""
|
| 51 |
+
ids = list(dict.fromkeys(user_ids))
|
| 52 |
+
if not ids:
|
| 53 |
+
return
|
| 54 |
+
store, here = request.app.state.store, reads.of(request)
|
| 55 |
+
jobs = {"progress_of": reads.POOL.submit(store.progress_of_users, ids), "last_answered": reads.POOL.submit(store.last_answered_users, ids)}
|
| 56 |
+
jobs.update({("attempts_of", kind): reads.POOL.submit(store.attempts_of_users, ids, kind) for kind in kinds})
|
| 57 |
+
for key, future in jobs.items():
|
| 58 |
+
found = future.result()
|
| 59 |
+
for uid in ids:
|
| 60 |
+
if isinstance(key, tuple):
|
| 61 |
+
here.prime(key[0], (uid, key[1]), found[uid])
|
| 62 |
+
else:
|
| 63 |
+
here.prime(key, (uid,), found[uid])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def _student_reads(user_id: str) -> list[tuple]:
|
| 67 |
return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
|
| 68 |
|
|
|
|
| 173 |
def teacher_view(request: Request, test: dict, klass: dict, today: date) -> dict:
|
| 174 |
store, here = request.app.state.store, reads.of(request)
|
| 175 |
members = store.members(klass["id"])
|
| 176 |
+
prime_students(request, [m["user_id"] for m in members if m["user_id"]]) # every student's reads at once
|
| 177 |
students = []
|
| 178 |
for m in members:
|
| 179 |
facts = facts_for(request, m["user_id"], test, today)
|
mathly2/static/app.css
CHANGED
|
@@ -436,3 +436,25 @@ 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; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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; }
|
| 439 |
+
/* the teacher's progress tab: heat map, coverage, readiness */
|
| 440 |
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
| 441 |
+
#progress select { max-width: 100%; margin-bottom: 8px; }
|
| 442 |
+
.hm { border-collapse: separate; border-spacing: 0; font-size: 13px; min-width: 100%; }
|
| 443 |
+
.hm th, .hm td { border-bottom: 1px solid var(--line); padding: 6px 8px; text-align: center; white-space: nowrap; }
|
| 444 |
+
.hm .lesson-col { white-space: normal; min-width: 90px; max-width: 150px; font-size: 12px; border-inline-start: 1px solid var(--line); }
|
| 445 |
+
.hm .skill-col { font-weight: 400; color: var(--muted); padding: 2px 4px; min-width: 28px; }
|
| 446 |
+
.hm .sticky { position: sticky; inset-inline-start: 0; z-index: 1; background: var(--surface); text-align: start; border-inline-end: 1px solid var(--line); max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
| 447 |
+
.hm td.c { width: 28px; min-width: 28px; font-weight: 700; }
|
| 448 |
+
.hm .c-m { background: color-mix(in srgb, var(--sky-gold) 28%, var(--surface)); color: var(--sky-gold); }
|
| 449 |
+
.hm .c-l { background: color-mix(in srgb, var(--sky-accent) 22%, var(--surface)); color: var(--sky-accent); }
|
| 450 |
+
.hm .c-n { color: var(--muted); }
|
| 451 |
+
.legend { margin: 6px 0 0; font-size: 12px; }
|
| 452 |
+
#progress, #progress .tablewrap { min-width: 0; max-width: 100%; }
|
| 453 |
+
#progress .tablewrap { position: relative; } /* so the hidden words inside it (absolute) are clipped by its scrolling, not by the page */
|
| 454 |
+
.hm td.cov { min-width: 84px; padding: 6px 8px; }
|
| 455 |
+
.pbar { position: relative; height: 18px; border-radius: 9px; background: var(--line); overflow: hidden; }
|
| 456 |
+
.pbar i { position: absolute; inset-block: 0; inset-inline-start: 0; background: var(--sky-accent); opacity: .75; }
|
| 457 |
+
.hm td.cov.done .pbar i { background: var(--sky-gold); opacity: 1; }
|
| 458 |
+
.pbar span { position: relative; display: block; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; color: var(--ink); text-shadow: 0 0 3px var(--surface); }
|
| 459 |
+
.qgrade { margin-top: 3px; font-size: 13px; font-weight: 600; }
|
| 460 |
+
.qgrade .warn { color: var(--sky-danger); }
|
mathly2/static/teacher.js
CHANGED
|
@@ -9,7 +9,8 @@ const tabs = active => `<div class="subtabs" id="teacher-tabs" role="group" aria
|
|
| 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>
|
|
|
|
| 13 |
|
| 14 |
const work = () => import("./teacher_work.js");
|
| 15 |
const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() };
|
|
@@ -24,7 +25,7 @@ async function fillCounts() {
|
|
| 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 |
|
|
@@ -42,7 +43,7 @@ const showClasses = (notes = {}) => swr("/api/teaching", classes => drawClasses(
|
|
| 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>
|
|
@@ -56,6 +57,7 @@ async function drawClasses(classes, notes) {
|
|
| 56 |
<p><button class="primary" type="submit">הוספה</button></p></form></article>`).join("")}</div>`
|
| 57 |
: `<section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section>`}</section>`);
|
| 58 |
wireTabs();
|
|
|
|
| 59 |
document.querySelectorAll(".class-card").forEach(card => {
|
| 60 |
const id = Number(card.dataset.id);
|
| 61 |
card.querySelector("form").addEventListener("submit", async event => {
|
|
|
|
| 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>
|
| 13 |
+
<button type="button" data-tab="progress" aria-pressed="${active === "progress"}">התקדמות <span class="tab-count" data-count="progress"></span></button></div>`;
|
| 14 |
|
| 15 |
const work = () => import("./teacher_work.js");
|
| 16 |
const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() };
|
|
|
|
| 25 |
|
| 26 |
function wireTabs() {
|
| 27 |
fillCounts();
|
| 28 |
+
const screens = { classes: showClasses, tests: showTests, assignments: () => work().then(m => m.showAssignments(context)), inbox: () => work().then(m => m.showInbox(context)), progress: () => import("./teacher_progress.js").then(m => m.show(context)) };
|
| 29 |
document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => screens[b.dataset.tab]()));
|
| 30 |
}
|
| 31 |
|
|
|
|
| 43 |
async function drawClasses(classes, notes) {
|
| 44 |
draw(`${tabs("classes")}<section id="teaching"><h2>הכיתות שלי</h2>
|
| 45 |
${classes.length ? `<div class="cards">${classes.map(c => `<article class="card class-card" data-id="${c.id}">
|
| 46 |
+
<div class="hrow"><h3 class="grow">${esc(c.name)}</h3><span class="tag">${c.students.length} תלמידים</span><button type="button" data-progress="${c.id}">התקדמות</button></div>
|
| 47 |
<p class="muted" style="margin:0 0 8px">${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}</p>
|
| 48 |
${c.students.length ? `<ul class="plain students">${c.students.map(s => `<li><span dir="ltr">${esc(s.email)}</span>
|
| 49 |
<span class="muted">${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"}</span>
|
|
|
|
| 57 |
<p><button class="primary" type="submit">הוספה</button></p></form></article>`).join("")}</div>`
|
| 58 |
: `<section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section>`}</section>`);
|
| 59 |
wireTabs();
|
| 60 |
+
document.querySelectorAll("[data-progress]").forEach(b => b.addEventListener("click", () => import("./teacher_progress.js").then(m => m.show(context, Number(b.dataset.progress)))));
|
| 61 |
document.querySelectorAll(".class-card").forEach(card => {
|
| 62 |
const id = Number(card.dataset.id);
|
| 63 |
card.querySelector("form").addEventListener("submit", async event => {
|
mathly2/static/teacher_progress.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// A class's progress for its teacher, per lesson (never per skill), and the readiness for the coming test, with the two period tabs. One class at a time; the class is chosen here when the teacher has several.
|
| 2 |
+
import { api, swr } from "./api.js";
|
| 3 |
+
import { esc, fmt } from "./math.js";
|
| 4 |
+
import { shortDate } from "./ui.js";
|
| 5 |
+
|
| 6 |
+
const STATE = { completed: ["✓", "הושלם", "m"], learning: ["◐", "בלמידה", "l"], not_started: ["○", "לא התחילו", "n"] };
|
| 7 |
+
const VIEWS = [["lessons", "לפי שיעור"], ["readiness", "מוכנות למבחן"]];
|
| 8 |
+
let chosen = { classId: null, period: null, view: "lessons" }; // what the teacher last looked at
|
| 9 |
+
|
| 10 |
+
const dash = value => value === null || value === undefined ? "—" : value;
|
| 11 |
+
const first = email => esc(email.split("@")[0]);
|
| 12 |
+
const who = s => `<th scope="row" class="sticky"><span dir="ltr" title="${esc(s.email)}">${first(s.email)}</span>${s.signed_in ? "" : ` <span class="muted">(לא נכנס/ה)</span>`}</th>`;
|
| 13 |
+
|
| 14 |
+
function lessonsView(data) {
|
| 15 |
+
if (!data.lessons.length) return `<p class="muted">אין שיעורים בתקופה הזו.</p>`;
|
| 16 |
+
const head = data.lessons.map(l => `<th class="lesson-col">${fmt(l.name)}${l.due_date ? `<br><span class="muted">עד ${shortDate(l.due_date)}</span>` : ""}</th>`).join("");
|
| 17 |
+
const rows = data.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}${data.lessons.map(l => {
|
| 18 |
+
const c = s.lessons[l.id], word = (STATE[c.state] || STATE.not_started)[1];
|
| 19 |
+
return `<td class="cov${c.completed ? " done" : ""}${c.behind ? " behind" : ""}" data-lesson="${esc(l.id)}" data-state="${esc(c.state)}" aria-label="${word}${c.behind ? ", בפיגור" : ""}">
|
| 20 |
+
<div class="pbar"><i style="width:${c.percent}%"></i><span>${c.percent}%</span></div>
|
| 21 |
+
<div class="qgrade">${dash(c.best)}${c.behind ? ` <span class="warn" data-behind aria-hidden="true">⚠</span>` : ""}</div></td>`;
|
| 22 |
+
}).join("")}</tr>`).join("");
|
| 23 |
+
return `<div class="tablewrap" tabindex="0" role="region" aria-label="התקדמות לפי שיעור"><table class="hm"><thead><tr><th class="sticky">תלמיד</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
|
| 24 |
+
<p class="muted legend">הסרגל: אחוז המיומנויות שנשלטו (זהב: השיעור הושלם) · המספר: הציון בבוחן · ⚠ בפיגור</p>`;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function readiness(data) {
|
| 28 |
+
const t = data.test;
|
| 29 |
+
if (!t) return `<p class="muted" id="no-test">עוד לא נקבע מבחן קרוב לכיתה הזו.</p>`;
|
| 30 |
+
const rows = t.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}<td><b>${s.readiness.number}</b> · ${esc(s.readiness.label)}</td><td>${dash(s.mastery)}${s.mastery === null ? "" : "%"}</td><td>${dash(s.practice)}</td><td>${dash(s.simulation)}</td></tr>`).join("");
|
| 31 |
+
return `<p>מבחן ב־${shortDate(t.date)} (${t.days_left === 0 ? "היום" : `בעוד ${t.days_left} ימים`}): ${t.lessons.map(esc).join(", ")}</p>
|
| 32 |
+
<div class="tablewrap" tabindex="0" role="region" aria-label="מוכנות"><table class="hm"><thead><tr><th class="sticky">תלמיד</th><th>מוכנות</th><th>שליטה</th><th>תרגול</th><th>סימולציה</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function page(ctx, classes, data) {
|
| 36 |
+
const periodTabs = chosen.view === "readiness" ? "" : `<div class="subtabs" id="period-tabs" role="group" aria-label="תקופה">${data.periods.map(p =>
|
| 37 |
+
`<button type="button" data-period="${p.id}" aria-pressed="${p.id === data.period}">${p.label}${p.current ? " (עכשיו)" : ""}</button>`).join("")}</div>`;
|
| 38 |
+
const body = chosen.view === "readiness" ? readiness(data) : lessonsView(data);
|
| 39 |
+
ctx.draw(`${ctx.tabs("progress")}<section id="progress"><h2>התקדמות</h2>
|
| 40 |
+
${classes.length > 1 ? `<label for="class-pick">כיתה</label><select id="class-pick">${classes.map(c => `<option value="${c.id}"${c.id === data.class.id ? " selected" : ""}>${esc(c.name)}</option>`).join("")}</select>` : `<p class="muted">${esc(data.class.name)}</p>`}
|
| 41 |
+
<div class="subtabs" id="view-tabs" role="group" aria-label="תצוגה">${VIEWS.map(([id, label]) => `<button type="button" data-view="${id}" aria-pressed="${id === chosen.view}">${label}</button>`).join("")}</div>
|
| 42 |
+
${periodTabs}${body}</section>`);
|
| 43 |
+
ctx.wire();
|
| 44 |
+
document.getElementById("class-pick")?.addEventListener("change", e => show(ctx, Number(e.target.value), null));
|
| 45 |
+
document.getElementById("view-tabs").addEventListener("click", e => {
|
| 46 |
+
const view = e.target.closest("[data-view]")?.dataset.view;
|
| 47 |
+
if (view) { chosen.view = view; page(ctx, classes, data); } // the same data, another view
|
| 48 |
+
});
|
| 49 |
+
document.getElementById("period-tabs")?.addEventListener("click", e => {
|
| 50 |
+
const period = Number(e.target.closest("[data-period]")?.dataset.period);
|
| 51 |
+
if (period) show(ctx, data.class.id, period);
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** Open the progress tab for `classId` (the one last looked at, or the first, when not given). */
|
| 56 |
+
export async function show(ctx, classId = null, period = null) {
|
| 57 |
+
const classes = await api("/api/teaching");
|
| 58 |
+
if (!classes.length) {
|
| 59 |
+
ctx.draw(`${ctx.tabs("progress")}<section id="progress"><h2>התקדמות</h2><section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section></section>`);
|
| 60 |
+
return ctx.wire();
|
| 61 |
+
}
|
| 62 |
+
const id = classes.some(c => c.id === (classId ?? chosen.classId)) ? (classId ?? chosen.classId) : classes[0].id;
|
| 63 |
+
if (id !== chosen.classId || (classId !== null && period === null)) chosen.period = null;
|
| 64 |
+
chosen = { ...chosen, classId: id, period: period ?? chosen.period };
|
| 65 |
+
await swr(`/api/classes/${id}/progress${chosen.period ? `?period=${chosen.period}` : ""}`, data => page(ctx, classes, data));
|
| 66 |
+
}
|
mathly2/store.py
CHANGED
|
@@ -270,6 +270,36 @@ class InMemoryStore:
|
|
| 270 |
with self._lock:
|
| 271 |
return [dict(r) for (u, _), r in self._progress.items() if u == user_id]
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
|
| 274 |
mistake_id: str | None, day: date) -> None:
|
| 275 |
with self._lock:
|
|
@@ -788,6 +818,36 @@ class SupabaseStore:
|
|
| 788 |
def progress_of(self, user_id: str) -> list[dict]:
|
| 789 |
return self._request("GET", "m2_skill_progress", {"user_id": self._eq(user_id), "select": "*"})
|
| 790 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
|
| 792 |
mistake_id: str | None, day: date) -> None:
|
| 793 |
self._request("POST", "m2_answers", body={"user_id": user_id, "skill_id": skill_id, "question_key": question_key,
|
|
|
|
| 270 |
with self._lock:
|
| 271 |
return [dict(r) for (u, _), r in self._progress.items() if u == user_id]
|
| 272 |
|
| 273 |
+
# --- many students at once (a class's progress screen: one call each, not one per student) ---
|
| 274 |
+
|
| 275 |
+
def progress_of_users(self, user_ids) -> dict[str, list[dict]]:
|
| 276 |
+
wanted = set(user_ids)
|
| 277 |
+
with self._lock:
|
| 278 |
+
found: dict[str, list[dict]] = {u: [] for u in dict.fromkeys(user_ids)}
|
| 279 |
+
for (u, _), r in self._progress.items():
|
| 280 |
+
if u in wanted:
|
| 281 |
+
found[u].append(dict(r))
|
| 282 |
+
return found
|
| 283 |
+
|
| 284 |
+
def last_answered_users(self, user_ids) -> dict[str, dict[str, date]]:
|
| 285 |
+
wanted = set(user_ids)
|
| 286 |
+
with self._lock:
|
| 287 |
+
found: dict[str, dict[str, date]] = {u: {} for u in dict.fromkeys(user_ids)}
|
| 288 |
+
for a in self._answers:
|
| 289 |
+
days = found.get(a["user_id"])
|
| 290 |
+
if days is not None and (a["skill_id"] not in days or a["day"] > days[a["skill_id"]]):
|
| 291 |
+
days[a["skill_id"]] = a["day"]
|
| 292 |
+
return found
|
| 293 |
+
|
| 294 |
+
def attempts_of_users(self, user_ids, kind: str) -> dict[str, list[dict]]:
|
| 295 |
+
wanted = set(user_ids)
|
| 296 |
+
with self._lock:
|
| 297 |
+
found: dict[str, list[dict]] = {u: [] for u in dict.fromkeys(user_ids)}
|
| 298 |
+
for r in sorted(self._attempts.values(), key=lambda r: r["started_at"]):
|
| 299 |
+
if r["user_id"] in wanted and r["kind"] == kind:
|
| 300 |
+
found[r["user_id"]].append(_copy_attempt(r))
|
| 301 |
+
return found
|
| 302 |
+
|
| 303 |
def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
|
| 304 |
mistake_id: str | None, day: date) -> None:
|
| 305 |
with self._lock:
|
|
|
|
| 818 |
def progress_of(self, user_id: str) -> list[dict]:
|
| 819 |
return self._request("GET", "m2_skill_progress", {"user_id": self._eq(user_id), "select": "*"})
|
| 820 |
|
| 821 |
+
# --- many students at once ---
|
| 822 |
+
|
| 823 |
+
@staticmethod
|
| 824 |
+
def _in(ids) -> str:
|
| 825 |
+
return "in.(" + ",".join(dict.fromkeys(ids)) + ")"
|
| 826 |
+
|
| 827 |
+
def progress_of_users(self, user_ids) -> dict[str, list[dict]]:
|
| 828 |
+
ids = list(dict.fromkeys(user_ids))
|
| 829 |
+
found: dict[str, list[dict]] = {u: [] for u in ids}
|
| 830 |
+
if ids:
|
| 831 |
+
for r in self._request("GET", "m2_skill_progress", {"user_id": self._in(ids), "select": "*"}):
|
| 832 |
+
found[str(r["user_id"])].append(r)
|
| 833 |
+
return found
|
| 834 |
+
|
| 835 |
+
def last_answered_users(self, user_ids) -> dict[str, dict[str, date]]:
|
| 836 |
+
ids = list(dict.fromkeys(user_ids))
|
| 837 |
+
found: dict[str, dict[str, date]] = {u: {} for u in ids}
|
| 838 |
+
if ids:
|
| 839 |
+
for r in self._request("GET", "m2_last_answered", {"user_id": self._in(ids), "select": "user_id,skill_id,day"}):
|
| 840 |
+
found[str(r["user_id"])][r["skill_id"]] = date.fromisoformat(r["day"])
|
| 841 |
+
return found
|
| 842 |
+
|
| 843 |
+
def attempts_of_users(self, user_ids, kind: str) -> dict[str, list[dict]]:
|
| 844 |
+
ids = list(dict.fromkeys(user_ids))
|
| 845 |
+
found: dict[str, list[dict]] = {u: [] for u in ids}
|
| 846 |
+
if ids:
|
| 847 |
+
for r in self._request("GET", "m2_attempts", {"user_id": self._in(ids), "kind": self._eq(kind), "select": "*", "order": "started_at"}):
|
| 848 |
+
found[str(r["user_id"])].append(_attempt_row(r))
|
| 849 |
+
return found
|
| 850 |
+
|
| 851 |
def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
|
| 852 |
mistake_id: str | None, day: date) -> None:
|
| 853 |
self._request("POST", "m2_answers", body={"user_id": user_id, "skill_id": skill_id, "question_key": question_key,
|
mathly2/tests/test_assignments_api.py
CHANGED
|
@@ -467,7 +467,7 @@ def counts(client, who=TEACHER):
|
|
| 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()
|
|
@@ -478,7 +478,7 @@ def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world)
|
|
| 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):
|
|
@@ -490,13 +490,13 @@ def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counte
|
|
| 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
|
|
|
|
| 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, "progress": 2}
|
| 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()
|
|
|
|
| 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, "progress": 2}
|
| 482 |
|
| 483 |
|
| 484 |
def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
|
|
|
|
| 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, "progress": 2}
|
| 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, "progress": 1}
|
| 500 |
+
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0, "progress": 2}
|
| 501 |
+
assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0, "progress": 3}
|
| 502 |
assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
|
mathly2/tests/test_permissions.py
CHANGED
|
@@ -64,6 +64,7 @@ NEEDS = { # (method, path) -> permission
|
|
| 64 |
("POST", "/api/classes"): "classes.manage",
|
| 65 |
("GET", "/api/classes/{class_id}"): "classes.manage",
|
| 66 |
("PUT", "/api/classes/{class_id}"): "classes.manage",
|
|
|
|
| 67 |
("GET", "/api/curriculum"): "classes.manage",
|
| 68 |
("PUT", "/api/curriculum"): "classes.manage",
|
| 69 |
("DELETE", "/api/classes/{class_id}"): "classes.manage",
|
|
|
|
| 64 |
("POST", "/api/classes"): "classes.manage",
|
| 65 |
("GET", "/api/classes/{class_id}"): "classes.manage",
|
| 66 |
("PUT", "/api/classes/{class_id}"): "classes.manage",
|
| 67 |
+
("GET", "/api/classes/{class_id}/progress"): "class.teach",
|
| 68 |
("GET", "/api/curriculum"): "classes.manage",
|
| 69 |
("PUT", "/api/curriculum"): "classes.manage",
|
| 70 |
("DELETE", "/api/classes/{class_id}"): "classes.manage",
|
mathly2/tests/test_progress_api.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A class's progress for its teacher (add-class-progress-views 1.2): heat map, coverage, periods, readiness and who may read them."""
|
| 2 |
+
import time
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
|
| 5 |
+
from mathly2 import clock
|
| 6 |
+
from mathly2.tests.test_assignments_api import DEV, KID, KID2, MGR, MON, OTHER_TEACHER, OUTSIDER, TEACHER, auth, bank, uid, world # noqa: F401
|
| 7 |
+
|
| 8 |
+
STAMP = "2026-10-05T10:00:00+00:00"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def progress(client, who=TEACHER, period=None, klass=None):
|
| 12 |
+
q = f"?period={period}" if period else ""
|
| 13 |
+
return client.get(f"/api/classes/{(klass or client.a)['id']}/progress{q}", headers=auth(who))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def lesson_of(bank, lesson_id):
|
| 17 |
+
return next(l for l in bank.lessons_with_skills() if l["id"] == lesson_id)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_progress_is_per_lesson_with_a_state_and_never_per_skill(world, bank):
|
| 21 |
+
lesson = lesson_of(bank, "t")
|
| 22 |
+
first, second = lesson["skills"][0]["id"], lesson["skills"][1]["id"]
|
| 23 |
+
world.store.save_progress(uid(KID), first, started_at=STAMP, mastered_at=STAMP)
|
| 24 |
+
world.store.save_progress(uid(KID), second, started_at=STAMP)
|
| 25 |
+
world.store.add_member(world.a["id"], "new@school.test") # never signed in
|
| 26 |
+
got = progress(world, period=1).json()
|
| 27 |
+
rows = {s["email"]: s for s in got["students"]}
|
| 28 |
+
assert set(rows) == {KID, KID2, "new@school.test"} and [l["id"] for l in got["lessons"]][0] == "t"
|
| 29 |
+
assert rows[KID]["lessons"]["t"]["state"] == "learning" and rows[KID2]["lessons"]["t"]["state"] == "not_started"
|
| 30 |
+
assert {c["state"] for c in rows["new@school.test"]["lessons"].values()} == {"not_started"} and rows["new@school.test"]["signed_in"] is False
|
| 31 |
+
text = str(got)
|
| 32 |
+
assert not any(k in text for k in ("states", "skills")) and first not in text # nothing stands for a single skill
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_coverage_is_the_percent_mastered_and_the_best_quiz_and_completed_or_behind(world, bank):
|
| 36 |
+
lesson = lesson_of(bank, "t")
|
| 37 |
+
skills = [s["id"] for s in lesson["skills"]]
|
| 38 |
+
for s in skills[:3]:
|
| 39 |
+
world.store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
|
| 40 |
+
q = world.store.create_attempt(uid(KID), "quiz", "t", MON - timedelta(hours=2), MON - timedelta(hours=1), [])
|
| 41 |
+
world.store.save_attempt(q["id"], submitted_at=MON - timedelta(hours=1), grade=80)
|
| 42 |
+
world.store.set_lesson_dates({"t": clock.school_day() - timedelta(days=3)})
|
| 43 |
+
got = {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}
|
| 44 |
+
assert got[KID] == {"state": "learning", "percent": round(300 / len(skills)), "best": 80, "completed": False, "behind": True}
|
| 45 |
+
assert got[KID2] == {"state": "not_started", "percent": 0, "best": None, "completed": False, "behind": True}
|
| 46 |
+
for s in skills[3:]: # all mastered: completed, no longer behind
|
| 47 |
+
world.store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
|
| 48 |
+
again = {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}
|
| 49 |
+
assert again[KID]["percent"] == 100 and again[KID]["state"] == "completed" and again[KID]["completed"] is True and again[KID]["behind"] is False
|
| 50 |
+
q2 = world.store.create_attempt(uid(KID2), "quiz", "t", MON - timedelta(hours=2), MON - timedelta(hours=1), [])
|
| 51 |
+
world.store.save_attempt(q2["id"], submitted_at=MON - timedelta(hours=1), grade=87)
|
| 52 |
+
assert {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}[KID2]["completed"] is True # a passed quiz completes it
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_periods_pick_lessons_by_date_or_month_and_show_the_states_as_they_were(world, bank):
|
| 56 |
+
# "t" is a September lesson, "n" a March lesson; a date in the curriculum moves a lesson
|
| 57 |
+
first = progress(world, period=1).json()
|
| 58 |
+
second = progress(world, period=2).json()
|
| 59 |
+
ids1, ids2 = [l["id"] for l in first["lessons"]], [l["id"] for l in second["lessons"]]
|
| 60 |
+
assert "t" in ids1 and "n" in ids2 and "t" not in ids2 and not set(ids1) & set(ids2) and len(ids1) + len(ids2) == 24
|
| 61 |
+
assert first["current_period"] == 1 and first["periods"][0]["current"] is True
|
| 62 |
+
world.store.set_lesson_dates({"t": date_in(2027, 2, 15)})
|
| 63 |
+
assert "t" in [l["id"] for l in progress(world, period=2).json()["lessons"]]
|
| 64 |
+
# a skill mastered on 20 January: not mastered in the first period (ended 31 December), mastered in the second, seen in March
|
| 65 |
+
skill = lesson_of(bank, "n")["skills"][0]["id"]
|
| 66 |
+
world.store.save_progress(uid(KID), skill, started_at="2026-12-01T10:00:00+00:00", mastered_at="2027-01-20T10:00:00+00:00")
|
| 67 |
+
world.set_time(datetime(2027, 3, 10, 9, 0, tzinfo=timezone.utc))
|
| 68 |
+
pct = lambda data, lesson="n": {s["email"]: s["lessons"][lesson] for s in data["students"]}[KID]
|
| 69 |
+
assert pct(progress(world, period=2).json())["percent"] > 0 and pct(progress(world, period=2).json())["state"] == "learning"
|
| 70 |
+
world.store.set_lesson_dates({"n": date_in(2026, 12, 20)}) # move "n" into the first period to see its past state
|
| 71 |
+
past = progress(world, period=1).json()
|
| 72 |
+
assert pct(past)["percent"] == 0 and pct(past)["state"] == "learning" and past["as_of"] == "2026-12-31" # started by then, mastered only in January
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def date_in(y, m, d):
|
| 76 |
+
from datetime import date
|
| 77 |
+
return date(y, m, d)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_readiness_per_student_for_the_nearest_coming_test_and_none_without(world, bank):
|
| 81 |
+
assert progress(world).json()["test"] is None
|
| 82 |
+
far = world.store.create_test(world.a["id"], clock.school_day() + timedelta(days=30), ["t"], TEACHER, created_at=clock.now())
|
| 83 |
+
near = world.store.create_test(world.a["id"], clock.school_day() + timedelta(days=10), ["t"], TEACHER, created_at=clock.now())
|
| 84 |
+
got = progress(world).json()["test"]
|
| 85 |
+
assert got["id"] == near["id"] and got["days_left"] == 10
|
| 86 |
+
rows = {r["email"]: r for r in got["students"]}
|
| 87 |
+
assert set(rows) == {KID, KID2} and rows[KID]["readiness"]["number"] == 0 and rows[KID]["simulation"] is None and rows[KID]["practice"] is None
|
| 88 |
+
assert far["id"] != near["id"]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_only_the_classs_teacher_or_a_developer_may_read_it(world):
|
| 92 |
+
assert progress(world, TEACHER).status_code == 200 and progress(world, DEV).status_code == 200
|
| 93 |
+
assert progress(world, OTHER_TEACHER).status_code == 404 # another teacher's class: not found
|
| 94 |
+
assert progress(world, MGR).status_code == 403 and progress(world, KID).status_code == 403
|
| 95 |
+
assert progress(world, TEACHER, klass=world.b).status_code == 404
|
| 96 |
+
assert progress(world, period=3).status_code == 422
|
| 97 |
+
assert world.get(f"/api/classes/{world.a['id']}/progress").status_code in (401, 403)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_a_class_of_twenty_five_needs_no_more_waits_in_a_row_than_a_class_of_two(bank):
|
| 101 |
+
"""Every store call waits 0.1 s: the progress screen asks for all students at once, so it takes the same time for 2 as for 25."""
|
| 102 |
+
from fastapi.testclient import TestClient
|
| 103 |
+
from mathly2.config import Settings
|
| 104 |
+
from mathly2.main import create_app
|
| 105 |
+
from mathly2.tests.test_database_waits import DELAY, SlowStore
|
| 106 |
+
|
| 107 |
+
def took(n):
|
| 108 |
+
store = SlowStore()
|
| 109 |
+
klass = store.create_class("setup", "A", lessons=["t"], teacher_email=TEACHER)
|
| 110 |
+
for i in range(n):
|
| 111 |
+
email = f"s{i}@school.test"
|
| 112 |
+
store.add_member(klass["id"], email)
|
| 113 |
+
store.link_user(email, uid(email))
|
| 114 |
+
store.create_test(klass["id"], clock.school_day() + timedelta(days=10), ["t"], TEACHER, created_at=clock.now())
|
| 115 |
+
client = TestClient(create_app(Settings(), bank=bank, store=store))
|
| 116 |
+
client.get("/api/me", headers=auth(TEACHER))
|
| 117 |
+
store.slow = True
|
| 118 |
+
start = time.perf_counter()
|
| 119 |
+
assert client.get(f"/api/classes/{klass['id']}/progress", headers=auth(TEACHER)).status_code == 200
|
| 120 |
+
return time.perf_counter() - start
|
| 121 |
+
|
| 122 |
+
small, big = took(2), took(25)
|
| 123 |
+
assert big < small + 2 * DELAY, (small, big)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_the_store_reads_many_students_at_once_and_does_not_mix_them():
|
| 127 |
+
from mathly2.store import InMemoryStore
|
| 128 |
+
store = InMemoryStore()
|
| 129 |
+
a, b, c = "a", "b", "c"
|
| 130 |
+
store.save_progress(a, "s1", started_at=STAMP)
|
| 131 |
+
store.save_progress(b, "s2", started_at=STAMP, mastered_at=STAMP)
|
| 132 |
+
store.save_progress(c, "s3", started_at=STAMP)
|
| 133 |
+
store.add_answer(a, "s1", "s1:k", True, True, None, clock.school_day())
|
| 134 |
+
row = store.create_attempt(a, "quiz", "t", MON, MON + timedelta(minutes=20), [])
|
| 135 |
+
store.create_attempt(c, "quiz", "t", MON, MON + timedelta(minutes=20), [])
|
| 136 |
+
store.create_attempt(a, "simulation", "1", MON, MON + timedelta(minutes=20), [])
|
| 137 |
+
got = store.progress_of_users([a, b])
|
| 138 |
+
assert set(got) == {a, b} and [r["skill_id"] for r in got[a]] == ["s1"] and got[b][0]["mastered_at"] == STAMP
|
| 139 |
+
assert store.progress_of_users([]) == {} and store.progress_of_users(["nobody"]) == {"nobody": []}
|
| 140 |
+
assert list(store.last_answered_users([a, b])) == [a, b] and "s1" in store.last_answered_users([a])[a] and store.last_answered_users([b])[b] == {}
|
| 141 |
+
quizzes = store.attempts_of_users([a, b], "quiz")
|
| 142 |
+
assert [r["id"] for r in quizzes[a]] == [row["id"]] and quizzes[b] == []
|
mathly2/tests/test_progress_browser.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The teacher's progress tab (add-class-progress-views 2.1), in a real browser."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
playwright = pytest.importorskip("playwright.sync_api")
|
| 5 |
+
|
| 6 |
+
from mathly2 import clock
|
| 7 |
+
from mathly2.tests.serve import offline_formulas, running
|
| 8 |
+
from mathly2.tests.test_written_browser import DESKTOP, KID, KID2, PHONE, TEACHER, bank, browser, day, uid, world # noqa: F401
|
| 9 |
+
|
| 10 |
+
STAMP = "2026-10-05T10:00:00+00:00"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def open_progress(browser, url, viewport=PHONE, via="tab"):
|
| 14 |
+
page = offline_formulas(browser.new_page(viewport=viewport))
|
| 15 |
+
errors = []
|
| 16 |
+
page.on("pageerror", lambda e: errors.append(str(e)))
|
| 17 |
+
page.goto(url)
|
| 18 |
+
page.fill("#email", TEACHER)
|
| 19 |
+
page.click("#dev button")
|
| 20 |
+
page.wait_for_selector("#teaching")
|
| 21 |
+
if via == "tab":
|
| 22 |
+
page.click("#teacher-tabs [data-tab=progress]")
|
| 23 |
+
else:
|
| 24 |
+
page.click(".class-card [data-progress]")
|
| 25 |
+
page.wait_for_selector("#progress table, #progress .muted")
|
| 26 |
+
page.errors = errors
|
| 27 |
+
return page
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def no_sideways(page):
|
| 31 |
+
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("viewport", [PHONE, DESKTOP], ids=["390px", "1280px"])
|
| 35 |
+
def test_progress_is_per_lesson_each_cell_says_its_state_and_the_table_scrolls_inside_its_frame(browser, bank, viewport):
|
| 36 |
+
app, store, klass, lessons = world(bank)
|
| 37 |
+
skills = [s["id"] for s in bank.skills if s["unit"] == "t"]
|
| 38 |
+
store.save_progress(uid(KID), skills[0], started_at=STAMP, mastered_at=STAMP)
|
| 39 |
+
for s in skills:
|
| 40 |
+
store.save_progress(uid(KID2), s, started_at=STAMP, mastered_at=STAMP)
|
| 41 |
+
store.add_member(klass["id"], "new@school.test")
|
| 42 |
+
with running(app) as url:
|
| 43 |
+
page = open_progress(browser, url, viewport)
|
| 44 |
+
assert page.locator("#progress tbody tr").count() == 3 and page.locator("th.skill-col, .hm td.c").count() == 0 # no skill column or cell
|
| 45 |
+
cell = lambda who: page.locator(f"tr[data-student='{who}'] td[data-lesson=t]")
|
| 46 |
+
assert cell(KID).get_attribute("data-state") == "learning" and cell(KID).get_attribute("aria-label") == "בלמידה"
|
| 47 |
+
assert cell(KID2).get_attribute("data-state") == "completed" and cell(KID2).get_attribute("aria-label") == "הושלם" and cell(KID2).inner_text().split() == ["100%", "—"]
|
| 48 |
+
assert cell("new@school.test").get_attribute("data-state") == "not_started" and cell("new@school.test").get_attribute("aria-label") == "לא התחילו"
|
| 49 |
+
assert cell("new@school.test").inner_text().split() == ["0%", "—"] # a bar with its percent and a dash: nothing else
|
| 50 |
+
assert page.locator("td.cov .pbar").count() == page.locator("td.cov").count() and "בוחן" not in page.inner_text("tbody")
|
| 51 |
+
assert page.locator("td.cov:not([data-state])").count() == 0
|
| 52 |
+
assert page.evaluate("(() => { const w = document.querySelector('.tablewrap'); return w.scrollWidth > w.clientWidth; })()") or viewport["width"] > 800
|
| 53 |
+
no_sideways(page)
|
| 54 |
+
assert not page.errors
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_the_card_shortcut_the_picker_the_views_and_the_periods(browser, bank):
|
| 58 |
+
app, store, klass, lessons = world(bank)
|
| 59 |
+
other = store.create_class("setup", "B", lessons=lessons[:1], teacher_email=TEACHER)
|
| 60 |
+
store.create_test(klass["id"], day(10), ["t"], TEACHER, created_at=clock.now())
|
| 61 |
+
with running(app) as url:
|
| 62 |
+
page = open_progress(browser, url, via="card")
|
| 63 |
+
assert page.locator("#class-pick option").count() == 2 and "לפי שיעור" in page.inner_text("#view-tabs")
|
| 64 |
+
page.click("#period-tabs [data-period='2']")
|
| 65 |
+
page.wait_for_function("document.querySelector('#period-tabs [data-period=\"2\"]').getAttribute('aria-pressed') === 'true'")
|
| 66 |
+
assert page.locator("th.lesson-col").count() > 0 and page.locator("td.cov .pbar").count() > 0
|
| 67 |
+
page.click("#view-tabs [data-view=readiness]")
|
| 68 |
+
assert page.locator("#period-tabs").count() == 0 and "מוכנות" in page.inner_text("#progress table")
|
| 69 |
+
page.select_option("#class-pick", str(other["id"]))
|
| 70 |
+
page.wait_for_selector("#no-test") # class B has no coming test
|
| 71 |
+
no_sideways(page)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_the_table_marks_a_completed_and_a_behind_lesson_in_words(browser, bank):
|
| 75 |
+
app, store, klass, lessons = world(bank)
|
| 76 |
+
skills = [s["id"] for s in bank.skills if s["unit"] == "t"]
|
| 77 |
+
for s in skills:
|
| 78 |
+
store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
|
| 79 |
+
store.set_lesson_dates({"t": day(-3)})
|
| 80 |
+
with running(app) as url:
|
| 81 |
+
page = open_progress(browser, url)
|
| 82 |
+
assert page.get_attribute(f"tr[data-student='{KID}'] td[data-lesson=t]", "aria-label") == "הושלם" and page.locator(f"tr[data-student='{KID}'] [data-behind]").count() == 0
|
| 83 |
+
assert page.get_attribute(f"tr[data-student='{KID2}'] td[data-lesson=t]", "aria-label") == "לא התחילו, בפיגור"
|
| 84 |
+
assert page.locator(f"tr[data-student='{KID2}'] td[data-lesson=t] [data-behind]").inner_text() == "⚠" # the visual mark
|
| 85 |
+
assert page.inner_text("#teacher-tabs [data-tab=progress] .tab-count") == "2" # the tab's number: the students
|
| 86 |
+
no_sideways(page)
|
mathly2/tests/test_written_browser.py
CHANGED
|
@@ -841,11 +841,11 @@ def test_every_teacher_tab_shows_its_number_and_the_numbers_change_after_grading
|
|
| 841 |
page.wait_for_selector("#teaching")
|
| 842 |
page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
|
| 843 |
numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
|
| 844 |
-
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"}
|
| 845 |
for tab in ("tests", "assignments", "inbox"): # on every tab
|
| 846 |
page.click(f"#teacher-tabs [data-tab='{tab}']")
|
| 847 |
page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
|
| 848 |
-
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"}, tab
|
| 849 |
page.click(f"[data-open='{monday['id']}']")
|
| 850 |
page.fill("[name=grade]", "80")
|
| 851 |
page.click("#give-grade")
|
|
|
|
| 841 |
page.wait_for_selector("#teaching")
|
| 842 |
page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
|
| 843 |
numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
|
| 844 |
+
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2"}
|
| 845 |
for tab in ("tests", "assignments", "inbox"): # on every tab
|
| 846 |
page.click(f"#teacher-tabs [data-tab='{tab}']")
|
| 847 |
page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
|
| 848 |
+
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2"}, tab
|
| 849 |
page.click(f"[data-open='{monday['id']}']")
|
| 850 |
page.fill("[name=grade]", "80")
|
| 851 |
page.click("#give-grade")
|
openspec/changes/add-class-progress-views/proposal.md
CHANGED
|
@@ -2,26 +2,26 @@
|
|
| 2 |
|
| 3 |
## Why
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
## What Changes
|
| 8 |
|
| 9 |
-
- A
|
| 10 |
-
-
|
| 11 |
-
- Period tabs
|
| 12 |
-
-
|
| 13 |
-
- A developer can open
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
## Capabilities
|
| 18 |
|
| 19 |
### New Capabilities
|
| 20 |
-
- `class-progress-views`: the
|
| 21 |
|
| 22 |
### Modified Capabilities
|
| 23 |
|
| 24 |
## Impact
|
| 25 |
|
| 26 |
-
-
|
| 27 |
-
-
|
|
|
|
| 2 |
|
| 3 |
## Why
|
| 4 |
|
| 5 |
+
A teacher cannot see where a class stands: which skills each student has covered, which lessons are done or behind the curriculum, and who is ready for the coming test (PRD Goal 3, TCH-7, TCH-8, RDY).
|
| 6 |
|
| 7 |
## What Changes
|
| 8 |
|
| 9 |
+
- A new **"התקדמות" tab** on the teacher's screen (with its number, like the other tabs) and a "התקדמות" shortcut on each class card. It shows one class at a time, with a class picker when the teacher has several.
|
| 10 |
+
- **Progress by lesson** (TCH-7, TCH-8): students by lessons, never by skills. Each cell is kept short: a progress bar with the percent of the lesson's skills mastered, the best quiz grade as a bare number, and a warning mark when the lesson is behind the curriculum (PTH-11). The state in words is the cell's accessible name.
|
| 11 |
+
- **Period tabs**: ספט׳–דצמ׳ and ינו׳–יוני. A period shows the lessons of that period (by the lesson's date in the curriculum, else the bank's month) and states as they were at the end of the period, or today for the current period.
|
| 12 |
+
- **Readiness** for the class's nearest coming test: each student's readiness number and word, mastery, practice average and simulation grade.
|
| 13 |
+
- A developer can open all of these for any class. A manager or an admin cannot: they see no learning data.
|
| 14 |
|
| 15 |
+
No learning rule changes. Skills never lose mastery, so a state at a past date is read from the stored started/mastered times.
|
| 16 |
|
| 17 |
## Capabilities
|
| 18 |
|
| 19 |
### New Capabilities
|
| 20 |
+
- `class-progress-views`: the progress by lesson, the periods and the readiness per student.
|
| 21 |
|
| 22 |
### Modified Capabilities
|
| 23 |
|
| 24 |
## Impact
|
| 25 |
|
| 26 |
+
- New `GET /api/classes/{id}/progress`; the counts endpoint gains the number of students; two store reads that fetch many students at once (progress and attempts), so a class costs a few database waits and not one per student; `teacher.js` and a new `teacher_progress.js`; PRD TCH-1, TCH-7, TCH-8; the permissions matrix.
|
| 27 |
+
- Screens: `docs/mockups/mathly2-screens.html` (teacher tab: heat map).
|
openspec/changes/add-class-progress-views/specs/class-progress-views/spec.md
CHANGED
|
@@ -1,49 +1,68 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
##
|
|
|
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
##
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
###
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
-
###
|
| 13 |
-
|
| 14 |
-
- **THEN** every cell's state can still be read from its symbol
|
| 15 |
|
| 16 |
-
###
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
#### Scenario:
|
| 20 |
-
- **WHEN** a
|
| 21 |
-
- **THEN**
|
| 22 |
|
| 23 |
-
###
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
-
#### Scenario:
|
| 27 |
-
- **WHEN** a teacher opens the
|
| 28 |
-
- **THEN**
|
| 29 |
|
| 30 |
-
### Requirement:
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
#### Scenario:
|
| 34 |
-
- **WHEN**
|
| 35 |
-
- **THEN**
|
| 36 |
|
| 37 |
-
### Requirement:
|
| 38 |
-
The
|
| 39 |
|
| 40 |
-
#### Scenario:
|
| 41 |
-
- **WHEN** a
|
| 42 |
-
- **THEN** the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
###
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
-
#### Scenario:
|
| 48 |
-
- **WHEN**
|
| 49 |
-
- **THEN**
|
|
|
|
| 1 |
+
## ADDED Requirements
|
| 2 |
|
| 3 |
+
### Requirement: The progress tab
|
| 4 |
+
A teacher's screen SHALL have a "התקדמות" tab, with the number of students of the teacher's classes like the other tabs, and each class card SHALL have a "התקדמות" shortcut to it. The tab SHALL show one class at a time, with a way to choose the class when the teacher has several. Only a class's own teacher or a developer SHALL be able to read its progress. A manager or an admin MUST NOT, and a teacher MUST NOT read another teacher's class.
|
| 5 |
|
| 6 |
+
#### Scenario: Two classes
|
| 7 |
+
- **WHEN** a teacher of two classes opens the tab
|
| 8 |
+
- **THEN** they see the first class and can switch to the other
|
| 9 |
|
| 10 |
+
#### Scenario: Another teacher's class
|
| 11 |
+
- **WHEN** a teacher asks for the progress of a class they do not teach, or a manager asks for any class
|
| 12 |
+
- **THEN** the server refuses
|
| 13 |
|
| 14 |
+
#### Scenario: A developer
|
| 15 |
+
- **WHEN** a developer opens any class's progress
|
| 16 |
+
- **THEN** they see it as the class's teacher would
|
| 17 |
|
| 18 |
+
### Requirement: Progress by lesson
|
| 19 |
+
The progress view SHALL list the students in rows and the lessons in columns, never the skills. Each cell SHALL show only three things: a progress bar with the percent of the lesson's skills mastered written on it, the best quiz grade as a bare number (a dash for none, without the word "בוחן"), and, in the current period, a warning mark when the lesson is behind the curriculum and not completed. The cell's accessible name SHALL still say the state in words (הושלם, בלמידה, לא התחילו, בפיגור).
|
|
|
|
| 20 |
|
| 21 |
+
#### Scenario: Cell contents
|
| 22 |
+
- **WHEN** a student has mastered 3 of 5 skills of a lesson and their best quiz grade is 80
|
| 23 |
+
- **THEN** the cell shows a bar with 60% and the number 80, and nothing else
|
| 24 |
|
| 25 |
+
#### Scenario: Behind
|
| 26 |
+
- **WHEN** a lesson's date has passed and a student has neither mastered all its skills nor passed its quiz
|
| 27 |
+
- **THEN** that student's cell for the lesson carries the warning mark
|
| 28 |
|
| 29 |
+
#### Scenario: Completed or not started
|
| 30 |
+
- **WHEN** a student has passed a lesson's quiz, or has done nothing in it
|
| 31 |
+
- **THEN** the cell's accessible name says "הושלם" or "לא התחילו", and the cell shows no extra text
|
| 32 |
|
| 33 |
+
#### Scenario: No skill columns
|
| 34 |
+
- **WHEN** a teacher opens the progress view
|
| 35 |
+
- **THEN** no column or cell stands for a single skill
|
| 36 |
|
| 37 |
+
### Requirement: The progress table
|
| 38 |
+
A student who has not signed in yet SHALL have every lesson not started. The table SHALL scroll inside its own frame with the student column fixed, and the page MUST NOT scroll sideways.
|
| 39 |
+
|
| 40 |
+
#### Scenario: Not signed in yet
|
| 41 |
+
- **WHEN** a student was added to the class and has never signed in
|
| 42 |
+
- **THEN** their row shows every lesson as not started
|
| 43 |
|
| 44 |
+
#### Scenario: A phone
|
| 45 |
+
- **WHEN** the table is wider than the screen
|
| 46 |
+
- **THEN** it scrolls inside its frame and the page does not scroll sideways
|
| 47 |
|
| 48 |
+
### Requirement: Periods
|
| 49 |
+
The progress by lesson SHALL have two period tabs, "ספט׳–דצמ׳" and "ינו׳–יוני". A period SHALL show the lessons of that period, by the lesson's date in the curriculum or, when it has none, by the last month of the bank's month for it, and the states as they were at the end of the period, or today for the current period. The current period SHALL be open first.
|
| 50 |
|
| 51 |
+
#### Scenario: A past period
|
| 52 |
+
- **WHEN** a skill was mastered on 20 January and the teacher opens the progress view in March
|
| 53 |
+
- **THEN** that lesson's percent does not include the skill in the first period's view (which ends on 31 December), and does in the second
|
| 54 |
+
|
| 55 |
+
#### Scenario: Lessons of the period
|
| 56 |
+
- **WHEN** a lesson is dated 15 February
|
| 57 |
+
- **THEN** it appears in the second period and not in the first
|
| 58 |
+
|
| 59 |
+
### Requirement: Readiness per student
|
| 60 |
+
For the class's nearest coming test the tab SHALL list each student's readiness number and its word, their mastery percent, their practice-test average and their simulation grade, with a dash for what they have not done. A class with no coming test SHALL say so.
|
| 61 |
|
| 62 |
+
#### Scenario: A coming test
|
| 63 |
+
- **WHEN** a class has a test in ten days and one student has done nothing
|
| 64 |
+
- **THEN** the table lists that student with a dash for what is missing
|
| 65 |
|
| 66 |
+
#### Scenario: No test
|
| 67 |
+
- **WHEN** the class has no coming test
|
| 68 |
+
- **THEN** the readiness view says no test is set
|
openspec/changes/add-class-progress-views/tasks.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tasks
|
| 2 |
+
|
| 3 |
+
## 1. The data
|
| 4 |
+
|
| 5 |
+
- [x] 1.1 Add store reads that fetch the progress rows and the attempts of many students in one call (in memory and Supabase); verify unit tests: rows of several students come back together, an empty list gives nothing, other students are not mixed in
|
| 6 |
+
- [x] 1.2 Add `GET /api/classes/{id}/progress?period=1|2` returning the students, the lessons of the period with their skills and dates, each student's skill states as of the period's end, the coverage (percent, best quiz grade, completed, behind) and the readiness for the nearest coming test; add the number of students to `GET /api/teaching/counts`; verify API tests for every scenario of the progress by lesson, periods and readiness, the permissions (own teacher, other teacher, developer, manager, admin, student) and the permissions matrix; verify a timing test: a class of 25 students needs no more waits in a row than a class of 2
|
| 7 |
+
|
| 8 |
+
## 2. The screens
|
| 9 |
+
|
| 10 |
+
- [x] 2.1 Add the "התקדמות" tab (with its number) and the card shortcut, the class picker, the period tabs and the two views (progress by lesson, readiness); verify Playwright tests: each cell shows its state in a symbol and a word, no column stands for a skill, the table scrolls inside its frame and the page does not scroll sideways at 360px and 1280px, a student who never signed in is all not started,, the picker switches classes, the period tabs change the lessons shown
|
| 11 |
+
|
| 12 |
+
## 3. Wrap-up
|
| 13 |
+
|
| 14 |
+
- [x] 3.1 Update the PRD (TCH-1, TCH-7, TCH-8); run the full suite and `openspec validate add-class-progress-views --strict`; verify both pass
|