Refactor logo animation and update service worker cache
Browse files- Updated the logo animation in `logo-intro.svg` to enhance visual effects and timing.
- Changed the service worker cache version from v7 to v17 and removed an unused logo file.
- Adjusted test cases to reflect the updated logo path.
- Introduced a new `live.py` module to manage live class sessions, including session creation, participant management, and question handling.
- Added tests for the new live session functionality to ensure correct behavior for student participation and session lifecycle.
- README.md +19 -0
- app/auth.py +4 -1
- app/live.py +167 -0
- app/main.py +147 -5
- static/index.html +114 -12
- static/logo-intro.svg +12 -14
- static/service-worker.js +1 -2
- tests/test_live.py +45 -0
- tests/test_scoring.py +1 -1
README.md
CHANGED
|
@@ -576,6 +576,7 @@ create table public.classes (
|
|
| 576 |
nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
|
| 577 |
lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
|
| 578 |
subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
|
|
|
|
| 579 |
homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
|
| 580 |
due date, -- the day the homework is due, if it has one
|
| 581 |
homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
|
|
@@ -593,6 +594,7 @@ alter table public.classes add column if not exists nicknames jsonb not null def
|
|
| 593 |
add column if not exists homework text not null default '',
|
| 594 |
add column if not exists due date,
|
| 595 |
add column if not exists homeworks jsonb not null default '[]',
|
|
|
|
| 596 |
add column if not exists dailies jsonb not null default '[]',
|
| 597 |
add column if not exists subject text not null default 'ערבית';
|
| 598 |
update public.classes set subject = 'ערבית' where subject = '';
|
|
@@ -600,6 +602,23 @@ update public.classes set subject = 'ערבית' where subject = '';
|
|
| 600 |
|
| 601 |
The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
|
| 602 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
The **ניטור** activity dashboard can switch between the last 5 minutes, 24 hours and 7 days. It also reports Sawtak cache usage: cached WAV count, disk size, cache hits, generated misses, and the hit rate since the current server process started. `GET /api/tts-cache` returns only these aggregate figures; clearing the cache resets the process counters.
|
| 604 |
|
| 605 |
The panel reads through `GET /api/class`, which answers teachers only. Row-level security hides other students' rows from the teacher's own token, so the server reads them with `SUPABASE_SECRET_KEY` (Supabase → Project Settings → API Keys → secret key). Unlike the publishable key it must stay private: set it as a Space **secret**. Without it the panel opens and says the data is not available.
|
|
|
|
| 576 |
nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
|
| 577 |
lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
|
| 578 |
subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
|
| 579 |
+
teachers text[] not null default '{}', -- class-scoped teachers; each address is also in emails
|
| 580 |
homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
|
| 581 |
due date, -- the day the homework is due, if it has one
|
| 582 |
homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
|
|
|
|
| 594 |
add column if not exists homework text not null default '',
|
| 595 |
add column if not exists due date,
|
| 596 |
add column if not exists homeworks jsonb not null default '[]',
|
| 597 |
+
add column if not exists teachers text[] not null default '{}',
|
| 598 |
add column if not exists dailies jsonb not null default '[]',
|
| 599 |
add column if not exists subject text not null default 'ערבית';
|
| 600 |
update public.classes set subject = 'ערבית' where subject = '';
|
|
|
|
| 602 |
|
| 603 |
The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
|
| 604 |
|
| 605 |
+
### Live lessons
|
| 606 |
+
|
| 607 |
+
An admin can choose several class teachers from the class's own student list. The assignment is scoped to that class:
|
| 608 |
+
a person may teach one class and remain a student in another. Developers can edit every class; subject admins can edit
|
| 609 |
+
only classes in their assigned subject.
|
| 610 |
+
|
| 611 |
+
A class teacher opens **שיעור חי**, chooses their class and starts its single active session. Students see active sessions
|
| 612 |
+
from their registered classes in the same tab. A six-digit code is also shown as a fallback; any signed-in account may
|
| 613 |
+
join with it for that session only, without becoming a member of the class. The teacher chooses an existing sentence or
|
| 614 |
+
searches the class's lesson bank by Hebrew text. No translation or LLM is used.
|
| 615 |
+
|
| 616 |
+
The page polls every three seconds. The teacher sees the whole roster, who has joined, who is online, and each submitted
|
| 617 |
+
score; a student sees only their own result. Every student gets one scored attempt per question. Empty or undecodable
|
| 618 |
+
recordings may be retried because they never became a scored attempt. Session state, attendance and answers live only in
|
| 619 |
+
the application process: ending a session, restarting the server, or two hours without a teacher heartbeat erases them.
|
| 620 |
+
No live-session audio or result history is stored.
|
| 621 |
+
|
| 622 |
The **ניטור** activity dashboard can switch between the last 5 minutes, 24 hours and 7 days. It also reports Sawtak cache usage: cached WAV count, disk size, cache hits, generated misses, and the hit rate since the current server process started. `GET /api/tts-cache` returns only these aggregate figures; clearing the cache resets the process counters.
|
| 623 |
|
| 624 |
The panel reads through `GET /api/class`, which answers teachers only. Row-level security hides other students' rows from the teacher's own token, so the server reads them with `SUPABASE_SECRET_KEY` (Supabase → Project Settings → API Keys → secret key). Unlike the publishable key it must stay private: set it as a Space **secret**. Without it the panel opens and says the data is not available.
|
app/auth.py
CHANGED
|
@@ -259,6 +259,7 @@ class SupabaseAuth:
|
|
| 259 |
r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
|
| 260 |
r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
|
| 261 |
r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
|
|
|
|
| 262 |
except AuthUnavailable as e:
|
| 263 |
if "answered 404" not in str(e):
|
| 264 |
raise
|
|
@@ -269,7 +270,7 @@ class SupabaseAuth:
|
|
| 269 |
|
| 270 |
def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
|
| 271 |
lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
|
| 272 |
-
subject: str = "") -> None:
|
| 273 |
"""Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
|
| 274 |
and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
|
| 275 |
still takes a class that has none). A name already taken raises ClassConflict."""
|
|
@@ -278,6 +279,8 @@ class SupabaseAuth:
|
|
| 278 |
"homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
|
| 279 |
if dailies is not None:
|
| 280 |
body["dailies"] = list(dailies)
|
|
|
|
|
|
|
| 281 |
if id is None:
|
| 282 |
body["subject"] = subject
|
| 283 |
self._admin("/rest/v1/classes", "POST", body)
|
|
|
|
| 259 |
r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
|
| 260 |
r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
|
| 261 |
r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
|
| 262 |
+
r["teachers"] = [str(e).lower() for e in (r.get("teachers") or [])]
|
| 263 |
except AuthUnavailable as e:
|
| 264 |
if "answered 404" not in str(e):
|
| 265 |
raise
|
|
|
|
| 270 |
|
| 271 |
def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
|
| 272 |
lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
|
| 273 |
+
subject: str = "", teachers: list[str] | None = None) -> None:
|
| 274 |
"""Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
|
| 275 |
and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
|
| 276 |
still takes a class that has none). A name already taken raises ClassConflict."""
|
|
|
|
| 279 |
"homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
|
| 280 |
if dailies is not None:
|
| 281 |
body["dailies"] = list(dailies)
|
| 282 |
+
if teachers is not None:
|
| 283 |
+
body["teachers"] = list(teachers)
|
| 284 |
if id is None:
|
| 285 |
body["subject"] = subject
|
| 286 |
self._admin("/rest/v1/classes", "POST", body)
|
app/live.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Temporary live-class state.
|
| 2 |
+
|
| 3 |
+
Live sessions deliberately live in process memory: class/teacher assignments are durable in
|
| 4 |
+
Supabase, while attendance, questions and answers disappear when a session ends or the service
|
| 5 |
+
restarts. The browser polls this store; no recording bytes are retained here.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import secrets
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SESSION_TTL = 2 * 60 * 60
|
| 16 |
+
PRESENCE_TTL = 20
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now_iso() -> str:
|
| 20 |
+
return datetime.now(timezone.utc).isoformat()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LiveConflict(ValueError):
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class LiveStore:
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self._lock = threading.Lock()
|
| 30 |
+
self._sessions: dict[str, dict] = {}
|
| 31 |
+
|
| 32 |
+
def _prune(self, now: float) -> None:
|
| 33 |
+
for code, session in list(self._sessions.items()):
|
| 34 |
+
if session["ended"] or now - session["teacher_seen"] > SESSION_TTL:
|
| 35 |
+
self._sessions.pop(code, None)
|
| 36 |
+
|
| 37 |
+
def _new_code(self) -> str:
|
| 38 |
+
for _ in range(100):
|
| 39 |
+
code = f"{secrets.randbelow(1_000_000):06d}"
|
| 40 |
+
if code not in self._sessions:
|
| 41 |
+
return code
|
| 42 |
+
raise RuntimeError("could not allocate a live-session code")
|
| 43 |
+
|
| 44 |
+
def start(self, course: dict, teacher: dict) -> dict:
|
| 45 |
+
now = time.monotonic()
|
| 46 |
+
with self._lock:
|
| 47 |
+
self._prune(now)
|
| 48 |
+
existing = next((s for s in self._sessions.values() if s["class_id"] == course["id"]), None)
|
| 49 |
+
if existing:
|
| 50 |
+
existing["teacher_seen"] = now
|
| 51 |
+
return self._public(existing)
|
| 52 |
+
code = self._new_code()
|
| 53 |
+
roster = {email: (course.get("nicknames") or {}).get(email) or email.split("@", 1)[0]
|
| 54 |
+
for email in course.get("emails", [])}
|
| 55 |
+
session = {"code": code, "class_id": course["id"], "class_name": course["name"],
|
| 56 |
+
"teacher_id": teacher["id"], "teacher_email": teacher["email"].lower(),
|
| 57 |
+
"started_at": _now_iso(), "teacher_seen": now, "ended": False,
|
| 58 |
+
"question": None, "participants": {}, "roster": roster, "answers": {}, "question_no": 0}
|
| 59 |
+
self._sessions[code] = session
|
| 60 |
+
return self._public(session)
|
| 61 |
+
|
| 62 |
+
def list_for(self, class_ids: set[int], user_id: str) -> list[dict]:
|
| 63 |
+
now = time.monotonic()
|
| 64 |
+
with self._lock:
|
| 65 |
+
self._prune(now)
|
| 66 |
+
return [self._public(s) for s in self._sessions.values()
|
| 67 |
+
if s["class_id"] in class_ids or user_id in s["participants"]]
|
| 68 |
+
|
| 69 |
+
def get(self, code: str) -> dict | None:
|
| 70 |
+
now = time.monotonic()
|
| 71 |
+
with self._lock:
|
| 72 |
+
self._prune(now)
|
| 73 |
+
return self._sessions.get(code)
|
| 74 |
+
|
| 75 |
+
def join(self, code: str, user: dict, name: str = "") -> dict:
|
| 76 |
+
now = time.monotonic()
|
| 77 |
+
with self._lock:
|
| 78 |
+
self._prune(now)
|
| 79 |
+
session = self._sessions.get(code)
|
| 80 |
+
if not session:
|
| 81 |
+
raise KeyError(code)
|
| 82 |
+
session["participants"][user["id"]] = {"email": user["email"].lower(),
|
| 83 |
+
"name": name or user["email"].split("@", 1)[0],
|
| 84 |
+
"last_seen": now}
|
| 85 |
+
return self._public(session)
|
| 86 |
+
|
| 87 |
+
def heartbeat(self, code: str, user: dict, teacher: bool = False) -> dict:
|
| 88 |
+
now = time.monotonic()
|
| 89 |
+
with self._lock:
|
| 90 |
+
self._prune(now)
|
| 91 |
+
session = self._sessions.get(code)
|
| 92 |
+
if not session:
|
| 93 |
+
raise KeyError(code)
|
| 94 |
+
if teacher:
|
| 95 |
+
session["teacher_seen"] = now
|
| 96 |
+
elif user["id"] in session["participants"]:
|
| 97 |
+
session["participants"][user["id"]]["last_seen"] = now
|
| 98 |
+
else:
|
| 99 |
+
raise PermissionError(code)
|
| 100 |
+
return self._detail(session, user["id"], teacher, now)
|
| 101 |
+
|
| 102 |
+
def ask(self, code: str, phrase: dict) -> dict:
|
| 103 |
+
now = time.monotonic()
|
| 104 |
+
with self._lock:
|
| 105 |
+
session = self._sessions.get(code)
|
| 106 |
+
if not session:
|
| 107 |
+
raise KeyError(code)
|
| 108 |
+
session["question_no"] += 1
|
| 109 |
+
session["question"] = {"no": session["question_no"], "phrase_id": phrase["id"],
|
| 110 |
+
"he": phrase["he"], "ar": phrase["ar"], "tr": phrase["tr"],
|
| 111 |
+
"started_at": _now_iso()}
|
| 112 |
+
session["answers"] = {}
|
| 113 |
+
session["teacher_seen"] = now
|
| 114 |
+
return self._public(session)
|
| 115 |
+
|
| 116 |
+
def begin_answer(self, code: str, user_id: str) -> dict:
|
| 117 |
+
with self._lock:
|
| 118 |
+
session = self._sessions.get(code)
|
| 119 |
+
if not session or not session["question"]:
|
| 120 |
+
raise KeyError(code)
|
| 121 |
+
if user_id not in session["participants"]:
|
| 122 |
+
raise PermissionError(code)
|
| 123 |
+
if user_id in session["answers"]:
|
| 124 |
+
raise LiveConflict("one attempt only")
|
| 125 |
+
session["answers"][user_id] = {"pending": True}
|
| 126 |
+
return dict(session["question"])
|
| 127 |
+
|
| 128 |
+
def cancel_answer(self, code: str, user_id: str) -> None:
|
| 129 |
+
with self._lock:
|
| 130 |
+
session = self._sessions.get(code)
|
| 131 |
+
if session and session["answers"].get(user_id, {}).get("pending"):
|
| 132 |
+
session["answers"].pop(user_id, None)
|
| 133 |
+
|
| 134 |
+
def finish_answer(self, code: str, user_id: str, answer: dict) -> None:
|
| 135 |
+
with self._lock:
|
| 136 |
+
session = self._sessions.get(code)
|
| 137 |
+
if not session or user_id not in session["answers"]:
|
| 138 |
+
raise KeyError(code)
|
| 139 |
+
session["answers"][user_id] = answer
|
| 140 |
+
|
| 141 |
+
def end(self, code: str) -> None:
|
| 142 |
+
with self._lock:
|
| 143 |
+
if code not in self._sessions:
|
| 144 |
+
raise KeyError(code)
|
| 145 |
+
self._sessions.pop(code, None)
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _public(session: dict) -> dict:
|
| 149 |
+
return {k: session[k] for k in ("code", "class_id", "class_name", "started_at", "question")}
|
| 150 |
+
|
| 151 |
+
def _detail(self, session: dict, user_id: str, teacher: bool, now: float) -> dict:
|
| 152 |
+
base = self._public(session)
|
| 153 |
+
if teacher:
|
| 154 |
+
participants = []
|
| 155 |
+
joined_emails = set()
|
| 156 |
+
for uid, person in session["participants"].items():
|
| 157 |
+
joined_emails.add(person["email"])
|
| 158 |
+
answer = session["answers"].get(uid)
|
| 159 |
+
participants.append({"id": uid, "name": person["name"], "email": person["email"],
|
| 160 |
+
"online": now - person["last_seen"] <= PRESENCE_TTL,
|
| 161 |
+
"answer": None if not answer or answer.get("pending") else answer})
|
| 162 |
+
participants += [{"id": "", "name": name, "email": email, "online": False, "answer": None}
|
| 163 |
+
for email, name in session["roster"].items() if email not in joined_emails]
|
| 164 |
+
return {**base, "participants": participants}
|
| 165 |
+
answer = session["answers"].get(user_id)
|
| 166 |
+
return {**base, "answered": user_id in session["answers"],
|
| 167 |
+
"answer": None if not answer or answer.get("pending") else answer}
|
app/main.py
CHANGED
|
@@ -35,6 +35,7 @@ from . import lab
|
|
| 35 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 36 |
from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
|
| 37 |
check as check_key, load_keys)
|
|
|
|
| 38 |
from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
|
| 39 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 40 |
from .reference import References
|
|
@@ -53,7 +54,7 @@ STATIC = Path(__file__).resolve().parent.parent / "static"
|
|
| 53 |
load_dotenv(STATIC.parent / ".env")
|
| 54 |
# A class topic's opening screen, by the path it is served at; "/" is Arabic.
|
| 55 |
SUBJECTS = {
|
| 56 |
-
"arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro
|
| 57 |
"__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש"},
|
| 58 |
"math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
|
| 59 |
"__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל"},
|
|
@@ -230,7 +231,8 @@ MIGRATE_CLASSES = ("alter table public.classes add column if not exists nickname
|
|
| 230 |
"add column if not exists homework text not null default '', add column if not exists due date, "
|
| 231 |
"add column if not exists homeworks jsonb not null default '[]', "
|
| 232 |
"add column if not exists dailies jsonb not null default '[]', "
|
| 233 |
-
"add column if not exists subject text not null default 'ערבית'
|
|
|
|
| 234 |
"update public.classes set subject = 'ערבית' where subject = '';")
|
| 235 |
MAX_HOMEWORKS = 30
|
| 236 |
|
|
@@ -451,6 +453,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 451 |
|
| 452 |
app = FastAPI(title="ihki", lifespan=lifespan)
|
| 453 |
app.state.verb_game = VerbExerciseGenerator()
|
|
|
|
| 454 |
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 455 |
|
| 456 |
def signed_in(request: Request) -> dict | None:
|
|
@@ -471,6 +474,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 471 |
except AuthUnavailable as e:
|
| 472 |
raise HTTPException(503, f"class list not available: {e}")
|
| 473 |
mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
|
|
|
|
| 474 |
roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS), None
|
| 475 |
if hasattr(a, "access_for"):
|
| 476 |
try:
|
|
@@ -501,7 +505,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 501 |
return {**user, "token": token.strip(), "roles": sorted(roles), "entitlements": sorted(entitlements),
|
| 502 |
"subject": str((assigned or {}).get("subject") or ""),
|
| 503 |
"permissions": sorted(permissions), "teacher": bool(roles & {"teacher", "admin", "developer"}),
|
| 504 |
-
"tutor": "flags.create" in permissions or "*" in permissions, "classes": mine}
|
| 505 |
|
| 506 |
def require(permission: str):
|
| 507 |
def allowed(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
|
|
@@ -609,8 +613,135 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 609 |
**({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
|
| 610 |
"classes": [{"name": c["name"], "lessons": lessons_of(c), **homework_of(c, topics()),
|
| 611 |
"dailies": dailies_of(c, daily_rows())} for c in mine],
|
|
|
|
|
|
|
| 612 |
"lessons": lessons}
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
@app.get("/api/progress")
|
| 615 |
def progress(user: dict | None = Depends(signed_in)):
|
| 616 |
"""The student's best scores and compact practice totals, including this week's active days."""
|
|
@@ -1551,7 +1682,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1551 |
if "answered 404" in str(e):
|
| 1552 |
return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
|
| 1553 |
"אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 1554 |
-
if any(col in str(e) for col in ("nicknames", "lessons", "homework", "'due'", "dailies", "subject")): # a table made before them
|
| 1555 |
return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
|
| 1556 |
return f"Supabase לא זמין: {e}"
|
| 1557 |
|
|
@@ -1572,6 +1703,13 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1572 |
if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
|
| 1573 |
raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
|
| 1574 |
lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1575 |
listed = body.get("homeworks")
|
| 1576 |
if listed is None:
|
| 1577 |
listed = [{"topic": body.get("homework"), "due": body.get("due")}]
|
|
@@ -1620,7 +1758,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1620 |
if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
|
| 1621 |
and "developer" not in actor.get("roles", ()) and not subject):
|
| 1622 |
raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
|
| 1623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1624 |
except ClassConflict:
|
| 1625 |
raise HTTPException(409, f"כבר יש כיתה בשם {name}")
|
| 1626 |
except KeyError:
|
|
|
|
| 35 |
from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
|
| 36 |
from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
|
| 37 |
check as check_key, load_keys)
|
| 38 |
+
from .live import LiveConflict, LiveStore
|
| 39 |
from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
|
| 40 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 41 |
from .reference import References
|
|
|
|
| 54 |
load_dotenv(STATIC.parent / ".env")
|
| 55 |
# A class topic's opening screen, by the path it is served at; "/" is Arabic.
|
| 56 |
SUBJECTS = {
|
| 57 |
+
"arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro.svg",
|
| 58 |
"__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש"},
|
| 59 |
"math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
|
| 60 |
"__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל"},
|
|
|
|
| 231 |
"add column if not exists homework text not null default '', add column if not exists due date, "
|
| 232 |
"add column if not exists homeworks jsonb not null default '[]', "
|
| 233 |
"add column if not exists dailies jsonb not null default '[]', "
|
| 234 |
+
"add column if not exists subject text not null default 'ערבית', "
|
| 235 |
+
"add column if not exists teachers text[] not null default '{}'; "
|
| 236 |
"update public.classes set subject = 'ערבית' where subject = '';")
|
| 237 |
MAX_HOMEWORKS = 30
|
| 238 |
|
|
|
|
| 453 |
|
| 454 |
app = FastAPI(title="ihki", lifespan=lifespan)
|
| 455 |
app.state.verb_game = VerbExerciseGenerator()
|
| 456 |
+
app.state.live = LiveStore()
|
| 457 |
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 458 |
|
| 459 |
def signed_in(request: Request) -> dict | None:
|
|
|
|
| 474 |
except AuthUnavailable as e:
|
| 475 |
raise HTTPException(503, f"class list not available: {e}")
|
| 476 |
mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
|
| 477 |
+
teaches = [c for c in classes if email in (c.get("teachers") or [])]
|
| 478 |
roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS), None
|
| 479 |
if hasattr(a, "access_for"):
|
| 480 |
try:
|
|
|
|
| 505 |
return {**user, "token": token.strip(), "roles": sorted(roles), "entitlements": sorted(entitlements),
|
| 506 |
"subject": str((assigned or {}).get("subject") or ""),
|
| 507 |
"permissions": sorted(permissions), "teacher": bool(roles & {"teacher", "admin", "developer"}),
|
| 508 |
+
"tutor": "flags.create" in permissions or "*" in permissions, "classes": mine, "teaches": teaches}
|
| 509 |
|
| 510 |
def require(permission: str):
|
| 511 |
def allowed(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
|
|
|
|
| 613 |
**({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
|
| 614 |
"classes": [{"name": c["name"], "lessons": lessons_of(c), **homework_of(c, topics()),
|
| 615 |
"dailies": dailies_of(c, daily_rows())} for c in mine],
|
| 616 |
+
**({"teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
|
| 617 |
+
for c in user.get("teaches", [])]} if user and user.get("teaches") else {}),
|
| 618 |
"lessons": lessons}
|
| 619 |
|
| 620 |
+
def live_course(class_id: int) -> dict:
|
| 621 |
+
course = next((c for c in app.state.auth.classes() if c["id"] == class_id), None)
|
| 622 |
+
if not course:
|
| 623 |
+
raise HTTPException(404, "הכיתה לא נמצאה")
|
| 624 |
+
return course
|
| 625 |
+
|
| 626 |
+
def live_teacher(course: dict, user: dict | None) -> None:
|
| 627 |
+
if not user or user["email"].lower() not in (course.get("teachers") or []):
|
| 628 |
+
raise HTTPException(403, "רק מורה של הכיתה יכול/ה לנהל את המפגש")
|
| 629 |
+
|
| 630 |
+
@app.get("/api/live")
|
| 631 |
+
def live_list(user: dict | None = Depends(signed_in)):
|
| 632 |
+
if not user:
|
| 633 |
+
return {"sessions": [], "teaches": []}
|
| 634 |
+
mine = {c["id"] for c in user.get("classes", [])}
|
| 635 |
+
teaches = {c["id"] for c in user.get("teaches", [])}
|
| 636 |
+
sessions = app.state.live.list_for(mine | teaches, user["id"])
|
| 637 |
+
return {"sessions": [{**s, "teacher": s["class_id"] in teaches} for s in sessions],
|
| 638 |
+
"teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
|
| 639 |
+
for c in user.get("teaches", [])]}
|
| 640 |
+
|
| 641 |
+
@app.post("/api/live")
|
| 642 |
+
def live_start(body: dict = Body(...), user: dict | None = Depends(signed_in)):
|
| 643 |
+
try:
|
| 644 |
+
class_id = int(body.get("class_id"))
|
| 645 |
+
except (TypeError, ValueError):
|
| 646 |
+
raise HTTPException(400, "צריך לבחור כיתה")
|
| 647 |
+
course = live_course(class_id)
|
| 648 |
+
live_teacher(course, user)
|
| 649 |
+
return {"session": app.state.live.start(course, user)}
|
| 650 |
+
|
| 651 |
+
@app.post("/api/live/join")
|
| 652 |
+
def live_join(body: dict = Body(...), user: dict | None = Depends(signed_in)):
|
| 653 |
+
code = re.sub(r"\D", "", str(body.get("code", "")))
|
| 654 |
+
if len(code) != 6:
|
| 655 |
+
raise HTTPException(400, "קוד המפגש מכיל 6 ספרות")
|
| 656 |
+
session = app.state.live.get(code)
|
| 657 |
+
if not session:
|
| 658 |
+
raise HTTPException(404, "לא נמצא מפגש פעיל בקוד הזה")
|
| 659 |
+
course = live_course(session["class_id"])
|
| 660 |
+
email = user["email"].lower()
|
| 661 |
+
name = (course.get("nicknames") or {}).get(email, "")
|
| 662 |
+
try:
|
| 663 |
+
joined = app.state.live.join(code, user, name)
|
| 664 |
+
except KeyError:
|
| 665 |
+
raise HTTPException(404, "המפגש הסתיים")
|
| 666 |
+
return {"session": joined}
|
| 667 |
+
|
| 668 |
+
def live_session_teacher(code: str, user: dict | None) -> tuple[dict, dict]:
|
| 669 |
+
session = app.state.live.get(code)
|
| 670 |
+
if not session:
|
| 671 |
+
raise HTTPException(404, "המפגש הסתיים")
|
| 672 |
+
course = live_course(session["class_id"])
|
| 673 |
+
live_teacher(course, user)
|
| 674 |
+
return session, course
|
| 675 |
+
|
| 676 |
+
@app.get("/api/live/{code}")
|
| 677 |
+
def live_status(code: str, user: dict | None = Depends(signed_in)):
|
| 678 |
+
session = app.state.live.get(code)
|
| 679 |
+
if not session:
|
| 680 |
+
raise HTTPException(404, "המפגש הסתיים")
|
| 681 |
+
course = live_course(session["class_id"])
|
| 682 |
+
teacher = bool(user and user["email"].lower() in (course.get("teachers") or []))
|
| 683 |
+
try:
|
| 684 |
+
return {"session": app.state.live.heartbeat(code, user, teacher)}
|
| 685 |
+
except PermissionError:
|
| 686 |
+
raise HTTPException(403, "יש להצטרף למפגש תחילה")
|
| 687 |
+
except KeyError:
|
| 688 |
+
raise HTTPException(404, "המפגש הסתיים")
|
| 689 |
+
|
| 690 |
+
@app.post("/api/live/{code}/question")
|
| 691 |
+
def live_question(code: str, body: dict = Body(...), user: dict | None = Depends(signed_in)):
|
| 692 |
+
_session, course = live_session_teacher(code, user)
|
| 693 |
+
phrase_id = body.get("phrase_id")
|
| 694 |
+
if not isinstance(phrase_id, int):
|
| 695 |
+
raise HTTPException(400, "צריך לבחור משפט מהמאגר")
|
| 696 |
+
phrase = phrase_of(phrase_id)
|
| 697 |
+
if topics().get(phrase["cat"]) not in lessons_of(course):
|
| 698 |
+
raise HTTPException(403, "המשפט אינו בשיעורים של הכיתה")
|
| 699 |
+
return {"session": app.state.live.ask(code, {**phrase, "id": phrase_id})}
|
| 700 |
+
|
| 701 |
+
@app.post("/api/live/{code}/answer")
|
| 702 |
+
def live_answer(code: str, audio: UploadFile = File(...), user: dict | None = Depends(signed_in)):
|
| 703 |
+
try:
|
| 704 |
+
question = app.state.live.begin_answer(code, user["id"])
|
| 705 |
+
except LiveConflict:
|
| 706 |
+
raise HTTPException(409, "כבר ענית על השאלה הזו")
|
| 707 |
+
except PermissionError:
|
| 708 |
+
raise HTTPException(403, "יש להצטרף למפגש תחילה")
|
| 709 |
+
except KeyError:
|
| 710 |
+
raise HTTPException(404, "אין כרגע שאלה פעילה")
|
| 711 |
+
try:
|
| 712 |
+
data = audio.file.read(MAX_UPLOAD + 1)
|
| 713 |
+
if len(data) > MAX_UPLOAD:
|
| 714 |
+
raise HTTPException(413, "recording too long")
|
| 715 |
+
try:
|
| 716 |
+
wav = decode_audio(data)
|
| 717 |
+
except ValueError:
|
| 718 |
+
raise HTTPException(400, "could not decode audio")
|
| 719 |
+
if wav.size < SAMPLE_RATE * .3:
|
| 720 |
+
raise HTTPException(400, "לא נשמע מספיק דיבור. נסו שוב.")
|
| 721 |
+
phrase = phrase_of(question["phrase_id"])
|
| 722 |
+
refs = app.state.refs.get(question["phrase_id"], phrase) if app.state.refs else []
|
| 723 |
+
result = app.state.scorer.score(wav, phrase, refs)
|
| 724 |
+
if result.get("error"):
|
| 725 |
+
raise HTTPException(400, result.get("message") or "ההקלטה לא זוהתה")
|
| 726 |
+
compact = {"score": round(result["score"]), "unrelated": bool(result.get("unrelated")),
|
| 727 |
+
"expected_tr": result.get("variant", {}).get("tr") or phrase.get("tr", ""),
|
| 728 |
+
"heard_tr": " ".join(w.get("heard_tr") or "–" for w in result.get("words", [])),
|
| 729 |
+
"issues": result.get("issues", [])[:8]}
|
| 730 |
+
app.state.live.finish_answer(code, user["id"], compact)
|
| 731 |
+
return {"answer": compact, "result": result}
|
| 732 |
+
except Exception:
|
| 733 |
+
app.state.live.cancel_answer(code, user["id"])
|
| 734 |
+
raise
|
| 735 |
+
|
| 736 |
+
@app.delete("/api/live/{code}")
|
| 737 |
+
def live_end(code: str, user: dict | None = Depends(signed_in)):
|
| 738 |
+
live_session_teacher(code, user)
|
| 739 |
+
try:
|
| 740 |
+
app.state.live.end(code)
|
| 741 |
+
except KeyError:
|
| 742 |
+
raise HTTPException(404, "המפגש הסתיים")
|
| 743 |
+
return {"ok": True}
|
| 744 |
+
|
| 745 |
@app.get("/api/progress")
|
| 746 |
def progress(user: dict | None = Depends(signed_in)):
|
| 747 |
"""The student's best scores and compact practice totals, including this week's active days."""
|
|
|
|
| 1682 |
if "answered 404" in str(e):
|
| 1683 |
return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
|
| 1684 |
"אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 1685 |
+
if any(col in str(e) for col in ("nicknames", "lessons", "homework", "'due'", "dailies", "subject", "teachers")): # a table made before them
|
| 1686 |
return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
|
| 1687 |
return f"Supabase לא זמין: {e}"
|
| 1688 |
|
|
|
|
| 1703 |
if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
|
| 1704 |
raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
|
| 1705 |
lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
|
| 1706 |
+
teachers = body.get("teachers")
|
| 1707 |
+
if teachers is not None:
|
| 1708 |
+
if not isinstance(teachers, list) or not all(isinstance(e, str) for e in teachers):
|
| 1709 |
+
raise HTTPException(400, "רשימת המורים אינה תקינה")
|
| 1710 |
+
teachers = list(dict.fromkeys(e.strip().lower() for e in teachers if e.strip()))
|
| 1711 |
+
if not set(teachers) <= set(emails):
|
| 1712 |
+
raise HTTPException(400, "מורה של הכיתה צריך להיות אחד מהתלמידים הרשומים בה")
|
| 1713 |
listed = body.get("homeworks")
|
| 1714 |
if listed is None:
|
| 1715 |
listed = [{"topic": body.get("homework"), "due": body.get("due")}]
|
|
|
|
| 1758 |
if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
|
| 1759 |
and "developer" not in actor.get("roles", ()) and not subject):
|
| 1760 |
raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
|
| 1761 |
+
args = (id, name, emails, nicks, lessons, homeworks, dailies if dailies or had else None, subject)
|
| 1762 |
+
if teachers is None:
|
| 1763 |
+
a.save_class(*args)
|
| 1764 |
+
else:
|
| 1765 |
+
a.save_class(*args, teachers=teachers)
|
| 1766 |
except ClassConflict:
|
| 1767 |
raise HTTPException(409, f"כבר יש כיתה בשם {name}")
|
| 1768 |
except KeyError:
|
static/index.html
CHANGED
|
@@ -94,11 +94,13 @@ body:not(.home) .help,body.manage .help,body:not(.home) .guidetip,body.manage .g
|
|
| 94 |
#nav{position:fixed;inset:auto 0 0 0;z-index:8;display:flex;justify-content:center;gap:4px;padding:6px 10px calc(6px + env(safe-area-inset-bottom));background:var(--surface);border-top:1px solid var(--line)}
|
| 95 |
#nav button{position:relative;flex:1;max-width:160px;padding:10px 6px;border:0;border-radius:10px;background:none;color:var(--muted);font:700 14px/1.2 var(--f-body);cursor:pointer}
|
| 96 |
#nav button[aria-current=page]{background:var(--olive-soft);color:var(--olive)}
|
|
|
|
| 97 |
#nav button.promo{color:var(--orange);animation:refer-pulse 1.8s ease-in-out infinite}
|
| 98 |
#nav button.promo::after{content:"🎁 הטבה";position:absolute;top:-11px;inset-inline-end:4px;padding:2px 6px;border-radius:999px;background:var(--orange);color:#fff;font:700 11px/1.35 var(--f-body);white-space:nowrap;box-shadow:0 2px 5px rgba(0,0,0,.2)}
|
| 99 |
@keyframes refer-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--orange) 0%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--orange) 22%,transparent)}}
|
| 100 |
@media (prefers-reduced-motion:reduce){#nav button.promo{animation:none}}
|
| 101 |
body:not(.home) #nav,body.manage #nav,body:not(.home) #vids,body.manage #vids,body:not([data-view]) #vids,body[data-view] #path,body[data-view] #today,body[data-view] #toNow,body[data-view] .maphint{display:none}
|
|
|
|
| 102 |
body.home:not(.manage){padding-bottom:84px}
|
| 103 |
body.home:not(.manage) #toNow{bottom:76px} body.home:not(.manage) #logoutBtn{bottom:62px}
|
| 104 |
#vids{display:flex;flex-direction:column;gap:6px;width:100%;max-width:460px;margin:8px auto 0}
|
|
@@ -1201,13 +1203,15 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
|
|
| 1201 |
.prow[aria-current="true"]{background:var(--olive-soft)}
|
| 1202 |
.prow .i{font-size:13px;font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums}
|
| 1203 |
.prow .none{font-size:13px;font-weight:400;color:var(--muted)}
|
| 1204 |
-
/* Opening screen:
|
| 1205 |
-
#logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:
|
| 1206 |
#logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
|
| 1207 |
-
#logo-splash img{width:min(82vw,620px);height:auto}
|
| 1208 |
-
#startBtn,#loginBtn,#roles button{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .
|
|
|
|
| 1209 |
/* Under the Google button: sign-in by a link sent to the email, for a student without a Google account. */
|
| 1210 |
#mailForm{display:flex;flex-direction:column;align-items:center;gap:10px;width:min(82vw,340px);margin-top:-8px;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
|
|
|
|
| 1211 |
#mailForm span{font:400 14px/1 var(--f-body);color:#5B665C}
|
| 1212 |
#mailForm input{width:100%;box-sizing:border-box;font:400 17px/1 var(--f-body);padding:11px 14px;border-radius:10px;border:1px solid #B9C2B4;background:#fff;color:#1C241D;text-align:center}
|
| 1213 |
#mailForm button{font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
|
@@ -1228,6 +1232,8 @@ html[data-subject="math"] #roles :is(#libraryBtn,#manageBtn,#labBtn){background:
|
|
| 1228 |
html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
|
| 1229 |
@keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 1230 |
@media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
|
|
|
|
|
|
|
| 1231 |
</style>
|
| 1232 |
</head>
|
| 1233 |
<body class="home">
|
|
@@ -1278,8 +1284,9 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
|
|
| 1278 |
<span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
|
| 1279 |
</button>
|
| 1280 |
<section id="vids" aria-live="polite"></section>
|
| 1281 |
-
<nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
|
| 1282 |
<nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
|
|
|
|
| 1283 |
<button id="toNow" hidden></button>
|
| 1284 |
|
| 1285 |
<div class="wrap" id="practice">
|
|
@@ -1462,6 +1469,7 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
|
|
| 1462 |
<fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
|
| 1463 |
<label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
|
| 1464 |
<textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה avi@gmail.com"></textarea></label>
|
|
|
|
| 1465 |
<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>
|
| 1466 |
</form>
|
| 1467 |
</dialog>
|
|
@@ -2430,7 +2438,87 @@ function mgWin(){ // every pair found: the misbaha dances, beads rain, and the
|
|
| 2430 |
}
|
| 2431 |
function showView(view){ // "" is the path, which is laid out again: it was not while hidden
|
| 2432 |
if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
|
| 2433 |
-
scrollTo(0,0); drawPath(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2434 |
}
|
| 2435 |
const referTab=$("nav").querySelector('[data-view="refer"]'), referSeenKey="ihki.referSeen.2027";
|
| 2436 |
if(!store.get(referSeenKey,false)) referTab.classList.add("promo");
|
|
@@ -4911,6 +4999,8 @@ function drawClasses(classes){
|
|
| 4911 |
fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
|
| 4912 |
const mine=CLASS.students.filter(s=>c.name in s.classes);
|
| 4913 |
fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
|
|
|
|
|
|
|
| 4914 |
const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
|
| 4915 |
fact("שיעורי בית",...(hws.length?hws:["אין"]));
|
| 4916 |
const days=classDailies(c);
|
|
@@ -5333,9 +5423,21 @@ function dailyRows(box,levels,days){
|
|
| 5333 |
}
|
| 5334 |
const daysOf=(days,levels)=>DAILIES.filter(v=>levels.includes(v.level)&&days[v.id]).map(v=>({video:v.id,date:days[v.id]}));
|
| 5335 |
const editLevels=()=>{ const on=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value); return ALL_LEVELS.filter(v=>ALL_LESSONS.some(l=>l.level===v.id&&on.includes(l.id))).map(v=>v.id); };
|
| 5336 |
-
let editing=null, editDays={};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5337 |
function editClass(c){
|
| 5338 |
editing=c;
|
|
|
|
| 5339 |
$("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
|
| 5340 |
$("editName").value=c?c.name:"";
|
| 5341 |
// 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
|
|
@@ -5357,7 +5459,7 @@ function editClass(c){
|
|
| 5357 |
$("editHws").replaceChildren(); for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
|
| 5358 |
$("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
|
| 5359 |
$("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
|
| 5360 |
-
countStudents(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
|
| 5361 |
}
|
| 5362 |
/* The class's homeworks in its dialog: a row each, a lesson, a topic of it and its day, and a button to drop it.
|
| 5363 |
"+" adds the last topic of the first ticked lesson that has no homework yet: its written homework, where it has one. */
|
|
@@ -5380,7 +5482,7 @@ $("editHwAdd").onclick=()=>{
|
|
| 5380 |
$("editMsg").textContent=""; hwRow(last.id,"").focus();
|
| 5381 |
};
|
| 5382 |
function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
|
| 5383 |
-
$("editEmails").oninput=countStudents;
|
| 5384 |
$("editClose").onclick=()=>$("editDlg").close();
|
| 5385 |
async function sendClass(method,body){
|
| 5386 |
const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
|
|
@@ -5401,7 +5503,7 @@ $("editForm").onsubmit=e=>{
|
|
| 5401 |
const homeworks=hwRows();
|
| 5402 |
if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
|
| 5403 |
if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
|
| 5404 |
-
sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,lessons,homeworks,dailies:daysOf(editDays,editLevels())});
|
| 5405 |
};
|
| 5406 |
$("editDelete").onclick=()=>{
|
| 5407 |
if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
|
|
@@ -6547,7 +6649,7 @@ const ready=fetch("/api/phrases").then(r=>{ if(!r.ok) throw new Error(`phrases:
|
|
| 6547 |
/* The opening screen stays until the start button is pressed. */
|
| 6548 |
function enter(){
|
| 6549 |
const splash=$("logo-splash");
|
| 6550 |
-
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),
|
| 6551 |
if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
|
| 6552 |
if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
|
| 6553 |
}
|
|
@@ -6609,7 +6711,7 @@ if(AUTH_URL){
|
|
| 6609 |
else{ const last=MY_CLASSES.find(c=>c.name===store.get("ihki.class","")); if(last) useClass(last); } // the class practised in last
|
| 6610 |
const management=can("management.access"), laboratory=can("lab.access"), library=can("library.view"), practice=can("practice.use")||can("path.use")||can("games.use")||can("tutor.use");
|
| 6611 |
start.hidden=!ok||management||laboratory||library; roles.hidden=!management&&!laboratory&&!library; $("practiceBtn").hidden=!practice; $("libraryBtn").hidden=!library; $("manageBtn").hidden=$("toManage").hidden=!management; $("labBtn").hidden=!laboratory; $("mLab").hidden=!laboratory; denied.hidden=ok;
|
| 6612 |
-
if(ok){ [...roles.querySelectorAll("button:not([hidden])"),start].find(x=>!x.hidden)?.focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); loadContent(); loadWriting(); loadTutor(); }
|
| 6613 |
}catch(e){ start.hidden=false; throw e; } },0); // a failure here still lets the student in, and shows under the logo
|
| 6614 |
});
|
| 6615 |
}
|
|
|
|
| 94 |
#nav{position:fixed;inset:auto 0 0 0;z-index:8;display:flex;justify-content:center;gap:4px;padding:6px 10px calc(6px + env(safe-area-inset-bottom));background:var(--surface);border-top:1px solid var(--line)}
|
| 95 |
#nav button{position:relative;flex:1;max-width:160px;padding:10px 6px;border:0;border-radius:10px;background:none;color:var(--muted);font:700 14px/1.2 var(--f-body);cursor:pointer}
|
| 96 |
#nav button[aria-current=page]{background:var(--olive-soft);color:var(--olive)}
|
| 97 |
+
#nav button.live-on{color:var(--sumac)}#nav button.live-on::after{content:"";position:absolute;top:7px;inset-inline-end:12px;width:7px;height:7px;border-radius:50%;background:var(--sumac)}
|
| 98 |
#nav button.promo{color:var(--orange);animation:refer-pulse 1.8s ease-in-out infinite}
|
| 99 |
#nav button.promo::after{content:"🎁 הטבה";position:absolute;top:-11px;inset-inline-end:4px;padding:2px 6px;border-radius:999px;background:var(--orange);color:#fff;font:700 11px/1.35 var(--f-body);white-space:nowrap;box-shadow:0 2px 5px rgba(0,0,0,.2)}
|
| 100 |
@keyframes refer-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--orange) 0%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--orange) 22%,transparent)}}
|
| 101 |
@media (prefers-reduced-motion:reduce){#nav button.promo{animation:none}}
|
| 102 |
body:not(.home) #nav,body.manage #nav,body:not(.home) #vids,body.manage #vids,body:not([data-view]) #vids,body[data-view] #path,body[data-view] #today,body[data-view] #toNow,body[data-view] .maphint{display:none}
|
| 103 |
+
body:not([data-view=live]) #liveView{display:none}.live-page{display:flex;flex-direction:column;gap:12px;padding-bottom:90px}.live-page>.card{padding:18px}.live-page h1,.live-page h2{margin:0;font-family:var(--f-display);font-weight:400}.live-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.live-class{display:flex;flex-direction:column;gap:7px;padding:14px;border:1px solid var(--line);border-radius:13px;background:var(--surface)}.live-class .acts{display:flex;gap:7px;margin-top:auto}.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-inline-end:6px}.live-code{font:700 24px/1 var(--f-body);letter-spacing:.12em;direction:ltr}.live-question{text-align:center;padding:24px!important}.live-question .prompt{font-size:30px;margin:14px 0}.live-question .waiting-ring{width:64px;height:64px;margin:16px auto;border:6px solid var(--olive-soft);border-top-color:var(--olive);border-radius:50%;animation:live-turn 1.4s linear infinite}@keyframes live-turn{to{transform:rotate(360deg)}}.live-mic{display:grid;place-items:center;width:86px;height:86px;margin:20px auto;border:0;border-radius:50%;background:var(--sumac);color:white;font-size:28px;box-shadow:0 7px 0 color-mix(in srgb,var(--sumac) 75%,#000)}.live-mic.recording{animation:live-pulse 1s infinite}@keyframes live-pulse{50%{transform:scale(.94)}}.live-result{max-width:560px;margin:14px auto 0;padding:14px;border-radius:12px;background:var(--bg);text-align:start}.live-result .score{font-size:38px}.live-people{display:flex;flex-direction:column}.live-person{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:9px 0;border-top:1px solid var(--line)}.live-person small{display:block;color:var(--muted)}.live-person .online{color:var(--ok)}.live-person .offline{color:var(--muted)}.live-ask{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.live-ask select,.live-ask input{grid-column:1}.live-ask button{grid-column:2;grid-row:1/3}.live-join{display:flex;gap:8px}.live-join input{direction:ltr;text-align:center;letter-spacing:.15em;max-width:180px}.teacher-picks{display:flex;flex-direction:column;gap:6px;margin-top:8px}.teacher-picks label{display:flex;align-items:center;gap:8px;padding:7px 9px;border:1px solid var(--line);border-radius:9px}.teacher-picks small{display:block;color:var(--muted)}
|
| 104 |
body.home:not(.manage){padding-bottom:84px}
|
| 105 |
body.home:not(.manage) #toNow{bottom:76px} body.home:not(.manage) #logoutBtn{bottom:62px}
|
| 106 |
#vids{display:flex;flex-direction:column;gap:6px;width:100%;max-width:460px;margin:8px auto 0}
|
|
|
|
| 1203 |
.prow[aria-current="true"]{background:var(--olive-soft)}
|
| 1204 |
.prow .i{font-size:13px;font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums}
|
| 1205 |
.prow .none{font-size:13px;font-weight:400;color:var(--muted)}
|
| 1206 |
+
/* Opening screen: its fixed top padding prevents late auth controls from shifting the logo. */
|
| 1207 |
+
#logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:28px;box-sizing:border-box;padding:clamp(36px,14vh,120px) 16px 24px;overflow:auto;background:white;transition:opacity .65s ease,visibility .65s ease}
|
| 1208 |
#logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
|
| 1209 |
+
#logo-splash img{display:block;flex:none;width:min(82vw,620px);height:auto;aspect-ratio:489/225}
|
| 1210 |
+
#startBtn,#loginBtn,#roles button{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .65s ease-out 2.1s forwards}
|
| 1211 |
+
html[data-subject="arabic"] :is(#startBtn,#loginBtn,#roles button){animation-delay:6.55s}
|
| 1212 |
/* Under the Google button: sign-in by a link sent to the email, for a student without a Google account. */
|
| 1213 |
#mailForm{display:flex;flex-direction:column;align-items:center;gap:10px;width:min(82vw,340px);margin-top:-8px;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
|
| 1214 |
+
html[data-subject="arabic"] #mailForm{animation-delay:6.55s}
|
| 1215 |
#mailForm span{font:400 14px/1 var(--f-body);color:#5B665C}
|
| 1216 |
#mailForm input{width:100%;box-sizing:border-box;font:400 17px/1 var(--f-body);padding:11px 14px;border-radius:10px;border:1px solid #B9C2B4;background:#fff;color:#1C241D;text-align:center}
|
| 1217 |
#mailForm button{font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
|
|
|
| 1232 |
html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
|
| 1233 |
@keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 1234 |
@media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
|
| 1235 |
+
#wrap{opacity:1;transition:opacity .65s ease .1s}
|
| 1236 |
+
#wrap[inert]{opacity:0}
|
| 1237 |
</style>
|
| 1238 |
</head>
|
| 1239 |
<body class="home">
|
|
|
|
| 1284 |
<span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
|
| 1285 |
</button>
|
| 1286 |
<section id="vids" aria-live="polite"></section>
|
| 1287 |
+
<nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="live" hidden>שיעור חי</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
|
| 1288 |
<nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
|
| 1289 |
+
<section class="live-page" id="liveView" aria-live="polite"></section>
|
| 1290 |
<button id="toNow" hidden></button>
|
| 1291 |
|
| 1292 |
<div class="wrap" id="practice">
|
|
|
|
| 1469 |
<fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
|
| 1470 |
<label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
|
| 1471 |
<textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה avi@gmail.com"></textarea></label>
|
| 1472 |
+
<fieldset class="lpick"><legend>מורי הכיתה<small>בחרו אחד או יותר מתלמידי הכיתה. ההרשאה היא לכיתה הזו בלבד.</small></legend><div class="teacher-picks" id="editTeachers"></div></fieldset>
|
| 1473 |
<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>
|
| 1474 |
</form>
|
| 1475 |
</dialog>
|
|
|
|
| 2438 |
}
|
| 2439 |
function showView(view){ // "" is the path, which is laid out again: it was not while hidden
|
| 2440 |
if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
|
| 2441 |
+
scrollTo(0,0); drawPath(true); if(view==="live") loadLive();
|
| 2442 |
+
}
|
| 2443 |
+
const LIVE_TAB_ENABLED=false; // Keep the finished live-class flow hidden until it is ready for release.
|
| 2444 |
+
let LIVE={sessions:[],teaches:[],code:"",timer:null,rec:null,busy:false};
|
| 2445 |
+
const liveEsc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
| 2446 |
+
const livePhrase=p=>`${liveEsc(p.he)} · ${liveEsc((ALL_CATS.find(c=>c.id===p.cat)||{}).name||"")}`;
|
| 2447 |
+
function liveSchedule(){ clearTimeout(LIVE.timer); if(document.body.dataset.view==="live"&&LIVE.code) LIVE.timer=setTimeout(()=>loadLiveSession(),3000); }
|
| 2448 |
+
async function loadLive(silent=false){
|
| 2449 |
+
try{
|
| 2450 |
+
const res=await api("/api/live"), data=await res.json(); if(!res.ok) throw new Error(data.detail||res.status);
|
| 2451 |
+
LIVE.sessions=data.sessions||[]; LIVE.teaches=data.teaches||[];
|
| 2452 |
+
const tab=$("nav").querySelector('[data-view="live"]'); tab.hidden=!LIVE_TAB_ENABLED; tab.classList.toggle("live-on",LIVE_TAB_ENABLED&&LIVE.sessions.length>0);
|
| 2453 |
+
if(!silent&&document.body.dataset.view==="live"&&!LIVE.code) drawLiveHome();
|
| 2454 |
+
}catch(err){ if(!silent) $("liveView").innerHTML=`<div class="card">השיעורים החיים אינם זמינים (${liveEsc(err.message)}).</div>`; }
|
| 2455 |
+
}
|
| 2456 |
+
function drawLiveHome(){
|
| 2457 |
+
clearTimeout(LIVE.timer); const box=$("liveView"), active=new Map(LIVE.sessions.map(s=>[s.class_id,s]));
|
| 2458 |
+
let html='<div class="card"><h1>שיעור חי</h1><p class="sub">הצטרפו למפגש פעיל של אחת הכיתות שלכם, או הכניסו קוד שקיבלתם ממורה.</p></div>';
|
| 2459 |
+
if(LIVE.teaches.length){ html+='<div class="card"><h2>הכיתות שאני מלמד/ת</h2><div class="live-grid">'+LIVE.teaches.map(c=>{const s=active.get(c.id);return `<div class="live-class"><b>${liveEsc(c.name)}</b><small>${s?'<span class="live-dot"></span>מפגש פעיל':'אין מפגש פעיל'}</small><div class="acts"><button class="btn primary" data-live-${s?'open':'start'}="${s?liveEsc(s.code):c.id}">${s?'כניסה למפגש':'פתיחת שיעור חי'}</button></div></div>`}).join('')+'</div></div>'; }
|
| 2460 |
+
const student=LIVE.sessions.filter(s=>!s.teacher);
|
| 2461 |
+
html+='<div class="card"><h2>שיעורים פעילים בכיתות שלי</h2>'+(student.length?'<div class="live-grid">'+student.map(s=>`<div class="live-class"><b>${liveEsc(s.class_name)}</b><small><span class="live-dot"></span>המפגש פתוח עכשיו</small><button class="btn primary" data-live-join="${liveEsc(s.code)}">הצטרפות</button></div>`).join('')+'</div>':'<p class="sub">אין כרגע שיעור חי בכיתות שלכם.</p>')+'</div>';
|
| 2462 |
+
html+='<div class="card"><h2>הצטרפות עם קוד</h2><p class="sub">אפשרות גיבוי למפגש שאינו ברשימת הכיתות שלכם.</p><form class="live-join" id="liveCodeForm"><input id="liveCode" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" placeholder="000000" required aria-label="קוד מפגש"><button class="btn">הצטרפות</button></form><p class="msg" id="liveMsg"></p></div>';
|
| 2463 |
+
box.innerHTML=html;
|
| 2464 |
+
box.querySelectorAll("[data-live-start]").forEach(b=>b.onclick=()=>startLive(+b.dataset.liveStart));
|
| 2465 |
+
box.querySelectorAll("[data-live-open]").forEach(b=>b.onclick=()=>openLive(b.dataset.liveOpen,true));
|
| 2466 |
+
box.querySelectorAll("[data-live-join]").forEach(b=>b.onclick=()=>joinLive(b.dataset.liveJoin));
|
| 2467 |
+
$("liveCodeForm").onsubmit=e=>{e.preventDefault();joinLive($("liveCode").value)};
|
| 2468 |
+
}
|
| 2469 |
+
async function startLive(classId){
|
| 2470 |
+
const res=await api("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({class_id:classId})}), data=await res.json().catch(()=>({}));
|
| 2471 |
+
if(!res.ok){ $("liveMsg").textContent=data.detail||`הפתיחה נכשלה (${res.status})`; return; }
|
| 2472 |
+
openLive(data.session.code,true);
|
| 2473 |
+
}
|
| 2474 |
+
async function joinLive(code){
|
| 2475 |
+
code=String(code).replace(/\D/g,""); const res=await api("/api/live/join",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code})}), data=await res.json().catch(()=>({}));
|
| 2476 |
+
if(!res.ok){ const msg=$("liveMsg"); if(msg) msg.textContent=data.detail||`ההצטרפות נכשלה (${res.status})`; return; }
|
| 2477 |
+
openLive(data.session.code,false);
|
| 2478 |
+
}
|
| 2479 |
+
function openLive(code,teacher){ LIVE.code=code; LIVE.teacher=teacher; loadLiveSession(); }
|
| 2480 |
+
async function loadLiveSession(){
|
| 2481 |
+
if(!LIVE.code||LIVE.busy) return liveSchedule();
|
| 2482 |
+
try{
|
| 2483 |
+
const res=await api(`/api/live/${LIVE.code}`), data=await res.json().catch(()=>({}));
|
| 2484 |
+
if(res.status===404){ LIVE.code=""; await loadLive(); return; }
|
| 2485 |
+
if(!res.ok) throw new Error(data.detail||res.status);
|
| 2486 |
+
if(LIVE.teacher) drawLiveTeacher(data.session); else drawLiveStudent(data.session);
|
| 2487 |
+
}catch(err){ $("liveView").innerHTML=`<div class="card">המפגש אינו זמין (${liveEsc(err.message)}). <button class="btn" id="liveBack">חזרה</button></div>`; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; }
|
| 2488 |
+
liveSchedule();
|
| 2489 |
+
}
|
| 2490 |
+
function eligibleLivePhrases(session){
|
| 2491 |
+
const course=LIVE.teaches.find(c=>c.id===session.class_id), lessons=new Set(course&&course.lessons||[]), cats=new Set(ALL_CATS.filter(c=>lessons.has(c.lesson)).map(c=>c.id));
|
| 2492 |
+
return PHRASES.filter(p=>cats.has(p.cat)&&p.listed!==false&&!p.read);
|
| 2493 |
+
}
|
| 2494 |
+
function drawLiveTeacher(s){
|
| 2495 |
+
const list=eligibleLivePhrases(s), answered=(s.participants||[]).filter(p=>p.answer).length;
|
| 2496 |
+
let html=`<div class="card"><button class="btn" id="liveBack">חזרה</button> <button class="btn danger" id="liveEnd">סיום המפגש</button><h1>${liveEsc(s.class_name)}</h1><p>קוד גיבוי: <b class="live-code">${liveEsc(s.code)}</b></p></div>`;
|
| 2497 |
+
html+=`<div class="card"><h2>שליחת שאלה</h2><p class="sub">בחרו משפט או הקלידו בעברית כדי לחפש במאגר של שיעורי הכיתה.</p><div class="live-ask"><input id="liveSearch" placeholder="חיפוש משפט בעברית"><select id="livePhrase">${list.map(p=>`<option value="${p.id}">${livePhrase(p)}</option>`).join('')}</select><button class="btn primary" id="liveAsk" ${list.length?'':'disabled'}>שליחה לכיתה</button></div><p class="msg" id="liveAskMsg"></p></div>`;
|
| 2498 |
+
if(s.question) html+=`<div class="card"><h2>השאלה הנוכחית</h2><p><b>${liveEsc(s.question.he)}</b></p><p class="sub">ענו ${answered} מתוך ${(s.participants||[]).length}</p></div>`;
|
| 2499 |
+
html+=`<div class="card"><h2>תלמידים במפגש</h2><div class="live-people">${(s.participants||[]).length?(s.participants||[]).map(p=>`<div class="live-person"><span><b>${liveEsc(p.name)}</b><small class="${p.online?'online':'offline'}">${p.online?'מחובר/ת עכשיו':'לא מחובר/ת'}</small>${p.answer?`<small>צריך: ${liveEsc(p.answer.expected_tr)}<br>נאמר: ${liveEsc(p.answer.heard_tr)}</small>`:''}</span><span>${p.answer?`<b class="pill">${p.answer.score}</b>`:'ממתינ/ה'}</span></div>`).join(''):'<p class="sub">עוד אין תלמידים במפגש.</p>'}</div></div>`;
|
| 2500 |
+
$("liveView").innerHTML=html; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; $("liveEnd").onclick=endLive;
|
| 2501 |
+
const search=$("liveSearch"), select=$("livePhrase"); search.oninput=()=>{ const q=search.value.trim(); select.replaceChildren(...list.filter(p=>!q||p.he.includes(q)).slice(0,100).map(p=>new Option(`${p.he} · ${(ALL_CATS.find(c=>c.id===p.cat)||{}).name||""}`,p.id))); $("liveAsk").disabled=!select.value; };
|
| 2502 |
+
$("liveAsk").onclick=async()=>{ const res=await api(`/api/live/${s.code}/question`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({phrase_id:+select.value})}), data=await res.json().catch(()=>({})); if(!res.ok) $("liveAskMsg").textContent=data.detail||res.status; else loadLiveSession(); };
|
| 2503 |
+
}
|
| 2504 |
+
async function endLive(){ if(!confirm("לסיים את המפגש? התוצאות הזמניות יימחקו."))return; await api(`/api/live/${LIVE.code}`,{method:"DELETE"}); LIVE.code=""; loadLive(); }
|
| 2505 |
+
function drawLiveStudent(s){
|
| 2506 |
+
let body;
|
| 2507 |
+
if(!s.question) body='<div class="waiting-ring"></div><h1>מחכים לשאלה</h1><p class="sub">אתם מחוברים. השאלה תופיע כאן כשהמורה ישלח אותה.</p>';
|
| 2508 |
+
else if(s.answer) body=`<h1>המשוב האישי שלך</h1><div class="live-result"><b class="score">${s.answer.score}</b><p><small>צריך</small><br><b>${liveEsc(s.answer.expected_tr)}</b></p><p><small>נאמר</small><br><b>${liveEsc(s.answer.heard_tr)}</b></p></div><p class="sub">ניסיון אחד בלבד. רק את/ה והמורה רואים את התוצאה.</p>`;
|
| 2509 |
+
else if(s.answered) body='<div class="waiting-ring"></div><h1>בודקים את ההגייה…</h1>';
|
| 2510 |
+
else body=`<small>${liveEsc(s.class_name)}</small><h1 class="prompt">${liveEsc(s.question.he)}</h1><p class="sub">ענו בערבית. התשובה אינה מוצגת לפני ההקלטה.</p><button class="live-mic" id="liveMic" aria-label="הקלטת תשובה"><svg class="ic" aria-hidden="true"><use href="#i-mic"/></svg></button><b id="liveStatus">לחצו ודברו</b>`;
|
| 2511 |
+
$("liveView").innerHTML=`<div class="card"><button class="btn" id="liveBack">יציאה</button></div><div class="card live-question">${body}</div>`; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; if($("liveMic")) $("liveMic").onclick=liveRecord;
|
| 2512 |
+
}
|
| 2513 |
+
async function liveRecord(){
|
| 2514 |
+
if(LIVE.rec&&LIVE.rec.state==="recording"){ LIVE.rec.stop(); return; }
|
| 2515 |
+
let stream; try{ stream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}}); }catch{ $("liveStatus").textContent="אין גישה למיקרופון"; return; }
|
| 2516 |
+
const chunks=[], type=pickType(); LIVE.rec=new MediaRecorder(stream,type?{mimeType:type}:{}); LIVE.busy=true;
|
| 2517 |
+
LIVE.rec.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)}; LIVE.rec.onstart=()=>{ $("liveMic").classList.add("recording"); $("liveStatus").textContent="מקליט… לחצו לסיום"; };
|
| 2518 |
+
LIVE.rec.onstop=async()=>{ stream.getTracks().forEach(t=>t.stop()); const blob=new Blob(chunks,{type:LIVE.rec.mimeType||"audio/webm"}), fd=new FormData(); fd.append("audio",blob,"live"); $("liveStatus").textContent="בודקים…";
|
| 2519 |
+
try{ const res=await api(`/api/live/${LIVE.code}/answer`,{method:"POST",body:fd}), data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); }catch(err){ $("liveStatus").textContent=`לא הצלחנו לבדוק: ${err.message}`; }
|
| 2520 |
+
LIVE.busy=false; LIVE.rec=null; loadLiveSession(); };
|
| 2521 |
+
LIVE.rec.start(); setTimeout(()=>LIVE.rec&&LIVE.rec.state==="recording"&&LIVE.rec.stop(),10000);
|
| 2522 |
}
|
| 2523 |
const referTab=$("nav").querySelector('[data-view="refer"]'), referSeenKey="ihki.referSeen.2027";
|
| 2524 |
if(!store.get(referSeenKey,false)) referTab.classList.add("promo");
|
|
|
|
| 4999 |
fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
|
| 5000 |
const mine=CLASS.students.filter(s=>c.name in s.classes);
|
| 5001 |
fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
|
| 5002 |
+
const teachers=(c.teachers||[]).map(e=>{ const s=mine.find(x=>x.email.toLowerCase()===e); return s&&(s.classes[c.name]||s.name)||e; });
|
| 5003 |
+
fact("מורים",teachers.length?teachers.join(" · "):"עוד לא נבחרו");
|
| 5004 |
const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
|
| 5005 |
fact("שיעורי בית",...(hws.length?hws:["אין"]));
|
| 5006 |
const days=classDailies(c);
|
|
|
|
| 5423 |
}
|
| 5424 |
const daysOf=(days,levels)=>DAILIES.filter(v=>levels.includes(v.level)&&days[v.id]).map(v=>({video:v.id,date:days[v.id]}));
|
| 5425 |
const editLevels=()=>{ const on=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value); return ALL_LEVELS.filter(v=>ALL_LESSONS.some(l=>l.level===v.id&&on.includes(l.id))).map(v=>v.id); };
|
| 5426 |
+
let editing=null, editDays={}, editTeacherSet=new Set();
|
| 5427 |
+
function editTeacherChoices(){
|
| 5428 |
+
const box=$("editTeachers"), found=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).map(e=>e.toLowerCase());
|
| 5429 |
+
editTeacherSet=new Set([...editTeacherSet].filter(e=>found.includes(e))); box.replaceChildren();
|
| 5430 |
+
if(!found.length){ box.append(mark("sub","הוסיפו קודם תלמידים לכיתה.")); return; }
|
| 5431 |
+
for(const email of found){
|
| 5432 |
+
const row=document.createElement("label"), tick=document.createElement("input"), known=(CLASS.students||[]).find(s=>s.email.toLowerCase()===email), text=document.createElement("span");
|
| 5433 |
+
tick.type="checkbox"; tick.checked=editTeacherSet.has(email); tick.onchange=()=>tick.checked?editTeacherSet.add(email):editTeacherSet.delete(email);
|
| 5434 |
+
text.append(known&&(known.name||Object.values(known.classes||{}).find(Boolean))||email); if(known&&known.name){ const small=mark("",email,"small"); small.dir="ltr"; text.append(small); }
|
| 5435 |
+
row.append(tick,text); box.append(row);
|
| 5436 |
+
}
|
| 5437 |
+
}
|
| 5438 |
function editClass(c){
|
| 5439 |
editing=c;
|
| 5440 |
+
editTeacherSet=new Set(c&&c.teachers||[]);
|
| 5441 |
$("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
|
| 5442 |
$("editName").value=c?c.name:"";
|
| 5443 |
// 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
|
|
|
|
| 5459 |
$("editHws").replaceChildren(); for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
|
| 5460 |
$("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
|
| 5461 |
$("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
|
| 5462 |
+
countStudents(); editTeacherChoices(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
|
| 5463 |
}
|
| 5464 |
/* The class's homeworks in its dialog: a row each, a lesson, a topic of it and its day, and a button to drop it.
|
| 5465 |
"+" adds the last topic of the first ticked lesson that has no homework yet: its written homework, where it has one. */
|
|
|
|
| 5482 |
$("editMsg").textContent=""; hwRow(last.id,"").focus();
|
| 5483 |
};
|
| 5484 |
function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
|
| 5485 |
+
$("editEmails").oninput=()=>{ countStudents(); editTeacherChoices(); };
|
| 5486 |
$("editClose").onclick=()=>$("editDlg").close();
|
| 5487 |
async function sendClass(method,body){
|
| 5488 |
const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
|
|
|
|
| 5503 |
const homeworks=hwRows();
|
| 5504 |
if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
|
| 5505 |
if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
|
| 5506 |
+
sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,teachers:[...editTeacherSet],lessons,homeworks,dailies:daysOf(editDays,editLevels())});
|
| 5507 |
};
|
| 5508 |
$("editDelete").onclick=()=>{
|
| 5509 |
if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
|
|
|
|
| 6649 |
/* The opening screen stays until the start button is pressed. */
|
| 6650 |
function enter(){
|
| 6651 |
const splash=$("logo-splash");
|
| 6652 |
+
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),700);
|
| 6653 |
if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
|
| 6654 |
if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
|
| 6655 |
}
|
|
|
|
| 6711 |
else{ const last=MY_CLASSES.find(c=>c.name===store.get("ihki.class","")); if(last) useClass(last); } // the class practised in last
|
| 6712 |
const management=can("management.access"), laboratory=can("lab.access"), library=can("library.view"), practice=can("practice.use")||can("path.use")||can("games.use")||can("tutor.use");
|
| 6713 |
start.hidden=!ok||management||laboratory||library; roles.hidden=!management&&!laboratory&&!library; $("practiceBtn").hidden=!practice; $("libraryBtn").hidden=!library; $("manageBtn").hidden=$("toManage").hidden=!management; $("labBtn").hidden=!laboratory; $("mLab").hidden=!laboratory; denied.hidden=ok;
|
| 6714 |
+
if(ok){ $("nav").querySelector('[data-view="live"]').hidden=!LIVE_TAB_ENABLED; loadLive(true); [...roles.querySelectorAll("button:not([hidden])"),start].find(x=>!x.hidden)?.focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); loadContent(); loadWriting(); loadTutor(); }
|
| 6715 |
}catch(e){ start.hidden=false; throw e; } },0); // a failure here still lets the student in, and shows under the logo
|
| 6716 |
});
|
| 6717 |
}
|
static/logo-intro.svg
CHANGED
|
|
|
|
static/service-worker.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
const CACHE = "ihki-shell-
|
| 2 |
const SHELL = [
|
| 3 |
"/static/manifest.webmanifest",
|
| 4 |
"/static/icons/icon-192.png?v=2",
|
| 5 |
"/static/icons/icon-512.png?v=2",
|
| 6 |
"/static/icons/icon-maskable-512.png?v=2",
|
| 7 |
"/static/logo-intro.svg",
|
| 8 |
-
"/static/logo-intro-alt.svg",
|
| 9 |
"/static/logo-math.svg",
|
| 10 |
"/static/assets/arabic-calligraphy-path.webp"
|
| 11 |
];
|
|
|
|
| 1 |
+
const CACHE = "ihki-shell-v17";
|
| 2 |
const SHELL = [
|
| 3 |
"/static/manifest.webmanifest",
|
| 4 |
"/static/icons/icon-192.png?v=2",
|
| 5 |
"/static/icons/icon-512.png?v=2",
|
| 6 |
"/static/icons/icon-maskable-512.png?v=2",
|
| 7 |
"/static/logo-intro.svg",
|
|
|
|
| 8 |
"/static/logo-math.svg",
|
| 9 |
"/static/assets/arabic-calligraphy-path.webp"
|
| 10 |
];
|
tests/test_live.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from app.live import LiveConflict, LiveStore
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
COURSE = {"id": 7, "name": "כיתה א", "emails": ["student@example.com"],
|
| 7 |
+
"nicknames": {"student@example.com": "דנה"}, "teachers": ["teacher@example.com"]}
|
| 8 |
+
TEACHER = {"id": "teacher-id", "email": "teacher@example.com"}
|
| 9 |
+
STUDENT = {"id": "student-id", "email": "student@example.com"}
|
| 10 |
+
PHRASE = {"id": 12, "he": "איפה היית?", "ar": "وين كنت؟", "tr": "וֵין כֻּנְת?"}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_live_session_one_attempt_and_private_student_view():
|
| 14 |
+
live = LiveStore()
|
| 15 |
+
session = live.start(COURSE, TEACHER)
|
| 16 |
+
assert len(session["code"]) == 6 and session["question"] is None
|
| 17 |
+
assert live.start(COURSE, TEACHER)["code"] == session["code"] # one active session per class
|
| 18 |
+
|
| 19 |
+
live.join(session["code"], STUDENT, "דנה")
|
| 20 |
+
live.ask(session["code"], PHRASE)
|
| 21 |
+
assert live.begin_answer(session["code"], STUDENT["id"])["phrase_id"] == 12
|
| 22 |
+
with pytest.raises(LiveConflict):
|
| 23 |
+
live.begin_answer(session["code"], STUDENT["id"])
|
| 24 |
+
|
| 25 |
+
answer = {"score": 81, "expected_tr": PHRASE["tr"], "heard_tr": "וֵין כּוּנְת?", "issues": []}
|
| 26 |
+
live.finish_answer(session["code"], STUDENT["id"], answer)
|
| 27 |
+
student = live.heartbeat(session["code"], STUDENT)
|
| 28 |
+
assert student["answered"] is True and student["answer"] == answer
|
| 29 |
+
assert "participants" not in student
|
| 30 |
+
|
| 31 |
+
teacher = live.heartbeat(session["code"], TEACHER, teacher=True)
|
| 32 |
+
assert teacher["participants"][0]["name"] == "דנה"
|
| 33 |
+
assert teacher["participants"][0]["answer"] == answer
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_failed_recording_can_be_retried_and_end_erases_session():
|
| 37 |
+
live = LiveStore()
|
| 38 |
+
code = live.start(COURSE, TEACHER)["code"]
|
| 39 |
+
live.join(code, STUDENT)
|
| 40 |
+
live.ask(code, PHRASE)
|
| 41 |
+
live.begin_answer(code, STUDENT["id"])
|
| 42 |
+
live.cancel_answer(code, STUDENT["id"])
|
| 43 |
+
live.begin_answer(code, STUDENT["id"])
|
| 44 |
+
live.end(code)
|
| 45 |
+
assert live.get(code) is None
|
tests/test_scoring.py
CHANGED
|
@@ -2107,7 +2107,7 @@ def test_each_class_topic_opens_on_its_own_screen():
|
|
| 2107 |
with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
|
| 2108 |
for path in ("/", "/arabic"): # "/" stays Arabic
|
| 2109 |
page = c.get(path).text
|
| 2110 |
-
assert 'data-subject="arabic"' in page and "/static/logo-intro
|
| 2111 |
math = c.get("/math").text
|
| 2112 |
assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math
|
| 2113 |
assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
|
|
|
|
| 2107 |
with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
|
| 2108 |
for path in ("/", "/arabic"): # "/" stays Arabic
|
| 2109 |
page = c.get(path).text
|
| 2110 |
+
assert 'data-subject="arabic"' in page and "/static/logo-intro.svg" in page and "<title>אהלא וסהלא</title>" in page
|
| 2111 |
math = c.get("/math").text
|
| 2112 |
assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math
|
| 2113 |
assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
|