Refactor tests to account for lesson visibility and class lesson assignments
Browse files- Updated assertions in `test_api_end_to_end_with_stub_model` to check for lessons that are not marked as "soon" and added checks for the number of lessons and levels.
- Modified `test_workbook_lesson_1` to filter out lessons marked as "soon" when asserting lesson IDs.
- Introduced `test_levels_plan_every_lesson` to validate lesson and level relationships, ensuring all lessons are accounted for in their respective levels.
- Enhanced `test_sign_in_guards_the_practice_endpoints` to include lesson assignments in class creation and retrieval, ensuring students see the correct lessons based on their class memberships.
- Adjusted assertions to reflect changes in class and lesson handling, ensuring proper validation of class creation and student visibility.
- README.md +15 -3
- app/auth.py +11 -7
- app/main.py +56 -40
- app/phrases.py +3 -2
- app/workbook.py +28 -8
- static/index.html +348 -142
- tests/test_scoring.py +66 -33
|
@@ -32,7 +32,7 @@ app/
|
|
| 32 |
reference.py native baseline: the TTS voices scored by the same model
|
| 33 |
phrases.py phrase bank: accepted variants (Arabic + IPA) per phrase
|
| 34 |
grammar.py day-2 sentences as grammar: every pronoun × positive/negative, for the negation and conjugation buttons
|
| 35 |
-
workbook.py the course workbook lesson by lesson: each lesson's topics (pronouns, verbs, conjugations, the Hebrew → Arabic and Arabic → Hebrew sentences)
|
| 36 |
main.py FastAPI: GET / (page), GET /api/phrases, GET /api/health, POST /api/score
|
| 37 |
static/index.html frontend (MediaRecorder → /api/score)
|
| 38 |
train/finetune.py CTC fine-tuning on your own recordings
|
|
@@ -100,9 +100,15 @@ The page's "ההתקדמות שלי" button reads these rows back through `GET /
|
|
| 100 |
|
| 101 |
`TEACHER_EMAILS` names the teacher's Google account (several are separated by commas or spaces). After sign-in a teacher chooses between **תרגול**, the same practice a student gets, and **ניהול**, the management panel. The panel lists every student with their usage (recordings, those of the last 7 days, days practised, last practice), progress (sentences practised) and grade (the average of the best scores), and a student opens on the same figures per topic.
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
### Classes
|
| 104 |
|
| 105 |
-
In the panel the teacher opens a class
|
|
|
|
|
|
|
| 106 |
|
| 107 |
The classes are kept in Supabase and read and written with `SUPABASE_SECRET_KEY` only, so the table has row-level security and no policy. Create it once:
|
| 108 |
|
|
@@ -112,12 +118,18 @@ create table public.classes (
|
|
| 112 |
name text not null unique, -- e.g. אוקטובר 2026
|
| 113 |
emails text[] not null default '{}', -- the students' Google emails, lower-case
|
| 114 |
nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
|
|
|
|
| 115 |
created_at timestamptz not null default now()
|
| 116 |
);
|
| 117 |
alter table public.classes enable row level security;
|
| 118 |
```
|
| 119 |
|
| 120 |
-
A table made before the nicknames
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
|
| 123 |
|
|
|
|
| 32 |
reference.py native baseline: the TTS voices scored by the same model
|
| 33 |
phrases.py phrase bank: accepted variants (Arabic + IPA) per phrase
|
| 34 |
grammar.py day-2 sentences as grammar: every pronoun × positive/negative, for the negation and conjugation buttons
|
| 35 |
+
workbook.py the course workbook lesson by lesson: each lesson's topics (pronouns, verbs, conjugations, the Hebrew → Arabic and Arabic → Hebrew sentences), and the levels
|
| 36 |
main.py FastAPI: GET / (page), GET /api/phrases, GET /api/health, POST /api/score
|
| 37 |
static/index.html frontend (MediaRecorder → /api/score)
|
| 38 |
train/finetune.py CTC fine-tuning on your own recordings
|
|
|
|
| 100 |
|
| 101 |
`TEACHER_EMAILS` names the teacher's Google account (several are separated by commas or spaces). After sign-in a teacher chooses between **תרגול**, the same practice a student gets, and **ניהול**, the management panel. The panel lists every student with their usage (recordings, those of the last 7 days, days practised, last practice), progress (sentences practised) and grade (the average of the best scores), and a student opens on the same figures per topic.
|
| 102 |
|
| 103 |
+
### Levels
|
| 104 |
+
|
| 105 |
+
The course has levels, each with a planned number of lessons (`LEVELS` in `app/workbook.py`: רמה 1 and רמה 2, 10 lessons each). Every lesson in `LESSONS` is keyed by its level and number, `(level, lesson)`; one outside the plan stops the app at startup. The path shows each level under its name, and the lessons not written yet as "בקרוב". Level 1's lessons keep their first ids ("1", topics "l1-…") so recorded attempts and saved classes still match; later levels' are "2.1", topics "l2.1-…".
|
| 106 |
+
|
| 107 |
### Classes
|
| 108 |
|
| 109 |
+
In the panel the teacher opens a class: a name such as "מתחילים אוקטובר 2026", the lessons it studies (by level: a level's box ticks all of it; a lesson not written yet can be ticked too, and opens to the class once it is), and its students, a line each with the Google email and an optional nickname (`dana@gmail.com דנה`). A class is edited or deleted from the same dialog. A student may be in several classes: after the opening screen the page asks which one they practise in now, and the learning path shows that class's lessons (a teacher, and anyone before the first class, sees every lesson and is not asked). The page shows the class under the app's name, and pressing it lists the class's students, with a button to move to another class; each student appears by their nickname in it or else their Google name (no addresses; `GET /api/classmates`). Once there is a class, only the classes' students and the teachers can sign in; before the first one, any Google account can.
|
| 110 |
+
|
| 111 |
+
The panel has a tab per class with its figures, its lessons (each with the sentences the class finds hard: the class average of each student's best score under 70) and its students, with a column per lesson of the class (per level, past 4 lessons). Before the first class it shows every account that signed in. This replaces the former `ALLOWED_EMAILS`, which is no longer read. The lessons limit what the path shows; the sentences themselves stay public, as before.
|
| 112 |
|
| 113 |
The classes are kept in Supabase and read and written with `SUPABASE_SECRET_KEY` only, so the table has row-level security and no policy. Create it once:
|
| 114 |
|
|
|
|
| 118 |
name text not null unique, -- e.g. אוקטובר 2026
|
| 119 |
emails text[] not null default '{}', -- the students' Google emails, lower-case
|
| 120 |
nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
|
| 121 |
+
lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
|
| 122 |
created_at timestamptz not null default now()
|
| 123 |
);
|
| 124 |
alter table public.classes enable row level security;
|
| 125 |
```
|
| 126 |
|
| 127 |
+
A table made before the nicknames or the lessons needs the columns (a class saved before then studies every lesson):
|
| 128 |
+
|
| 129 |
+
```sql
|
| 130 |
+
alter table public.classes add column if not exists nicknames jsonb not null default '{}',
|
| 131 |
+
add column if not exists lessons text[] not null default '{}';
|
| 132 |
+
```
|
| 133 |
|
| 134 |
The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
|
| 135 |
|
|
@@ -10,8 +10,9 @@ Teachers: the accounts in TEACHER_EMAILS also get the management panel, which sh
|
|
| 10 |
progress. Those rows are read with the project's secret key, which passes row-level security and
|
| 11 |
never leaves the server.
|
| 12 |
|
| 13 |
-
Classes: the teacher opens classes in the panel (a name, e.g. "October 2026",
|
| 14 |
-
optional nickname of each), kept in the Supabase table `classes`.
|
|
|
|
| 15 |
before the first one (or without the secret key, which reads the table) any Google account is.
|
| 16 |
|
| 17 |
Env: SUPABASE_URL (https://<project-ref>.supabase.co)
|
|
@@ -143,7 +144,8 @@ class SupabaseAuth:
|
|
| 143 |
raise AuthUnavailable(str(e)) from e
|
| 144 |
|
| 145 |
def classes(self, fresh: bool = False) -> list[dict]:
|
| 146 |
-
"""Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}),
|
|
|
|
| 147 |
Kept for CLASSES_TTL, as each sign-in
|
| 148 |
check asks. Without the secret key, or before the table is created, there are none."""
|
| 149 |
now = time.monotonic()
|
|
@@ -157,7 +159,7 @@ class SupabaseAuth:
|
|
| 157 |
# every column: a table made before the nicknames still lets its students in
|
| 158 |
rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
|
| 159 |
for r in rows:
|
| 160 |
-
r["nicknames"] = r.get("nicknames") or {}
|
| 161 |
except AuthUnavailable as e:
|
| 162 |
if "answered 404" not in str(e):
|
| 163 |
raise
|
|
@@ -166,9 +168,11 @@ class SupabaseAuth:
|
|
| 166 |
self._classes = (now + CLASSES_TTL, rows)
|
| 167 |
return rows
|
| 168 |
|
| 169 |
-
def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str]
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
if id is None:
|
| 173 |
self._admin("/rest/v1/classes", "POST", body)
|
| 174 |
elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
|
|
|
|
| 10 |
progress. Those rows are read with the project's secret key, which passes row-level security and
|
| 11 |
never leaves the server.
|
| 12 |
|
| 13 |
+
Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", the lessons it studies, its
|
| 14 |
+
students' emails and an optional nickname of each), kept in the Supabase table `classes`. A student may be in
|
| 15 |
+
several. Once there is a class, only the classes' students and the teachers are let in;
|
| 16 |
before the first one (or without the secret key, which reads the table) any Google account is.
|
| 17 |
|
| 18 |
Env: SUPABASE_URL (https://<project-ref>.supabase.co)
|
|
|
|
| 144 |
raise AuthUnavailable(str(e)) from e
|
| 145 |
|
| 146 |
def classes(self, fresh: bool = False) -> list[dict]:
|
| 147 |
+
"""Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
|
| 148 |
+
empty in a class saved before classes had lessons), created_at.
|
| 149 |
Kept for CLASSES_TTL, as each sign-in
|
| 150 |
check asks. Without the secret key, or before the table is created, there are none."""
|
| 151 |
now = time.monotonic()
|
|
|
|
| 159 |
# every column: a table made before the nicknames still lets its students in
|
| 160 |
rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
|
| 161 |
for r in rows:
|
| 162 |
+
r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
|
| 163 |
except AuthUnavailable as e:
|
| 164 |
if "answered 404" not in str(e):
|
| 165 |
raise
|
|
|
|
| 168 |
self._classes = (now + CLASSES_TTL, rows)
|
| 169 |
return rows
|
| 170 |
|
| 171 |
+
def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
|
| 172 |
+
lessons: list[str]) -> None:
|
| 173 |
+
"""Adds a class (id None) or replaces one's name, emails, nicknames and lessons. A name already taken raises
|
| 174 |
+
ClassConflict."""
|
| 175 |
+
body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons}
|
| 176 |
if id is None:
|
| 177 |
self._admin("/rest/v1/classes", "POST", body)
|
| 178 |
elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
|
|
@@ -26,7 +26,7 @@ from fastapi.responses import HTMLResponse, Response
|
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
|
| 28 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 29 |
-
from .phrases import CATEGORIES, LESSONS, PHRASES, public_phrases
|
| 30 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 31 |
from .reference import References
|
| 32 |
from .scorer import Scorer, decode_audio, load_scorer
|
|
@@ -41,6 +41,14 @@ LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' cale
|
|
| 41 |
MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
|
| 42 |
EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
|
| 43 |
MAX_NICKNAME = 40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def class_members(text: str) -> tuple[list[str], dict[str, str]]:
|
|
@@ -124,11 +132,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 124 |
classes = a.classes()
|
| 125 |
except AuthUnavailable as e:
|
| 126 |
raise HTTPException(503, f"class list not available: {e}")
|
| 127 |
-
|
| 128 |
teacher = email in a.teachers
|
| 129 |
-
if classes and not
|
| 130 |
raise HTTPException(403, "this account is not on the list")
|
| 131 |
-
return {**user, "token": token.strip(), "teacher": teacher, "
|
| 132 |
|
| 133 |
def teacher_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
|
| 134 |
if not user or not user["teacher"]:
|
|
@@ -148,9 +156,14 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 148 |
|
| 149 |
@app.get("/api/me")
|
| 150 |
def me(user: dict | None = Depends(signed_in)):
|
| 151 |
-
"""Who is signed in,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
|
| 153 |
-
"
|
| 154 |
|
| 155 |
@app.get("/api/progress")
|
| 156 |
def progress(user: dict | None = Depends(signed_in)):
|
|
@@ -160,9 +173,9 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 160 |
|
| 161 |
@app.get("/api/class")
|
| 162 |
def class_progress(a: SupabaseAuth = Depends(teacher_only)):
|
| 163 |
-
"""The teacher's panel: the classes, and every student's summary (see `summary`) with
|
| 164 |
-
students are the classes' accounts, or every account that signed in
|
| 165 |
-
never signed in has joined = false."""
|
| 166 |
try:
|
| 167 |
classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
|
| 168 |
except AuthUnavailable as e:
|
|
@@ -170,43 +183,45 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 170 |
by: dict[str, list[dict]] = {}
|
| 171 |
for r in rows:
|
| 172 |
by.setdefault(r["user_id"], []).append(r)
|
| 173 |
-
member
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
| 177 |
**summary(by.get(u["id"], []))} for u in users if not classes or u["email"].lower() in member]
|
| 178 |
missing = member.keys() - {s["email"].lower() for s in students}
|
| 179 |
-
students += [{"email": e, "name": "", "joined": False,
|
| 180 |
-
return {"classes": classes, "students": students}
|
| 181 |
|
| 182 |
def class_store_error(e: AuthUnavailable) -> str:
|
| 183 |
"""The teacher's message when a class cannot be written; a 404 means Supabase has no `classes` table."""
|
| 184 |
if "answered 404" in str(e):
|
| 185 |
return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
|
| 186 |
"אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 187 |
-
if "nicknames" in str(e): # a table made before the nicknames
|
| 188 |
-
return
|
| 189 |
-
"alter table public.classes add column nicknames jsonb not null default '{}';")
|
| 190 |
return f"Supabase לא זמין: {e}"
|
| 191 |
|
| 192 |
def save_class(a: SupabaseAuth, id: int | None, body: dict) -> dict:
|
| 193 |
-
"""Checks a class from the panel ({"name", "emails": the student list, see `class_members`
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
name = str(body.get("name", "")).strip()
|
| 197 |
if not name or len(name) > 80:
|
| 198 |
raise HTTPException(400, "לכיתה צריך שם (עד 80 תווים)")
|
| 199 |
try:
|
| 200 |
emails, nicks = class_members(str(body.get("emails", "")))
|
| 201 |
except ValueError as e:
|
| 202 |
raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
try:
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
a.save_class(id, name, emails, nicks)
|
| 210 |
except ClassConflict:
|
| 211 |
raise HTTPException(409, f"כבר יש כיתה בשם {name}")
|
| 212 |
except KeyError:
|
|
@@ -233,25 +248,26 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 233 |
|
| 234 |
@app.get("/api/classmates")
|
| 235 |
def classmates(user: dict | None = Depends(signed_in)):
|
| 236 |
-
"""The signed-in student's
|
| 237 |
-
the name Google gave (one with neither, who has not signed in yet, is only counted).
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
c = next(c for c in a.classes() if c["name"] == user["class"])
|
| 242 |
try:
|
| 243 |
-
google = {u["email"].lower(): u["name"] for u in
|
| 244 |
except AuthUnavailable as e: # the nicknames are still known
|
| 245 |
log.warning("classmates' names not read: %s", e)
|
| 246 |
google = {}
|
| 247 |
-
me = user["email"].lower()
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
|
| 252 |
@app.get("/api/phrases")
|
| 253 |
def phrases():
|
| 254 |
-
return {"lessons": LESSONS, "categories": CATEGORIES, "phrases": public_phrases(),
|
| 255 |
"voices": [{"id": i, "name": n} for n, i in VOICES.items()]}
|
| 256 |
|
| 257 |
@app.get("/api/health")
|
|
|
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
|
| 28 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 29 |
+
from .phrases import CATEGORIES, LESSONS, LEVELS, PHRASES, public_phrases
|
| 30 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 31 |
from .reference import References
|
| 32 |
from .scorer import Scorer, decode_audio, load_scorer
|
|
|
|
| 41 |
MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
|
| 42 |
EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
|
| 43 |
MAX_NICKNAME = 40
|
| 44 |
+
ALL_LESSONS = [l["id"] for l in LESSONS] # with the lessons still to come: a class may take them ahead
|
| 45 |
+
MIGRATE_CLASSES = ("alter table public.classes add column if not exists nicknames jsonb not null default '{}', "
|
| 46 |
+
"add column if not exists lessons text[] not null default '{}';")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def lessons_of(c: dict) -> list[str]:
|
| 50 |
+
"""A class's lessons in the course's order; a class saved before classes had lessons has every one."""
|
| 51 |
+
return [l for l in ALL_LESSONS if l in c["lessons"]] or ALL_LESSONS
|
| 52 |
|
| 53 |
|
| 54 |
def class_members(text: str) -> tuple[list[str], dict[str, str]]:
|
|
|
|
| 132 |
classes = a.classes()
|
| 133 |
except AuthUnavailable as e:
|
| 134 |
raise HTTPException(503, f"class list not available: {e}")
|
| 135 |
+
mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
|
| 136 |
teacher = email in a.teachers
|
| 137 |
+
if classes and not mine and not teacher: # before the first class, any account is let in
|
| 138 |
raise HTTPException(403, "this account is not on the list")
|
| 139 |
+
return {**user, "token": token.strip(), "teacher": teacher, "classes": mine}
|
| 140 |
|
| 141 |
def teacher_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
|
| 142 |
if not user or not user["teacher"]:
|
|
|
|
| 156 |
|
| 157 |
@app.get("/api/me")
|
| 158 |
def me(user: dict | None = Depends(signed_in)):
|
| 159 |
+
"""Who is signed in, their classes (each with its lessons: a student in several chooses one to practise in), and
|
| 160 |
+
the lessons of all of them together (every lesson for a teacher, and for an account in no class). The page
|
| 161 |
+
asks this right after sign-in to learn whether the account is let in."""
|
| 162 |
+
mine = user["classes"] if user else []
|
| 163 |
+
lessons = ALL_LESSONS if not mine or user["teacher"] else \
|
| 164 |
+
[l for l in ALL_LESSONS if any(l in lessons_of(c) for c in mine)]
|
| 165 |
return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
|
| 166 |
+
"classes": [{"name": c["name"], "lessons": lessons_of(c)} for c in mine], "lessons": lessons}
|
| 167 |
|
| 168 |
@app.get("/api/progress")
|
| 169 |
def progress(user: dict | None = Depends(signed_in)):
|
|
|
|
| 173 |
|
| 174 |
@app.get("/api/class")
|
| 175 |
def class_progress(a: SupabaseAuth = Depends(teacher_only)):
|
| 176 |
+
"""The teacher's panel: the classes (with their lessons), and every student's summary (see `summary`) with
|
| 177 |
+
their classes ({name: nickname}). The students are the classes' accounts, or every account that signed in
|
| 178 |
+
when there is no class yet; one that never signed in has joined = false."""
|
| 179 |
try:
|
| 180 |
classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
|
| 181 |
except AuthUnavailable as e:
|
|
|
|
| 183 |
by: dict[str, list[dict]] = {}
|
| 184 |
for r in rows:
|
| 185 |
by.setdefault(r["user_id"], []).append(r)
|
| 186 |
+
member: dict[str, dict[str, str]] = {} # email -> {class name: nickname}
|
| 187 |
+
for c in classes:
|
| 188 |
+
for e in c["emails"]:
|
| 189 |
+
member.setdefault(e, {})[c["name"]] = c["nicknames"].get(e, "")
|
| 190 |
+
students = [{"email": u["email"], "name": u["name"], "joined": True, "classes": member.get(u["email"].lower(), {}),
|
| 191 |
**summary(by.get(u["id"], []))} for u in users if not classes or u["email"].lower() in member]
|
| 192 |
missing = member.keys() - {s["email"].lower() for s in students}
|
| 193 |
+
students += [{"email": e, "name": "", "joined": False, "classes": member[e], **summary([])} for e in sorted(missing)]
|
| 194 |
+
return {"classes": [{**c, "lessons": lessons_of(c)} for c in classes], "students": students}
|
| 195 |
|
| 196 |
def class_store_error(e: AuthUnavailable) -> str:
|
| 197 |
"""The teacher's message when a class cannot be written; a 404 means Supabase has no `classes` table."""
|
| 198 |
if "answered 404" in str(e):
|
| 199 |
return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
|
| 200 |
"אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 201 |
+
if "nicknames" in str(e) or "lessons" in str(e): # a table made before the nicknames or the lessons
|
| 202 |
+
return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
|
|
|
|
| 203 |
return f"Supabase לא זמין: {e}"
|
| 204 |
|
| 205 |
def save_class(a: SupabaseAuth, id: int | None, body: dict) -> dict:
|
| 206 |
+
"""Checks a class from the panel ({"name", "emails": the student list, see `class_members`, "lessons": lesson
|
| 207 |
+
ids}) and stores it. A student may be in several classes, e.g. a beginners' class and an advanced one."""
|
| 208 |
+
name = " ".join(str(body.get("name", "")).split()) # one space between words, none around
|
|
|
|
| 209 |
if not name or len(name) > 80:
|
| 210 |
raise HTTPException(400, "לכיתה צריך שם (עד 80 תווים)")
|
| 211 |
try:
|
| 212 |
emails, nicks = class_members(str(body.get("emails", "")))
|
| 213 |
except ValueError as e:
|
| 214 |
raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
|
| 215 |
+
lessons = body.get("lessons")
|
| 216 |
+
if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
|
| 217 |
+
raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
|
| 218 |
+
lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
|
| 219 |
try:
|
| 220 |
+
# a class's name is its own: the same words in other letter case or spacing are the same name
|
| 221 |
+
same = lambda other: " ".join(other.split()).casefold() == name.casefold()
|
| 222 |
+
if taken := next((c["name"] for c in a.classes(fresh=True) if c["id"] != id and same(c["name"])), None):
|
| 223 |
+
raise HTTPException(409, f"כבר יש כיתה בשם {taken}")
|
| 224 |
+
a.save_class(id, name, emails, nicks, lessons)
|
|
|
|
| 225 |
except ClassConflict:
|
| 226 |
raise HTTPException(409, f"כבר יש כיתה בשם {name}")
|
| 227 |
except KeyError:
|
|
|
|
| 248 |
|
| 249 |
@app.get("/api/classmates")
|
| 250 |
def classmates(user: dict | None = Depends(signed_in)):
|
| 251 |
+
"""The signed-in student's classes: each one's name and its students, each by the nickname the teacher gave
|
| 252 |
+
in that class, or else the name Google gave (one with neither, who has not signed in yet, is only counted).
|
| 253 |
+
No addresses."""
|
| 254 |
+
if not user or not user["classes"]:
|
| 255 |
+
return {"classes": []}
|
|
|
|
| 256 |
try:
|
| 257 |
+
google = {u["email"].lower(): u["name"] for u in app.state.auth.users()}
|
| 258 |
except AuthUnavailable as e: # the nicknames are still known
|
| 259 |
log.warning("classmates' names not read: %s", e)
|
| 260 |
google = {}
|
| 261 |
+
me, out = user["email"].lower(), []
|
| 262 |
+
for c in user["classes"]:
|
| 263 |
+
named = [{"name": c["nicknames"].get(e) or google.get(e, ""), "me": e == me} for e in c["emails"]]
|
| 264 |
+
students = sorted((s for s in named if s["name"]), key=lambda s: s["name"])
|
| 265 |
+
out.append({"name": c["name"], "students": students, "unnamed": len(named) - len(students)})
|
| 266 |
+
return {"classes": out}
|
| 267 |
|
| 268 |
@app.get("/api/phrases")
|
| 269 |
def phrases():
|
| 270 |
+
return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "phrases": public_phrases(),
|
| 271 |
"voices": [{"id": i, "name": n} for n, i in VOICES.items()]}
|
| 272 |
|
| 273 |
@app.get("/api/health")
|
|
@@ -12,7 +12,7 @@ Conventions (Palestinian urban speech as the main form):
|
|
| 12 |
Geminates are written doubled; the tokenizer collapses them for CTC.
|
| 13 |
"""
|
| 14 |
from .grammar import day2_phrases
|
| 15 |
-
from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, LESSON_TABS, workbook_phrases
|
| 16 |
|
| 17 |
PHRASES = [
|
| 18 |
# ---- היכרות ----
|
|
@@ -131,7 +131,8 @@ PHRASES += workbook_phrases()
|
|
| 131 |
# The page asks for a lesson first, then one of its categories: the course's lessons (workbook.py).
|
| 132 |
# The phrases that belong to no lesson (meet, talk, city, food, day2) have no tab. They stay in the
|
| 133 |
# bank, so phrase ids and the attempts recorded on them hold; public_phrases leaves them out.
|
| 134 |
-
|
|
|
|
| 135 |
CATEGORIES = [*WORKBOOK_CATEGORIES]
|
| 136 |
|
| 137 |
|
|
|
|
| 12 |
Geminates are written doubled; the tokenizer collapses them for CTC.
|
| 13 |
"""
|
| 14 |
from .grammar import day2_phrases
|
| 15 |
+
from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, LESSON_TABS, LEVEL_TABS, workbook_phrases
|
| 16 |
|
| 17 |
PHRASES = [
|
| 18 |
# ---- היכרות ----
|
|
|
|
| 131 |
# The page asks for a lesson first, then one of its categories: the course's lessons (workbook.py).
|
| 132 |
# The phrases that belong to no lesson (meet, talk, city, food, day2) have no tab. They stay in the
|
| 133 |
# bank, so phrase ids and the attempts recorded on them hold; public_phrases leaves them out.
|
| 134 |
+
LEVELS = [*LEVEL_TABS]
|
| 135 |
+
LESSONS = [*LESSON_TABS] # every planned lesson; one not written yet is `soon` and has no categories
|
| 136 |
CATEGORIES = [*WORKBOOK_CATEGORIES]
|
| 137 |
|
| 138 |
|
|
@@ -268,9 +268,14 @@ def _possessives(nouns):
|
|
| 268 |
for he, stem in nouns for owner, say, suffix, tag, *_ in OWNERS]
|
| 269 |
|
| 270 |
|
| 271 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
LESSONS = {
|
| 273 |
-
1: [
|
| 274 |
("pron", "הגופים", "say", True, [(_plain(he) + " / הן" * (he == "הֵם"), ar, he) for he, ar in zip(HE_PRONOUNS, AR_PRONOUNS)]),
|
| 275 |
("verbs", "פעלים", "say", True, _base_forms(VERBS_1)),
|
| 276 |
("past", "הטיות בעבר", "say", False, _past(VERBS_1)),
|
|
@@ -311,7 +316,7 @@ LESSONS = {
|
|
| 311 |
("המורה שאלה את הילדה: \"חרוצה, למה ישבת על השולחן?\"", "المعلّمة سألت#f البنت: يا شاطرة ليش قعدتي على الطّاولة؟"),
|
| 312 |
]),
|
| 313 |
],
|
| 314 |
-
2: [
|
| 315 |
("dual", "הזוגי", "say", True, [(f"{he}, {he2}", f"{ar}، {ar2}") for he, he2, ar, ar2 in DUALS]),
|
| 316 |
("family", "המשפחה", "say", True, FAMILY),
|
| 317 |
("poss", "כינויי הקניין", "say", False, _possessives(POSSESSIVE_DRILL)),
|
|
@@ -439,7 +444,21 @@ LESSONS = {
|
|
| 439 |
]),
|
| 440 |
],
|
| 441 |
}
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
# Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
|
| 444 |
# Each has its two speakers' voices, by name in the voice list: the odd lines are the first one's, the even the second's.
|
| 445 |
STORIES = {"l2-talk": ("אמיר", "ראאף")}
|
|
@@ -448,9 +467,10 @@ STORIES = {"l2-talk": ("אמיר", "ראאף")}
|
|
| 448 |
UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
|
| 449 |
TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
|
| 450 |
"dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
|
| 451 |
-
CATEGORIES = [{"id": f"l{
|
|
|
|
| 452 |
**({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
|
| 453 |
-
for
|
| 454 |
|
| 455 |
|
| 456 |
def _word(token: str) -> tuple[str, str, list[str]]:
|
|
@@ -489,11 +509,11 @@ def workbook_phrases() -> list[dict]:
|
|
| 489 |
`read` marks the Arabic -> Hebrew ones, where the page shows `tr` and hides `he`;
|
| 490 |
`he_say` is the pointed Hebrew for the prompt voice, where the plain text is misread."""
|
| 491 |
out = []
|
| 492 |
-
for
|
| 493 |
for tid, _, kind, in_order, items in topics:
|
| 494 |
for seq, (he, sentence, *he_say) in enumerate(items, 1):
|
| 495 |
words = [_word(t) for t in sentence.split()]
|
| 496 |
-
phrase = dict(cat=f"l{
|
| 497 |
variants=_variants(words), read=kind == "read")
|
| 498 |
say = _voiced(he_say[0] if he_say else he)
|
| 499 |
if say != he:
|
|
|
|
| 268 |
for he, stem in nouns for owner, say, suffix, tag, *_ in OWNERS]
|
| 269 |
|
| 270 |
|
| 271 |
+
# The course's levels: level -> (name, how many lessons it will have). A level's lessons that are not in LESSONS yet
|
| 272 |
+
# are shown on the path as "בקרוב".
|
| 273 |
+
LEVELS = {1: ("רמה 1", 10), 2: ("רמה 2", 10)}
|
| 274 |
+
|
| 275 |
+
# (level, lesson) -> its topics: (id, name, kind, shown in order, [(Hebrew, Arabic) or (Hebrew, Arabic, Hebrew as read
|
| 276 |
+
# aloud)]); sentence numbering is the workbook's. Every lesson names its level.
|
| 277 |
LESSONS = {
|
| 278 |
+
(1, 1): [
|
| 279 |
("pron", "הגופים", "say", True, [(_plain(he) + " / הן" * (he == "הֵם"), ar, he) for he, ar in zip(HE_PRONOUNS, AR_PRONOUNS)]),
|
| 280 |
("verbs", "פעלים", "say", True, _base_forms(VERBS_1)),
|
| 281 |
("past", "הטיות בעבר", "say", False, _past(VERBS_1)),
|
|
|
|
| 316 |
("המורה שאלה את הילדה: \"חרוצה, למה ישבת על השולחן?\"", "المعلّمة سألت#f البنت: يا شاطرة ليش قعدتي على الطّاولة؟"),
|
| 317 |
]),
|
| 318 |
],
|
| 319 |
+
(1, 2): [
|
| 320 |
("dual", "הזוגי", "say", True, [(f"{he}, {he2}", f"{ar}، {ar2}") for he, he2, ar, ar2 in DUALS]),
|
| 321 |
("family", "המשפחה", "say", True, FAMILY),
|
| 322 |
("poss", "כינויי הקניין", "say", False, _possessives(POSSESSIVE_DRILL)),
|
|
|
|
| 444 |
]),
|
| 445 |
],
|
| 446 |
}
|
| 447 |
+
for _level, _n in LESSONS:
|
| 448 |
+
assert _level in LEVELS and 1 <= _n <= LEVELS[_level][1], f"lesson {_n} of level {_level} is not in LEVELS"
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def lesson_id(level: int, n: int) -> str:
|
| 452 |
+
"""Level 1's lessons keep their first ids ("1", and its topics "l1-..."): recorded attempts and saved classes name
|
| 453 |
+
them. Later levels' are "2.1" (topics "l2.1-...")."""
|
| 454 |
+
return str(n) if level == 1 else f"{level}.{n}"
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
LEVEL_TABS = [{"id": str(level), "name": name} for level, (name, _) in LEVELS.items()]
|
| 458 |
+
# every planned lesson, in the course's order; one not written yet is `soon`
|
| 459 |
+
LESSON_TABS = [{"id": lesson_id(level, n), "name": f"שיעור {n}", "level": str(level), "n": n,
|
| 460 |
+
**({} if (level, n) in LESSONS else {"soon": True})}
|
| 461 |
+
for level, (_, count) in LEVELS.items() for n in range(1, count + 1)]
|
| 462 |
# Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
|
| 463 |
# Each has its two speakers' voices, by name in the voice list: the odd lines are the first one's, the even the second's.
|
| 464 |
STORIES = {"l2-talk": ("אמיר", "ראאף")}
|
|
|
|
| 467 |
UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
|
| 468 |
TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
|
| 469 |
"dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
|
| 470 |
+
CATEGORIES = [{"id": f"l{lesson_id(*key)}-{tid}", "name": name, "lesson": lesson_id(*key),
|
| 471 |
+
**({"story": True, "voices": list(STORIES[f"l{lesson_id(*key)}-{tid}"])} if f"l{lesson_id(*key)}-{tid}" in STORIES else {}),
|
| 472 |
**({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
|
| 473 |
+
for key, topics in LESSONS.items() for tid, name, *_ in topics]
|
| 474 |
|
| 475 |
|
| 476 |
def _word(token: str) -> tuple[str, str, list[str]]:
|
|
|
|
| 509 |
`read` marks the Arabic -> Hebrew ones, where the page shows `tr` and hides `he`;
|
| 510 |
`he_say` is the pointed Hebrew for the prompt voice, where the plain text is misread."""
|
| 511 |
out = []
|
| 512 |
+
for key, topics in LESSONS.items():
|
| 513 |
for tid, _, kind, in_order, items in topics:
|
| 514 |
for seq, (he, sentence, *he_say) in enumerate(items, 1):
|
| 515 |
words = [_word(t) for t in sentence.split()]
|
| 516 |
+
phrase = dict(cat=f"l{lesson_id(*key)}-{tid}", he=he, tr=" ".join(t for _, t, _ in words),
|
| 517 |
variants=_variants(words), read=kind == "read")
|
| 518 |
say = _voiced(he_say[0] if he_say else he)
|
| 519 |
if say != he:
|
|
@@ -35,6 +35,16 @@ header{display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
|
|
| 35 |
.mates{list-style:none;margin:0;padding:0;columns:2 10em;column-gap:16px}
|
| 36 |
.mates li{padding:7px 2px;border-bottom:1px solid var(--line);font-size:16px;break-inside:avoid}
|
| 37 |
.mates li.me{font-weight:700;color:var(--olive)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
.sub{color:var(--muted);font-size:14px}
|
| 39 |
/* on the practice card the header names where the student is: the lesson, and under it the topic */
|
| 40 |
.where{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font:400 22px/1.15 var(--f-display)}
|
|
@@ -57,6 +67,10 @@ header .sub.maphint{flex:0 0 100%;color:var(--ink)}
|
|
| 57 |
.chapter i::after{content:"";display:block;width:var(--p,0%);height:100%;border-radius:999px;background:var(--on-olive)}
|
| 58 |
.chapter.gold{background:var(--amber-bg);color:var(--amber);box-shadow:0 4px 0 var(--amber)}
|
| 59 |
.chapter:active{transform:translateY(3px);box-shadow:0 1px 0 var(--olive-deep)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
.sat{--c:var(--olive);--t:6px;--s:4px;position:absolute;left:calc(50% + var(--x));top:var(--y);width:72px;height:72px;margin:-36px 0 0 -36px;padding:0;border:0;background:none;color:var(--ink);font:600 15px/1.25 var(--f-body);cursor:pointer}
|
| 61 |
.ring{display:block;position:relative;width:72px;height:72px;padding:10px 10px 14px;border-radius:50%;background:radial-gradient(closest-side,var(--bg) calc(100% - var(--t)),transparent calc(100% - var(--t) + 1px)),conic-gradient(var(--c) var(--p,0%),var(--track) 0)}
|
| 62 |
.dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--surface);color:var(--ink);font:400 22px/1 var(--f-display);box-shadow:0 var(--s) 0 var(--track);transition:transform .08s,box-shadow .08s}
|
|
@@ -146,21 +160,12 @@ header .sub.maphint{flex:0 0 100%;color:var(--ink)}
|
|
| 146 |
.tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
|
| 147 |
#denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
|
| 148 |
#denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
| 149 |
-
/* The teacher's panel
|
| 150 |
body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
|
| 151 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 152 |
-
|
| 153 |
-
.stu{display:block;padding:0}
|
| 154 |
-
.stu summary{cursor:pointer;list-style:none}
|
| 155 |
-
.stu>summary{display:grid;grid-template-columns:1fr auto;gap:6px 10px;align-items:center;padding:12px 16px}
|
| 156 |
-
.stu summary::-webkit-details-marker{display:none}
|
| 157 |
-
.stu .who{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
|
| 158 |
-
.stu .who b{font-weight:700;overflow-wrap:anywhere}
|
| 159 |
-
.stu .who small,.stu .stats{color:var(--muted);font-size:14px}
|
| 160 |
-
.stu .none{color:var(--muted);font-size:13px}
|
| 161 |
-
.stu .stats,.stu .progress{grid-column:1/-1;margin:0}
|
| 162 |
/* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
|
| 163 |
-
.sec>summary{display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
|
| 164 |
.sec>summary>:first-child::before{content:"◂";display:inline-block;margin-inline-end:6px;color:var(--muted);transition:transform .12s}
|
| 165 |
.sec[open]>summary>:first-child::before{transform:rotate(-90deg)}
|
| 166 |
.sec>summary small{font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
@@ -175,17 +180,69 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
|
|
| 175 |
.hard h2 small{display:block;margin-top:2px;font:600 13px/1.3 var(--f-body);color:var(--muted)}
|
| 176 |
.hard .trow{grid-template-columns:1fr auto auto 3em;border-top:1px solid var(--line);padding-block:7px}
|
| 177 |
.trow .two{display:flex;flex-direction:column;font-weight:600}
|
| 178 |
-
.
|
| 179 |
-
/* the
|
| 180 |
-
.
|
| 181 |
-
.
|
| 182 |
-
.
|
| 183 |
-
.
|
| 184 |
-
.
|
| 185 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
.cform .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
| 187 |
-
.cform .
|
| 188 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
#logoutBtn{position:fixed;bottom:4px;right:6px;z-index:9;font:500 11px/1 var(--f-body);padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
| 190 |
.drive{margin-inline-start:auto;font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
|
| 191 |
body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
|
|
@@ -276,7 +333,7 @@ body.driving .card.storymode .prompt{display:block}
|
|
| 276 |
<div id="logo-splash"><img src="static/logo-intro.svg" alt="אהלא וסהלא, בית הספר ללימוד השפה הערבית" width="489" height="225"><button id="startBtn">יאללה נבלש</button><div id="roles" hidden><button id="practiceBtn">תרגול</button><button id="manageBtn">ניהול</button></div><button id="loginBtn" hidden>כניסה עם Google</button><div id="denied" hidden>החשבון הזה אינו באף כיתה. בקשו מהמורה להוסיף אותו.<button id="switchBtn">כניסה עם חשבון אחר</button></div></div>
|
| 277 |
<div class="wrap" id="wrap" inert>
|
| 278 |
<header>
|
| 279 |
-
<div class="brand">אהלא וסהלא<button id="myClass" title="התלמידים בכית
|
| 280 |
<div class="where"><small id="lessonName"></small><span id="catName"></span></div>
|
| 281 |
<div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
|
| 282 |
<button class="drive" id="toManage" hidden>ניהול</button>
|
|
@@ -338,11 +395,12 @@ body.driving .card.storymode .prompt{display:block}
|
|
| 338 |
</div>
|
| 339 |
|
| 340 |
<section class="wrap" id="manage" aria-labelledby="mTitle">
|
| 341 |
-
<div class="roundbar"><h1 class="mtitle" id="mTitle">ניהול</h1><span class="sep"></span><
|
| 342 |
<div class="sub" id="mState" aria-live="polite"></div>
|
| 343 |
-
<div class="
|
| 344 |
-
<div class="card
|
| 345 |
-
<div id="
|
|
|
|
| 346 |
</section>
|
| 347 |
</div>
|
| 348 |
<dialog id="listDlg" aria-labelledby="listTitle">
|
|
@@ -350,10 +408,25 @@ body.driving .card.storymode .prompt{display:block}
|
|
| 350 |
<label class="donebox"><input type="checkbox" id="showDone"><span id="doneText"></span></label>
|
| 351 |
<ol class="plist" id="plist"></ol>
|
| 352 |
</dialog>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
<dialog id="classDlg" aria-labelledby="classTitle">
|
| 354 |
<div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
|
| 355 |
-
<
|
| 356 |
<p class="sub" id="matesNote"></p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
</dialog>
|
| 358 |
<dialog id="driveDlg" aria-labelledby="driveTitle">
|
| 359 |
<div class="dlg-head"><h2 id="driveTitle">מצב נסיעה</h2></div>
|
|
@@ -385,7 +458,9 @@ async function api(url,opts={}){ // fetch with the session token; a refused to
|
|
| 385 |
}
|
| 386 |
const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
|
| 387 |
let driving=false;
|
| 388 |
-
|
|
|
|
|
|
|
| 389 |
let myAudioUrl=null;
|
| 390 |
const phraseOf=id=>PHRASES.find(p=>p.id===id); // by id: the list skips the phrases that have no tab
|
| 391 |
|
|
@@ -424,6 +499,14 @@ function count(t){ // "11/70 תורגלו", or the mark of a finished one
|
|
| 424 |
if(t.over){ s.textContent=t.gold?"★ מצוין":"✓ הושלם"; return s; }
|
| 425 |
n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; s.append(n," תורגלו"); return s;
|
| 426 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
const STEP=108, SWAY=[0,.7,1,.7,0,-.7,-1,-.7]; // the road: the way down from one topic to the next, and how far aside each one stands
|
| 428 |
let drawnW=0, nowWatch=null;
|
| 429 |
function drawPath(stay){
|
|
@@ -432,7 +515,18 @@ function drawPath(stay){
|
|
| 432 |
const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
|
| 433 |
road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
|
| 434 |
let next=true, y=0; // next: the first lesson that is not finished is the one to do now
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
const cats=CATS.filter(c=>c.lesson===l.id), parts=cats.map(c=>tally(listed.filter(p=>p.cat===c.id)));
|
| 437 |
const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
|
| 438 |
const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic the banner opens
|
|
@@ -520,11 +614,11 @@ function setRound(){
|
|
| 520 |
box.checked=all; box.disabled=!todo.length;
|
| 521 |
order=all?topic:todo; pos=0; marks();
|
| 522 |
}
|
| 523 |
-
function startCat(wait=true){ setRound(); show(null,wait); }
|
| 524 |
// What tells how much was practised, redrawn when a score comes in: the path and the box's text. The count itself is in the list's title.
|
| 525 |
function marks(){
|
|
|
|
| 526 |
if(!CATS.length) return;
|
| 527 |
-
drawPath();
|
| 528 |
const done=topic.filter(id=>best[id]!=null).length;
|
| 529 |
$("doneText").textContent=done>=topic.length?"כל המשפטים בנושא תורגלו":"לכלול בתרגול גם משפטים שכבר תורגלו";
|
| 530 |
}
|
|
@@ -557,18 +651,47 @@ $("setBtn").onclick=()=>$("setDlg").showModal();
|
|
| 557 |
$("setClose").onclick=()=>$("setDlg").close();
|
| 558 |
$("listClose").onclick=()=>$("listDlg").close();
|
| 559 |
$("classClose").onclick=()=>$("classDlg").close();
|
| 560 |
-
$("
|
| 561 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
try{
|
| 564 |
const res=await api("/api/classmates"); if(!res.ok) throw 0;
|
| 565 |
-
const d=await res.json();
|
| 566 |
-
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
}catch{ note.textContent="הרשימה לא זמינה כרגע."; }
|
| 569 |
};
|
| 570 |
-
for(const d of document.querySelectorAll("dialog")) d.onclick=e=>{ // a tap outside the box closes it
|
| 571 |
-
const r=d.getBoundingClientRect(); if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom) d.close(); };
|
| 572 |
// Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
|
| 573 |
function variation(kind){
|
| 574 |
if(cur.grp==null) return;
|
|
@@ -753,7 +876,7 @@ function show(p,wait){ // wait: do not record until the microphone button is p
|
|
| 753 |
const c=CATS.find(c=>c.id===cat), mine=topic.includes(cur.id); // a topic may name its items (a verb, a word) and its own task
|
| 754 |
$("task").textContent=cur.read?"קראו בקול ותרגמו לעברית":mine&&c.task||"איך אומרים את זה בערבית מדוברת?";
|
| 755 |
$("answerBtn").textContent=cur.read?"הצג תרגום":"הצג תשובה";
|
| 756 |
-
$("catName").textContent=c.name; $("lessonName").textContent=(LESSONS.find(l=>l.id===c.lesson)
|
| 757 |
// the number and the bar are the sentence's place in the topic, whatever the round holds
|
| 758 |
const b=best[cur.id], at=topic.indexOf(cur.id), hintBox=$("doneHint");
|
| 759 |
$("counter").textContent=ordered?"":`${pos+1} / ${order.length}`;
|
|
@@ -1090,10 +1213,20 @@ function sentence(p,under){ // a sentence as the student sees it, and a small
|
|
| 1090 |
const text=mark("two",""), name=document.createElement("span");
|
| 1091 |
setTr(name,p.read?p.tr:p.he); text.append(name,mark("",under,"small")); return text;
|
| 1092 |
}
|
| 1093 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
async function loadManage(){
|
| 1095 |
-
const state=$("mState")
|
| 1096 |
-
state.textContent="טוען…";
|
| 1097 |
try{
|
| 1098 |
const res=await api("/api/class"); await ready;
|
| 1099 |
if(!res.ok){
|
|
@@ -1102,120 +1235,188 @@ async function loadManage(){
|
|
| 1102 |
}
|
| 1103 |
CLASS=await res.json();
|
| 1104 |
}catch{ state.textContent="השרת לא זמין."; return; }
|
| 1105 |
-
|
| 1106 |
-
filter.innerHTML=""; filter.add(new Option("כל הכיתות",""));
|
| 1107 |
-
for(const c of CLASS.classes||[]) filter.add(new Option(c.name,c.name));
|
| 1108 |
-
filter.value=[...filter.options].some(o=>o.value===was)?was:""; filter.hidden=!(CLASS.classes||[]).length;
|
| 1109 |
-
drawClasses(); drawStudents();
|
| 1110 |
}
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
const
|
| 1116 |
-
|
| 1117 |
-
const
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
const in_=CLASS.students.filter(s=>s.class===c.name);
|
| 1123 |
-
sum.append(mark("",c.name),mark("",`${c.emails.length} תלמידים · ${in_.filter(s=>s.joined).length} נכנסו`,"small"));
|
| 1124 |
-
}else sum.append(mark("","+ כיתה חדשה"));
|
| 1125 |
-
sec.append(sum,classForm(c)); box.append(sec);
|
| 1126 |
-
}
|
| 1127 |
}
|
| 1128 |
-
function
|
| 1129 |
-
const
|
| 1130 |
-
const
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
if(c){
|
| 1137 |
-
const del=mark("btn","מחיקת הכיתה","button"); del.type="button"; row.append(del);
|
| 1138 |
-
del.onclick=()=>{ if(confirm(`למחוק את הכיתה ${c.name}? התלמידים שלה לא יוכלו להיכנס, וההקלטות שלהם נשמרות.`)) send("DELETE"); };
|
| 1139 |
}
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
|
|
|
| 1149 |
}
|
| 1150 |
-
|
| 1151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
}
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
const
|
| 1158 |
-
const
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
.filter(
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
const
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
}
|
|
|
|
| 1173 |
}
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
|
|
|
| 1188 |
det.append(sum);
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1198 |
}
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1208 |
}
|
| 1209 |
-
|
|
|
|
|
|
|
| 1210 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1213 |
const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
|
| 1214 |
-
|
| 1215 |
-
for(const c of
|
| 1216 |
for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
|
| 1217 |
const saved=store.get("ihki.voice",""); if((d.voices||[]).some(v=>v.id===saved)) $("voiceId").value=saved;
|
| 1218 |
-
|
| 1219 |
startCat(); opened=true;
|
| 1220 |
}).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
|
| 1221 |
</script>
|
|
@@ -1224,6 +1425,7 @@ const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
|
|
| 1224 |
function enter(){
|
| 1225 |
const splash=$("logo-splash");
|
| 1226 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
|
|
|
| 1227 |
}
|
| 1228 |
$("startBtn").onclick=$("practiceBtn").onclick=enter;
|
| 1229 |
$("manageBtn").onclick=()=>{ enter(); showManage(); };
|
|
@@ -1251,7 +1453,11 @@ if(AUTH_URL){
|
|
| 1251 |
const res=await api("/api/me").catch(()=>null);
|
| 1252 |
const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
|
| 1253 |
const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
|
| 1254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1255 |
start.hidden=!ok||teacher; roles.hidden=$("toManage").hidden=!teacher; denied.hidden=ok;
|
| 1256 |
if(ok){ (teacher?$("practiceBtn"):start).focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
|
| 1257 |
},0);
|
|
|
|
| 35 |
.mates{list-style:none;margin:0;padding:0;columns:2 10em;column-gap:16px}
|
| 36 |
.mates li{padding:7px 2px;border-bottom:1px solid var(--line);font-size:16px;break-inside:avoid}
|
| 37 |
.mates li.me{font-weight:700;color:var(--olive)}
|
| 38 |
+
/* a student of several classes chooses the one to practise in: a button per class, the last one chosen marked */
|
| 39 |
+
.picks{display:flex;flex-direction:column;gap:8px}
|
| 40 |
+
.picks button{display:flex;flex-direction:column;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--ink);font:700 18px/1.2 var(--f-body);text-align:start;cursor:pointer}
|
| 41 |
+
.picks button small{font:500 13px/1.3 var(--f-body);color:var(--muted)}
|
| 42 |
+
.picks button.last{border-color:var(--olive);box-shadow:inset 0 0 0 1px var(--olive)}
|
| 43 |
+
.picks button:hover{background:var(--olive-soft)}
|
| 44 |
+
#classSwitch{align-self:flex-start;margin-top:10px}
|
| 45 |
+
#mates h3{margin:14px 0 4px;font:400 19px/1.2 var(--f-display)}
|
| 46 |
+
#mates h3:first-child{margin-top:4px}
|
| 47 |
+
#mates h3 small{margin-inline-start:8px;font:600 13px var(--f-body);color:var(--muted)}
|
| 48 |
.sub{color:var(--muted);font-size:14px}
|
| 49 |
/* on the practice card the header names where the student is: the lesson, and under it the topic */
|
| 50 |
.where{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font:400 22px/1.15 var(--f-display)}
|
|
|
|
| 67 |
.chapter i::after{content:"";display:block;width:var(--p,0%);height:100%;border-radius:999px;background:var(--on-olive)}
|
| 68 |
.chapter.gold{background:var(--amber-bg);color:var(--amber);box-shadow:0 4px 0 var(--amber)}
|
| 69 |
.chapter:active{transform:translateY(3px);box-shadow:0 1px 0 var(--olive-deep)}
|
| 70 |
+
/* a level opens with its name; the lessons still to come stand in one dashed banner, not a button */
|
| 71 |
+
.level{position:absolute;left:0;right:0;margin:0;padding:4px 0;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;background:var(--bg);font:400 24px/1.2 var(--f-display)}
|
| 72 |
+
.level small{font:600 13px/1.2 var(--f-body);color:var(--muted)}
|
| 73 |
+
.chapter.soon{height:52px;padding-block:10px;background:transparent;color:var(--muted);box-shadow:none;outline:2px dashed var(--line);outline-offset:-2px;cursor:default;font-size:18px}
|
| 74 |
.sat{--c:var(--olive);--t:6px;--s:4px;position:absolute;left:calc(50% + var(--x));top:var(--y);width:72px;height:72px;margin:-36px 0 0 -36px;padding:0;border:0;background:none;color:var(--ink);font:600 15px/1.25 var(--f-body);cursor:pointer}
|
| 75 |
.ring{display:block;position:relative;width:72px;height:72px;padding:10px 10px 14px;border-radius:50%;background:radial-gradient(closest-side,var(--bg) calc(100% - var(--t)),transparent calc(100% - var(--t) + 1px)),conic-gradient(var(--c) var(--p,0%),var(--track) 0)}
|
| 76 |
.dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--surface);color:var(--ink);font:400 22px/1 var(--f-display);box-shadow:0 var(--s) 0 var(--track);transition:transform .08s,box-shadow .08s}
|
|
|
|
| 160 |
.tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
|
| 161 |
#denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
|
| 162 |
#denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
| 163 |
+
/* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
|
| 164 |
body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
|
| 165 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 166 |
+
.hard>.note{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
/* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
|
| 168 |
+
.sec>summary{cursor:pointer;list-style:none;display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
|
| 169 |
.sec>summary>:first-child::before{content:"◂";display:inline-block;margin-inline-end:6px;color:var(--muted);transition:transform .12s}
|
| 170 |
.sec[open]>summary>:first-child::before{transform:rotate(-90deg)}
|
| 171 |
.sec>summary small{font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
|
|
| 180 |
.hard h2 small{display:block;margin-top:2px;font:600 13px/1.3 var(--f-body);color:var(--muted)}
|
| 181 |
.hard .trow{grid-template-columns:1fr auto auto 3em;border-top:1px solid var(--line);padding-block:7px}
|
| 182 |
.trow .two{display:flex;flex-direction:column;font-weight:600}
|
| 183 |
+
.trow.s3{grid-template-columns:1fr auto 3em}
|
| 184 |
+
/* the panel: a tab per class; under it the class's figures, its lessons with the sentences it finds hard, its students */
|
| 185 |
+
.tabs{display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}
|
| 186 |
+
.tab{flex:none;font:700 15px/1 var(--f-body);padding:9px 14px;border-radius:999px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;white-space:nowrap}
|
| 187 |
+
.tab small{font-weight:600;color:var(--muted);margin-inline-start:6px;font-variant-numeric:tabular-nums}
|
| 188 |
+
.tab[aria-selected=true]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
|
| 189 |
+
.tab[aria-selected=true] small{color:inherit;opacity:.8}
|
| 190 |
+
.tab.add{border-style:dashed;color:var(--olive);background:transparent}
|
| 191 |
+
.chead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
| 192 |
+
.chead h2{flex:1;margin:0;font:400 24px/1.1 var(--f-display);overflow-wrap:anywhere}
|
| 193 |
+
.chead p{flex:0 0 100%;margin:0;color:var(--muted);font-size:14px}
|
| 194 |
+
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
| 195 |
+
.tile{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:10px;background:var(--bg)}
|
| 196 |
+
.tile b{font:400 24px/1.1 var(--f-display);font-variant-numeric:tabular-nums}
|
| 197 |
+
.tile small{color:var(--muted);font-size:13px;line-height:1.25}
|
| 198 |
+
@media (max-width:520px){.tiles{grid-template-columns:repeat(2,1fr)}}
|
| 199 |
+
.lsum>summary{grid-template-columns:auto 1fr 3em}
|
| 200 |
+
.lsum>summary>:first-child{white-space:nowrap}
|
| 201 |
+
.lsum>summary small{text-align:end}
|
| 202 |
+
.lsum .progress{grid-column:1/-1;margin:2px 0 0}
|
| 203 |
+
.flag{color:var(--sumac);font-weight:700;white-space:nowrap}
|
| 204 |
+
.lsum .note{margin:0;padding:6px 16px;color:var(--muted);font-size:14px}
|
| 205 |
+
.trow .tries{text-align:end}
|
| 206 |
+
/* the students: a row each, a column per lesson (its sentences practised, and a bar), the grade; a row opens on its details */
|
| 207 |
+
.rhead,.rrow>summary{display:grid;grid-template-columns:var(--cols);gap:10px;align-items:center;padding:8px 16px}
|
| 208 |
+
.rhead{padding-block:0 6px;color:var(--muted);font:700 12px/1.2 var(--f-body)}
|
| 209 |
+
.rhead span:not(:first-child){text-align:center}
|
| 210 |
+
.rhead span:last-child{text-align:end}
|
| 211 |
+
.rrow{border-top:1px solid var(--line)}
|
| 212 |
+
.rrow>summary{cursor:pointer;list-style:none}
|
| 213 |
+
.rrow>summary::-webkit-details-marker{display:none}
|
| 214 |
+
.rrow>summary:hover{background:var(--bg)}
|
| 215 |
+
.rrow[open]>summary{background:var(--olive-soft)}
|
| 216 |
+
.rrow .who{display:flex;flex-direction:column;min-width:0}
|
| 217 |
+
.rrow .who b{align-self:flex-start;max-width:100%;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 218 |
+
.rrow .who small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 219 |
+
.cell{display:flex;flex-direction:column;align-items:stretch;gap:3px;font:600 12px/1 var(--f-body);color:var(--muted);text-align:center;font-variant-numeric:tabular-nums}
|
| 220 |
+
.cell .progress{height:5px;margin:0}
|
| 221 |
+
.rrow .pill,.rrow .none{justify-self:end}
|
| 222 |
+
.rrow .none{color:var(--muted);font-size:12px;text-align:end}
|
| 223 |
+
.rdetail{padding:0 0 8px}
|
| 224 |
+
.rdetail .stats{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
|
| 225 |
+
.cform{display:flex;flex-direction:column;gap:10px}
|
| 226 |
+
.cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
|
| 227 |
+
.cform label small{font-weight:500;color:var(--muted)}
|
| 228 |
+
.cform input,.cform textarea{font:500 15px/1.5 var(--f-body);padding:8px 10px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
|
| 229 |
+
.cform textarea{min-height:9em;resize:vertical;direction:ltr;text-align:left}
|
| 230 |
.cform .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
| 231 |
+
.cform .row .sep{flex:1}
|
| 232 |
+
.cform .msg{flex:0 0 100%;color:var(--sumac);font-size:14px}
|
| 233 |
+
.cform .msg:empty{display:none}
|
| 234 |
+
.lpick{margin:0;padding:0;border:0;min-width:0}
|
| 235 |
+
.lpick legend{padding:0;margin-bottom:6px;font:700 14px/1.3 var(--f-body)}
|
| 236 |
+
.lpick legend small{display:block;font-weight:500;color:var(--muted)}
|
| 237 |
+
#editLessons{display:flex;flex-direction:column;gap:10px}
|
| 238 |
+
.lvl{display:flex;flex-direction:column;gap:6px}
|
| 239 |
+
#editLessons .lvlhead{flex-direction:row;align-items:center;gap:8px;font:700 15px/1.2 var(--f-body);color:var(--ink);cursor:pointer}
|
| 240 |
+
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
| 241 |
+
#editLessons .chips label{flex-direction:row;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;font:600 14px/1 var(--f-body);color:var(--ink);cursor:pointer;font-variant-numeric:tabular-nums}
|
| 242 |
+
#editLessons .chips label.soon{border-style:dashed;color:var(--muted)}
|
| 243 |
+
#editLessons .chips label:has(:checked){border-color:var(--olive);background:var(--olive-soft);color:var(--olive)}
|
| 244 |
+
#editLessons input{width:18px;height:18px;margin:0;accent-color:var(--olive)}
|
| 245 |
+
.btn.danger{color:var(--sumac);border-color:currentColor}
|
| 246 |
#logoutBtn{position:fixed;bottom:4px;right:6px;z-index:9;font:500 11px/1 var(--f-body);padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
| 247 |
.drive{margin-inline-start:auto;font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
|
| 248 |
body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
|
|
|
|
| 333 |
<div id="logo-splash"><img src="static/logo-intro.svg" alt="אהלא וסהלא, בית הספר ללימוד השפה הערבית" width="489" height="225"><button id="startBtn">יאללה נבלש</button><div id="roles" hidden><button id="practiceBtn">תרגול</button><button id="manageBtn">ניהול</button></div><button id="loginBtn" hidden>כניסה עם Google</button><div id="denied" hidden>החשבון הזה אינו באף כיתה. בקשו מהמורה להוסיף אותו.<button id="switchBtn">כניסה עם חשבון אחר</button></div></div>
|
| 334 |
<div class="wrap" id="wrap" inert>
|
| 335 |
<header>
|
| 336 |
+
<div class="brand">אהלא וסהלא<button id="myClass" title="התלמידים בכיתות שלי"></button></div>
|
| 337 |
<div class="where"><small id="lessonName"></small><span id="catName"></span></div>
|
| 338 |
<div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
|
| 339 |
<button class="drive" id="toManage" hidden>ניהול</button>
|
|
|
|
| 395 |
</div>
|
| 396 |
|
| 397 |
<section class="wrap" id="manage" aria-labelledby="mTitle">
|
| 398 |
+
<div class="roundbar"><h1 class="mtitle" id="mTitle">ניהול</h1><span class="sep"></span><button class="btn" id="mRefresh">רענון</button><button class="btn primary" id="mPractice">לתרגול</button></div>
|
| 399 |
<div class="sub" id="mState" aria-live="polite"></div>
|
| 400 |
+
<div class="tabs" id="mTabs" role="tablist" aria-label="כיתות" hidden></div>
|
| 401 |
+
<div class="card" id="mHead" hidden></div>
|
| 402 |
+
<div class="card hard" id="mLessons" hidden></div>
|
| 403 |
+
<div class="card hard roster" id="mRoster" hidden></div>
|
| 404 |
</section>
|
| 405 |
</div>
|
| 406 |
<dialog id="listDlg" aria-labelledby="listTitle">
|
|
|
|
| 408 |
<label class="donebox"><input type="checkbox" id="showDone"><span id="doneText"></span></label>
|
| 409 |
<ol class="plist" id="plist"></ol>
|
| 410 |
</dialog>
|
| 411 |
+
<dialog id="editDlg" aria-labelledby="editTitle">
|
| 412 |
+
<div class="dlg-head"><h2 id="editTitle"></h2><button class="btn" id="editClose">סגירה</button></div>
|
| 413 |
+
<form class="cform" id="editForm">
|
| 414 |
+
<label>שם הכיתה<input id="editName" required maxlength="80" placeholder="למשל: אוקטובר 2026" autocomplete="off"></label>
|
| 415 |
+
<fieldset class="lpick"><legend>השיעורים<small>התלמידים של הכיתה רואים במסלול רק אותם. שיעור מקווקו עוד לא מוכן: הוא ייפתח לכיתה כשיהיה.</small></legend><div id="editLessons"></div></fieldset>
|
| 416 |
+
<label>התלמידים<small>שורה לכל תלמיד: כתובת ה-Gmail, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה ��יתות.</small>
|
| 417 |
+
<textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה avi@gmail.com"></textarea></label>
|
| 418 |
+
<div class="row"><button class="btn primary" id="editSave"></button><span class="sub" id="editCount"></span><span class="sep"></span><button class="btn danger" type="button" id="editDelete">מחיקת הכיתה</button><span class="msg" id="editMsg" role="alert"></span></div>
|
| 419 |
+
</form>
|
| 420 |
+
</dialog>
|
| 421 |
<dialog id="classDlg" aria-labelledby="classTitle">
|
| 422 |
<div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
|
| 423 |
+
<div id="mates"></div>
|
| 424 |
<p class="sub" id="matesNote"></p>
|
| 425 |
+
<button class="btn" id="classSwitch" hidden>מעבר לכיתה אחרת</button>
|
| 426 |
+
</dialog>
|
| 427 |
+
<dialog id="pickDlg" aria-labelledby="pickTitle">
|
| 428 |
+
<div class="dlg-head"><h2 id="pickTitle">באיזו כיתה מתרגלים עכשיו?<small>אפשר להחליף אחר כך, בלחיצה על שם הכיתה</small></h2></div>
|
| 429 |
+
<div class="picks" id="picks"></div>
|
| 430 |
</dialog>
|
| 431 |
<dialog id="driveDlg" aria-labelledby="driveTitle">
|
| 432 |
<div class="dlg-head"><h2 id="driveTitle">מצב נסיעה</h2></div>
|
|
|
|
| 458 |
}
|
| 459 |
const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
|
| 460 |
let driving=false;
|
| 461 |
+
/* LESSONS and CATS are the student's: the lessons of their classes (all of them for a teacher, or before the first class) */
|
| 462 |
+
let MY_CLASSES=[], MY_CLASS=null; // a student's classes ({name, lessons}), and the one they practise in now
|
| 463 |
+
let ALL_LEVELS=[], ALL_LESSONS=[], ALL_CATS=[], MY_LESSONS=null, LESSONS=[], CATS=[], PHRASES=[], cat=store.get("ihki.cat",""), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
|
| 464 |
let myAudioUrl=null;
|
| 465 |
const phraseOf=id=>PHRASES.find(p=>p.id===id); // by id: the list skips the phrases that have no tab
|
| 466 |
|
|
|
|
| 499 |
if(t.over){ s.textContent=t.gold?"★ מצוין":"✓ הושלם"; return s; }
|
| 500 |
n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; s.append(n," תורגלו"); return s;
|
| 501 |
}
|
| 502 |
+
const levelOf=l=>ALL_LEVELS.find(v=>v.id===l.level)||{name:""};
|
| 503 |
+
const lessonName=l=>l?(ALL_LEVELS.length>1?levelOf(l).name+" · ":"")+l.name:"";
|
| 504 |
+
function lessonsWord(ls){ // lessons of one level by their numbers: "שיעור 3", "שיעורים 3–10", "שיעורים 1–2, 5"
|
| 505 |
+
const runs=[]; for(const l of ls){ const r=runs[runs.length-1]; if(r&&l.n===r[1]+1) r[1]=l.n; else runs.push([l.n,l.n]); }
|
| 506 |
+
// each run isolated left to right, so Hebrew text around it does not turn "1–10" into "10–1"
|
| 507 |
+
return (ls.length>1?"שיעורים ":"שיעור ")+runs.map(([a,b])=>a===b?a:`\u2066${a}–${b}\u2069`).join(", ");
|
| 508 |
+
}
|
| 509 |
+
const byLevel=ls=>ALL_LEVELS.map(level=>({level,lessons:ls.filter(l=>l.level===level.id)})).filter(g=>g.lessons.length);
|
| 510 |
const STEP=108, SWAY=[0,.7,1,.7,0,-.7,-1,-.7]; // the road: the way down from one topic to the next, and how far aside each one stands
|
| 511 |
let drawnW=0, nowWatch=null;
|
| 512 |
function drawPath(stay){
|
|
|
|
| 515 |
const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
|
| 516 |
road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
|
| 517 |
let next=true, y=0; // next: the first lesson that is not finished is the one to do now
|
| 518 |
+
let level=null;
|
| 519 |
+
for(let k=0;k<LESSONS.length;k++){
|
| 520 |
+
const l=LESSONS[k];
|
| 521 |
+
if(l.level!==level){ // a level opens with its name and how many of its lessons are ready
|
| 522 |
+
level=l.level; const of=LESSONS.filter(x=>x.level===level), h=mark("level",levelOf(l).name,"h2");
|
| 523 |
+
h.style.top=y+"px"; h.append(mark("",`${of.filter(x=>!x.soon).length} מתוך ${of.length} שיעורים מוכנים`,"small")); box.append(h); y+=60;
|
| 524 |
+
}
|
| 525 |
+
if(l.soon){ // the lessons still to come, a run of them in one banner
|
| 526 |
+
const run=[l]; while(LESSONS[k+1]&&LESSONS[k+1].soon&&LESSONS[k+1].level===level) run.push(LESSONS[++k]);
|
| 527 |
+
const b=mark("chapter soon",""); b.style.top=y+"px"; b.append(mark("",lessonsWord(run)),mark("","בקרוב","small")); box.append(b);
|
| 528 |
+
y+=52+28; continue;
|
| 529 |
+
}
|
| 530 |
const cats=CATS.filter(c=>c.lesson===l.id), parts=cats.map(c=>tally(listed.filter(p=>p.cat===c.id)));
|
| 531 |
const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
|
| 532 |
const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic the banner opens
|
|
|
|
| 614 |
box.checked=all; box.disabled=!todo.length;
|
| 615 |
order=all?topic:todo; pos=0; marks();
|
| 616 |
}
|
| 617 |
+
function startCat(wait=true){ if(!CATS.length) return marks(); setRound(); show(null,wait); }
|
| 618 |
// What tells how much was practised, redrawn when a score comes in: the path and the box's text. The count itself is in the list's title.
|
| 619 |
function marks(){
|
| 620 |
+
drawPath(); // with no topic yet, the path still shows what is to come
|
| 621 |
if(!CATS.length) return;
|
|
|
|
| 622 |
const done=topic.filter(id=>best[id]!=null).length;
|
| 623 |
$("doneText").textContent=done>=topic.length?"כל המשפטים בנושא תורגלו":"לכלול בתרגול גם משפטים שכבר תורגלו";
|
| 624 |
}
|
|
|
|
| 651 |
$("setClose").onclick=()=>$("setDlg").close();
|
| 652 |
$("listClose").onclick=()=>$("listDlg").close();
|
| 653 |
$("classClose").onclick=()=>$("classDlg").close();
|
| 654 |
+
$("classSwitch").onclick=()=>{ $("classDlg").close(); askClass(); };
|
| 655 |
+
/* A student of several classes practises in one at a time: its lessons are the path. They are asked after the opening
|
| 656 |
+
screen, and again from the class's name in the header. */
|
| 657 |
+
function useClass(c){
|
| 658 |
+
MY_CLASS=c; store.set("ihki.class",c.name);
|
| 659 |
+
$("myClass").textContent=`כיתת ${c.name}`; $("classSwitch").hidden=MY_CLASSES.length<2;
|
| 660 |
+
MY_LESSONS=c.lessons;
|
| 661 |
+
if(ALL_LESSONS.length){ useLessons(MY_LESSONS); if(!home) showPath(); startCat(); }
|
| 662 |
+
}
|
| 663 |
+
async function askClass(){
|
| 664 |
+
await ready;
|
| 665 |
+
const box=$("picks"), last=MY_CLASS?MY_CLASS.name:store.get("ihki.class","");
|
| 666 |
+
box.innerHTML="";
|
| 667 |
+
for(const c of MY_CLASSES){
|
| 668 |
+
const b=mark(c.name===last?"last":"",c.name,"button"), ls=ALL_LESSONS.filter(l=>c.lessons.includes(l.id));
|
| 669 |
+
b.append(mark("",byLevel(ls).map(g=>`${g.level.name}: ${lessonsWord(g.lessons)}`).join(" · "),"small"));
|
| 670 |
+
b.onclick=()=>{ $("pickDlg").close(); useClass(c); };
|
| 671 |
+
box.append(b);
|
| 672 |
+
}
|
| 673 |
+
$("pickDlg").showModal(); (box.querySelector(".last")||box.firstChild).focus();
|
| 674 |
+
}
|
| 675 |
+
$("pickDlg").oncancel=e=>{ if(!MY_CLASS) e.preventDefault(); }; // Escape closes it only when a class was already chosen
|
| 676 |
+
$("myClass").onclick=async()=>{ // the students of my classes, by the nickname the teacher gave or their Google name
|
| 677 |
+
const box=$("mates"), note=$("matesNote");
|
| 678 |
+
$("classTitle").textContent=$("myClass").textContent; box.innerHTML=""; note.textContent="טוען…"; $("classDlg").showModal();
|
| 679 |
try{
|
| 680 |
const res=await api("/api/classmates"); if(!res.ok) throw 0;
|
| 681 |
+
const d=await res.json(), shown=MY_CLASS?d.classes.filter(c=>c.name===MY_CLASS.name):d.classes, several=shown.length>1;
|
| 682 |
+
if(several) $("classTitle").textContent="הכיתות שלי";
|
| 683 |
+
for(const c of shown){
|
| 684 |
+
const list=mark("mates","","ul"), count=`${c.students.length+c.unnamed} תלמידים`+(c.unnamed?` · ${c.unnamed} עוד לא נכנסו`:"");
|
| 685 |
+
if(several){ const h=mark("","כיתת "+c.name,"h3"); h.append(mark("",count,"small")); box.append(h); }
|
| 686 |
+
for(const s of c.students) list.append(mark(s.me?"me":"",s.me?`${s.name} (אני)`:s.name,"li"));
|
| 687 |
+
box.append(list);
|
| 688 |
+
if(!several) note.textContent=count;
|
| 689 |
+
}
|
| 690 |
+
if(several) note.textContent="";
|
| 691 |
}catch{ note.textContent="הרשימה לא זמינה כרגע."; }
|
| 692 |
};
|
| 693 |
+
for(const d of document.querySelectorAll("dialog:not(#editDlg,#pickDlg)")) d.onclick=e=>{ // a tap outside the box closes it (not a form being filled)
|
| 694 |
+
const r=d.getBoundingClientRect(); if(e.target===d&&(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)) d.close(); };
|
| 695 |
// Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
|
| 696 |
function variation(kind){
|
| 697 |
if(cur.grp==null) return;
|
|
|
|
| 876 |
const c=CATS.find(c=>c.id===cat), mine=topic.includes(cur.id); // a topic may name its items (a verb, a word) and its own task
|
| 877 |
$("task").textContent=cur.read?"קראו בקול ותרגמו לעברית":mine&&c.task||"איך אומרים את זה בערבית מדוברת?";
|
| 878 |
$("answerBtn").textContent=cur.read?"הצג תרגום":"הצג תשובה";
|
| 879 |
+
$("catName").textContent=c.name; $("lessonName").textContent=lessonName(LESSONS.find(l=>l.id===c.lesson));
|
| 880 |
// the number and the bar are the sentence's place in the topic, whatever the round holds
|
| 881 |
const b=best[cur.id], at=topic.indexOf(cur.id), hintBox=$("doneHint");
|
| 882 |
$("counter").textContent=ordered?"":`${pos+1} / ${order.length}`;
|
|
|
|
| 1213 |
const text=mark("two",""), name=document.createElement("span");
|
| 1214 |
setTr(name,p.read?p.tr:p.he); text.append(name,mark("",under,"small")); return text;
|
| 1215 |
}
|
| 1216 |
+
/* The panel shows one class at a time (a tab each). Before the first class it shows every account that signed in. */
|
| 1217 |
+
let CLASS=null, shownClass=store.get("ihki.mclass",""); // the panel's last answer {classes, students}; the class on show
|
| 1218 |
+
let CLASS_LESSONS=[], CLASS_SOON=[]; // the ready lessons of the class on show, and those still to come (every lesson before the first class)
|
| 1219 |
+
const HARD=70; // a sentence whose class average (of each student's best score) is under this is flagged as hard
|
| 1220 |
+
const listedPhrases=()=>PHRASES.filter(p=>p.listed!==false);
|
| 1221 |
+
const lessonPhrases=(l,list)=>{ const cats=ALL_CATS.filter(c=>c.lesson===l.id).map(c=>c.id); return list.filter(p=>cats.includes(p.cat)); };
|
| 1222 |
+
const stuTally=(s,list)=>{ const got=list.filter(p=>s.best[p.id]!=null); return {done:got.length, n:list.length, avg:mean(got.map(p=>s.best[p.id])), tries:list.reduce((t,p)=>t+(s.tries[p.id]||0),0)}; };
|
| 1223 |
+
const ratio=t=>{ const m=document.createElement("small"), n=document.createElement("bdi"); n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; m.append(n); return m; };
|
| 1224 |
+
const bar=frac=>{ const b=mark("progress",""), f=document.createElement("i"); f.style.width=`${Math.round(frac*100)}%`; b.append(f); return b; };
|
| 1225 |
+
const place=p=>{ const c=ALL_CATS.find(c=>c.id===p.cat), l=c&&ALL_LESSONS.find(l=>l.id===c.lesson); return [lessonName(l),c&&c.name].filter(Boolean).join(" · "); };
|
| 1226 |
+
const part=into=>{ const d=document.createElement("details"); d.className="sec"; d.append(document.createElement("summary")); into.append(d); return d; }; // a closed part of a card
|
| 1227 |
async function loadManage(){
|
| 1228 |
+
const state=$("mState");
|
| 1229 |
+
state.textContent="טוען…";
|
| 1230 |
try{
|
| 1231 |
const res=await api("/api/class"); await ready;
|
| 1232 |
if(!res.ok){
|
|
|
|
| 1235 |
}
|
| 1236 |
CLASS=await res.json();
|
| 1237 |
}catch{ state.textContent="השרת לא זמין."; return; }
|
| 1238 |
+
state.textContent=""; drawManage();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
}
|
| 1240 |
+
function drawManage(){
|
| 1241 |
+
const classes=CLASS.classes||[];
|
| 1242 |
+
if(!classes.some(c=>c.name===shownClass)) shownClass=classes.length?classes[0].name:"";
|
| 1243 |
+
const klass=classes.find(c=>c.name===shownClass)||null;
|
| 1244 |
+
const mine=ALL_LESSONS.filter(l=>!klass||klass.lessons.includes(l.id));
|
| 1245 |
+
CLASS_LESSONS=mine.filter(l=>!l.soon); CLASS_SOON=mine.filter(l=>l.soon);
|
| 1246 |
+
const listed=CLASS_LESSONS.flatMap(l=>lessonPhrases(l,listedPhrases())); // the class's sentences: its figures count these only
|
| 1247 |
+
// a student of the class, with its nickname in it; the totals beside their own fields (`tries` stays the per-sentence one)
|
| 1248 |
+
const students=CLASS.students.filter(s=>!klass||klass.name in s.classes).map(s=>{ const t=stuTally(s,listed);
|
| 1249 |
+
return {...s,nick:klass?s.classes[klass.name]:Object.values(s.classes).find(Boolean)||"",done:t.done,n:t.n,avg:t.avg}; });
|
| 1250 |
+
drawTabs(classes); drawHead(klass,students); drawLessons(students,listed); drawRoster(students,listed);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1251 |
}
|
| 1252 |
+
function drawTabs(classes){
|
| 1253 |
+
const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
|
| 1254 |
+
for(const c of classes){
|
| 1255 |
+
const t=mark("tab","","button"), name=document.createElement("bdi"); t.setAttribute("role","tab");
|
| 1256 |
+
name.textContent=c.name; t.append(name); // isolated, so the count does not join a year in the name t.setAttribute("aria-selected",c.name===shownClass);
|
| 1257 |
+
t.append(mark("",c.emails.length,"small"));
|
| 1258 |
+
t.onclick=()=>{ shownClass=c.name; store.set("ihki.mclass",c.name); drawManage(); };
|
| 1259 |
+
tabs.append(t);
|
|
|
|
|
|
|
|
|
|
| 1260 |
}
|
| 1261 |
+
const add=mark("tab add","+ כיתה חדשה","button"); add.onclick=()=>editClass(null); tabs.append(add);
|
| 1262 |
+
}
|
| 1263 |
+
function drawHead(klass,students){ // the class's name, its edit button and its figures; before the first class, how to open one
|
| 1264 |
+
const head=$("mHead"); head.innerHTML=""; head.hidden=false;
|
| 1265 |
+
const top=mark("chead","");
|
| 1266 |
+
if(!klass){
|
| 1267 |
+
top.append(mark("","עוד אין כיתות","h2"));
|
| 1268 |
+
const open=mark("btn primary","פתיחת כיתה","button"); open.onclick=()=>editClass(null); top.append(open);
|
| 1269 |
+
top.append(mark("","עד שנפתחת כיתה, כל חשבון Google יכול להיכנס, ולמטה מופיע כל מי שנכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
|
| 1270 |
+
head.append(top); return;
|
| 1271 |
}
|
| 1272 |
+
const edit=mark("btn","עריכת הכיתה","button"); edit.onclick=()=>editClass(klass);
|
| 1273 |
+
const what=byLevel([...CLASS_LESSONS,...CLASS_SOON].sort((a,b)=>ALL_LESSONS.indexOf(a)-ALL_LESSONS.indexOf(b))) // "רמה 1: שיעורים 1–10 (2 מוכנים)"
|
| 1274 |
+
.map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · ");
|
| 1275 |
+
top.append(mark("",klass.name,"h2"),edit,mark("",what,"p")); head.append(top);
|
| 1276 |
+
const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
|
| 1277 |
+
const avg=mean(students.filter(s=>s.avg!=null).map(s=>s.avg));
|
| 1278 |
+
tile(`${students.filter(s=>s.joined).length}/${students.length}`,"נכנסו לאפליקציה");
|
| 1279 |
+
tile(students.filter(s=>s.week).length,"תרגלו בשבוע האחרון");
|
| 1280 |
+
tile(students.reduce((t,s)=>t+s.week,0),"הקלטות בשבוע האחרון");
|
| 1281 |
+
tile(avg??"–","ציון ממוצע");
|
| 1282 |
+
head.append(tiles);
|
| 1283 |
}
|
| 1284 |
+
/* A lesson of the class: how much of it the students practised (on average), their grade, and the sentences the class
|
| 1285 |
+
finds hard: the class average of the students' best scores is under HARD. A sentence counts once enough students tried
|
| 1286 |
+
it (2, or 1 when only one practised the lesson), so one student's bad try does not flag it. */
|
| 1287 |
+
function drawLessons(students,listed){
|
| 1288 |
+
const box=$("mLessons"); box.innerHTML=""; box.hidden=!students.length&&!CLASS_SOON.length;
|
| 1289 |
+
const h=mark("","שיעורים","h2"); h.append(mark("",`⚑ משפט קשה: ממוצע הכיתה בו נמוך מ-${HARD}`,"small")); box.append(h);
|
| 1290 |
+
for(const l of CLASS_LESSONS){
|
| 1291 |
+
const list=lessonPhrases(l,listed), per=students.map(s=>stuTally(s,list)), active=per.filter(t=>t.done).length;
|
| 1292 |
+
const need=Math.min(2,active);
|
| 1293 |
+
const hard=list.map(p=>{ const by=students.filter(s=>s.best[p.id]!=null);
|
| 1294 |
+
return {p,k:by.length,avg:mean(by.map(s=>s.best[p.id])),tries:by.length?by.reduce((t,s)=>t+(s.tries[p.id]||0),0)/by.length:0}; })
|
| 1295 |
+
.filter(x=>x.k&&x.k>=need&&x.avg<HARD).sort((a,b)=>a.avg-b.avg||b.k-a.k);
|
| 1296 |
+
const sec=part(box), sum=sec.firstChild, avg=mean(per.filter(t=>t.avg!=null).map(t=>t.avg));
|
| 1297 |
+
const done=per.length?per.reduce((t,x)=>t+(x.n?x.done/x.n:0),0)/per.length:0;
|
| 1298 |
+
sec.classList.add("lsum"); sec.open=hard.length>0;
|
| 1299 |
+
const info=mark("",`${Math.round(done*100)}% מהמשפטים · ${active} תלמידים תרגלו`,"small");
|
| 1300 |
+
if(hard.length) info.append(" · ",mark("flag",`⚑ ${hard.length} קשים`));
|
| 1301 |
+
sum.append(mark("",lessonName(l)),info,avg!=null?grade(avg):mark("none","–"),bar(done));
|
| 1302 |
+
if(!hard.length){ sec.append(mark("note",active?"אין משפטים קשים בשיעור הזה.":"עוד אף תלמיד לא תרגל את השיעור הזה.","p")); continue; }
|
| 1303 |
+
for(const x of hard.slice(0,10)){
|
| 1304 |
+
const row=mark("trow","");
|
| 1305 |
+
row.append(sentence(x.p,(ALL_CATS.find(c=>c.id===x.p.cat)||{}).name||""),mark("",`${x.k}/${students.length} תלמידים`,"small"),
|
| 1306 |
+
mark("tries",`${x.tries.toFixed(1)} ניסיונות`,"small"),grade(x.avg));
|
| 1307 |
+
sec.append(row);
|
| 1308 |
}
|
| 1309 |
+
if(hard.length>10) sec.append(mark("note",`ועוד ${hard.length-10}.`,"p"));
|
| 1310 |
}
|
| 1311 |
+
if(CLASS_SOON.length) box.append(mark("note","בקרוב: "+byLevel(CLASS_SOON).map(g=>`${g.level.name}, ${lessonsWord(g.lessons)}`).join(" · "),"p"));
|
| 1312 |
+
}
|
| 1313 |
+
/* The students: the latest to practise first, those who never signed in last. A row opens on the student's numbers, their
|
| 1314 |
+
best and weakest sentences, and each lesson by topic. */
|
| 1315 |
+
function drawRoster(students,listed){
|
| 1316 |
+
// a column per lesson, or per level once the class has more ready lessons than fit
|
| 1317 |
+
const cols=CLASS_LESSONS.length<=4?CLASS_LESSONS.map(l=>({name:l.name,title:lessonName(l),list:lessonPhrases(l,listed)}))
|
| 1318 |
+
:byLevel(CLASS_LESSONS).map(g=>({name:g.level.name,title:g.level.name,list:g.lessons.flatMap(l=>lessonPhrases(l,listed))}));
|
| 1319 |
+
const box=$("mRoster"); box.innerHTML=""; box.hidden=false; box.style.setProperty("--cols",["minmax(0,1fr)",...cols.map(()=>"4.6em"),"3em"].join(" ")); // no lesson column when none is ready
|
| 1320 |
+
const h=mark("","תלמידים","h2"); h.append(mark("","לחיצה על תלמיד פותחת את הפירוט","small")); box.append(h);
|
| 1321 |
+
if(!students.length){ box.append(mark("note","אין עדיין תלמידים בכיתה הזו. הוסיפו אותם ב\"עריכת הכיתה\".","p")); return; }
|
| 1322 |
+
const head=mark("rhead",""); head.append(mark("","תלמיד/ה"),...cols.map(c=>mark("",c.name)),mark("","ציון")); box.append(head);
|
| 1323 |
+
students.sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
|
| 1324 |
+
for(const s of students){
|
| 1325 |
+
const det=document.createElement("details"), sum=document.createElement("summary"), who=mark("who","");
|
| 1326 |
+
det.className="rrow";
|
| 1327 |
const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
|
| 1328 |
+
const shown=mark("",s.nick||s.name||s.email,"b"); shown.dir="auto"; // an address is cut at its end
|
| 1329 |
+
who.append(shown,mark("",!s.joined?"עוד לא נכנס/ה":when?`לאחרונה ${when}`:"עוד לא תרגל/ה","small"));
|
| 1330 |
+
who.title=s.email; sum.append(who);
|
| 1331 |
+
for(const col of cols){ const t=stuTally(s,col.list), c=mark("cell",""); c.append(ratio(t),bar(t.n?t.done/t.n:0)); c.title=`${col.title}: תורגלו ${t.done} מתוך ${t.n}`+(t.avg!=null?`, ציון ${t.avg}`:""); sum.append(c); }
|
| 1332 |
+
sum.append(s.avg!=null?grade(s.avg):mark("none","–"));
|
| 1333 |
det.append(sum);
|
| 1334 |
+
det.ontoggle=()=>{ if(det.open&&!det.querySelector(".rdetail")) det.append(studentDetail(s,listed)); }; // built when first opened
|
| 1335 |
+
box.append(det);
|
| 1336 |
+
}
|
| 1337 |
+
}
|
| 1338 |
+
function studentDetail(s,listed){
|
| 1339 |
+
const out=mark("rdetail",""), mail=document.createElement("bdi"); mail.dir="ltr"; mail.textContent=s.email;
|
| 1340 |
+
const stats=mark("stats","","p"); stats.append(mail,` · תורגלו ${s.done} מתוך ${s.n} משפטים · ${s.recordings} הקלטות, ${s.week} מהן בשבוע האחרון · ${s.days} ימי תרגול`);
|
| 1341 |
+
out.append(stats);
|
| 1342 |
+
// the best and weakest sentences, by the best score; a sentence is in one list only
|
| 1343 |
+
const got=listed.filter(p=>s.best[p.id]!=null).sort((a,b)=>s.best[b.id]-s.best[a.id]), top=got.slice(0,5), low=got.slice(5).slice(-5).reverse();
|
| 1344 |
+
for(const [title,list] of [["5 המשפטים הטובים ביותר",top],["5 המשפטים החלשים ביותר",low]]){
|
| 1345 |
+
if(!list.length) continue;
|
| 1346 |
+
const sec=part(out); sec.firstChild.append(mark("",title));
|
| 1347 |
+
for(const p of list){ const row=mark("trow s3",""), n=s.tries[p.id]||0; row.append(sentence(p,place(p)),mark("",n>1?`${n} ניסיונות`:"","small"),grade(s.best[p.id])); sec.append(row); }
|
| 1348 |
+
}
|
| 1349 |
+
for(const l of CLASS_LESSONS){ // a lesson's topics
|
| 1350 |
+
const cats=ALL_CATS.filter(c=>c.lesson===l.id), t=stuTally(s,lessonPhrases(l,listed)), sec=part(out);
|
| 1351 |
+
sec.firstChild.append(mark("",`${lessonName(l)} לפי נושאים`),ratio(t),t.avg!=null?grade(t.avg):mark("none","–"));
|
| 1352 |
+
for(const c of cats){
|
| 1353 |
+
const t=stuTally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),"");
|
| 1354 |
+
row.append(mark("",c.name),mark("",t.tries?`${t.tries} ניסיונות`:"","small"),ratio(t),t.avg!=null?grade(t.avg):mark("none","–")); sec.append(row);
|
| 1355 |
}
|
| 1356 |
+
}
|
| 1357 |
+
return out;
|
| 1358 |
+
}
|
| 1359 |
+
/* A class is opened, edited and deleted in one dialog: a name, and a line per student (email, then an optional nickname). */
|
| 1360 |
+
let editing=null;
|
| 1361 |
+
function editClass(c){
|
| 1362 |
+
editing=c;
|
| 1363 |
+
$("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
|
| 1364 |
+
$("editName").value=c?c.name:"";
|
| 1365 |
+
// the lessons by level: a level's box ticks all of it, its chips one lesson each; a new class starts with the first level
|
| 1366 |
+
const pick=$("editLessons"), chosen=c?c.lessons:ALL_LESSONS.filter(l=>l.level===(ALL_LEVELS[0]||{}).id).map(l=>l.id);
|
| 1367 |
+
pick.innerHTML="";
|
| 1368 |
+
for(const v of ALL_LEVELS){
|
| 1369 |
+
const row=mark("lvl",""), head=mark("lvlhead","","label"), all=document.createElement("input"), chips=mark("chips",""), boxes=[];
|
| 1370 |
+
all.type="checkbox"; head.append(all,v.name); row.append(head,chips); pick.append(row);
|
| 1371 |
+
for(const l of ALL_LESSONS.filter(l=>l.level===v.id)){
|
| 1372 |
+
const box=document.createElement("input"), lab=mark(l.soon?"soon":"","","label");
|
| 1373 |
+
box.type="checkbox"; box.value=l.id; box.checked=chosen.includes(l.id); boxes.push(box);
|
| 1374 |
+
lab.append(box,String(l.n)); lab.title=l.soon?`${l.name}: עוד לא מוכן, ייפתח לכיתה כשיהיה`:l.name; chips.append(lab);
|
| 1375 |
}
|
| 1376 |
+
const sync=()=>{ const n=boxes.filter(b=>b.checked).length; all.checked=n===boxes.length; all.indeterminate=n>0&&n<boxes.length; };
|
| 1377 |
+
all.onchange=()=>{ boxes.forEach(b=>b.checked=all.checked); sync(); };
|
| 1378 |
+
boxes.forEach(b=>b.onchange=sync); sync();
|
| 1379 |
}
|
| 1380 |
+
$("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
|
| 1381 |
+
$("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
|
| 1382 |
+
countStudents(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
|
| 1383 |
+
}
|
| 1384 |
+
function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
|
| 1385 |
+
$("editEmails").oninput=countStudents;
|
| 1386 |
+
$("editClose").onclick=()=>$("editDlg").close();
|
| 1387 |
+
async function sendClass(method,body){
|
| 1388 |
+
const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
|
| 1389 |
+
msg.textContent=""; buttons.forEach(b=>b.disabled=true);
|
| 1390 |
+
try{
|
| 1391 |
+
const res=await api(editing?`/api/classes/${editing.id}`:"/api/classes",{method,headers:{"Content-Type":"application/json"},body:body&&JSON.stringify(body)});
|
| 1392 |
+
if(res.ok){
|
| 1393 |
+
shownClass=method==="DELETE"?"":body.name.trim(); store.set("ihki.mclass",shownClass);
|
| 1394 |
+
$("editDlg").close(); loadManage();
|
| 1395 |
+
}else msg.textContent=(await res.json().catch(()=>({}))).detail||`השמירה נכשלה (${res.status})`;
|
| 1396 |
+
}catch{ msg.textContent="השרת לא זמין."; }
|
| 1397 |
+
buttons.forEach(b=>b.disabled=false);
|
| 1398 |
}
|
| 1399 |
+
$("editForm").onsubmit=e=>{
|
| 1400 |
+
e.preventDefault();
|
| 1401 |
+
const lessons=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value);
|
| 1402 |
+
if(!lessons.length){ $("editMsg").textContent="בחרו לפחות שיעור אחד."; return; }
|
| 1403 |
+
sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,lessons});
|
| 1404 |
+
};
|
| 1405 |
+
$("editDelete").onclick=()=>{
|
| 1406 |
+
if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
|
| 1407 |
+
};
|
| 1408 |
|
| 1409 |
+
function useLessons(ids){ // the path shows these lessons only (null: every one)
|
| 1410 |
+
LESSONS=ALL_LESSONS.filter(l=>!ids||ids.includes(l.id)); if(!LESSONS.length) LESSONS=ALL_LESSONS;
|
| 1411 |
+
CATS=ALL_CATS.filter(c=>LESSONS.some(l=>l.id===c.lesson)); // none when every lesson of the student's is still to come
|
| 1412 |
+
if(CATS.length&&!CATS.some(c=>c.id===cat)) cat=CATS[0].id;
|
| 1413 |
+
}
|
| 1414 |
const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
|
| 1415 |
+
ALL_LEVELS=d.levels||[]; ALL_LESSONS=d.lessons; ALL_CATS=d.categories; PHRASES=d.phrases;
|
| 1416 |
+
for(const c of ALL_CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
|
| 1417 |
for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
|
| 1418 |
const saved=store.get("ihki.voice",""); if((d.voices||[]).some(v=>v.id===saved)) $("voiceId").value=saved;
|
| 1419 |
+
useLessons(MY_LESSONS);
|
| 1420 |
startCat(); opened=true;
|
| 1421 |
}).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
|
| 1422 |
</script>
|
|
|
|
| 1425 |
function enter(){
|
| 1426 |
const splash=$("logo-splash");
|
| 1427 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
| 1428 |
+
if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes: which one now?
|
| 1429 |
}
|
| 1430 |
$("startBtn").onclick=$("practiceBtn").onclick=enter;
|
| 1431 |
$("manageBtn").onclick=()=>{ enter(); showManage(); };
|
|
|
|
| 1453 |
const res=await api("/api/me").catch(()=>null);
|
| 1454 |
const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
|
| 1455 |
const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
|
| 1456 |
+
const mine=me.classes||[]; // a teacher practises every lesson and is not asked for a class
|
| 1457 |
+
MY_CLASSES=teacher?[]:mine; MY_CLASS=null;
|
| 1458 |
+
$("myClass").textContent=mine.length>1?`כיתות: ${mine.map(c=>c.name).join(" · ")}`:mine.length?`כיתת ${mine[0].name}`:"";
|
| 1459 |
+
MY_LESSONS=me.lessons||null; if(ALL_LESSONS.length) useLessons(MY_LESSONS);
|
| 1460 |
+
if(MY_CLASSES.length===1) MY_CLASS=mine[0];
|
| 1461 |
start.hidden=!ok||teacher; roles.hidden=$("toManage").hidden=!teacher; denied.hidden=ok;
|
| 1462 |
if(ok){ (teacher?$("practiceBtn"):start).focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
|
| 1463 |
},0);
|
|
@@ -131,7 +131,8 @@ def test_api_end_to_end_with_stub_model():
|
|
| 131 |
ph = c.get("/api/phrases").json()
|
| 132 |
shown = {c["id"] for c in ph["categories"]} # the phrases of no lesson are in the bank, not on the page
|
| 133 |
assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0]
|
| 134 |
-
assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"]] == ["1", "2"]
|
|
|
|
| 135 |
pid = PHRASES.index(TOILET)
|
| 136 |
r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
|
| 137 |
assert r.status_code == 200, r.text
|
|
@@ -354,7 +355,7 @@ def test_references_use_one_generated_voice_and_any_cached_one():
|
|
| 354 |
def test_workbook_lesson_1():
|
| 355 |
from app.phrases import CATEGORIES, LESSONS, public_phrases
|
| 356 |
|
| 357 |
-
assert [l["id"] for l in LESSONS] == ["1", "2"]
|
| 358 |
assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-pron", "l1-verbs", "l1-past", "l1-say", "l1-read"]
|
| 359 |
pub = public_phrases()
|
| 360 |
count = lambda cat: sum(p["cat"] == cat for p in pub)
|
|
@@ -377,6 +378,19 @@ def test_workbook_lesson_1():
|
|
| 377 |
assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"]) # rural ق accepted
|
| 378 |
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
def test_workbook_lesson_2():
|
| 381 |
from app.phrases import CATEGORIES, public_phrases
|
| 382 |
|
|
@@ -424,11 +438,12 @@ def test_sign_in_guards_the_practice_endpoints():
|
|
| 424 |
def classes(self, fresh=False):
|
| 425 |
return self.cls
|
| 426 |
|
| 427 |
-
def save_class(self, id, name, emails, nicknames):
|
|
|
|
| 428 |
if id is None:
|
| 429 |
-
self.cls.insert(0, {"id": len(self.cls) + 1,
|
| 430 |
elif c := next((c for c in self.cls if c["id"] == id), None):
|
| 431 |
-
c.update(
|
| 432 |
else:
|
| 433 |
raise KeyError(id)
|
| 434 |
|
|
@@ -461,45 +476,63 @@ def test_sign_in_guards_the_practice_endpoints():
|
|
| 461 |
assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
|
| 462 |
assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
|
| 463 |
good = {"Authorization": "Bearer good"}
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
| 466 |
assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
|
| 467 |
-
assert c.post("/api/classes", json={"name": "x", "emails": ""}, headers=good).status_code == 403 # teachers only
|
| 468 |
-
assert c.get("/api/classmates", headers=good).json() == {"
|
| 469 |
-
|
|
|
|
| 470 |
assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
|
| 471 |
-
|
| 472 |
-
assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"})
|
| 475 |
-
assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c"
|
| 476 |
-
|
| 477 |
-
|
|
|
|
|
|
|
| 478 |
assert c.post("/api/classes", json=new_class, headers=good).status_code == 200
|
| 479 |
-
assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c"],
|
| 480 |
-
"nicknames": {"new@b.c": "דנה"}}
|
| 481 |
-
for body, code in (({"name": "", "emails": ""}, 400), ({"name": "y", "emails": "not-an-email"}, 400),
|
| 482 |
-
({"name": "y", "emails": "z@b.c w@b.c Dana"}, 400), # whose nickname?
|
| 483 |
-
({"name": "
|
| 484 |
-
({"name": "y", "emails": "
|
|
|
|
|
|
|
| 485 |
r = c.post("/api/classes", json=body, headers=good)
|
| 486 |
assert r.status_code == code, (body, r.text)
|
| 487 |
-
assert "
|
| 488 |
-
|
| 489 |
-
assert c.put("/api/classes/1", json={"name": "
|
| 490 |
-
assert c.
|
| 491 |
-
|
|
|
|
|
|
|
| 492 |
d = c.get("/api/class", headers=good).json()
|
| 493 |
-
assert [k["name"] for k in d["classes"]] == ["October 2026"]
|
| 494 |
-
student, new = d["students"]
|
| 495 |
assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
|
| 496 |
assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
|
| 497 |
assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
|
| 498 |
-
assert student["
|
| 499 |
-
|
|
|
|
|
|
|
| 500 |
c.app.state.auth.cls[0]["emails"].append("a@b.c")
|
| 501 |
-
assert c.get("/api/classmates", headers=good).json() == {"
|
| 502 |
-
{"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}
|
| 503 |
c.app.state.auth.cls[0]["emails"].remove("a@b.c")
|
| 504 |
c.app.state.auth.cls = [] # no class: every account that signed in
|
| 505 |
assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"]
|
|
|
|
| 131 |
ph = c.get("/api/phrases").json()
|
| 132 |
shown = {c["id"] for c in ph["categories"]} # the phrases of no lesson are in the bank, not on the page
|
| 133 |
assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0]
|
| 134 |
+
assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"] if not l.get("soon")] == ["1", "2"]
|
| 135 |
+
assert len(ph["lessons"]) == 20 and [v["name"] for v in ph["levels"]] == ["רמה 1", "רמה 2"]
|
| 136 |
pid = PHRASES.index(TOILET)
|
| 137 |
r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
|
| 138 |
assert r.status_code == 200, r.text
|
|
|
|
| 355 |
def test_workbook_lesson_1():
|
| 356 |
from app.phrases import CATEGORIES, LESSONS, public_phrases
|
| 357 |
|
| 358 |
+
assert [l["id"] for l in LESSONS if not l.get("soon")] == ["1", "2"]
|
| 359 |
assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-pron", "l1-verbs", "l1-past", "l1-say", "l1-read"]
|
| 360 |
pub = public_phrases()
|
| 361 |
count = lambda cat: sum(p["cat"] == cat for p in pub)
|
|
|
|
| 378 |
assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"]) # rural ق accepted
|
| 379 |
|
| 380 |
|
| 381 |
+
def test_levels_plan_every_lesson():
|
| 382 |
+
from app.phrases import CATEGORIES, LESSONS, LEVELS
|
| 383 |
+
from app.workbook import LESSONS as WRITTEN, LEVELS as PLAN, lesson_id
|
| 384 |
+
|
| 385 |
+
# every lesson names its level, and each level lists all of its planned lessons, the unwritten ones as soon
|
| 386 |
+
assert [v["id"] for v in LEVELS] == ["1", "2"] and len(LESSONS) == 20
|
| 387 |
+
assert all(isinstance(key, tuple) and key[0] in PLAN for key in WRITTEN)
|
| 388 |
+
assert [(l["level"], l["n"]) for l in LESSONS] == [(str(v), n) for v in (1, 2) for n in range(1, 11)]
|
| 389 |
+
assert [l["id"] for l in LESSONS if l["level"] == "2"][:2] == ["2.1", "2.2"] # level 1 keeps "1", "2": attempts name them
|
| 390 |
+
assert {l["id"] for l in LESSONS if not l.get("soon")} == {lesson_id(*k) for k in WRITTEN} == {c["lesson"] for c in CATEGORIES}
|
| 391 |
+
assert lesson_id(2, 1) == "2.1" and lesson_id(1, 3) == "3"
|
| 392 |
+
|
| 393 |
+
|
| 394 |
def test_workbook_lesson_2():
|
| 395 |
from app.phrases import CATEGORIES, public_phrases
|
| 396 |
|
|
|
|
| 438 |
def classes(self, fresh=False):
|
| 439 |
return self.cls
|
| 440 |
|
| 441 |
+
def save_class(self, id, name, emails, nicknames, lessons):
|
| 442 |
+
row = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons}
|
| 443 |
if id is None:
|
| 444 |
+
self.cls.insert(0, {"id": len(self.cls) + 1, **row})
|
| 445 |
elif c := next((c for c in self.cls if c["id"] == id), None):
|
| 446 |
+
c.update(row)
|
| 447 |
else:
|
| 448 |
raise KeyError(id)
|
| 449 |
|
|
|
|
| 476 |
assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
|
| 477 |
assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
|
| 478 |
good = {"Authorization": "Bearer good"}
|
| 479 |
+
from app.phrases import LESSONS
|
| 480 |
+
ALL = [l["id"] for l in LESSONS]
|
| 481 |
+
# no class yet: any account is let in, to every lesson
|
| 482 |
+
assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "classes": [], "lessons": ALL}
|
| 483 |
assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
|
| 484 |
+
assert c.post("/api/classes", json={"name": "x", "emails": "", "lessons": ALL}, headers=good).status_code == 403 # teachers only
|
| 485 |
+
assert c.get("/api/classmates", headers=good).json() == {"classes": []}
|
| 486 |
+
beginners = {"id": 1, "name": "Beginners", "emails": ["other@b.c"], "nicknames": {}, "lessons": ALL[:1]}
|
| 487 |
+
c.app.state.auth.cls = [beginners] # a class without this account
|
| 488 |
assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
|
| 489 |
+
beginners["emails"].append("a@b.c") # case does not matter; the student studies their class's lessons
|
| 490 |
+
assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "lessons": ALL[:1],
|
| 491 |
+
"classes": [{"name": "Beginners", "lessons": ALL[:1]}]}
|
| 492 |
+
# in a second class too: the lessons of both, in the course's order
|
| 493 |
+
c.app.state.auth.cls = [{"id": 2, "name": "Advanced", "emails": ["a@b.c"], "nicknames": {}, "lessons": ALL[1:]}, beginners]
|
| 494 |
+
me = c.get("/api/me", headers=good).json() # each class with its own lessons: the student chooses one to practise in
|
| 495 |
+
assert me["lessons"] == ALL and me["classes"] == [{"name": "Advanced", "lessons": ALL[1:]}, {"name": "Beginners", "lessons": ALL[:1]}]
|
| 496 |
+
c.app.state.auth.cls = [{**beginners, "lessons": []}] # a class saved before classes had lessons has every one
|
| 497 |
+
assert c.get("/api/me", headers=good).json()["lessons"] == ALL
|
| 498 |
+
# a teacher is let in without a class, studies every lesson, and opens classes in the panel
|
| 499 |
c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"})
|
| 500 |
+
assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c", "lessons": ALL[:1]},
|
| 501 |
+
headers=good).status_code == 200
|
| 502 |
+
assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True, "classes": [], "lessons": ALL}
|
| 503 |
+
new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \nx@b.c\n", # an optional nickname
|
| 504 |
+
"lessons": ALL[::-1]} # x is in September's class too: a student may be in several
|
| 505 |
assert c.post("/api/classes", json=new_class, headers=good).status_code == 200
|
| 506 |
+
assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c", "x@b.c"],
|
| 507 |
+
"nicknames": {"new@b.c": "דנה"}, "lessons": ALL}
|
| 508 |
+
for body, code in (({"name": "", "emails": "", "lessons": ALL}, 400), ({"name": "y", "emails": "not-an-email", "lessons": ALL}, 400),
|
| 509 |
+
({"name": "y", "emails": "z@b.c w@b.c Dana", "lessons": ALL}, 400), # whose nickname?
|
| 510 |
+
({"name": "y", "emails": "", "lessons": []}, 400), ({"name": "y", "emails": ""}, 400), # no lesson
|
| 511 |
+
({"name": "y", "emails": "", "lessons": ["no-such-lesson"]}, 400),
|
| 512 |
+
({"name": "October 2026", "emails": "", "lessons": ALL}, 409), # the name is taken,
|
| 513 |
+
({"name": " october 2026", "emails": "", "lessons": ALL}, 409)): # in any letter case and spacing
|
| 514 |
r = c.post("/api/classes", json=body, headers=good)
|
| 515 |
assert r.status_code == code, (body, r.text)
|
| 516 |
+
assert "October 2026" in r.json()["detail"]
|
| 517 |
+
# renaming a class to another's name is refused too; keeping its own name is not
|
| 518 |
+
assert c.put("/api/classes/1", json={"name": "OCTOBER 2026", "emails": "", "lessons": ALL}, headers=good).status_code == 409
|
| 519 |
+
assert c.put("/api/classes/9", json={"name": "z", "emails": "", "lessons": ALL}, headers=good).status_code == 404
|
| 520 |
+
assert c.put("/api/classes/1", json={"name": "September 2026", "emails": "x@b.c y@b.c", "lessons": ALL[:1]},
|
| 521 |
+
headers=good).status_code == 200
|
| 522 |
+
# the panel: the classes with their lessons; the classes' accounts, each with {class: nickname}
|
| 523 |
d = c.get("/api/class", headers=good).json()
|
| 524 |
+
assert [(k["name"], k["lessons"]) for k in d["classes"]] == [("October 2026", ALL), ("September 2026", ALL[:1])]
|
| 525 |
+
student, new, x, y = d["students"] # the teacher, in no class, is not a student
|
| 526 |
assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
|
| 527 |
assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
|
| 528 |
assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
|
| 529 |
+
assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"})
|
| 530 |
+
assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""}
|
| 531 |
+
assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
|
| 532 |
+
# a student sees each of their classes: by nickname, else the Google name; one with neither is only counted
|
| 533 |
c.app.state.auth.cls[0]["emails"].append("a@b.c")
|
| 534 |
+
assert c.get("/api/classmates", headers=good).json() == {"classes": [{"name": "October 2026", "unnamed": 2, "students": [
|
| 535 |
+
{"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}]}
|
| 536 |
c.app.state.auth.cls[0]["emails"].remove("a@b.c")
|
| 537 |
c.app.state.auth.cls = [] # no class: every account that signed in
|
| 538 |
assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"]
|