feat(activity): implement scoped activity monitoring and fallback mechanism
Browse files- app/auth.py +44 -0
- app/main.py +8 -5
- docs/mathly-supabase.sql +34 -0
- docs/scoped-activity-migration.sql +35 -0
- static/index.html +15 -7
- tests/test_activity.py +38 -0
app/auth.py
CHANGED
|
@@ -69,6 +69,7 @@ import urllib.error
|
|
| 69 |
import urllib.parse
|
| 70 |
import urllib.request
|
| 71 |
from datetime import datetime, timezone
|
|
|
|
| 72 |
|
| 73 |
log = logging.getLogger("ihki.auth")
|
| 74 |
TIMEOUT = 10
|
|
@@ -891,6 +892,49 @@ class SupabaseAuth:
|
|
| 891 |
raise
|
| 892 |
return {"buckets": buckets, "summary": summary}
|
| 893 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
|
| 895 |
"""Best-effort append to the management activity feed; a missing audit table never blocks the action."""
|
| 896 |
try:
|
|
|
|
| 69 |
import urllib.parse
|
| 70 |
import urllib.request
|
| 71 |
from datetime import datetime, timezone
|
| 72 |
+
from zoneinfo import ZoneInfo
|
| 73 |
|
| 74 |
log = logging.getLogger("ihki.auth")
|
| 75 |
TIMEOUT = 10
|
|
|
|
| 892 |
raise
|
| 893 |
return {"buckets": buckets, "summary": summary}
|
| 894 |
|
| 895 |
+
def scoped_activity(self, user_ids: set[str], since: datetime, bucket_minutes: int = 15) -> dict:
|
| 896 |
+
"""Aggregated monitoring data for one admin's students, calculated in Postgres without downloading attempts."""
|
| 897 |
+
if not user_ids:
|
| 898 |
+
return {"buckets": [], "summary": {"attempts": 0, "active_students": 0,
|
| 899 |
+
"returning_students": 0, "active_days": 0}}
|
| 900 |
+
body = {"p_user_ids": sorted(user_ids), "p_since": since.isoformat()}
|
| 901 |
+
try:
|
| 902 |
+
buckets = self._admin("/rest/v1/rpc/activity_buckets_scoped", "POST",
|
| 903 |
+
{**body, "p_bucket_minutes": bucket_minutes})
|
| 904 |
+
rows = self._admin("/rest/v1/rpc/activity_summary_scoped", "POST", body) or []
|
| 905 |
+
return {"buckets": buckets or [], "summary": rows[0] if rows else None}
|
| 906 |
+
except AuthUnavailable as e:
|
| 907 |
+
if "answered 404" not in str(e):
|
| 908 |
+
raise
|
| 909 |
+
log.warning("scoped activity RPCs are missing; using the paged timestamp fallback")
|
| 910 |
+
return self._scoped_activity_fallback(user_ids, since, bucket_minutes)
|
| 911 |
+
|
| 912 |
+
def _scoped_activity_fallback(self, user_ids: set[str], since: datetime, bucket_minutes: int) -> dict:
|
| 913 |
+
"""Accurate compatibility path until the scoped aggregate RPC migration is installed."""
|
| 914 |
+
rows: list[dict] = []
|
| 915 |
+
for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
|
| 916 |
+
query = urllib.parse.urlencode({"select": "user_id,created_at", "user_id": f"in.({','.join(sorted(user_ids))})",
|
| 917 |
+
"created_at": f"gte.{since.isoformat()}", "order": "created_at.desc",
|
| 918 |
+
"limit": PAGE, "offset": offset})
|
| 919 |
+
batch = self._admin(f"/rest/v1/attempts?{query}") or []
|
| 920 |
+
rows += batch
|
| 921 |
+
if len(batch) < PAGE:
|
| 922 |
+
break
|
| 923 |
+
seconds, buckets, per_user = max(1, min(bucket_minutes, 60)) * 60, {}, {}
|
| 924 |
+
local = ZoneInfo("Asia/Jerusalem")
|
| 925 |
+
for row in rows:
|
| 926 |
+
stamp = datetime.strptime(row["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
| 927 |
+
slot = int(stamp.timestamp()) // seconds * seconds
|
| 928 |
+
item = buckets.setdefault(slot, {"users": set(), "attempts": 0})
|
| 929 |
+
item["attempts"] += 1; item["users"].add(row["user_id"])
|
| 930 |
+
per_user.setdefault(row["user_id"], set()).add(stamp.astimezone(local).date())
|
| 931 |
+
return {"buckets": [{"bucket": datetime.fromtimestamp(slot, timezone.utc).isoformat(),
|
| 932 |
+
"attempts": item["attempts"], "active_students": len(item["users"])}
|
| 933 |
+
for slot, item in sorted(buckets.items())],
|
| 934 |
+
"summary": {"attempts": len(rows), "active_students": len(per_user),
|
| 935 |
+
"returning_students": sum(len(days) >= 2 for days in per_user.values()),
|
| 936 |
+
"active_days": len({day for days in per_user.values() for day in days})}}
|
| 937 |
+
|
| 938 |
def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
|
| 939 |
"""Best-effort append to the management activity feed; a missing audit table never blocks the action."""
|
| 940 |
try:
|
app/main.py
CHANGED
|
@@ -914,13 +914,16 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 914 |
"""Anonymous, aggregated practice activity for the teacher's monitoring graph."""
|
| 915 |
try:
|
| 916 |
scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for"))
|
| 917 |
-
if scoped
|
| 918 |
ids = None
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
ids = {u["id"] for u in users if u["email"].lower() in emails}
|
| 923 |
start = datetime.now(timezone.utc) - (timedelta(minutes=window_minutes) if window_minutes else timedelta(hours=hours))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
rows = [r for r in a.all_attempts(limit=MAX_MONITOR_ATTEMPTS)
|
| 925 |
if (ids is None or r.get("user_id") in ids) and utc(r["created_at"]) >= start]
|
| 926 |
seconds, buckets = bucket_minutes * 60, {}
|
|
|
|
| 914 |
"""Anonymous, aggregated practice activity for the teacher's monitoring graph."""
|
| 915 |
try:
|
| 916 |
scoped = bool(actor and "developer" not in actor.get("roles", ()) and hasattr(a, "access_for"))
|
| 917 |
+
if scoped:
|
| 918 |
ids = None
|
| 919 |
+
classes, users = managed_classes(a, actor, fresh=True), a.users()
|
| 920 |
+
emails = {email for c in classes for email in c.get("emails", ())}
|
| 921 |
+
ids = {u["id"] for u in users if u["email"].lower() in emails}
|
|
|
|
| 922 |
start = datetime.now(timezone.utc) - (timedelta(minutes=window_minutes) if window_minutes else timedelta(hours=hours))
|
| 923 |
+
data = a.scoped_activity(ids, start, bucket_minutes)
|
| 924 |
+
elif window_minutes is not None:
|
| 925 |
+
ids = None
|
| 926 |
+
start = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
|
| 927 |
rows = [r for r in a.all_attempts(limit=MAX_MONITOR_ATTEMPTS)
|
| 928 |
if (ids is None or r.get("user_id") in ids) and utc(r["created_at"]) >= start]
|
| 929 |
seconds, buckets = bucket_minutes * 60, {}
|
docs/mathly-supabase.sql
CHANGED
|
@@ -58,6 +58,40 @@ $$;
|
|
| 58 |
revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
|
| 59 |
grant execute on function public.activity_summary(integer) to service_role;
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
-- Recent system activity shown only in the management panel. The backend writes with the service-role key;
|
| 62 |
-- no browser role gets direct table access.
|
| 63 |
create table public.system_events (
|
|
|
|
| 58 |
revoke execute on function public.activity_summary(integer) from public, anon, authenticated;
|
| 59 |
grant execute on function public.activity_summary(integer) to service_role;
|
| 60 |
|
| 61 |
+
-- Subject-scoped monitoring aggregates. These keep busy classes accurate without downloading individual attempts.
|
| 62 |
+
create or replace function public.activity_buckets_scoped(
|
| 63 |
+
p_user_ids uuid[], p_since timestamptz, p_bucket_minutes integer default 15
|
| 64 |
+
)
|
| 65 |
+
returns table (bucket timestamptz, attempts bigint, active_students bigint)
|
| 66 |
+
language sql stable security invoker set search_path = '' as $$
|
| 67 |
+
select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
|
| 68 |
+
timestamptz '2000-01-01 00:00:00+00'),
|
| 69 |
+
count(*)::bigint, count(distinct a.user_id)::bigint
|
| 70 |
+
from public.attempts a
|
| 71 |
+
where a.created_at >= p_since and a.user_id = any(p_user_ids)
|
| 72 |
+
group by 1 order by 1
|
| 73 |
+
$$;
|
| 74 |
+
revoke execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) from public, anon, authenticated;
|
| 75 |
+
grant execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) to service_role;
|
| 76 |
+
|
| 77 |
+
create or replace function public.activity_summary_scoped(p_user_ids uuid[], p_since timestamptz)
|
| 78 |
+
returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
|
| 79 |
+
language sql stable security invoker set search_path = '' as $$
|
| 80 |
+
with windowed as (
|
| 81 |
+
select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
|
| 82 |
+
from public.attempts a
|
| 83 |
+
where a.created_at >= p_since and a.user_id = any(p_user_ids)
|
| 84 |
+
), per_user as (
|
| 85 |
+
select user_id, count(distinct practice_day) as days from windowed group by user_id
|
| 86 |
+
)
|
| 87 |
+
select (select count(*) from windowed)::bigint,
|
| 88 |
+
(select count(*) from per_user)::bigint,
|
| 89 |
+
(select count(*) from per_user where days >= 2)::bigint,
|
| 90 |
+
(select count(distinct practice_day) from windowed)::bigint
|
| 91 |
+
$$;
|
| 92 |
+
revoke execute on function public.activity_summary_scoped(uuid[], timestamptz) from public, anon, authenticated;
|
| 93 |
+
grant execute on function public.activity_summary_scoped(uuid[], timestamptz) to service_role;
|
| 94 |
+
|
| 95 |
-- Recent system activity shown only in the management panel. The backend writes with the service-role key;
|
| 96 |
-- no browser role gets direct table access.
|
| 97 |
create table public.system_events (
|
docs/scoped-activity-migration.sql
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Accurate, subject-scoped monitoring aggregates. Safe to run more than once.
|
| 2 |
+
create or replace function public.activity_buckets_scoped(
|
| 3 |
+
p_user_ids uuid[], p_since timestamptz, p_bucket_minutes integer default 15
|
| 4 |
+
)
|
| 5 |
+
returns table (bucket timestamptz, attempts bigint, active_students bigint)
|
| 6 |
+
language sql stable security invoker set search_path = '' as $$
|
| 7 |
+
select date_bin(make_interval(mins => greatest(1, least(p_bucket_minutes, 60))), a.created_at,
|
| 8 |
+
timestamptz '2000-01-01 00:00:00+00'),
|
| 9 |
+
count(*)::bigint, count(distinct a.user_id)::bigint
|
| 10 |
+
from public.attempts a
|
| 11 |
+
where a.created_at >= p_since and a.user_id = any(p_user_ids)
|
| 12 |
+
group by 1 order by 1
|
| 13 |
+
$$;
|
| 14 |
+
revoke execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer)
|
| 15 |
+
from public, anon, authenticated;
|
| 16 |
+
grant execute on function public.activity_buckets_scoped(uuid[], timestamptz, integer) to service_role;
|
| 17 |
+
|
| 18 |
+
create or replace function public.activity_summary_scoped(p_user_ids uuid[], p_since timestamptz)
|
| 19 |
+
returns table (attempts bigint, active_students bigint, returning_students bigint, active_days bigint)
|
| 20 |
+
language sql stable security invoker set search_path = '' as $$
|
| 21 |
+
with windowed as (
|
| 22 |
+
select a.user_id, (a.created_at at time zone 'Asia/Jerusalem')::date as practice_day
|
| 23 |
+
from public.attempts a
|
| 24 |
+
where a.created_at >= p_since and a.user_id = any(p_user_ids)
|
| 25 |
+
), per_user as (
|
| 26 |
+
select user_id, count(distinct practice_day) as days from windowed group by user_id
|
| 27 |
+
)
|
| 28 |
+
select (select count(*) from windowed)::bigint,
|
| 29 |
+
(select count(*) from per_user)::bigint,
|
| 30 |
+
(select count(*) from per_user where days >= 2)::bigint,
|
| 31 |
+
(select count(distinct practice_day) from windowed)::bigint
|
| 32 |
+
$$;
|
| 33 |
+
revoke execute on function public.activity_summary_scoped(uuid[], timestamptz)
|
| 34 |
+
from public, anon, authenticated;
|
| 35 |
+
grant execute on function public.activity_summary_scoped(uuid[], timestamptz) to service_role;
|
static/index.html
CHANGED
|
@@ -373,7 +373,8 @@ body:not(.home) .reward-shelf,body.manage .reward-shelf,body[data-view] .reward-
|
|
| 373 |
.label{position:absolute;top:50%;width:var(--w);transform:translateY(-50%);display:flex;flex-direction:column;gap:2px}
|
| 374 |
.sat.R .label{left:calc(100% + 10px);text-align:left;align-items:flex-end}
|
| 375 |
.sat.L .label{right:calc(100% + 10px);text-align:right;align-items:flex-start}
|
| 376 |
-
.label
|
|
|
|
| 377 |
.way{padding:1px 7px;border-radius:999px;background:var(--olive-soft);color:var(--olive);font:700 11px/1.3 var(--f-body);white-space:nowrap}
|
| 378 |
.way.read{background:var(--surface);color:var(--ink);box-shadow:inset 0 0 0 1px var(--track)}
|
| 379 |
.sat.now .label b{font-weight:700}
|
|
@@ -747,6 +748,8 @@ body.manage #manage{width:100%;max-width:none}
|
|
| 747 |
.access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
|
| 748 |
@media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
|
| 749 |
.flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
|
|
|
|
|
|
|
| 750 |
.flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
|
| 751 |
.flag-review textarea{width:100%;resize:vertical}
|
| 752 |
.scoring-rules summary{cursor:pointer;font-size:17px}.scoring-rules details[open] summary{margin-bottom:8px}
|
|
@@ -1236,7 +1239,7 @@ html[data-subject="math"] #bootErr{color:#F08A8A}
|
|
| 1236 |
#wrap{opacity:1;transition:opacity .65s ease .1s}
|
| 1237 |
#wrap[inert]{opacity:0}
|
| 1238 |
/* Accessibility toolbar: available on the splash screen and throughout every app view. */
|
| 1239 |
-
.a11y-launch{position:fixed;top:max(12px,env(safe-area-inset-top));left:12px;z-index:100;display:
|
| 1240 |
.a11y-launch:hover,.a11y-launch:focus-visible{background:var(--olive-soft);outline:3px solid var(--surface);outline-offset:2px}
|
| 1241 |
.a11y-panel{position:fixed;top:max(66px,calc(env(safe-area-inset-top) + 66px));left:12px;z-index:101;width:min(310px,calc(100vw - 24px));padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);box-shadow:0 8px 28px rgb(0 0 0/.28)}
|
| 1242 |
.a11y-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.a11y-head strong{font-size:19px}.a11y-close{width:36px;height:36px;border:0;border-radius:50%;background:transparent;color:var(--ink);font:700 18px/1 var(--f-body);cursor:pointer}.a11y-close:hover,.a11y-close:focus-visible{background:var(--olive-soft);outline:2px solid var(--olive)}
|
|
@@ -4747,14 +4750,19 @@ function drawFlags(){
|
|
| 4747 |
if(!pending.length){ head.append(mark("meta","אין כרגע דיווחים שממתינים לבדיקה.","p")); return; }
|
| 4748 |
const byUser=new Map; for(const f of pending){ const who=f.email||""; if(!byUser.has(who)) byUser.set(who,[]); byUser.get(who).push(f); }
|
| 4749 |
head.append(mark("meta",`${pending.length} ${pending.length===1?"דיווח":"דיווחים"} מ־${byUser.size} ${byUser.size===1?"מדווח/ת":"מדווחים"}, מקובצים לפי המדווח/ת.`,"p"));
|
| 4750 |
-
|
|
|
|
|
|
|
| 4751 |
const group=document.createElement("details"), summary=document.createElement("summary");
|
| 4752 |
group.className="flag-group"; group.open=flagGroupOpen[who]===true; group.ontoggle=()=>{ flagGroupOpen[who]=group.open; };
|
| 4753 |
-
|
| 4754 |
-
|
| 4755 |
-
|
|
|
|
| 4756 |
const ar=mark("",f.ar||"","p"); ar.dir="rtl";
|
| 4757 |
-
|
|
|
|
|
|
|
| 4758 |
if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
|
| 4759 |
if(f.audio){
|
| 4760 |
const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
|
|
|
|
| 373 |
.label{position:absolute;top:50%;width:var(--w);transform:translateY(-50%);display:flex;flex-direction:column;gap:2px}
|
| 374 |
.sat.R .label{left:calc(100% + 10px);text-align:left;align-items:flex-end}
|
| 375 |
.sat.L .label{right:calc(100% + 10px);text-align:right;align-items:flex-start}
|
| 376 |
+
.label b{color:#F7FAEF;text-shadow:0 1px 2px #000,0 0 5px rgb(0 0 0/.8)}
|
| 377 |
+
.label small{font:600 12px/1.2 var(--f-body);color:#D5DDCE;text-shadow:0 1px 2px #000,0 0 5px rgb(0 0 0/.8)}
|
| 378 |
.way{padding:1px 7px;border-radius:999px;background:var(--olive-soft);color:var(--olive);font:700 11px/1.3 var(--f-body);white-space:nowrap}
|
| 379 |
.way.read{background:var(--surface);color:var(--ink);box-shadow:inset 0 0 0 1px var(--track)}
|
| 380 |
.sat.now .label b{font-weight:700}
|
|
|
|
| 748 |
.access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
|
| 749 |
@media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
|
| 750 |
.flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
|
| 751 |
+
.flag-review.flag-urgent{border-inline-start:4px solid var(--sumac)}.flag-review.flag-lower{border-inline-start:4px solid var(--line)}
|
| 752 |
+
.flag-priority{display:inline-block;margin-inline-start:6px;padding:2px 7px;border-radius:999px;font-size:12px;font-weight:700}.flag-priority.urgent{background:var(--sumac-bg);color:var(--sumac)}.flag-priority.lower{background:var(--bg);color:var(--muted)}
|
| 753 |
.flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
|
| 754 |
.flag-review textarea{width:100%;resize:vertical}
|
| 755 |
.scoring-rules summary{cursor:pointer;font-size:17px}.scoring-rules details[open] summary{margin-bottom:8px}
|
|
|
|
| 1239 |
#wrap{opacity:1;transition:opacity .65s ease .1s}
|
| 1240 |
#wrap[inert]{opacity:0}
|
| 1241 |
/* Accessibility toolbar: available on the splash screen and throughout every app view. */
|
| 1242 |
+
.a11y-launch{position:fixed;top:max(12px,env(safe-area-inset-top));left:12px;z-index:100;display:none;place-items:center;width:46px;height:46px;padding:0;border:2px solid var(--olive);border-radius:50%;background:var(--surface);color:var(--olive);box-shadow:0 3px 12px rgb(0 0 0/.2);font:700 24px/1 var(--f-body);cursor:pointer}
|
| 1243 |
.a11y-launch:hover,.a11y-launch:focus-visible{background:var(--olive-soft);outline:3px solid var(--surface);outline-offset:2px}
|
| 1244 |
.a11y-panel{position:fixed;top:max(66px,calc(env(safe-area-inset-top) + 66px));left:12px;z-index:101;width:min(310px,calc(100vw - 24px));padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);box-shadow:0 8px 28px rgb(0 0 0/.28)}
|
| 1245 |
.a11y-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.a11y-head strong{font-size:19px}.a11y-close{width:36px;height:36px;border:0;border-radius:50%;background:transparent;color:var(--ink);font:700 18px/1 var(--f-body);cursor:pointer}.a11y-close:hover,.a11y-close:focus-visible{background:var(--olive-soft);outline:2px solid var(--olive)}
|
|
|
|
| 4750 |
if(!pending.length){ head.append(mark("meta","אין כרגע דיווחים שממתינים לבדיקה.","p")); return; }
|
| 4751 |
const byUser=new Map; for(const f of pending){ const who=f.email||""; if(!byUser.has(who)) byUser.set(who,[]); byUser.get(who).push(f); }
|
| 4752 |
head.append(mark("meta",`${pending.length} ${pending.length===1?"דיווח":"דיווחים"} מ־${byUser.size} ${byUser.size===1?"מדווח/ת":"מדווחים"}, מקובצים לפי המדווח/ת.`,"p"));
|
| 4753 |
+
const flagScore=f=>Number.isFinite(Number(f.score))?Number(f.score):101;
|
| 4754 |
+
const groupPriority=flags=>Math.min(...flags.map(flagScore));
|
| 4755 |
+
for(const [who,flags] of [...byUser].sort((a,b)=>groupPriority(a[1])-groupPriority(b[1])||b[1].length-a[1].length||a[0].localeCompare(b[0]))){
|
| 4756 |
const group=document.createElement("details"), summary=document.createElement("summary");
|
| 4757 |
group.className="flag-group"; group.open=flagGroupOpen[who]===true; group.ontoggle=()=>{ flagGroupOpen[who]=group.open; };
|
| 4758 |
+
const urgent=flags.filter(f=>flagScore(f)<75).length;
|
| 4759 |
+
summary.append(mark("",who||"ללא משתמש","b"),mark("meta",` · ${flags.length} ${flags.length===1?"דיווח":"דיווחים"}${urgent?` · ${urgent} מתחת ל־75`:" · כולם בחומרה נמוכה"}`)); group.append(summary); box.append(group);
|
| 4760 |
+
for(const f of [...flags].sort((a,b)=>flagScore(a)-flagScore(b))){
|
| 4761 |
+
const lower=flagScore(f)>=75, card=mark(`card flag-review ${lower?"flag-lower":"flag-urgent"}`,""), title=mark("",f.he||f.phrase,"h2"), result=f.result||{}, issues=flagIssues(f);
|
| 4762 |
const ar=mark("",f.ar||"","p"); ar.dir="rtl";
|
| 4763 |
+
const scoreLine=mark("meta",`ציון ${f.score} · המודל שמע: ${f.heard_ipa||"–"}`,"p");
|
| 4764 |
+
scoreLine.append(mark(`flag-priority ${lower?"lower":"urgent"}`,lower?"חומרה נמוכה":"עדיפות לבדיקה"));
|
| 4765 |
+
card.append(title,ar,scoreLine);
|
| 4766 |
if(f.note) card.append(mark("",`הערת המדווח/ת: ${f.note}`,"p"));
|
| 4767 |
if(f.audio){
|
| 4768 |
const audioRow=mark("row",""), play=mark("btn","▶ השמעת ההקלטה","button"), audio=document.createElement("audio"), audioMsg=mark("msg","");
|
tests/test_activity.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
|
| 3 |
+
from app.auth import AuthUnavailable, SupabaseAuth
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_scoped_activity_uses_database_aggregates():
|
| 7 |
+
auth = SupabaseAuth("https://example.supabase.co", "public", secret="secret")
|
| 8 |
+
calls = []
|
| 9 |
+
|
| 10 |
+
def admin(path, method="GET", body=None, prefer="return=representation", timeout=10):
|
| 11 |
+
calls.append((path, body))
|
| 12 |
+
if path.endswith("activity_buckets_scoped"):
|
| 13 |
+
return [{"bucket": "2026-10-22T10:00:00+00:00", "attempts": 4, "active_students": 2}]
|
| 14 |
+
return [{"attempts": 4, "active_students": 2, "returning_students": 1, "active_days": 2}]
|
| 15 |
+
|
| 16 |
+
auth._admin = admin
|
| 17 |
+
data = auth.scoped_activity({"u2", "u1"}, datetime(2026, 10, 15, tzinfo=timezone.utc), 60)
|
| 18 |
+
assert data["summary"]["returning_students"] == 1
|
| 19 |
+
assert calls[0][1]["p_user_ids"] == ["u1", "u2"]
|
| 20 |
+
assert calls[0][1]["p_bucket_minutes"] == 60
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_scoped_activity_fallback_is_not_limited_to_the_newest_day():
|
| 24 |
+
auth = SupabaseAuth("https://example.supabase.co", "public", secret="secret")
|
| 25 |
+
|
| 26 |
+
def admin(path, method="GET", body=None, prefer="return=representation", timeout=10):
|
| 27 |
+
if "/rpc/" in path:
|
| 28 |
+
raise AuthUnavailable("supabase answered 404: function missing")
|
| 29 |
+
return [
|
| 30 |
+
{"user_id": "u1", "created_at": "2026-10-22T08:00:00.12345+00:00"},
|
| 31 |
+
{"user_id": "u1", "created_at": "2026-10-21T08:00:00+00:00"},
|
| 32 |
+
{"user_id": "u2", "created_at": "2026-10-22T09:00:00+00:00"},
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
auth._admin = admin
|
| 36 |
+
data = auth.scoped_activity({"u1", "u2"}, datetime(2026, 10, 15, tzinfo=timezone.utc), 60)
|
| 37 |
+
assert data["summary"] == {"attempts": 3, "active_students": 2, "returning_students": 1, "active_days": 2}
|
| 38 |
+
assert sum(bucket["attempts"] for bucket in data["buckets"]) == 3
|