File size: 54,566 Bytes
e5f0c48 08aea0e a9f22dc c8045f2 0b390f8 e5f0c48 a5c9173 94af308 1a26e87 3b6eb00 66d03bb 0242444 0cfc3a7 d8b47d7 4233380 ac702e3 08aea0e a5c9173 0242444 3cb8d24 0242444 08aea0e 3ed1d24 08aea0e b9cb1c2 08aea0e 269a3c8 08aea0e b9cb1c2 a9f22dc a5c9173 08aea0e 69eaa4c 08aea0e a5c9173 08aea0e 0242444 3cb8d24 08aea0e a9f22dc 0242444 3cb8d24 a5c9173 08aea0e a5c9173 1a26e87 3b6eb00 0242444 3cb8d24 66d03bb 08aea0e 3ed1d24 08aea0e 3ed1d24 08aea0e b9cb1c2 6953a11 b9cb1c2 6953a11 b9cb1c2 a9f22dc b9cb1c2 d8b47d7 071b4ec d8b47d7 071b4ec d8b47d7 a9f22dc 69eaa4c a5c9173 a9f22dc a5c9173 269a3c8 a9f22dc 69eaa4c a5c9173 a9f22dc 69eaa4c a5c9173 c8045f2 a6c789c 66d03bb a5c9173 c8045f2 0b390f8 66d03bb f90708b a5c9173 c8045f2 f98e127 f90708b 66d03bb a6c789c 0b390f8 a6c789c 66d03bb f90708b a5c9173 f98e127 a5c9173 1a26e87 94af308 1a26e87 3b6eb00 1a26e87 3b6eb00 1a26e87 3b6eb00 1a26e87 66d03bb 5d06ea2 66d03bb 5d06ea2 66d03bb 5d06ea2 0242444 0cfc3a7 8d3d08e 0cfc3a7 69eaa4c c15166b 0242444 3ed1d24 0cfc3a7 3ed1d24 0cfc3a7 24a3395 0cfc3a7 24a3395 0cfc3a7 af78672 0cfc3a7 ac702e3 0cfc3a7 3ed1d24 ac702e3 8d3d08e 3ed1d24 269a3c8 a9f22dc e5f0c48 a9f22dc 3cb8d24 f98e127 3cb8d24 f98e127 3cb8d24 f98e127 3cb8d24 f98e127 3cb8d24 f98e127 3cb8d24 a243a76 3cb8d24 f98e127 35394c4 a9f22dc 6953a11 a9f22dc 01f0f9e a9f22dc a243a76 3ed1d24 ac702e3 3ed1d24 08aea0e 19ed294 08aea0e a9f22dc 3cb8d24 a9f22dc e5f0c48 a5c9173 a9f22dc 0242444 3cb8d24 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 | """Sign-in: the page logs in through Supabase, with Google or with a link sent to the email, and sends the session token with each call.
The token is checked by asking Supabase who it belongs to (GET /auth/v1/user), so no signing key is
kept here and a session revoked in Supabase stops working within CACHE_TTL.
Progress: each scored recording is added to the Supabase table `attempts` with the student's own
token, so row-level security ties the row to them (the table's SQL is in the README).
Teachers: the accounts in TEACHER_EMAILS also get the management panel, which shows every student's
progress. Those rows are read with the project's secret key, which passes row-level security and
never leaves the server.
Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", the lessons it studies, its
students' emails and an optional nickname of each, and the homework: a topic of its lessons, with the day it is due),
kept in the Supabase table `classes`. A student may be in several. Once there is a class, only the classes'
students and the teachers are let in; before the first one (or without the secret key, which reads the table) any account is.
Videos: the teacher adds lesson videos in the panel's content part (a lesson, a title and a Vimeo or YouTube link) and moves
each to its place among the lesson's topics, kept in the Supabase table `videos`. A student's path shows them as
topics of their lessons.
Texts: the teacher also adds texts there (a story or a dialogue: a lesson, a title, its lines in Hebrew and in
Arabic written in Hebrew letters, and a dialogue's two voices), kept in the Supabase table `texts` and placed among
the lesson's topics like the videos. Each is a topic practised line by line (see app/texts.py).
Daily videos: a library the teacher keeps in the content part, a level's videos each with a title and a Vimeo or
YouTube link, in the Supabase table `dailies`. A class gives a video its day (the class's `dailies`); on that day its
students get a reminder above the path to watch it.
Library: other videos (a title and a link each), in the Supabase table `library`, one for every level. Every student
browses them from the tab at the screen's foot, whatever their level and class.
Tutors: the teacher gives a student the tutor's rank in the panel, in the list of all the students above the classes' tabs. It is the student's,
whatever classes they are in, so it is kept by email in the Supabase table `tutors`.
Flags: a teacher, a tutor, and an account in TUTORS_EMAIL, gets a flag under each score, for a recording that was said right and scored
wrong. A flag keeps what describes the case in the Supabase table `flags`, and the recording itself in the storage
bucket `flags`, both written with the secret key: the material for changing the scoring, and later for fine-tuning.
Lab: a teacher's recordings of the lab's diagnostic list (see lab.py), each labelled said right or with one planted
mistake, in the Supabase table `lab_samples` and the storage bucket `lab`, written with the secret key: the
benchmark set of the phoneme recognition.
Writing: a lesson's written homework (see workbook.py) is kept in the Supabase table `writings`, a row per student
and topic, written with the student's own token like the attempts, so it follows them to any device. The panel
reads every student's with the secret key.
Tutor: each conversation of the game יאללה נדבר (see tutor.py), what the student said and how the tutor answered, in the
Supabase table `tutor_chats`, written with the secret key, for the teacher to read.
Env: SUPABASE_URL (https://<project-ref>.supabase.co)
SUPABASE_PUBLISHABLE_KEY (the publishable/anon key; it is public and also goes to the page)
TEACHER_EMAILS (optional: the accounts with the management panel, separated by commas or spaces)
TUTORS_EMAIL (optional: more accounts that flag a wrong score, as the teachers do; separated by commas or spaces)
DEVELOPER_EMAILS (optional bootstrap accounts with full access, including the Lab)
SUPABASE_SECRET_KEY (the secret/service-role key, needed by the panel, the classes and the flags; never public)
Without them the service is open, as before.
"""
from __future__ import annotations
import json
import logging
import mimetypes
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
log = logging.getLogger("ihki.auth")
TIMEOUT = 10
CACHE_TTL = 300 # seconds a checked token is trusted without asking again
CACHE_MAX = 2000
PAGE = 1000 # rows per request: Supabase's default ceiling
MAX_ATTEMPTS = 20_000 # of one student, read for their progress
MAX_CLASS_ATTEMPTS = 200_000 # of all students, read for the teacher's panel
CLASSES_TTL = 60 # seconds the class list is trusted without reading it again (each sign-in check uses it)
# Application-owned data. Supabase's auth users and managed schemas are deliberately not copied.
BACKUP_TABLES = (
"dailies", "library", "videos", "texts", "classes", "writings", "tutors", "flags",
"lab_samples", "calibrations", "scoring_rules", "user_access", "access_audit", "app_secrets",
"system_events", "tutor_chats",
)
BACKUP_NATURAL_KEYS = {"writings": "user_id,topic", "tutors": "email", "scoring_rules": "id",
"user_access": "user_id", "app_secrets": "name", "tutor_chats": "id"}
class AuthUnavailable(RuntimeError):
pass
class ClassConflict(ValueError):
"""Supabase refused a class: its name is taken."""
class SupabaseAuth:
def __init__(self, url: str, key: str, teachers: frozenset[str] = frozenset(), secret: str = "",
tutors: frozenset[str] = frozenset(), developers: frozenset[str] = frozenset()):
self.url, self.key = url.rstrip("/"), key
self.teachers = teachers # lower-case emails of the accounts with the management panel
self.tutors = tutors # lower-case emails of the accounts that flag a wrong score, besides the teachers
self.developers = developers # bootstrap accounts; only developers may use the lab
self.secret = secret # reads every student's rows and the classes; empty = the panel has no data
self._cache: dict[str, tuple[float, dict]] = {}
self._classes: tuple[float, list[dict]] | None = None
self._videos: tuple[float, list[dict]] | None = None
self._texts: tuple[float, list[dict]] | None = None
self._ranked: tuple[float, frozenset[str] | None] | None = None
self._access: dict[str, tuple[float, dict | None]] = {}
self._level_videos: dict[str, tuple[float, list[dict]]] = {} # by table: the daily videos, the library
self._lock = threading.Lock()
def user(self, token: str) -> dict | None:
"""The signed-in user of this access token ({"id", "email"}), or None when it is not valid."""
now = time.monotonic()
with self._lock:
hit = self._cache.get(token)
if hit and hit[0] > now:
return hit[1]
req = urllib.request.Request(
self.url + "/auth/v1/user",
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
data = json.load(res)
except urllib.error.HTTPError as e:
if e.code in (400, 401, 403):
return None
raise AuthUnavailable(f"supabase answered {e.code}") from e
except (urllib.error.URLError, TimeoutError, ValueError) as e:
raise AuthUnavailable(str(e)) from e
if not data.get("id"):
return None
user = {"id": data["id"], "email": data.get("email", "")}
with self._lock:
if len(self._cache) >= CACHE_MAX:
self._cache.clear()
self._cache[token] = (now + CACHE_TTL, user)
return user
def record(self, token: str, row: dict) -> None:
"""Adds one row to `attempts` as the student of this token. A failure is logged, never raised."""
def request(values: dict):
return urllib.request.Request(
self.url + "/rest/v1/attempts", data=json.dumps(values).encode(), method="POST",
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
"Prefer": "return=minimal", "User-Agent": "ihki/1.0"})
try:
urllib.request.urlopen(request(row), timeout=TIMEOUT).close()
except urllib.error.HTTPError as e:
if e.code == 400 and any(k in row for k in ("issues", "model", "version")):
legacy = {k: v for k, v in row.items() if k not in ("issues", "model", "version")}
try:
urllib.request.urlopen(request(legacy), timeout=TIMEOUT).close()
log.warning("attempt stored without issue diagnostics; migrate the attempts table (see README)")
return
except (urllib.error.URLError, TimeoutError) as retry:
e = retry
log.warning("attempt not recorded: %s", e)
except (urllib.error.URLError, TimeoutError) as e: # HTTPError is a URLError
log.warning("attempt not recorded: %s", e)
def attempts(self, token: str) -> list[dict]:
"""The student's non-empty scored recordings, newest first: cat, phrase, score, unrelated, created_at.
Row-level security limits them to the token's student. What cannot be read is logged."""
rows: list[dict] = []
while len(rows) < MAX_ATTEMPTS:
query = urllib.parse.urlencode({"select": "cat,phrase,score,unrelated,created_at",
"order": "created_at.desc", "limit": PAGE, "offset": len(rows)})
try:
batch = self._get(f"/rest/v1/attempts?{query}", token, self.key)
except (urllib.error.URLError, TimeoutError, ValueError) as e:
log.warning("attempts not read: %s", e)
break
rows += batch
if len(batch) < PAGE:
break
return rows
def writings(self, token: str) -> dict[str, list[str]]:
"""The student's written homework, {topic id: its lines}. Row-level security limits it to the token's student.
Before the table is created there is none; any other failure is raised, so the page keeps its own copy."""
try:
rows = self._get("/rest/v1/writings?select=topic,lines", token, self.key)
except urllib.error.HTTPError as e:
if e.code == 404:
log.warning("the table `writings` is missing (see the README): written homework stays on the device")
raise AuthUnavailable("the table writings is missing") from e
raise AuthUnavailable(f"supabase answered {e.code}") from e
except (urllib.error.URLError, TimeoutError, ValueError) as e:
raise AuthUnavailable(str(e)) from e
return {r["topic"]: [str(x) for x in r["lines"]] for r in rows if isinstance(r.get("lines"), list)}
def save_writing(self, token: str, user_id: str, topic: str, lines: list[str]) -> None:
"""Keeps a topic's lines as the student of this token, in place of what they wrote in it before."""
row = {"user_id": user_id, "topic": topic, "lines": lines, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
req = urllib.request.Request(
self.url + "/rest/v1/writings?on_conflict=user_id,topic", data=json.dumps(row).encode(), method="POST",
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates,return=minimal", "User-Agent": "ihki/1.0"})
try:
urllib.request.urlopen(req, timeout=TIMEOUT).close()
except urllib.error.HTTPError as e:
raise AuthUnavailable(f"supabase answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
except (urllib.error.URLError, TimeoutError) as e:
raise AuthUnavailable(str(e)) from e
def all_writings(self) -> list[dict] | None:
"""Every student's written homework, for the panel: user_id, topic, lines. Before the table is created it is
None: nothing a student writes is kept, which the panel says."""
try:
return self._admin(f"/rest/v1/writings?select=user_id,topic,lines&limit={PAGE * 10}")
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `writings` is missing (see the README): the panel shows no written homework")
return None
def _get(self, path: str, token: str, key: str):
req = urllib.request.Request(
self.url + path, headers={"Authorization": f"Bearer {token}", "apikey": key, "User-Agent": "ihki/1.0"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
return json.load(res)
def _admin(self, path: str, method: str = "GET", body: dict | None = None, prefer: str = "return=representation",
timeout: int = TIMEOUT):
"""A call with the secret key, for the teacher's panel. Unlike a student's own progress, a failure is raised."""
if not self.secret:
raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
req = urllib.request.Request(
self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": "application/json",
"Prefer": prefer, "User-Agent": "ihki/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
return json.loads(res.read() or b"null")
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
if e.code == 409:
raise ClassConflict(detail) from e
raise AuthUnavailable(f"supabase answered {e.code}: {detail}") from e
except (urllib.error.URLError, TimeoutError, ValueError) as e:
raise AuthUnavailable(str(e)) from e
def backup_rows(self) -> dict[str, list[dict]]:
"""All application-owned table rows. Tables not installed in an older deployment are omitted."""
result: dict[str, list[dict]] = {}
for table in BACKUP_TABLES:
rows = []
try:
for offset in range(0, 10_000_000, PAGE):
path = f"/rest/v1/{table}?select=*&limit={PAGE}&offset={offset}"
for attempt in range(3):
try:
batch = self._admin(path, timeout=60) or []
break
except AuthUnavailable as e:
if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
raise
rows.extend(batch)
if len(batch) < PAGE:
break
result[table] = rows
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
return result
def final_scores(self) -> list[dict]:
"""One compact row per student and exercise: the highest score that drives the panel's final grades."""
best: dict[tuple[str, str, str], dict] = {}
for offset in range(0, MAX_CLASS_ATTEMPTS, PAGE):
query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at",
"order": "id.asc", "limit": PAGE, "offset": offset})
path = f"/rest/v1/attempts?{query}"
for attempt in range(3):
try:
batch = self._admin(path, timeout=60) or []
break
except AuthUnavailable as e:
if attempt == 2 or not any(x in str(e).lower() for x in ("timed out", "timeout")):
raise
for row in batch:
key = (str(row["user_id"]), str(row["cat"]), str(row["phrase"]))
if key not in best or int(row["score"]) > int(best[key]["score"]):
best[key] = row
if len(batch) < PAGE:
break
return list(best.values())
def restore_final_scores(self, rows: list[dict]) -> int:
"""Restore only grades that improve the currently stored best score, without restoring attempt history."""
current = {(r["user_id"], r["cat"], r["phrase"]): int(r["score"]) for r in self.final_scores()}
add = [{**r, "unrelated": False} for r in rows
if int(r.get("score", -1)) > current.get((r.get("user_id"), r.get("cat"), r.get("phrase")), -1)]
for start in range(0, len(add), 200):
self._admin("/rest/v1/attempts", "POST", add[start:start + 200], prefer="return=minimal", timeout=60)
return len(add)
def restore_rows(self, tables: dict[str, list[dict]]) -> dict[str, int]:
"""Merge a validated application backup. Existing rows are updated; unrelated current rows remain."""
restored: dict[str, int] = {}
text_ids: dict[int, int] = {}
daily_ids: dict[int, int] = {}
for table in BACKUP_TABLES:
rows = [dict(r) for r in tables.get(table, [])]
if not rows:
continue
if table == "classes":
for row in rows:
row["dailies"] = [{**d, "video": daily_ids.get(d.get("video"), d.get("video"))}
for d in (row.get("dailies") or [])]
for field in ("homework",):
value = row.get(field, "")
if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
row["homeworks"] = [{**h, "topic": "t" + str(text_ids.get(int(h["topic"][1:]), int(h["topic"][1:])))
if isinstance(h.get("topic"), str) and h["topic"].startswith("t") and h["topic"][1:].isdigit()
else h.get("topic")} for h in (row.get("homeworks") or [])]
if table == "writings":
field = "topic"
for row in rows:
value = row.get(field, "")
if isinstance(value, str) and value.startswith("t") and value[1:].isdigit():
row[field] = "t" + str(text_ids.get(int(value[1:]), int(value[1:])))
if table in BACKUP_NATURAL_KEYS:
key = BACKUP_NATURAL_KEYS[table]
for start in range(0, len(rows), 200):
self._admin(f"/rest/v1/{table}?on_conflict={key}", "POST", rows[start:start + 200],
prefer="resolution=merge-duplicates,return=minimal")
restored[table] = len(rows)
continue
for original in rows:
row = dict(original)
old_id = row.get("id")
if old_id is not None:
patch = dict(row)
patch.pop("id", None)
existing = self._admin(f"/rest/v1/{table}?id=eq.{urllib.parse.quote(str(old_id))}", "PATCH", patch)
if existing:
new_id = old_id
else:
row.pop("id", None) # identity columns reject explicitly supplied values
made = self._admin(f"/rest/v1/{table}", "POST", row)
new_id = made[0].get("id") if made else None
if table == "texts" and new_id is not None:
text_ids[int(old_id)] = int(new_id)
if table == "dailies" and new_id is not None:
daily_ids[int(old_id)] = int(new_id)
else:
self._admin(f"/rest/v1/{table}", "POST", row, prefer="return=minimal")
restored[table] = len(rows)
self._classes = self._videos = self._texts = self._ranked = None
self._level_videos.clear()
self._access.clear()
return restored
def classes(self, fresh: bool = False) -> list[dict]:
"""Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
empty in a class saved before classes had lessons), homeworks ([{"topic": a topic id, "due": its ISO date or
None}], one for each topic the class has as homework), homework and due (the first of them, as a class had
before it could have several; a class saved then has only these), dailies ([{"video": the id of a daily
video, "date": the ISO date the class watches it}]), created_at.
Kept for CLASSES_TTL, as each sign-in
check asks. Without the secret key, or before the table is created, there are none."""
now = time.monotonic()
with self._lock:
hit = self._classes
if hit and hit[0] > now and not fresh:
return hit[1]
rows: list[dict] = []
if self.secret:
try:
# every column: a table made before the nicknames still lets its students in
rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
for r in rows:
r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
r["teachers"] = [str(e).lower() for e in (r.get("teachers") or [])]
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `classes` is missing (see the README): every account is let in")
with self._lock:
self._classes = (now + CLASSES_TTL, rows)
return rows
def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
subject: str = "", teachers: list[str] | None = None) -> None:
"""Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
still takes a class that has none). A name already taken raises ClassConflict."""
first = homeworks[0] if homeworks else {"topic": "", "due": None} # the columns of a class with one homework
body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons,
"homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
if dailies is not None:
body["dailies"] = list(dailies)
if teachers is not None:
body["teachers"] = list(teachers)
if id is None:
body["subject"] = subject
self._admin("/rest/v1/classes", "POST", body)
elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
raise KeyError(id)
self._classes = None
def delete_class(self, id: int) -> None:
self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "DELETE")
self._classes = None
def videos(self, fresh: bool = False) -> list[dict]:
"""Every lesson video, each lesson's in the order the teacher gave them: id, lesson (a lesson id), title, url
(the Vimeo or YouTube link), pos (how many of the lesson's own topics stand before it; None in a table made before videos
had places: after them all) and ord (its turn among the videos of one place).
Kept for CLASSES_TTL, as the path asks. Without the secret key, or before the table is created, there are none."""
now = time.monotonic()
with self._lock:
hit = self._videos
if hit and hit[0] > now and not fresh:
return hit[1]
rows: list[dict] = []
if self.secret:
try:
# every column: a table made before the places still shows its videos
rows = self._admin("/rest/v1/videos?select=*&order=id.asc")
for r in rows:
r["pos"], r["ord"] = r.get("pos"), r.get("ord") or 0
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `videos` is missing (see the README): the lessons have no videos")
with self._lock:
self._videos = (now + CLASSES_TTL, rows)
return rows
def add_video(self, lesson: str, title: str, url: str, pos: int, ord: int) -> None:
self._admin("/rest/v1/videos", "POST", {"lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})
self._videos = None
def place(self, places: list[tuple[str, int, int, int]]) -> None:
"""Moves videos and texts: (table, id, pos, ord) of each; the table is "videos" or "texts"."""
try:
for table, id, pos, ord in places:
assert table in ("videos", "texts")
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"pos": pos, "ord": ord})
finally:
self._videos = self._texts = None
def texts(self, fresh: bool = False) -> list[dict]:
"""Every text the teacher added: id, lesson (a lesson id), title, voices (a dialogue's two voices by name,
the odd lines' and the even lines'; empty for a story), lines ([Hebrew, Arabic in Hebrew letters] each), and
pos and ord as a video has them (see `videos`).
Kept for CLASSES_TTL, as each score asks. Without the secret key, or before the table is created, there are none."""
now = time.monotonic()
with self._lock:
hit = self._texts
if hit and hit[0] > now and not fresh:
return hit[1]
rows: list[dict] = []
if self.secret:
try:
rows = self._admin("/rest/v1/texts?select=*&order=id.asc")
for r in rows:
r["voices"], r["ord"] = r.get("voices") or [], r.get("ord") or 0
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `texts` is missing (see the README): the lessons have no texts")
with self._lock:
self._texts = (now + CLASSES_TTL, rows)
return rows
def add_text(self, lesson: str, title: str, voices: list[str], lines: list[list[str]], pos: int, ord: int) -> None:
self._admin("/rest/v1/texts", "POST", {"lesson": lesson, "title": title, "voices": voices, "lines": lines,
"pos": pos, "ord": ord})
self._texts = None
def delete_text(self, id: int) -> None:
self._admin(f"/rest/v1/texts?id=eq.{int(id)}", "DELETE")
self._texts = None
def delete_video(self, id: int) -> None:
self._admin(f"/rest/v1/videos?id=eq.{int(id)}", "DELETE")
self._videos = None
def level_videos(self, table: str, fresh: bool = False) -> list[dict]:
"""A level's videos that stand outside the path, in the order the teacher gave them (`ord`, see
`order_level_videos`) and then, those never moved, in the order they were added: id, level (a level id), title,
url (the Vimeo or YouTube link). `table` is "dailies" (the daily videos) or "library" (the video library,
which is one for every level: its `level` is empty). Kept for CLASSES_TTL. Without the secret key, or before the table is created, there are none."""
assert table in ("dailies", "library")
now = time.monotonic()
with self._lock:
hit = self._level_videos.get(table)
if hit and hit[0] > now and not fresh:
return hit[1]
rows: list[dict] = []
if self.secret:
try:
rows = self._admin(f"/rest/v1/{table}?select=*&order=id.asc")
rows.sort(key=lambda r: (r.get("ord") is None, r.get("ord") or 0, r["id"]))
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `%s` is missing (see the README): it has no videos", table)
with self._lock:
self._level_videos[table] = (now + CLASSES_TTL, rows)
return rows
def add_level_video(self, table: str, level: str, title: str, url: str) -> None:
assert table in ("dailies", "library")
self._admin(f"/rest/v1/{table}", "POST", {"level": level, "title": title, "url": url})
self._level_videos.pop(table, None)
def delete_level_video(self, table: str, id: int) -> None:
assert table in ("dailies", "library")
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "DELETE")
self._level_videos.pop(table, None)
def rename_level_video(self, table: str, id: int, title: str) -> None:
assert table in ("dailies", "library")
try:
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"title": title})
finally:
self._level_videos.pop(table, None)
def order_level_videos(self, table: str, ids: list[int]) -> None:
"""Puts the table's videos in this order: each one's `ord` is its turn."""
assert table in ("dailies", "library")
try:
for k, id in enumerate(ids):
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"ord": k})
finally:
self._level_videos.pop(table, None)
def ranked(self, fresh: bool = False) -> frozenset[str] | None:
"""The lower-case emails the teacher gave the tutor's rank (the table `tutors`). Kept for CLASSES_TTL, as each
sign-in check asks. None before the table is created; without the secret key there are none."""
now = time.monotonic()
with self._lock:
hit = self._ranked
if hit and hit[0] > now and not fresh:
return hit[1]
emails: frozenset[str] | None = frozenset()
if self.secret:
try:
emails = frozenset(r["email"].lower() for r in self._admin("/rest/v1/tutors?select=email"))
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
log.warning("the table `tutors` is missing (see the README): no student has the tutor's rank")
emails = None
with self._lock:
self._ranked = (now + CLASSES_TTL, emails)
return emails
def set_tutor(self, email: str, on: bool, by: str) -> None:
"""Gives the account of this email the tutor's rank, or takes it back. `by` is the teacher who gave it."""
email = email.lower()
try:
if on:
self._admin("/rest/v1/tutors", "POST", {"email": email, "granted_by": by})
else:
self._admin(f"/rest/v1/tutors?email=eq.{urllib.parse.quote(email)}", "DELETE")
except ClassConflict: # has it already
pass
finally:
self._ranked = None
def _put_object(self, bucket: str, name: str, data: bytes, mime: str) -> bool:
"""Stores a recording in a private storage bucket; False (logged) when it cannot be stored."""
if not self.secret:
return False
req = urllib.request.Request(
f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}", data=data, method="POST",
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "Content-Type": mime,
"User-Agent": "ihki/1.0"})
try:
urllib.request.urlopen(req, timeout=TIMEOUT).close()
return True
except (urllib.error.URLError, TimeoutError) as e: # HTTPError is a URLError
log.warning("a recording not stored (is there a storage bucket `%s`? see the README): %s", bucket, e)
return False
def _delete_objects(self, bucket: str, names: list[str]) -> bool:
"""Removes recordings of a private storage bucket; False (logged) when some could not be removed."""
ok = True
for k in range(0, len(names), 1000): # the storage API takes up to 1000 names a call
req = urllib.request.Request(
f"{self.url}/storage/v1/object/{bucket}", method="DELETE",
data=json.dumps({"prefixes": names[k:k + 1000]}).encode(),
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
"Content-Type": "application/json", "User-Agent": "ihki/1.0"})
try:
urllib.request.urlopen(req, timeout=TIMEOUT).close()
except (urllib.error.URLError, TimeoutError) as e:
log.warning("recordings of the storage bucket `%s` not removed: %s", bucket, e)
ok = False
return ok
def _get_object(self, bucket: str, name: str) -> tuple[bytes, str]:
"""A recording of a private storage bucket and its media type."""
req = urllib.request.Request(
f"{self.url}/storage/v1/object/{bucket}/{urllib.parse.quote(name)}",
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret, "User-Agent": "ihki/1.0"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
data = res.read()
media_type = res.headers.get_content_type()
except (urllib.error.URLError, TimeoutError) as e:
raise AuthUnavailable(f"{bucket} audio unavailable: {e}") from e
if media_type == "application/octet-stream":
media_type = mimetypes.guess_type(name)[0] or media_type
return data, media_type
def ensure_backup_bucket(self) -> None:
"""Create the private application-backup bucket on first use."""
try:
self._admin("/storage/v1/bucket/backups")
except AuthUnavailable as e:
detail = str(e).lower()
if not any(mark in detail for mark in ("answered 404", '"statuscode":"404"', "nosuchbucket", "bucket not found")):
raise
self._admin("/storage/v1/bucket", "POST", {"id": "backups", "name": "backups", "public": False},
prefer="return=minimal")
def save_backup(self, name: str, data: bytes) -> None:
self.ensure_backup_bucket()
req = urllib.request.Request(
f"{self.url}/storage/v1/object/backups/{urllib.parse.quote(name)}", data=data, method="POST",
headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
"Content-Type": "application/zip", "User-Agent": "ihki/1.0"})
try:
urllib.request.urlopen(req, timeout=max(TIMEOUT, 60)).close()
except urllib.error.HTTPError as e:
raise AuthUnavailable(f"supabase storage answered {e.code}: {e.read().decode(errors='replace')[:300]}") from e
except (urllib.error.URLError, TimeoutError) as e:
raise AuthUnavailable(str(e)) from e
def list_backups(self) -> list[dict]:
self.ensure_backup_bucket()
rows = self._admin("/storage/v1/object/list/backups", "POST",
{"prefix": "", "limit": 100, "offset": 0, "sortBy": {"column": "name", "order": "desc"}})
return [r for r in (rows or []) if str(r.get("name", "")).endswith(".zip")]
def add_flag(self, row: dict, audio: bytes, mime: str, name: str) -> bool:
"""Keeps a flagged score and its recording in the private `flags` bucket."""
kept = self._put_object("flags", name, audio, mime)
self._admin("/rest/v1/flags", "POST", {**row, "audio": name if kept else None})
return kept
def list_flags(self, status: str = "pending") -> list[dict]:
"""Flagged scores for the teacher's review, newest first."""
query = urllib.parse.urlencode({"select": "*", "status": f"eq.{status}", "order": "created_at.desc"})
return self._admin(f"/rest/v1/flags?{query}")
def review_flag(self, flag_id: int, values: dict) -> None:
"""Records a teacher's decision about one flagged score."""
self._admin(f"/rest/v1/flags?id=eq.{int(flag_id)}", "PATCH", values)
def flag(self, flag_id: int) -> dict | None:
"""One flag's row, or None."""
rows = self._admin(f"/rest/v1/flags?{urllib.parse.urlencode({'select': '*', 'id': f'eq.{int(flag_id)}', 'limit': 1})}")
return rows[0] if rows else None
def flag_audio(self, flag_id: int) -> tuple[bytes, str] | None:
"""Returns one private flag recording and its media type, or None when it was not stored."""
query = urllib.parse.urlencode({"select": "audio", "id": f"eq.{int(flag_id)}", "limit": 1})
rows = self._admin(f"/rest/v1/flags?{query}")
name = rows[0].get("audio") if rows else None
return self._get_object("flags", name) if name else None
def add_lab_sample(self, row: dict, audio: bytes, mime: str, name: str, attempts: int = 1) -> bool:
"""Keeps a lab recording (see lab.py): the recording in the storage bucket `lab`, the row in the table
`lab_samples`. As with a flag, a recording that cannot be stored leaves the row without it (False).
`attempts` retries the upload (a bulk import outlives the storage's transient 5xx errors)."""
kept = False
for attempt in range(max(1, attempts)):
if attempt:
time.sleep(2 * attempt)
kept = self._put_object("lab", name, audio, mime)
if kept:
break
self._admin("/rest/v1/lab_samples", "POST", {**row, "audio": name if kept else None}, prefer="return=minimal")
return kept
def lab_samples(self, full: bool = False) -> list[dict]:
"""The lab's recordings, oldest first: what labels each one (and, with `full`, the scorer's whole result)."""
cols = "*" if full else "id,item,speaker,label,mistake,score,heard_ipa,flagged,model,audio,created_at"
# PostgREST caps a response (1000 rows by default), so the table is read in pages by id.
rows, last, page = [], 0, 500 if full else 1000
while True:
query = urllib.parse.urlencode({"select": cols, "order": "id.asc", "id": f"gt.{last}", "limit": page})
batch = self._admin(f"/rest/v1/lab_samples?{query}") or []
rows.extend(batch)
if len(batch) < page:
return rows
last = batch[-1]["id"]
def lab_sample(self, sample_id: int) -> dict | None:
"""One lab recording's row, without the scorer's result, or None."""
query = urllib.parse.urlencode({"select": "id,item,ar,ipa,speaker,label,mistake,audio,result", "id": f"eq.{int(sample_id)}",
"limit": 1})
rows = self._admin(f"/rest/v1/lab_samples?{query}")
return rows[0] if rows else None
def lab_audio(self, sample_id: int) -> tuple[bytes, str] | None:
"""One lab recording and its media type, or None when it was not stored."""
row = self.lab_sample(sample_id)
return self._get_object("lab", row["audio"]) if row and row.get("audio") else None
def update_lab_result(self, sample_id: int, result: dict) -> None:
"""Updates a lab result's review metadata (the original scoring fields remain in the same JSON document)."""
self._admin(f"/rest/v1/lab_samples?id=eq.{int(sample_id)}", "PATCH", {"result": result})
def delete_lab_samples(self, speaker: str | None = None) -> int:
"""Deletes the lab's recordings of one speaker ("": the unnamed), or of every speaker when None: the rows,
then their files in the bucket `lab` (a file that cannot be removed is logged and left). Returns how many rows."""
where = {"speaker": f"eq.{speaker}"} if speaker is not None else {"id": "gt.0"} # a DELETE needs a filter
rows = self._admin(f"/rest/v1/lab_samples?{urllib.parse.urlencode({**where, 'select': 'id,audio'})}",
"DELETE") or []
names = [r["audio"] for r in rows if r.get("audio")]
if names:
self._delete_objects("lab", names)
return len(rows)
def calibrations(self, status: str | None = None) -> list[dict]:
"""Calibration proposals, optionally filtered by their workflow status."""
params = {"select": "*", "order": "created_at.desc"}
if status:
params["status"] = f"eq.{status}"
return self._admin(f"/rest/v1/calibrations?{urllib.parse.urlencode(params)}")
def add_calibration(self, row: dict) -> dict:
"""Creates one draft calibration proposal and returns its stored row."""
made = self._admin("/rest/v1/calibrations", "POST", row)
return made[0] if made else row
def update_calibration(self, calibration_id: int, values: dict) -> None:
"""Updates and optionally applies or rejects a calibration proposal."""
self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "PATCH", values)
def delete_calibration(self, calibration_id: int) -> bool:
"""Deletes a calibration proposal or rule; returns whether it existed."""
return bool(self._admin(f"/rest/v1/calibrations?id=eq.{int(calibration_id)}", "DELETE"))
def scoring_rules(self) -> list[dict]:
"""The teacher's switches over the rules of pronunciation-rules.md: id, active, updated_by, updated_at.
A rule with no row is on."""
return self._admin("/rest/v1/scoring_rules?select=*") or []
def set_scoring_rule(self, rule_id: str, active: bool, by: str) -> None:
"""Switches one rule on or off (an upsert on its id)."""
self._admin("/rest/v1/scoring_rules", "POST", {"id": rule_id, "active": bool(active), "updated_by": by,
"updated_at": datetime.now(timezone.utc).isoformat()},
prefer="resolution=merge-duplicates,return=minimal")
def users(self) -> list[dict]:
"""Every account that has signed in: id, email, name (as Google gave it; empty for one who signed in by email)."""
users: list[dict] = []
for page in range(1, 51):
batch = self._admin(f"/auth/v1/admin/users?page={page}&per_page={PAGE}").get("users", [])
users += [{"id": u["id"], "email": u.get("email") or "",
"name": (u.get("user_metadata") or {}).get("full_name") or ""} for u in batch]
if len(batch) < PAGE:
break
return users
def access_for(self, user_id: str) -> dict | None:
"""The roles and student entitlements assigned to an account. None also means the migration table is absent."""
now = time.monotonic()
with self._lock:
hit = self._access.get(user_id)
if hit and hit[0] > now:
return hit[1]
try:
rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
"user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,subject,updated_at"}))
value = rows[0] if rows else None
except AuthUnavailable as e:
if "subject" in str(e).lower():
rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode({
"user_id": f"eq.{user_id}", "select": "user_id,email,roles,entitlements,updated_at"}))
value = ({**rows[0], "subject": ""} if rows else None)
elif "answered 404" not in str(e):
raise
else:
value = None
with self._lock:
self._access[user_id] = (now + CLASSES_TTL, value)
return value
def access_rows(self) -> list[dict] | None:
"""All explicit access assignments, or None until the user_access table is installed."""
try:
return self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,subject,updated_at") or []
except AuthUnavailable as e:
if "subject" in str(e).lower():
return [{**r, "subject": ""} for r in
(self._admin("/rest/v1/user_access?select=user_id,email,roles,entitlements,updated_at") or [])]
if "answered 404" in str(e):
return None
raise
def set_access(self, target: dict, roles: list[str], entitlements: list[str], actor: dict) -> None:
"""Upsert an assignment and append a best-effort audit record."""
old = self.access_for(target["id"])
row = {"user_id": target["id"], "email": target["email"].lower(), "roles": roles,
"entitlements": entitlements, "updated_at": datetime.now(timezone.utc).isoformat(),
"updated_by": actor["id"]}
self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
prefer="resolution=merge-duplicates,return=minimal")
with self._lock:
self._access.pop(target["id"], None)
try:
self._admin("/rest/v1/access_audit", "POST",
{"target_user_id": target["id"], "changed_by": actor["id"],
"old_access": old or {}, "new_access": row}, prefer="return=minimal")
except AuthUnavailable as e:
log.warning("access changed but audit row was not stored: %s", e)
self.log_event("permission", actor, target["email"], {"roles": roles, "entitlements": entitlements})
def set_admin_subject(self, target: dict, subject: str, actor: dict) -> None:
"""Set an admin's subject without changing their access assignment."""
old = self.access_for(target["id"]) or {}
row = {"user_id": target["id"], "email": target["email"].lower(),
"roles": old.get("roles") or ["student"], "entitlements": old.get("entitlements") or [],
"subject": subject, "updated_at": datetime.now(timezone.utc).isoformat(),
"updated_by": actor["id"]}
self._admin("/rest/v1/user_access?on_conflict=user_id", "POST", row,
prefer="resolution=merge-duplicates,return=minimal")
with self._lock:
self._access.pop(target["id"], None)
self.log_event("permission", actor, target["email"], {"admin_subject": subject})
def secret_rows(self) -> list[dict] | None:
"""The API keys set in the panel, encrypted (see keystore.py), or None until the table `app_secrets` exists."""
try:
return self._admin("/rest/v1/app_secrets?select=name,ciphertext,last4,updated_by,updated_at") or []
except AuthUnavailable as e:
if "answered 404" in str(e):
return None
raise
def save_secret(self, row: dict) -> None:
self._admin("/rest/v1/app_secrets?on_conflict=name", "POST",
{**row, "updated_at": datetime.now(timezone.utc).isoformat()},
prefer="resolution=merge-duplicates,return=minimal")
def delete_secret(self, name: str) -> None:
self._admin("/rest/v1/app_secrets?" + urllib.parse.urlencode({"name": f"eq.{name}"}), "DELETE",
prefer="return=minimal")
def all_attempts(self) -> list[dict]:
"""Every student's attempts, as `attempts` gives one student's, each with its user_id. "Different sentence"
rows remain marked as unrelated for diagnostics, but count toward progress like every non-empty recording."""
rows: list[dict] = []
while len(rows) < MAX_CLASS_ATTEMPTS:
query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,unrelated,created_at",
"order": "id.desc", "limit": PAGE, "offset": len(rows)})
batch = self._admin(f"/rest/v1/attempts?{query}")
rows += batch
if len(batch) < PAGE:
break
return rows
def activity(self, hours: int = 24, bucket_minutes: int = 15) -> dict:
"""Aggregated attempt counts for monitoring, without returning student identities.
``activity_summary`` supplies a distinct-student count for the whole window. Keep the buckets usable with
older Supabase installations until that small migration is applied.
"""
buckets = self._admin("/rest/v1/rpc/activity_buckets", "POST",
{"p_hours": hours, "p_bucket_minutes": bucket_minutes})
summary = None
try:
rows = self._admin("/rest/v1/rpc/activity_summary", "POST", {"p_hours": hours}) or []
summary = rows[0] if rows else None
except AuthUnavailable as e:
if "answered 404" not in str(e):
raise
return {"buckets": buckets, "summary": summary}
def log_event(self, kind: str, user: dict | None, label: str = "", details: dict | None = None) -> None:
"""Best-effort append to the management activity feed; a missing audit table never blocks the action."""
try:
self._admin("/rest/v1/system_events", "POST", {
"kind": kind, "actor_id": user.get("id") if user else None,
"actor_email": (user or {}).get("email", ""), "label": label[:200], "details": details or {},
}, prefer="return=minimal")
except AuthUnavailable as e:
log.warning("system activity event not stored: %s", e)
def recent_events(self, limit: int = 60) -> list[dict] | None:
"""Newest audit events, or None when the optional table has not been installed yet."""
query = urllib.parse.urlencode({"select": "kind,actor_email,label,details,created_at",
"order": "created_at.desc", "limit": max(1, min(limit, 200))})
try:
return self._admin(f"/rest/v1/system_events?{query}") or []
except AuthUnavailable as e:
if "answered 404" in str(e):
return None
raise
def save_tutor_chat(self, row: dict) -> None:
"""Keeps a conversation with the tutor (see tutor.py), in place of what was kept of it before. A failure is
logged, never raised: the conversation goes on without it."""
try:
self._admin("/rest/v1/tutor_chats?on_conflict=id", "POST", row,
prefer="resolution=merge-duplicates,return=minimal")
except AuthUnavailable as e:
log.warning("a tutor conversation not kept%s: %s",
" (the table `tutor_chats` is missing, see tutor.CREATE_TUTOR_CHATS)" if "answered 404" in str(e) else "", e)
def tutor_chats_since(self, user_id: str, since: str) -> int:
"""How many conversations with the tutor the student started since this ISO time. 0 when they cannot be read."""
query = urllib.parse.urlencode({"select": "id", "user_id": f"eq.{user_id}", "created_at": f"gte.{since}"})
try:
return len(self._admin(f"/rest/v1/tutor_chats?{query}") or [])
except AuthUnavailable as e:
log.warning("tutor conversations not counted: %s", e)
return 0
def all_attempt_issues(self) -> list[dict] | None:
"""Compact phoneme issues from all attempts. None means the diagnostic columns have not been migrated yet."""
rows: list[dict] = []
while len(rows) < MAX_CLASS_ATTEMPTS:
query = urllib.parse.urlencode({"select": "user_id,phrase,score,issues,model,version",
"unrelated": "is.false", "order": "id.desc",
"limit": PAGE, "offset": len(rows)})
try:
batch = self._admin(f"/rest/v1/attempts?{query}")
except AuthUnavailable as e:
if any(name in str(e) for name in ("issues", "model", "version")):
return None
raise
rows += batch
if len(batch) < PAGE:
break
return rows
def load_auth() -> SupabaseAuth | None:
url = os.environ.get("SUPABASE_URL", "").strip()
key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
if not url or not key:
missing = [n for n, v in (("SUPABASE_URL", url), ("SUPABASE_PUBLISHABLE_KEY", key)) if not v]
seen = sorted(k for k in os.environ if "SUPA" in k.upper()) # names only: shows a misspelt variable
log.info("no sign-in, the service is open: %s missing or empty (similar names in the environment: %s)",
" and ".join(missing), seen or "none")
return None
emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
teachers, tutors, developers = emails("TEACHER_EMAILS"), emails("TUTORS_EMAIL"), emails("DEVELOPER_EMAILS")
secret = os.environ.get("SUPABASE_SECRET_KEY", "").strip()
log.info("sign-in on: %s", "the students of the classes" if secret else "any account (no SUPABASE_SECRET_KEY)")
if os.environ.get("ALLOWED_EMAILS", "").strip():
log.warning("ALLOWED_EMAILS is no longer read: the teacher lists the students in the panel's classes")
if teachers and not secret:
log.warning("TEACHER_EMAILS is set without SUPABASE_SECRET_KEY: the management panel has no data to show")
if tutors and not secret:
log.warning("TUTORS_EMAIL is set without SUPABASE_SECRET_KEY: a flag cannot be saved")
return SupabaseAuth(url, key, teachers, secret, tutors, developers)
|