3v324v23 commited on
Commit
a9f22dc
·
1 Parent(s): c6e245c

Add teacher management panel functionality and related configurations

Browse files
Files changed (6) hide show
  1. .env.example +8 -0
  2. README.md +7 -1
  3. app/auth.py +58 -9
  4. app/main.py +48 -17
  5. static/index.html +113 -11
  6. tests/test_scoring.py +18 -2
.env.example CHANGED
@@ -16,3 +16,11 @@ SUPABASE_PUBLISHABLE_KEY=
16
 
17
  # Optional: the only Google accounts let in, separated by commas or spaces. Empty = any Google account.
18
  ALLOWED_EMAILS=
 
 
 
 
 
 
 
 
 
16
 
17
  # Optional: the only Google accounts let in, separated by commas or spaces. Empty = any Google account.
18
  ALLOWED_EMAILS=
19
+
20
+ # Optional: the teacher's account (or several). It must also be in ALLOWED_EMAILS. After sign-in a teacher chooses
21
+ # between "תרגול" (the same as a student) and "ניהול" (the management panel: every student's progress).
22
+ TEACHER_EMAILS=
23
+
24
+ # Needed by the management panel only: the project's secret key (Project Settings -> API Keys -> secret, or the
25
+ # legacy service_role key). It reads every student's rows, so it is NOT public: on Hugging Face set it as a *secret*.
26
+ SUPABASE_SECRET_KEY=
README.md CHANGED
@@ -96,7 +96,13 @@ create policy "students read their own attempts" on public.attempts
96
 
97
  The page's "ההתקדמות שלי" button reads these rows back through `GET /api/progress` (best score and tries per phrase, recordings, days practised) and shows them per topic; the best scores then follow the student between devices.
98
 
99
- The teacher's view, also in the SQL editor (it runs as the owner, so it sees every student):
 
 
 
 
 
 
100
 
101
  ```sql
102
  select u.email, a.cat, count(distinct a.phrase) as phrases, count(*) as tries,
 
96
 
97
  The page's "ההתקדמות שלי" button reads these rows back through `GET /api/progress` (best score and tries per phrase, recordings, days practised) and shows them per topic; the best scores then follow the student between devices.
98
 
99
+ ### The teacher
100
+
101
+ `TEACHER_EMAILS` names the teacher's Google account (several are separated like `ALLOWED_EMAILS`, and each must also be on that list). 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. The students are the accounts in `ALLOWED_EMAILS`, including those who have not signed in yet; without a list, every account that signed in.
102
+
103
+ 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.
104
+
105
+ The same figures as a query, in the SQL editor (it runs as the owner, so it sees every student):
106
 
107
  ```sql
108
  select u.email, a.cat, count(distinct a.phrase) as phrases, count(*) as tries,
app/auth.py CHANGED
@@ -6,9 +6,15 @@ kept here and a session revoked in Supabase stops working within CACHE_TTL.
6
  Progress: each scored recording is added to the Supabase table `attempts` with the student's own
7
  token, so row-level security ties the row to them (the table's SQL is in the README).
8
 
 
 
 
 
9
  Env: SUPABASE_URL (https://<project-ref>.supabase.co)
10
  SUPABASE_PUBLISHABLE_KEY (the publishable/anon key; it is public and also goes to the page)
11
  ALLOWED_EMAILS (optional: the only accounts let in, separated by commas or spaces)
 
 
12
  Without them the service is open, as before.
13
  """
14
  from __future__ import annotations
@@ -29,6 +35,7 @@ CACHE_TTL = 300 # seconds a checked token is trusted without asking again
29
  CACHE_MAX = 2000
30
  PAGE = 1000 # rows per request: Supabase's default ceiling
31
  MAX_ATTEMPTS = 20_000 # of one student, read for their progress
 
32
 
33
 
34
  class AuthUnavailable(RuntimeError):
@@ -36,9 +43,12 @@ class AuthUnavailable(RuntimeError):
36
 
37
 
38
  class SupabaseAuth:
39
- def __init__(self, url: str, key: str, allowed: frozenset[str] = frozenset()):
 
40
  self.url, self.key = url.rstrip("/"), key
41
  self.allowed = allowed # lower-case emails; empty = every signed-in account
 
 
42
  self._cache: dict[str, tuple[float, dict]] = {}
43
  self._lock = threading.Lock()
44
 
@@ -90,13 +100,8 @@ class SupabaseAuth:
90
  while len(rows) < MAX_ATTEMPTS:
91
  query = urllib.parse.urlencode({"select": "cat,phrase,score,created_at", "unrelated": "is.false",
92
  "order": "created_at.desc", "limit": PAGE, "offset": len(rows)})
93
- req = urllib.request.Request(
94
- f"{self.url}/rest/v1/attempts?{query}",
95
- headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
96
- )
97
  try:
98
- with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
99
- batch = json.load(res)
100
  except (urllib.error.URLError, TimeoutError, ValueError) as e:
101
  log.warning("attempts not read: %s", e)
102
  break
@@ -105,6 +110,44 @@ class SupabaseAuth:
105
  break
106
  return rows
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  def load_auth() -> SupabaseAuth | None:
110
  url = os.environ.get("SUPABASE_URL", "").strip()
@@ -115,6 +158,12 @@ def load_auth() -> SupabaseAuth | None:
115
  log.info("no sign-in, the service is open: %s missing or empty (similar names in the environment: %s)",
116
  " and ".join(missing), seen or "none")
117
  return None
118
- allowed = frozenset(re.split(r"[\s,;]+", os.environ.get("ALLOWED_EMAILS", "").strip().lower())) - {""}
 
 
119
  log.info("sign-in on: %s", f"{len(allowed)} allowed accounts" if allowed else "any Google account")
120
- return SupabaseAuth(url, key, allowed)
 
 
 
 
 
6
  Progress: each scored recording is added to the Supabase table `attempts` with the student's own
7
  token, so row-level security ties the row to them (the table's SQL is in the README).
8
 
9
+ Teachers: the accounts in TEACHER_EMAILS also get the management panel, which shows every student's
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
  Env: SUPABASE_URL (https://<project-ref>.supabase.co)
14
  SUPABASE_PUBLISHABLE_KEY (the publishable/anon key; it is public and also goes to the page)
15
  ALLOWED_EMAILS (optional: the only accounts let in, separated by commas or spaces)
16
+ TEACHER_EMAILS (optional: the accounts with the management panel; each is also in ALLOWED_EMAILS)
17
+ SUPABASE_SECRET_KEY (the secret/service-role key, needed by the management panel only; never public)
18
  Without them the service is open, as before.
19
  """
20
  from __future__ import annotations
 
35
  CACHE_MAX = 2000
36
  PAGE = 1000 # rows per request: Supabase's default ceiling
37
  MAX_ATTEMPTS = 20_000 # of one student, read for their progress
38
+ MAX_CLASS_ATTEMPTS = 200_000 # of all students, read for the teacher's panel
39
 
40
 
41
  class AuthUnavailable(RuntimeError):
 
43
 
44
 
45
  class SupabaseAuth:
46
+ def __init__(self, url: str, key: str, allowed: frozenset[str] = frozenset(),
47
+ teachers: frozenset[str] = frozenset(), secret: str = ""):
48
  self.url, self.key = url.rstrip("/"), key
49
  self.allowed = allowed # lower-case emails; empty = every signed-in account
50
+ self.teachers = teachers # lower-case emails of the accounts with the management panel
51
+ self.secret = secret # reads every student's rows; empty = the panel has no data
52
  self._cache: dict[str, tuple[float, dict]] = {}
53
  self._lock = threading.Lock()
54
 
 
100
  while len(rows) < MAX_ATTEMPTS:
101
  query = urllib.parse.urlencode({"select": "cat,phrase,score,created_at", "unrelated": "is.false",
102
  "order": "created_at.desc", "limit": PAGE, "offset": len(rows)})
 
 
 
 
103
  try:
104
+ batch = self._get(f"/rest/v1/attempts?{query}", token, self.key)
 
105
  except (urllib.error.URLError, TimeoutError, ValueError) as e:
106
  log.warning("attempts not read: %s", e)
107
  break
 
110
  break
111
  return rows
112
 
113
+ def _get(self, path: str, token: str, key: str):
114
+ req = urllib.request.Request(
115
+ self.url + path, headers={"Authorization": f"Bearer {token}", "apikey": key, "User-Agent": "ihki/1.0"})
116
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
117
+ return json.load(res)
118
+
119
+ def _admin(self, path: str):
120
+ """A read with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
121
+ if not self.secret:
122
+ raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
123
+ try:
124
+ return self._get(path, self.secret, self.secret)
125
+ except (urllib.error.URLError, TimeoutError, ValueError) as e:
126
+ raise AuthUnavailable(str(e)) from e
127
+
128
+ def users(self) -> list[dict]:
129
+ """Every account that has signed in: id, email, name (as Google gave it)."""
130
+ users: list[dict] = []
131
+ for page in range(1, 51):
132
+ batch = self._admin(f"/auth/v1/admin/users?page={page}&per_page={PAGE}").get("users", [])
133
+ users += [{"id": u["id"], "email": u.get("email") or "",
134
+ "name": (u.get("user_metadata") or {}).get("full_name") or ""} for u in batch]
135
+ if len(batch) < PAGE:
136
+ break
137
+ return users
138
+
139
+ def all_attempts(self) -> list[dict]:
140
+ """Every student's attempts, as `attempts` gives one student's, each with its user_id."""
141
+ rows: list[dict] = []
142
+ while len(rows) < MAX_CLASS_ATTEMPTS:
143
+ query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at", "unrelated": "is.false",
144
+ "order": "id.desc", "limit": PAGE, "offset": len(rows)})
145
+ batch = self._admin(f"/rest/v1/attempts?{query}")
146
+ rows += batch
147
+ if len(batch) < PAGE:
148
+ break
149
+ return rows
150
+
151
 
152
  def load_auth() -> SupabaseAuth | None:
153
  url = os.environ.get("SUPABASE_URL", "").strip()
 
158
  log.info("no sign-in, the service is open: %s missing or empty (similar names in the environment: %s)",
159
  " and ".join(missing), seen or "none")
160
  return None
161
+ emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
162
+ allowed, teachers = emails("ALLOWED_EMAILS"), emails("TEACHER_EMAILS")
163
+ secret = os.environ.get("SUPABASE_SECRET_KEY", "").strip()
164
  log.info("sign-in on: %s", f"{len(allowed)} allowed accounts" if allowed else "any Google account")
165
+ if allowed and teachers - allowed: # counts only: the addresses stay out of the log
166
+ log.warning("%d of TEACHER_EMAILS are not in ALLOWED_EMAILS and will not be let in", len(teachers - allowed))
167
+ if teachers and not secret:
168
+ log.warning("TEACHER_EMAILS is set without SUPABASE_SECRET_KEY: the management panel has no data to show")
169
+ return SupabaseAuth(url, key, allowed, teachers, secret)
app/main.py CHANGED
@@ -4,6 +4,7 @@ Run: uvicorn app.main:app --host 0.0.0.0 --port 7860
4
  Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
5
  SAWTAK_API_KEY, SAWTAK_VOICE_ID (model-answer playback, see app/tts.py)
6
  SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, ALLOWED_EMAILS (Google sign-in, see app/auth.py)
 
7
  A local .env file is loaded if present; real environment variables win over it.
8
  """
9
  from __future__ import annotations
@@ -15,7 +16,7 @@ import re
15
  import time
16
  import urllib.parse
17
  from contextlib import asynccontextmanager
18
- from datetime import datetime, timezone
19
  from pathlib import Path
20
  from zoneinfo import ZoneInfo
21
 
@@ -49,6 +50,29 @@ def build_version() -> str:
49
  return f"{h.hexdigest()[:7]} · {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
53
  auth: SupabaseAuth | None = None) -> FastAPI:
54
  @asynccontextmanager
@@ -77,7 +101,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
77
  raise HTTPException(401, "sign in required")
78
  if a.allowed and user["email"].lower() not in a.allowed:
79
  raise HTTPException(403, "this account is not on the list")
80
- return {**user, "token": token.strip()}
81
 
82
  members = [Depends(signed_in)]
83
 
@@ -93,26 +117,33 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
93
  @app.get("/api/me")
94
  def me(user: dict | None = Depends(signed_in)):
95
  """Who is signed in; the page asks this right after sign-in to learn whether the account is let in."""
96
- return {"email": user["email"] if user else ""}
97
 
98
  @app.get("/api/progress")
99
  def progress(user: dict | None = Depends(signed_in)):
100
  """The signed-in student's progress: best score and tries per phrase id, recordings, days practised."""
101
- best: dict[int, int] = {}
102
- tries: dict[int, int] = {}
103
- days = set()
104
- rows = app.state.auth.attempts(user["token"]) if user else []
105
- shown = {c["id"] for c in CATEGORIES}
106
- ids = {(p["cat"], p["he"]): i for i, p in enumerate(PHRASES) if p["cat"] in shown}
 
 
 
 
 
 
 
 
 
107
  for r in rows:
108
- # the seconds part only: the fraction's length varies, which Python 3.10 does not parse
109
- at = datetime.strptime(r["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
110
- days.add(at.astimezone(LOCAL_TZ).date())
111
- i = ids.get((r["cat"], r["phrase"]))
112
- if i is not None: # a sentence the page no longer lists still counts as a recording
113
- best[i] = max(best.get(i, 0), r["score"])
114
- tries[i] = tries.get(i, 0) + 1
115
- return {"best": best, "tries": tries, "recordings": len(rows), "days": len(days)}
116
 
117
  @app.get("/api/phrases")
118
  def phrases():
 
4
  Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
5
  SAWTAK_API_KEY, SAWTAK_VOICE_ID (model-answer playback, see app/tts.py)
6
  SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, ALLOWED_EMAILS (Google sign-in, see app/auth.py)
7
+ TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, see app/auth.py)
8
  A local .env file is loaded if present; real environment variables win over it.
9
  """
10
  from __future__ import annotations
 
16
  import time
17
  import urllib.parse
18
  from contextlib import asynccontextmanager
19
+ from datetime import datetime, timedelta, timezone
20
  from pathlib import Path
21
  from zoneinfo import ZoneInfo
22
 
 
50
  return f"{h.hexdigest()[:7]} · {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
51
 
52
 
53
+ def summary(rows: list[dict]) -> dict:
54
+ """One student's attempts summed up: best score and tries per phrase id, recordings (all, and of the last
55
+ 7 days), days practised, time of the last one."""
56
+ shown = {c["id"] for c in CATEGORIES}
57
+ ids = {(p["cat"], p["he"]): i for i, p in enumerate(PHRASES) if p["cat"] in shown}
58
+ best: dict[int, int] = {}
59
+ tries: dict[int, int] = {}
60
+ days, week, last = set(), 0, None
61
+ since = datetime.now(timezone.utc) - timedelta(days=7)
62
+ for r in rows:
63
+ # the seconds part only: the fraction's length varies, which Python 3.10 does not parse
64
+ at = datetime.strptime(r["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
65
+ days.add(at.astimezone(LOCAL_TZ).date())
66
+ week += at >= since
67
+ last = max(last, at) if last else at
68
+ i = ids.get((r["cat"], r["phrase"]))
69
+ if i is not None: # a sentence the page no longer lists still counts as a recording
70
+ best[i] = max(best.get(i, 0), r["score"])
71
+ tries[i] = tries.get(i, 0) + 1
72
+ return {"best": best, "tries": tries, "recordings": len(rows), "days": len(days), "week": week,
73
+ "last": last.isoformat() if last else None}
74
+
75
+
76
  def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
77
  auth: SupabaseAuth | None = None) -> FastAPI:
78
  @asynccontextmanager
 
101
  raise HTTPException(401, "sign in required")
102
  if a.allowed and user["email"].lower() not in a.allowed:
103
  raise HTTPException(403, "this account is not on the list")
104
+ return {**user, "token": token.strip(), "teacher": user["email"].lower() in a.teachers}
105
 
106
  members = [Depends(signed_in)]
107
 
 
117
  @app.get("/api/me")
118
  def me(user: dict | None = Depends(signed_in)):
119
  """Who is signed in; the page asks this right after sign-in to learn whether the account is let in."""
120
+ return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"])}
121
 
122
  @app.get("/api/progress")
123
  def progress(user: dict | None = Depends(signed_in)):
124
  """The signed-in student's progress: best score and tries per phrase id, recordings, days practised."""
125
+ s = summary(app.state.auth.attempts(user["token"]) if user else [])
126
+ return {k: s[k] for k in ("best", "tries", "recordings", "days")}
127
+
128
+ @app.get("/api/class")
129
+ def class_progress(user: dict | None = Depends(signed_in)):
130
+ """The teacher's panel: every student's summary (see `summary`). The students are the allowed accounts,
131
+ or every account that signed in when there is no list; one that never signed in has joined = false."""
132
+ if not user or not user["teacher"]:
133
+ raise HTTPException(403, "teachers only")
134
+ a: SupabaseAuth = app.state.auth
135
+ try:
136
+ users, rows = a.users(), a.all_attempts()
137
+ except AuthUnavailable as e:
138
+ raise HTTPException(503, str(e))
139
+ by: dict[str, list[dict]] = {}
140
  for r in rows:
141
+ by.setdefault(r["user_id"], []).append(r)
142
+ students = [{"email": u["email"], "name": u["name"], "joined": True, **summary(by.get(u["id"], []))}
143
+ for u in users if not a.allowed or u["email"].lower() in a.allowed]
144
+ missing = a.allowed - {s["email"].lower() for s in students}
145
+ students += [{"email": e, "name": "", "joined": False, **summary([])} for e in sorted(missing)]
146
+ return {"students": students}
 
 
147
 
148
  @app.get("/api/phrases")
149
  def phrases():
static/index.html CHANGED
@@ -141,6 +141,28 @@ header .sub.maphint{flex:0 0 100%;color:var(--ink)}
141
  .tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
142
  #denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
143
  #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}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  #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}
145
  .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)}
146
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
@@ -208,9 +230,11 @@ body.driving .card.storymode .prompt{display:block}
208
  #logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:28px;background:white;transition:opacity .35s ease,visibility .35s ease}
209
  #logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
210
  #logo-splash img{width:min(82vw,620px);height:auto}
211
- #startBtn,#loginBtn{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 .45s ease-out 2.1s forwards}
 
 
212
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
213
- @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn{animation:none;opacity:1}#logo-splash{transition:none}}
214
  </style>
215
  </head>
216
  <body class="home">
@@ -226,12 +250,13 @@ body.driving .card.storymode .prompt{display:block}
226
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></symbol>
227
  <symbol id="i-car" viewBox="0 0 24 24"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2M9 17h6"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></symbol>
228
  </defs></svg>
229
- <div id="logo-splash"><img src="static/logo-intro.svg" alt="אהלא וסהלא, בית הספר ללימוד השפה הערבית" width="489" height="225"><button id="startBtn">יאללה נבלש</button><button id="loginBtn" hidden>כניסה עם Google</button><div id="denied" hidden>החשבון הזה אינו ברשימת התלמידים.<button id="switchBtn">כניסה עם חשבון אחר</button></div></div>
230
  <div class="wrap" id="wrap" inert>
231
  <header>
232
  <div class="brand">אהלא וסהלא</div>
233
  <div class="where"><small id="lessonName"></small><span id="catName"></span></div>
234
  <div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
 
235
  <button class="drive" id="driveBtn" title="מצב נסיעה: האפליקציה מקריאה, מקשיבה ואומרת את ההערכה בקול"><svg class="ic" aria-hidden="true"><use href="#i-car"/></svg> מצב נסיעה</button>
236
  </header>
237
 
@@ -288,6 +313,13 @@ body.driving .card.storymode .prompt{display:block}
288
  </main>
289
 
290
  </div>
 
 
 
 
 
 
 
291
  </div>
292
  <dialog id="listDlg" aria-labelledby="listTitle">
293
  <div class="dlg-head"><h2 id="listTitle"></h2><button class="btn" id="listClose">סגירה</button></div>
@@ -1003,7 +1035,73 @@ $("driveBtn").onclick=async()=>{
1003
  drive();
1004
  };
1005
 
1006
- fetch("/api/phrases").then(r=>r.json()).then(d=>{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  LESSONS=d.lessons; CATS=d.categories; PHRASES=d.phrases;
1008
  for(const c of CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
1009
  for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
@@ -1014,14 +1112,17 @@ fetch("/api/phrases").then(r=>r.json()).then(d=>{
1014
  </script>
1015
  <script>
1016
  /* The opening screen stays until the start button is pressed. */
1017
- $("startBtn").onclick=()=>{
1018
  const splash=$("logo-splash");
1019
  $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
1020
- };
 
 
1021
  $("startBtn").focus({preventScroll:true});
1022
- /* With sign-in configured, the start button waits behind the Google button. */
 
1023
  if(AUTH_URL){
1024
- const start=$("startBtn"), login=$("loginBtn"), logout=$("logoutBtn");
1025
  start.hidden=true; login.hidden=false;
1026
  if(!sb){ login.disabled=true; login.textContent="הכניסה לא זמינה כרגע"; }
1027
  else{
@@ -1034,14 +1135,15 @@ if(AUTH_URL){
1034
  sb.auth.onAuthStateChange((_e,session)=>{
1035
  login.hidden=!!session; logout.hidden=!session;
1036
  logout.title=session?session.user.email||"":"";
1037
- if(!session){ asked=""; start.hidden=true; denied.hidden=true; login.focus({preventScroll:true}); return; }
1038
  if(asked===session.user.id) return;
1039
  asked=session.user.id;
1040
  setTimeout(async()=>{ // outside this callback: api() asks supabase for the session
1041
  const res=await api("/api/me").catch(()=>null);
1042
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
1043
- start.hidden=!ok; denied.hidden=ok;
1044
- if(ok){ start.focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
 
1045
  },0);
1046
  });
1047
  }
 
141
  .tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
142
  #denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
143
  #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}
144
+ /* The teacher's panel: a card per student (name, grade, how much was practised and when) that opens on its topics. */
145
+ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
146
+ .mtitle{font:400 24px/1 var(--f-display);margin:0}
147
+ #mList{display:flex;flex-direction:column;gap:10px}
148
+ .stu{display:block;padding:0}
149
+ .stu summary{display:grid;grid-template-columns:1fr auto;gap:6px 10px;align-items:center;padding:12px 16px;cursor:pointer;list-style:none}
150
+ .stu summary::-webkit-details-marker{display:none}
151
+ .stu .who{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
152
+ .stu .who b{font-weight:700;overflow-wrap:anywhere}
153
+ .stu .who small,.stu .stats{color:var(--muted);font-size:14px}
154
+ .stu .none{color:var(--muted);font-size:13px}
155
+ .stu .stats,.stu .progress{grid-column:1/-1;margin:0}
156
+ .stu h3{margin:0;padding:8px 16px 2px;border-top:1px solid var(--line);font:700 13px/1.4 var(--f-body);color:var(--muted)}
157
+ .trow{display:grid;grid-template-columns:1fr auto auto 3em;gap:10px;align-items:center;padding:5px 16px;font-size:15px}
158
+ .trow small{color:var(--muted);font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums}
159
+ .trow .pill,.trow .none{justify-self:end}
160
+ .trow.untouched{color:var(--muted)}
161
+ .hard{gap:0;padding:12px 0 6px}
162
+ .hard h2{margin:0;padding:0 16px 6px;font:400 19px/1.2 var(--f-display)}
163
+ .hard h2 small{display:block;margin-top:2px;font:600 13px/1.3 var(--f-body);color:var(--muted)}
164
+ .hard .trow{grid-template-columns:1fr auto auto 3em;border-top:1px solid var(--line);padding-block:7px}
165
+ .hard .trow span:first-child{display:flex;flex-direction:column;font-weight:600}
166
  #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}
167
  .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)}
168
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
 
230
  #logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:28px;background:white;transition:opacity .35s ease,visibility .35s ease}
231
  #logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
232
  #logo-splash img{width:min(82vw,620px);height:auto}
233
+ #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 .45s ease-out 2.1s forwards}
234
+ #roles{display:flex;gap:14px;flex-wrap:wrap;justify-content:center}
235
+ #roles #manageBtn{background:#fff;color:#4B6A2C;box-shadow:inset 0 0 0 2px #4B6A2C}
236
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
237
+ @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button{animation:none;opacity:1}#logo-splash{transition:none}}
238
  </style>
239
  </head>
240
  <body class="home">
 
250
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/></symbol>
251
  <symbol id="i-car" viewBox="0 0 24 24"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2M9 17h6"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></symbol>
252
  </defs></svg>
253
+ <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>
254
  <div class="wrap" id="wrap" inert>
255
  <header>
256
  <div class="brand">אהלא וסהלא</div>
257
  <div class="where"><small id="lessonName"></small><span id="catName"></span></div>
258
  <div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
259
+ <button class="drive" id="toManage" hidden>ניהול</button>
260
  <button class="drive" id="driveBtn" title="מצב נסיעה: האפליקציה מקריאה, מקשיבה ואומרת את ההערכה בקול"><svg class="ic" aria-hidden="true"><use href="#i-car"/></svg> מצב נסיעה</button>
261
  </header>
262
 
 
313
  </main>
314
 
315
  </div>
316
+
317
+ <section class="wrap" id="manage" aria-labelledby="mTitle">
318
+ <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>
319
+ <div class="sub" id="mState" aria-live="polite"></div>
320
+ <div class="card hard" id="mHard" hidden></div>
321
+ <div id="mList"></div>
322
+ </section>
323
  </div>
324
  <dialog id="listDlg" aria-labelledby="listTitle">
325
  <div class="dlg-head"><h2 id="listTitle"></h2><button class="btn" id="listClose">סגירה</button></div>
 
1035
  drive();
1036
  };
1037
 
1038
+ /* ---------- the teacher's panel ----------
1039
+ Every student's usage (recordings, days, the last time), progress (sentences practised) and grades (the average
1040
+ of the best scores), as the server sums them up; the topics are tallied here, from the same lists the path uses. */
1041
+ function showManage(){
1042
+ if(!home) showPath();
1043
+ document.body.classList.add("manage"); scrollTo(0,0); loadManage();
1044
+ }
1045
+ $("toManage").onclick=$("mRefresh").onclick=showManage;
1046
+ $("mPractice").onclick=()=>{ document.body.classList.remove("manage"); drawPath(); }; // the path was not laid out while hidden
1047
+ const grade=n=>mark("pill "+(n>=EXCELLENT?"good":n>=60?"mid":"bad"),n);
1048
+ const mean=a=>a.length?Math.round(a.reduce((x,y)=>x+y,0)/a.length):null;
1049
+ async function loadManage(){
1050
+ const state=$("mState"), box=$("mList"), hard=$("mHard");
1051
+ state.textContent="טוען…"; box.innerHTML=""; hard.hidden=true;
1052
+ let d;
1053
+ try{
1054
+ const res=await api("/api/class"); await ready;
1055
+ if(!res.ok){
1056
+ const detail=(await res.json().catch(()=>({}))).detail||res.status;
1057
+ state.textContent=res.status===403?"הפאנל פתוח למורים בלבד.":`נתוני התלמידים לא זמינים כרגע (${detail}).`; return;
1058
+ }
1059
+ d=await res.json();
1060
+ }catch{ state.textContent="השרת לא זמין."; return; }
1061
+ const listed=PHRASES.filter(p=>p.listed!==false);
1062
+ const tally=(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)}; };
1063
+ const all=d.students.map(s=>({...s,...tally(s,listed)})) // the latest to practise first; those who never signed in last
1064
+ .sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
1065
+ state.textContent=`${all.length} תלמידים · ${all.filter(s=>s.week).length} תרגלו בשבוע האחרון · ${all.reduce((t,s)=>t+s.week,0)} הקלטות בשבוע האחרון`;
1066
+ /* The sentences the class finds hardest: the lowest average of the students' best scores. A sentence counts once
1067
+ enough students tried it (3, or all of those who practised when they are fewer), so one student's bad try does not lead. */
1068
+ const tried=p=>all.filter(s=>s.best[p.id]!=null), need=Math.min(3,all.filter(s=>s.done).length);
1069
+ const worst=listed.map(p=>{ const by=tried(p); return {p,k:by.length,avg:mean(by.map(s=>s.best[p.id]))}; })
1070
+ .filter(x=>x.k&&x.k>=need).sort((a,b)=>a.avg-b.avg||b.k-a.k).slice(0,5);
1071
+ hard.innerHTML=""; hard.hidden=!worst.length;
1072
+ if(worst.length){
1073
+ const h=mark("","המשפטים הקשים ביותר","h2"); h.append(mark("","הממוצע הנמוך ביותר של הציון הטוב של כל תלמיד","small")); hard.append(h);
1074
+ for(const x of worst){
1075
+ const row=mark("trow",""), text=document.createElement("span"), name=document.createElement("b"), c=CATS.find(c=>c.id===x.p.cat);
1076
+ name.style.fontWeight="inherit"; setTr(name,x.p.read?x.p.tr:x.p.he); text.append(name,mark("",c?c.name:"","small"));
1077
+ const l=c&&LESSONS.find(l=>l.id===c.lesson);
1078
+ row.append(text,mark("",l?l.name:"","small"),mark("",`${x.k} תלמידים`,"small"),grade(x.avg)); hard.append(row);
1079
+ }
1080
+ }
1081
+ for(const s of all){
1082
+ const det=document.createElement("details"), sum=document.createElement("summary"), who=mark("who",""), bar=mark("progress",""), fill=document.createElement("i");
1083
+ det.className="card stu";
1084
+ who.append(mark("",s.name||s.email,"b")); if(s.name){ const m=mark("",s.email,"small"); m.dir="ltr"; who.append(m); }
1085
+ const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
1086
+ fill.style.width=`${s.n?s.done/s.n*100:0}%`; bar.append(fill);
1087
+ sum.append(who, s.avg!=null?grade(s.avg):mark("none",s.joined?"עוד לא תרגל/ה":"עוד לא נכנס/ה"));
1088
+ if(s.avg!=null) sum.lastChild.title="ממוצע הציונים הטובים ביותר";
1089
+ sum.append(mark("stats",`תורגלו ${s.done} מתוך ${s.n} משפטים · ${s.recordings} הקלטות, ${s.week} מהן בשבוע האחרון · ${s.days} ימי תרגול`+(when?` · לאחרונה ${when}`:"")),bar);
1090
+ det.append(sum);
1091
+ for(const l of LESSONS){
1092
+ det.append(mark("",l.name,"h3"));
1093
+ for(const c of CATS.filter(c=>c.lesson===l.id)){
1094
+ const t=tally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),""), n=document.createElement("bdi");
1095
+ n.dir="ltr"; n.textContent=`${t.done}/${t.n}`;
1096
+ row.append(mark("",c.name),mark("",t.tries?`${t.tries} ניסיונות`:"","small"),mark("","","small"),t.avg!=null?grade(t.avg):mark("none","–"));
1097
+ row.children[2].append(n); det.append(row);
1098
+ }
1099
+ }
1100
+ box.append(det);
1101
+ }
1102
+ }
1103
+
1104
+ const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
1105
  LESSONS=d.lessons; CATS=d.categories; PHRASES=d.phrases;
1106
  for(const c of CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
1107
  for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
 
1112
  </script>
1113
  <script>
1114
  /* The opening screen stays until the start button is pressed. */
1115
+ function enter(){
1116
  const splash=$("logo-splash");
1117
  $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
1118
+ }
1119
+ $("startBtn").onclick=$("practiceBtn").onclick=enter;
1120
+ $("manageBtn").onclick=()=>{ enter(); showManage(); };
1121
  $("startBtn").focus({preventScroll:true});
1122
+ /* With sign-in configured, the start button waits behind the Google button. A teacher gets two buttons in its
1123
+ place: practice, as a student does, or the management panel. */
1124
  if(AUTH_URL){
1125
+ const start=$("startBtn"), login=$("loginBtn"), logout=$("logoutBtn"), roles=$("roles");
1126
  start.hidden=true; login.hidden=false;
1127
  if(!sb){ login.disabled=true; login.textContent="הכניסה לא זמינה כרגע"; }
1128
  else{
 
1135
  sb.auth.onAuthStateChange((_e,session)=>{
1136
  login.hidden=!!session; logout.hidden=!session;
1137
  logout.title=session?session.user.email||"":"";
1138
+ if(!session){ asked=""; start.hidden=roles.hidden=true; denied.hidden=true; login.focus({preventScroll:true}); return; }
1139
  if(asked===session.user.id) return;
1140
  asked=session.user.id;
1141
  setTimeout(async()=>{ // outside this callback: api() asks supabase for the session
1142
  const res=await api("/api/me").catch(()=>null);
1143
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
1144
+ const teacher=!!(res&&res.ok&&(await res.json().catch(()=>({}))).teacher);
1145
+ start.hidden=!ok||teacher; roles.hidden=$("toManage").hidden=!teacher; denied.hidden=ok;
1146
+ if(ok){ (teacher?$("practiceBtn"):start).focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
1147
  },0);
1148
  });
1149
  }
tests/test_scoring.py CHANGED
@@ -416,7 +416,7 @@ def test_sign_in_guards_the_practice_endpoints():
416
  VERB = next(p for p in PHRASES if p["cat"] == "l1-verbs")
417
 
418
  class FakeAuth:
419
- url, key, allowed = "https://proj.supabase.co", "sb_publishable_x", frozenset()
420
 
421
  def __init__(self):
422
  self.rows = []
@@ -432,6 +432,13 @@ def test_sign_in_guards_the_practice_endpoints():
432
  {"cat": VERB["cat"], "phrase": VERB["he"], "score": 88, "created_at": "2026-10-09T10:00:00+00:00"},
433
  {"cat": TOILET["cat"], "phrase": TOILET["he"], "score": 90, "created_at": "2026-10-01T10:00:00.123456+00:00"}]
434
 
 
 
 
 
 
 
 
435
  with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
436
  page = c.get("/").text
437
  assert 'AUTH_URL="https://proj.supabase.co"' in page and 'AUTH_KEY="sb_publishable_x"' in page
@@ -440,11 +447,20 @@ def test_sign_in_guards_the_practice_endpoints():
440
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
441
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
442
  good = {"Authorization": "Bearer good"}
443
- assert c.get("/api/me", headers=good).json() == {"email": "A@b.c"}
 
444
  c.app.state.auth.allowed = frozenset({"other@b.c"}) # a list without this account
445
  assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
446
  c.app.state.auth.allowed = frozenset({"a@b.c"}) # case does not matter
447
  assert c.get("/api/me", headers=good).status_code == 200
 
 
 
 
 
 
 
 
448
  # a scored recording is recorded as the student's attempt
449
  auth, pid = FakeAuth(), PHRASES.index(TOILET)
450
  buf = io.BytesIO()
 
416
  VERB = next(p for p in PHRASES if p["cat"] == "l1-verbs")
417
 
418
  class FakeAuth:
419
+ url, key, allowed, teachers = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset()
420
 
421
  def __init__(self):
422
  self.rows = []
 
432
  {"cat": VERB["cat"], "phrase": VERB["he"], "score": 88, "created_at": "2026-10-09T10:00:00+00:00"},
433
  {"cat": TOILET["cat"], "phrase": TOILET["he"], "score": 90, "created_at": "2026-10-01T10:00:00.123456+00:00"}]
434
 
435
+ def users(self): # the teacher, a student, and an account that is not on the list
436
+ return [{"id": "u1", "email": "A@b.c", "name": "Alef"}, {"id": "u2", "email": "s@b.c", "name": ""},
437
+ {"id": "u3", "email": "out@b.c", "name": ""}]
438
+
439
+ def all_attempts(self):
440
+ return [{**r, "user_id": "u2"} for r in self.attempts("")[:2]]
441
+
442
  with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
443
  page = c.get("/").text
444
  assert 'AUTH_URL="https://proj.supabase.co"' in page and 'AUTH_KEY="sb_publishable_x"' in page
 
447
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
448
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
449
  good = {"Authorization": "Bearer good"}
450
+ assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False}
451
+ assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
452
  c.app.state.auth.allowed = frozenset({"other@b.c"}) # a list without this account
453
  assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
454
  c.app.state.auth.allowed = frozenset({"a@b.c"}) # case does not matter
455
  assert c.get("/api/me", headers=good).status_code == 200
456
+ # a teacher is an allowed account that TEACHER_EMAILS names: the panel lists the allowed accounts
457
+ c.app.state.auth.allowed, c.app.state.auth.teachers = frozenset({"a@b.c", "s@b.c", "new@b.c"}), frozenset({"a@b.c"})
458
+ assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True}
459
+ teacher, student, new = c.get("/api/class", headers=good).json()["students"]
460
+ assert (teacher["email"], teacher["name"], teacher["recordings"], teacher["last"]) == ("A@b.c", "Alef", 0, None)
461
+ assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
462
+ assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
463
+ assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
464
  # a scored recording is recorded as the student's attempt
465
  auth, pid = FakeAuth(), PHRASES.index(TOILET)
466
  buf = io.BytesIO()