3v324v23 commited on
Commit
7a278ee
·
1 Parent(s): 89ab79b

Implement push notifications for photo requests from computer to phone

Browse files

- Added push notification functionality in app/push.py to notify phones when a photo of an answer is requested from the computer.
- Created a new service worker (static/service-worker.js) to handle push notifications.
- Introduced a new database table (supabase/migrations/20261103180000_math_push_subs.sql) to store push subscriptions.
- Updated the frontend (static/math.js, static/math.css) to remove phone pairing functionality and adjust UI accordingly.
- Removed related tests (tests/test_phone_browser.py) and added new tests for push notifications (tests/test_push.py).
- Ensured proper handling of push subscription lifecycle and error cases.

.env.example CHANGED
@@ -4,12 +4,30 @@ SUPABASE_PUBLISHABLE_KEY=
4
 
5
  # Required for class roster management.
6
  SUPABASE_SECRET_KEY=
7
- # Enable only after applying the additive answer-scan migration and creating its private bucket.
8
- MATHLY_ANSWER_SCANS_ENABLED=0
9
  TEACHER_EMAILS=
10
  DEVELOPER_EMAILS=
11
 
12
  # Enables photographed-problem help and checks of saved handwritten answers.
13
- # Apply the answer-grading migration before enabling checks in a deployment.
14
  ANTHROPIC_API_KEY=
15
  # MATH_MODEL=claude-opus-5-5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  # Required for class roster management.
6
  SUPABASE_SECRET_KEY=
 
 
7
  TEACHER_EMAILS=
8
  DEVELOPER_EMAILS=
9
 
10
  # Enables photographed-problem help and checks of saved handwritten answers.
 
11
  ANTHROPIC_API_KEY=
12
  # MATH_MODEL=claude-opus-5-5
13
+
14
+ # ---- Open-ended questions and unit quizzes (answers written on paper and photographed) ----
15
+ # All of these are needed, plus sign-in (SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY above)
16
+ # and ANTHROPIC_API_KEY for the automatic check.
17
+ # Before turning them on, apply these Supabase migrations in order:
18
+ # supabase/migrations/20261031133254_math_answer_scans.sql (answer photos and the private bucket)
19
+ # supabase/migrations/20261031193200_math_answer_grading.sql (automatic checks)
20
+ # supabase/migrations/20261103120000_math_answer_scan_decline.sql (declining a phone photo request)
21
+ # 1 stores photographed answers (computer and phone on the same account). 0 = off.
22
+ MATHLY_ANSWER_SCANS_ENABLED=0
23
+ # 0 opens open-ended questions and unit quizzes to students and lets the server check them with AI
24
+ # (this spends API tokens). Anything else, or unset, keeps them locked.
25
+ MATHLY_OPEN_ENDED_LOCKED=1
26
+
27
+ # ---- Phone notifications for photo requests (app/push.py) ----
28
+ # When the computer asks for a photo of an answer, the student's phone gets a notification, even with the app closed.
29
+ # Apply supabase/migrations/20261103180000_math_push_subs.sql first. Make a key with: python -m app.push
30
+ # Changing the key drops every subscription: each phone has to turn notifications on again.
31
+ VAPID_PRIVATE_KEY=
32
+ # A contact for the push services, "mailto:...". Default: the first developer's email.
33
+ # VAPID_SUBJECT=
README.md CHANGED
@@ -12,9 +12,11 @@ pinned: false
12
 
13
  Mathly is a Hebrew ninth-grade algebra and geometry practice path. It contains 140 skills across 14 units that follow the Ministry of Education's 9th-grade teaching schedule (see `docs/prisa_9A.md`), ten per unit, immediate feedback for known mistakes, and optional photographed-problem help.
14
 
15
- Choose **צילום בטלפון, פתרון במחשב** on the computer, scan the QR code with your phone, take a photo, and confirm sending it. The answer and worked solution appear automatically on the computer. Links last 15 minutes and accept one exercise; the phone does not need a separate login. Geometry practice includes diagrams for rectangle area, triangle area, and Pythagoras.
16
 
17
- Both devices must be able to reach the same app URL. For local use, open the computer's LAN address (for example `http://192.168.1.10:7860`) on both devices instead of `localhost`. Phone links and results are temporary in server memory: use a single server worker; restarting it clears active links. Images are sent to the configured AI provider for solving and are not stored by Mathly.
 
 
18
 
19
  ## Run locally
20
 
 
12
 
13
  Mathly is a Hebrew ninth-grade algebra and geometry practice path. It contains 140 skills across 14 units that follow the Ministry of Education's 9th-grade teaching schedule (see `docs/prisa_9A.md`), ten per unit, immediate feedback for known mistakes, and optional photographed-problem help.
14
 
15
+ Geometry practice includes diagrams for rectangle area, triangle area, and Pythagoras.
16
 
17
+ Photographed problems are sent to the configured AI provider for solving and are not stored by Mathly.
18
+
19
+ When the computer asks for a photo of a handwritten answer, the student's phone can get a notification, even with the app closed. The phone offers it after its first photo is sent. It needs `VAPID_PRIVATE_KEY` (make one with `python -m app.push`) and the migration `supabase/migrations/20261103180000_math_push_subs.sql`. On an iPhone it works only from the app added to the home screen.
20
 
21
  ## Run locally
22
 
app/answer_routes.py CHANGED
@@ -155,11 +155,16 @@ def install_answer_routes(app, signed_in):
155
  log.info("automatic answer grading stopped: %s", exc.code)
156
 
157
  @app.post("/api/math/answer-attempts/{attempt_id}/scans")
158
- def create_scan(attempt_id: str, body: dict, user=Depends(student)):
159
  fields = {"attempt_id": uuid_text(attempt_id), "renew": body.get("renew") is True}
160
  if "question_id" in body:
161
  fields["question_id"] = str(body["question_id"])
162
- return reply(call(user, "create_scan", **fields))
 
 
 
 
 
163
 
164
  @app.get("/api/math/answer-scans")
165
  def pending(user=Depends(student)):
 
155
  log.info("automatic answer grading stopped: %s", exc.code)
156
 
157
  @app.post("/api/math/answer-attempts/{attempt_id}/scans")
158
+ def create_scan(attempt_id: str, body: dict, background_tasks: BackgroundTasks, user=Depends(student)):
159
  fields = {"attempt_id": uuid_text(attempt_id), "renew": body.get("renew") is True}
160
  if "question_id" in body:
161
  fields["question_id"] = str(body["question_id"])
162
+ result = call(user, "create_scan", **fields)
163
+ push = getattr(app.state, "push", None)
164
+ if push is not None and push.on:
165
+ # the phone may be in a pocket with the app closed: a notification brings the student to the request
166
+ background_tasks.add_task(push.scan_requested, user, result)
167
+ return reply(result)
168
 
169
  @app.get("/api/math/answer-scans")
170
  def pending(user=Depends(student)):
app/main.py CHANGED
@@ -3,8 +3,6 @@ from __future__ import annotations
3
 
4
  import logging
5
  import os
6
- import io
7
- import secrets
8
  import time
9
  from datetime import datetime
10
  from pathlib import Path
@@ -22,6 +20,7 @@ from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
22
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
23
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
24
  from .math_skills import algebra9, practice_graph
 
25
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
26
  from .unit_resources import install_resource_routes
27
 
@@ -101,25 +100,12 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
101
  async def index():
102
  return page()
103
 
104
- @app.get("/phone", response_class=HTMLResponse, include_in_schema=False)
105
- async def phone():
106
- return HTMLResponse((STATIC / "phone.html").read_text(encoding="utf-8"),
107
- headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"})
108
-
109
  @app.get("/service-worker.js", include_in_schema=False)
110
- async def retired_service_worker():
111
- # An older version installed a service worker that served /static/ from its own cache forever.
112
- # Browsers that still have it fetch this file to update it: the new one clears that cache,
113
- # removes itself and reloads the open pages, so they get the current app.
114
- script = """
115
- self.addEventListener("install", () => self.skipWaiting());
116
- self.addEventListener("activate", event => event.waitUntil((async () => {
117
- await Promise.all((await caches.keys()).map(key => caches.delete(key)));
118
- await self.registration.unregister();
119
- for (const client of await self.clients.matchAll({type: "window"})) client.navigate(client.url);
120
- })()));
121
- """
122
- return Response(script, media_type="text/javascript", headers={"Cache-Control": "no-store"})
123
 
124
  @app.get("/manifest.webmanifest", include_in_schema=False)
125
  async def manifest():
@@ -202,47 +188,6 @@ self.addEventListener("activate", event => event.waitUntil((async () => {
202
  return {"ok": True}
203
 
204
  photo_counts: dict[tuple[str, str], int] = app.state.photo_counts
205
- photo_sessions: dict[str, dict] = {}
206
-
207
- def session_for(token: str) -> dict:
208
- session = photo_sessions.get(token)
209
- if not session or session["expires"] <= time.monotonic():
210
- photo_sessions.pop(token, None)
211
- raise HTTPException(410, "הקישור פג. פתחו קישור חדש במחשב.")
212
- return session
213
-
214
- @app.post("/api/math/photo-sessions")
215
- async def pair_phone(user: dict | None = Depends(signed_in)):
216
- solver() # Report missing configuration before asking the student to scan.
217
- for token in list(photo_sessions):
218
- if photo_sessions[token]["expires"] <= time.monotonic():
219
- del photo_sessions[token]
220
- if len(photo_sessions) >= 1000:
221
- raise HTTPException(429, "נסו שוב מאוחר יותר.")
222
- token = secrets.token_urlsafe(32)
223
- photo_sessions[token] = {"expires": time.monotonic() + 900, "user": user,
224
- "state": "waiting", "result": None, "error": ""}
225
- return {"token": token, "expires_in": 900}
226
-
227
- @app.get("/api/math/photo-sessions/{token}")
228
- async def paired_result(token: str, user: dict | None = Depends(signed_in)):
229
- session = session_for(token)
230
- if (session["user"] or {}).get("email") != (user or {}).get("email"):
231
- raise HTTPException(404, "session not found")
232
- return JSONResponse({key: session[key] for key in ("state", "result", "error")},
233
- headers={"Cache-Control": "no-store"})
234
-
235
- @app.post("/api/math/photo-qr")
236
- async def photo_qr(request: Request, _user: dict | None = Depends(signed_in)):
237
- import qrcode
238
- import qrcode.image.svg
239
- body = await request.json()
240
- url = body.get("url", "")
241
- if not isinstance(url, str) or len(url) > 2048 or not url.startswith(("https://", "http://")):
242
- raise HTTPException(400, "invalid phone URL")
243
- output = io.BytesIO()
244
- qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, border=4).save(output)
245
- return Response(output.getvalue(), media_type="image/svg+xml", headers={"Cache-Control": "no-store"})
246
 
247
  def solver() -> PhotoSolver:
248
  key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get("CLAUDE_API_KEY", "").strip()
@@ -289,23 +234,7 @@ self.addEventListener("activate", event => event.waitUntil((async () => {
289
  app.state.actions.record(user, "photo", skill if result.get("readable") else "התמונה לא נקראה")
290
  return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
291
 
292
- @app.post("/api/math/phone-photo")
293
- async def phone_photo(request: Request, photo: UploadFile = File(...)):
294
- session = session_for(request.headers.get("x-photo-token", ""))
295
- if session["state"] in ("solving", "done"):
296
- raise HTTPException(409, "התמונה כבר נשלחה. פתחו קישור חדש במחשב לתרגיל נוסף.")
297
- session.update(state="solving", error="")
298
- try:
299
- result = await solve_upload(photo, session["user"])
300
- except HTTPException as exc:
301
- session.update(state="error", error=str(exc.detail))
302
- raise
303
- except Exception:
304
- session.update(state="error", error="לא הצלחנו לפתור. נסו שוב.")
305
- raise
306
- session.update(state="done", result=result)
307
- return {"ok": True, "readable": result.get("readable", False)}
308
-
309
  install_answer_routes(app, signed_in)
310
  install_resource_routes(app, signed_in)
311
  install_monitoring_routes(app, manager)
 
3
 
4
  import logging
5
  import os
 
 
6
  import time
7
  from datetime import datetime
8
  from pathlib import Path
 
20
  from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
21
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
22
  from .math_skills import algebra9, practice_graph
23
+ from .push import install_push_routes
24
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
25
  from .unit_resources import install_resource_routes
26
 
 
100
  async def index():
101
  return page()
102
 
 
 
 
 
 
103
  @app.get("/service-worker.js", include_in_schema=False)
104
+ async def service_worker():
105
+ # Push notifications only (see app/push.py). It serves nothing from a cache: an older version kept /static/
106
+ # in its own cache forever, so on activation this one also deletes every cache that version left.
107
+ return Response((STATIC / "service-worker.js").read_text(encoding="utf-8"), media_type="text/javascript",
108
+ headers={"Cache-Control": "no-store"})
 
 
 
 
 
 
 
 
109
 
110
  @app.get("/manifest.webmanifest", include_in_schema=False)
111
  async def manifest():
 
188
  return {"ok": True}
189
 
190
  photo_counts: dict[tuple[str, str], int] = app.state.photo_counts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  def solver() -> PhotoSolver:
193
  key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get("CLAUDE_API_KEY", "").strip()
 
234
  app.state.actions.record(user, "photo", skill if result.get("readable") else "התמונה לא נקראה")
235
  return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
236
 
237
+ install_push_routes(app, signed_in)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  install_answer_routes(app, signed_in)
239
  install_resource_routes(app, signed_in)
240
  install_monitoring_routes(app, manager)
app/push.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Push notifications: the phone is told when the computer asks for a photo of an answer.
2
+
3
+ The computer creates a photo request (POST /api/math/answer-attempts/{id}/scans). The phone already looks for requests
4
+ while the app is open on it; this notification brings the student to the app when it is closed. A tap opens the app,
5
+ and its usual look for requests shows the request's window with the 4-digit code.
6
+
7
+ Only phones subscribe (the page offers it on the same devices that look for requests), so the computer that made the
8
+ request does not get it. The browser's subscription (its push service address and two keys) is kept in the Supabase
9
+ table `math_push_subs` with the account's user ID; only the server's secret key reads it. A subscription the push
10
+ service no longer knows (404/410) is deleted.
11
+
12
+ On an iPhone the browser sends them only from the app added to the home screen (iOS 16.4 and later).
13
+
14
+ Env: VAPID_PRIVATE_KEY (the server's push key; make one with python -m app.push and keep it as a secret.
15
+ Changing it drops every subscription: each phone has to turn notifications on again.)
16
+ VAPID_SUBJECT (a contact the push services may write to, "mailto:..."; default: the first developer's email)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import json
22
+ import logging
23
+ import os
24
+ import urllib.parse
25
+ from datetime import datetime, timezone
26
+
27
+ from .answer_scans import scan_code
28
+ from .auth import AuthUnavailable
29
+
30
+ log = logging.getLogger("mathly.push")
31
+ MAX_ENDPOINT = 1000
32
+ MIGRATION = "supabase/migrations/20261103180000_math_push_subs.sql"
33
+
34
+
35
+ def _b64(raw: bytes) -> str:
36
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
37
+
38
+
39
+ def new_private_key() -> str:
40
+ """A new VAPID private key, as VAPID_PRIVATE_KEY keeps it: the raw 32 bytes, base64url."""
41
+ from cryptography.hazmat.primitives.asymmetric import ec
42
+ return _b64(ec.generate_private_key(ec.SECP256R1()).private_numbers().private_value.to_bytes(32, "big"))
43
+
44
+
45
+ def public_key(private: str) -> str:
46
+ """The browser's applicationServerKey for this private key: the uncompressed point, base64url."""
47
+ from cryptography.hazmat.primitives import serialization
48
+ from cryptography.hazmat.primitives.asymmetric import ec
49
+ raw = base64.urlsafe_b64decode(private + "=" * (-len(private) % 4))
50
+ if len(raw) != 32 or base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != private:
51
+ raise ValueError("not a 32-byte base64url key")
52
+ key = ec.derive_private_key(int.from_bytes(raw, "big"), ec.SECP256R1())
53
+ return _b64(key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint))
54
+
55
+
56
+ def scan_payload(result: dict) -> dict:
57
+ """What the service worker shows for a photo request. No question text or name: it may show on a locked screen.
58
+ The tag is the attempt's, so a new request for the same question replaces the old notification."""
59
+ code = scan_code((result.get("scan") or {}).get("id"))
60
+ return {"title": "📷 בקשת צילום מהמחשב",
61
+ "body": (f"קוד {code} · " if code else "") + "צלמו את התשובה ושלחו למחשב.",
62
+ "url": "/?from=push-scan", "tag": f"scan-{result['attempt']['id']}"}
63
+
64
+
65
+ def seconds_left(result: dict, now: datetime | None = None) -> int:
66
+ """How long the push service should keep trying: until the request expires (a dead request is not worth a ring)."""
67
+ expires = (result.get("scan") or {}).get("expires_at")
68
+ try:
69
+ end = datetime.fromisoformat(str(expires).replace("Z", "+00:00"))
70
+ except ValueError:
71
+ return 900
72
+ return max(0, min(900, int((end - (now or datetime.now(timezone.utc))).total_seconds())))
73
+
74
+
75
+ class Push:
76
+ def __init__(self, auth, private: str = "", subject: str = ""):
77
+ self.auth, self.private, self.subject = auth, private.strip(), subject
78
+ self.public, self.why = "", "" # why: what keeps notifications off, in words for logs and the API
79
+ self.notified: set[str] = set() # scan IDs already announced (a repeated create_scan returns the same scan)
80
+ if not self.private:
81
+ self.why = "VAPID_PRIVATE_KEY is not set"
82
+ elif not getattr(auth, "secret", ""):
83
+ self.why = "SUPABASE_SECRET_KEY is not set"
84
+ else:
85
+ try:
86
+ self.public = public_key(self.private)
87
+ except ValueError as e:
88
+ log.error("VAPID_PRIVATE_KEY is not a valid key: push notifications are off (%s)", e)
89
+ self.why = "VAPID_PRIVATE_KEY is not a valid key"
90
+
91
+ @property
92
+ def on(self) -> bool:
93
+ return not self.why
94
+
95
+ # the table
96
+ def _admin(self, path: str, method: str = "GET", body=None, prefer: str = "return=representation"):
97
+ try:
98
+ return self.auth._admin(path, method, body, prefer=prefer)
99
+ except AuthUnavailable as e:
100
+ if "answered 404" in str(e) or "math_push_subs" in str(e):
101
+ raise AuthUnavailable(f"the table math_push_subs is missing; apply {MIGRATION}") from e
102
+ raise
103
+
104
+ def save(self, user_id: str, sub: dict) -> None:
105
+ """Keeps a browser's subscription for this account, raising ValueError when it is not one.
106
+ A subscription that belonged to another account on this phone moves to this one."""
107
+ endpoint, keys = str(sub.get("endpoint") or ""), sub.get("keys") or {}
108
+ if not endpoint.startswith("https://") or len(endpoint) > MAX_ENDPOINT or not isinstance(keys, dict):
109
+ raise ValueError("not a push subscription")
110
+ p256dh, auth = str(keys.get("p256dh") or ""), str(keys.get("auth") or "")
111
+ if not (0 < len(p256dh) <= 200 and 0 < len(auth) <= 100):
112
+ raise ValueError("not a push subscription")
113
+ self._admin("/rest/v1/math_push_subs?on_conflict=endpoint", "POST",
114
+ {"endpoint": endpoint, "user_id": user_id, "p256dh": p256dh, "auth": auth},
115
+ prefer="resolution=merge-duplicates,return=minimal")
116
+
117
+ def delete(self, endpoint: str, user_id: str | None = None) -> None:
118
+ q = {"endpoint": f"eq.{endpoint}", **({"user_id": f"eq.{user_id}"} if user_id else {})}
119
+ self._admin("/rest/v1/math_push_subs?" + urllib.parse.urlencode(q), "DELETE", prefer="return=minimal")
120
+
121
+ def mine(self, user_id: str) -> list[dict]:
122
+ return self._admin("/rest/v1/math_push_subs?select=*&" + urllib.parse.urlencode({"user_id": f"eq.{user_id}"})) or []
123
+
124
+ # sending
125
+ def send(self, row: dict, payload: dict, ttl: int) -> bool:
126
+ """One notification; False when it did not go (a subscription that is gone is deleted)."""
127
+ from pywebpush import WebPushException, webpush
128
+ try:
129
+ webpush({"endpoint": row["endpoint"], "keys": {"p256dh": row["p256dh"], "auth": row["auth"]}},
130
+ json.dumps(payload, ensure_ascii=False), vapid_private_key=self.private,
131
+ vapid_claims={"sub": self.subject}, ttl=ttl, timeout=10,
132
+ headers={"Urgency": "high"}) # the student is waiting at the computer
133
+ return True
134
+ except WebPushException as e:
135
+ status = getattr(e.response, "status_code", None)
136
+ if status in (404, 410):
137
+ log.info("push subscription is gone: deleted")
138
+ try:
139
+ self.delete(row["endpoint"])
140
+ except AuthUnavailable as e2:
141
+ log.warning("push subscription not deleted: %s", e2)
142
+ else:
143
+ log.warning("push failed: %s", e)
144
+ except Exception as e: # a network failure: the phone still finds the request when the app opens
145
+ log.warning("push failed: %s", e)
146
+ return False
147
+
148
+ def scan_requested(self, user_id: str, result: dict) -> int:
149
+ """Tells the account's phones about a new photo request; how many went. Called in the background."""
150
+ scan = result.get("scan") or {}
151
+ if not self.on or scan.get("state") != "waiting" or not scan.get("id") or scan["id"] in self.notified:
152
+ return 0
153
+ ttl = seconds_left(result)
154
+ if not ttl:
155
+ return 0
156
+ self.notified.add(scan["id"])
157
+ if len(self.notified) > 10_000:
158
+ self.notified.clear()
159
+ try:
160
+ rows = self.mine(user_id)
161
+ except AuthUnavailable as e:
162
+ log.warning("photo request not pushed: %s", e)
163
+ return 0
164
+ message = scan_payload(result)
165
+ return sum(self.send(row, message, ttl) for row in rows)
166
+
167
+
168
+ def load_push(auth) -> Push | None:
169
+ if auth is None:
170
+ return None
171
+ subject = os.environ.get("VAPID_SUBJECT", "").strip()
172
+ if not subject:
173
+ dev = sorted(getattr(auth, "developers", ()) or getattr(auth, "teachers", ()) or ())
174
+ subject = f"mailto:{dev[0]}" if dev else "mailto:admin@example.com"
175
+ push = Push(auth, os.environ.get("VAPID_PRIVATE_KEY", ""), subject)
176
+ log.info("photo request notifications: %s", "on" if push.on else f"off ({push.why})")
177
+ return push
178
+
179
+
180
+ def install_push_routes(app, signed_in):
181
+ """GET /api/push (the public key), POST /api/push/subscribe and /api/push/unsubscribe."""
182
+ from fastapi import Body, Depends, HTTPException
183
+ from fastapi.responses import JSONResponse
184
+
185
+ if not hasattr(app.state, "push"):
186
+ app.state.push = load_push(app.state.auth)
187
+
188
+ def account(user=Depends(signed_in)) -> str:
189
+ if not user or not user.get("id"):
190
+ raise HTTPException(401, "sign_in_required")
191
+ return user["id"]
192
+
193
+ def push_on() -> Push:
194
+ p = app.state.push
195
+ if not p or not p.on:
196
+ raise HTTPException(503, f"push notifications are off: {p.why if p else 'no sign-in'}")
197
+ return p
198
+
199
+ @app.get("/api/push")
200
+ def push_info(_user: str = Depends(account)):
201
+ """The server's push key, for the page to subscribe with; empty when notifications are off."""
202
+ p = app.state.push
203
+ return JSONResponse({"key": p.public if p and p.on else ""}, headers={"Cache-Control": "no-store"})
204
+
205
+ @app.post("/api/push/subscribe")
206
+ def push_subscribe(body: dict = Body(...), user: str = Depends(account)):
207
+ p = push_on()
208
+ try:
209
+ p.save(user, body)
210
+ except ValueError as e:
211
+ raise HTTPException(400, str(e)) from e
212
+ except AuthUnavailable as e:
213
+ raise HTTPException(503, str(e)) from e
214
+ return {"ok": True}
215
+
216
+ @app.post("/api/push/unsubscribe")
217
+ def push_unsubscribe(body: dict = Body(...), user: str = Depends(account)):
218
+ p = push_on()
219
+ try:
220
+ p.delete(str(body.get("endpoint") or ""), user)
221
+ except AuthUnavailable as e:
222
+ raise HTTPException(503, str(e)) from e
223
+ return {"ok": True}
224
+
225
+
226
+ if __name__ == "__main__":
227
+ key = new_private_key()
228
+ print(f"VAPID_PRIVATE_KEY={key}\n# its public key (the page reads it from the server): {public_key(key)}")
openspec/changes/add-open-question-phone-scan/proposal.md CHANGED
@@ -28,6 +28,6 @@ None. This repository has no main capability specifications yet.
28
 
29
  - Question validation in `app/math_skills.py`, sample content, and question rendering in `static/math.js`.
30
  - Authenticated attempt and scan APIs in `app/main.py` with a dedicated persistence module.
31
- - Existing account integration in `app/auth.py`, mobile/PWA entry in `static/index.html`, and reusable photo preparation from `static/phone.js`.
32
  - Additive Supabase tables and private image storage; use a separate migration rather than rerunning the destructive `docs/mathly-supabase.sql` script.
33
  - API, ownership, concurrency, and desktop/mobile browser coverage alongside current phone-solver tests.
 
28
 
29
  - Question validation in `app/math_skills.py`, sample content, and question rendering in `static/math.js`.
30
  - Authenticated attempt and scan APIs in `app/main.py` with a dedicated persistence module.
31
+ - Existing account integration in `app/auth.py`, mobile/PWA entry in `static/index.html`, and reusable photo preparation from `static/photo-image.js`.
32
  - Additive Supabase tables and private image storage; use a separate migration rather than rerunning the destructive `docs/mathly-supabase.sql` script.
33
  - API, ownership, concurrency, and desktop/mobile browser coverage alongside current phone-solver tests.
requirements.txt CHANGED
@@ -3,7 +3,7 @@ uvicorn[standard]>=0.29
3
  anyio>=4.10,<4.11 # Python 3.10: newer releases can hang Starlette's worker-thread bridge
4
  python-multipart>=0.0.9
5
  python-dotenv>=1.0
6
- qrcode>=7.4,<9
7
  anthropic>=1.0 # photographed-problem hints and solutions
8
  tzdata>=2024.1
9
  Pillow==12.3.0 # validate and strip metadata; compatible with Python 3.10 and 3.11
 
 
3
  anyio>=4.10,<4.11 # Python 3.10: newer releases can hang Starlette's worker-thread bridge
4
  python-multipart>=0.0.9
5
  python-dotenv>=1.0
 
6
  anthropic>=1.0 # photographed-problem hints and solutions
7
  tzdata>=2024.1
8
  Pillow==12.3.0 # validate and strip metadata; compatible with Python 3.10 and 3.11
9
+ pywebpush>=2.0,<3 # phone notifications for photo requests (app/push.py)
static/answer-scans.js CHANGED
@@ -31,8 +31,87 @@ function configure(options){
31
  reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;
32
  if(!account)return;
33
  // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
34
- if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);}
35
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  /* The phone looks for requests every few seconds while the app is open. A new request pops up as a window with its
37
  4-digit code (the same code the computer shows); the student photographs the answer or declines. */
38
  async function pollInbox(e){
@@ -48,7 +127,7 @@ async function pollInbox(e){
48
  if(!mine||mine.scan.id!==capture.result.scan.id&&!capture.busy)capture.withdrawn?.(mine);
49
  }
50
  const ask=list.find(r=>["waiting","confirmed","failed"].includes(r.scan.state)&&!handled.has(r.scan.id));
51
- if(ask&&!capture)openCapture(ask);
52
  }catch(error){
53
  if(!live(e))return;
54
  if(error.status===401){reset();return;}
@@ -194,7 +273,7 @@ function openCapture(result){
194
  const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
195
  const controls=busy=>{c.busy=busy;take.disabled=send.disabled=renew.disabled=decline.disabled=busy;};
196
  // the request is over: only closing is left
197
- const finish=message=>{c.ended=true;handled.add(c.result.scan.id);take.hidden=send.hidden=renew.hidden=decline.hidden=true;close.hidden=false;say(message);close.focus();};
198
  const expired=()=>{take.hidden=send.hidden=true;renew.hidden=false;say(errors.expired);};
199
  c.withdrawn=mine=>{ if(c.photo&&mine&&waitingState(mine.scan.state)){c.result=mine;return;} finish("הבקשה בוטלה במחשב."); };
200
  close.onclick=()=>{closeCapture();if(valid())pollInbox(e);};
@@ -223,7 +302,7 @@ function openCapture(result){
223
  }catch(error){say("לא ניתן להכין את התמונה. נסו לצלם שוב.");}
224
  finally{if(valid())controls(false);}
225
  };
226
- const success=()=>{c.photo=null;finish("✓ התשובה נשלחה למחשב.");};
227
  send.onclick=async()=>{
228
  if(!c.photo||c.busy)return;
229
  controls(true);say(states.uploading);
@@ -322,9 +401,9 @@ function waitForPhone(result,scanTo={}){
322
  }
323
  document.addEventListener("visibilitychange",()=>{
324
  if(document.hidden)return;
325
- if(account&&inbox)pollInbox(epoch);
326
  if(question?.result)pollQuestion(question);
327
  });
328
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
329
- window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset,waitForPhone};
330
  })();
 
31
  reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;
32
  if(!account)return;
33
  // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
34
+ if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);pushSync(epoch);}
35
  }
36
+ /* ---------- the phone: a notification when the computer asks for a photo (app/push.py) ----------
37
+ Only the phone subscribes, so the computer that made the request is not notified. The notification just brings the
38
+ student here; the look for requests above shows the request itself. On an iPhone only the app added to the home
39
+ screen may get notifications. A tap while the app is open comes as a message from the service worker. */
40
+ const PUSH_IOS=/iPhone|iPad|iPod/.test(navigator.userAgent)||(navigator.platform==="MacIntel"&&navigator.maxTouchPoints>1);
41
+ const PUSH_STANDALONE=matchMedia("(display-mode: standalone)").matches||navigator.standalone===true;
42
+ const PUSH_CAN="serviceWorker" in navigator&&"PushManager" in window&&"Notification" in window;
43
+ const PUSH_OFF="mathly.pushOff"; // turned off here: not turned on again by itself
44
+ const pushOptOut=v=>{try{if(v===undefined)return localStorage.getItem(PUSH_OFF)==="1";v?localStorage.setItem(PUSH_OFF,"1"):localStorage.removeItem(PUSH_OFF);}catch{return false;}};
45
+ let pushKey=null,pushReg=null;
46
+ function pushWorker(){
47
+ if(!("serviceWorker" in navigator))return Promise.resolve(null);
48
+ return pushReg||=navigator.serviceWorker.register("/service-worker.js").then(()=>navigator.serviceWorker.ready).catch(()=>null);
49
+ }
50
+ async function pushState(){ // {key, sub}: key is empty when the server sends no notifications
51
+ if(!PUSH_CAN||!account)return {key:"",sub:null};
52
+ if(pushKey===null)pushKey=await json("/api/push").then(d=>d.key||"").catch(()=>"");
53
+ const reg=await pushWorker();
54
+ const sub=reg?await reg.pushManager.getSubscription().catch(()=>null):null;
55
+ return {key:pushKey,sub};
56
+ }
57
+ const subscribeBody=sub=>({method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(sub.toJSON())});
58
+ async function pushOn(key){ // asks the permission (call it from a tap) and subscribes; the subscription, or null
59
+ if(await Notification.requestPermission()!=="granted")return null;
60
+ const reg=await pushWorker();if(!reg)throw Error("no worker");
61
+ const raw=atob(key.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-key.length%4)%4));
62
+ const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(raw,c=>c.charCodeAt(0))});
63
+ const r=await api("/api/push/subscribe",subscribeBody(sub));
64
+ if(!r.ok){await sub.unsubscribe().catch(()=>{});throw Error("not kept");}
65
+ pushOptOut(false);return sub;
66
+ }
67
+ async function pushOff(sub){
68
+ await api("/api/push/unsubscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:sub.endpoint})}).catch(()=>{});
69
+ await sub.unsubscribe().catch(()=>{});
70
+ }
71
+ /* A phone's subscription belongs to the account signed in on it: on sign-in it moves to that account (or, notifications
72
+ being allowed already, is made again after a sign-out), unless it was turned off here. */
73
+ async function pushSync(e){
74
+ try{
75
+ const st=await pushState();if(!live(e)||!st.key)return;
76
+ if(st.sub)await api("/api/push/subscribe",subscribeBody(st.sub));
77
+ else if(Notification.permission==="granted"&&!pushOptOut())await pushOn(st.key);
78
+ }catch{}
79
+ }
80
+ async function forgetPush(){ // signing out: the next account on this phone does not get this one's requests
81
+ try{const st=await pushState();if(st.sub)await pushOff(st.sub);}catch{}
82
+ }
83
+ async function clearScanNotices(){ // the app is open and shows requests itself: the tray's ones are old news
84
+ try{const reg=await navigator.serviceWorker?.getRegistration();for(const n of await reg?.getNotifications()||[])if(String(n.tag).startsWith("scan-"))n.close();}catch{}
85
+ }
86
+ /* The offer, at the end of a request on the phone: "next time, tell me". Nothing is shown when notifications cannot
87
+ work here or are blocked; an iPhone's browser gets the home-screen hint instead. */
88
+ function pushOffer(node,e){
89
+ const box=document.createElement("div");box.className="mx-push-offer";box.hidden=true;node.append(box);
90
+ const draw=(text,button,act)=>{
91
+ box.replaceChildren();const p=document.createElement("small");p.textContent=text;box.append(p);
92
+ if(button){const b=document.createElement("button");b.className="mx-btn";b.type="button";b.textContent=button;b.onclick=async()=>{
93
+ b.disabled=true;try{await act();}catch{p.textContent="לא הצלחנו להפעיל התראות. נסו שוב מאוחר יותר.";}finally{b.disabled=false;}};box.append(b);}
94
+ box.hidden=false;
95
+ };
96
+ if(!PUSH_CAN){
97
+ if(PUSH_IOS&&!PUSH_STANDALONE)draw("📱 רוצים שהטלפון יצלצל כשהמחשב מבקש צילום? באייפון זה עובד רק מהאפליקציה שעל מסך הבית: שיתוף ← „הוספה למסך הבית”.");
98
+ return;
99
+ }
100
+ pushState().then(st=>{
101
+ if(!live(e)||!st.key||Notification.permission==="denied")return;
102
+ const show=sub=>sub
103
+ ?draw("🔔 התראה תגיע לטלפון כשהמחשב מבקש צילום.","כיבוי ההתראות",async()=>{await pushOff(sub);pushOptOut(true);show(null);})
104
+ :draw("רוצים שהטלפון יודיע לכם בפעם הבאה שהמחשב מבקש צילום, גם כשהאפליקציה סגורה?","🔔 כן, להודיע לי",async()=>{const s=await pushOn(st.key);if(live(e))show(s);});
105
+ show(st.sub);
106
+ });
107
+ }
108
+ if("serviceWorker" in navigator){
109
+ navigator.serviceWorker.addEventListener("message",ev=>{
110
+ if(ev.data?.type!=="notified"||!account||!inbox)return;
111
+ clearScanNotices();pollInbox(epoch);
112
+ });
113
+ }
114
+ if(new URLSearchParams(location.search).get("from")==="push-scan")history.replaceState(null,"",location.pathname);
115
  /* The phone looks for requests every few seconds while the app is open. A new request pops up as a window with its
116
  4-digit code (the same code the computer shows); the student photographs the answer or declines. */
117
  async function pollInbox(e){
 
127
  if(!mine||mine.scan.id!==capture.result.scan.id&&!capture.busy)capture.withdrawn?.(mine);
128
  }
129
  const ask=list.find(r=>["waiting","confirmed","failed"].includes(r.scan.state)&&!handled.has(r.scan.id));
130
+ if(ask&&!capture){openCapture(ask);clearScanNotices();}
131
  }catch(error){
132
  if(!live(e))return;
133
  if(error.status===401){reset();return;}
 
273
  const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
274
  const controls=busy=>{c.busy=busy;take.disabled=send.disabled=renew.disabled=decline.disabled=busy;};
275
  // the request is over: only closing is left
276
+ const finish=(message,offer=false)=>{c.ended=true;handled.add(c.result.scan.id);take.hidden=send.hidden=renew.hidden=decline.hidden=true;close.hidden=false;say(message);if(offer)pushOffer($(".mx-scan-actions"),e);close.focus();};
277
  const expired=()=>{take.hidden=send.hidden=true;renew.hidden=false;say(errors.expired);};
278
  c.withdrawn=mine=>{ if(c.photo&&mine&&waitingState(mine.scan.state)){c.result=mine;return;} finish("הבקשה בוטלה במחשב."); };
279
  close.onclick=()=>{closeCapture();if(valid())pollInbox(e);};
 
302
  }catch(error){say("לא ניתן להכין את התמונה. נסו לצלם שוב.");}
303
  finally{if(valid())controls(false);}
304
  };
305
+ const success=()=>{c.photo=null;finish("✓ התשובה נשלחה למחשב.",true);};
306
  send.onclick=async()=>{
307
  if(!c.photo||c.busy)return;
308
  controls(true);say(states.uploading);
 
401
  }
402
  document.addEventListener("visibilitychange",()=>{
403
  if(document.hidden)return;
404
+ if(account&&inbox){clearScanNotices();pollInbox(epoch);}
405
  if(question?.result)pollQuestion(question);
406
  });
407
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
408
+ window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset,waitForPhone,forgetPush};
409
  })();
static/math.css CHANGED
@@ -129,9 +129,6 @@ html.math-on, html.math-on body{overflow:hidden}
129
  .mx-geometry path{stroke:var(--uc);stroke-width:2;fill:none}
130
  .mx-geometry text{fill:var(--ink);font:16px var(--f-body)}
131
  .mx-geometry small{font-size:12px;color:var(--muted)}
132
- .mx-pair-content{display:flex;flex-direction:column;gap:14px;align-items:stretch}
133
- .mx-phone-qr{display:block;width:230px;max-width:100%;height:auto;background:white;margin:auto;border-radius:8px}
134
- .mx-phone-url{display:block;box-sizing:border-box;width:100%;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;font:14px monospace}
135
  /* Handwritten answer scanning */
136
  .mx-answer{display:flex;flex-direction:column;gap:12px}
137
  .mx-answer [hidden]{display:none}
@@ -148,6 +145,8 @@ html.math-on, html.math-on body{overflow:hidden}
148
  .mx-scan-actions{display:flex;flex-direction:column;gap:10px}
149
  .mx-scan-actions .mx-btn{min-height:50px;font-size:17px}
150
  .mx-btn-quiet{border-color:#485050;color:#d7dcd8}
 
 
151
  .mx-scan-live{display:flex;align-items:center;justify-content:center;gap:10px;font-weight:700}
152
  .mx-scan-dot{width:10px;height:10px;border-radius:50%;background:var(--mx-accent);animation:sky-pulse 1.4s ease-out infinite}
153
  .mx-scan-wait.is-over .mx-scan-dot{display:none}
 
129
  .mx-geometry path{stroke:var(--uc);stroke-width:2;fill:none}
130
  .mx-geometry text{fill:var(--ink);font:16px var(--f-body)}
131
  .mx-geometry small{font-size:12px;color:var(--muted)}
 
 
 
132
  /* Handwritten answer scanning */
133
  .mx-answer{display:flex;flex-direction:column;gap:12px}
134
  .mx-answer [hidden]{display:none}
 
145
  .mx-scan-actions{display:flex;flex-direction:column;gap:10px}
146
  .mx-scan-actions .mx-btn{min-height:50px;font-size:17px}
147
  .mx-btn-quiet{border-color:#485050;color:#d7dcd8}
148
+ .mx-push-offer{display:flex;flex-direction:column;gap:8px;padding-top:12px;border-top:1px solid #485050}
149
+ .mx-push-offer[hidden]{display:none}
150
  .mx-scan-live{display:flex;align-items:center;justify-content:center;gap:10px;font-weight:700}
151
  .mx-scan-dot{width:10px;height:10px;border-radius:50%;background:var(--mx-accent);animation:sky-pulse 1.4s ease-out infinite}
152
  .mx-scan-wait.is-over .mx-scan-dot{display:none}
static/math.js CHANGED
@@ -33,10 +33,8 @@ let G=null, BY={}, POST={}, ORDER=[], P={done:{},tries:{},solved:{},at:{}}, root
33
  let nav=null, FIRST=true, COMBO=0, REV=null, QZ=null, UQ=null; // UQ: the unit quiz on the sheet (see unit quiz below) // QZ: the practice exam in progress (see quiz below) // the tab bar; whether the shown question is still at its first try; first-try answers in a row
34
  let picker=null, PH=null, resPicker=null, RES=null; // RES: the open lesson-resources sheet (see openResources()) // the photo file picker, and the photographed problem on the sheet (see photo())
35
  let advanceTimer=null;
36
- let phonePair=null;
37
  function stopPhone(){
38
  window.MathlyAnswers?.closeQuestion();
39
- if(phonePair){clearTimeout(phonePair.timer);if(phonePair.qr)URL.revokeObjectURL(phonePair.qr);phonePair=null;}
40
  }
41
  function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
42
 
@@ -988,18 +986,16 @@ function onClick(e){
988
  case "cal-prev": return showStreak(-1);
989
  case "cal-next": return showStreak(1);
990
  case "sound": Sky.setSound(!Sky.soundOn()); Sky.sound.tap(); return draw();
991
- case "phone": return pairPhone();
992
  case "resources": return openResources(t.dataset.unit);
993
  case "res-take": case "res-retake": return resPicker.click();
994
  case "res-cancel": RES.photo=null; RES.msg=""; return drawResources();
995
  case "res-delete": return deleteResource(t.dataset.res);
996
- case "copy-phone": return copyPhone(t);
997
  case "photo": case "retake": return picker.click();
998
  case "hint": PH.shown++; return drawPhoto();
999
  case "work": PH.works.add(+t.dataset.i); return drawPhoto();
1000
  case "all": PH.all=true; return drawPhoto();
1001
  case "close": return closeSheet();
1002
- case "out": window.MathlyAnswers?.reset();closeSheet();hide();return client?.auth.signOut().then(()=>location.reload());
1003
  }
1004
  }
1005
 
@@ -1366,74 +1362,6 @@ function drawPhoto(){
1366
  if(PH.fresh){ PH.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
1367
  }
1368
 
1369
- async function copyPhone(button){
1370
- try{
1371
- await navigator.clipboard.writeText(phonePair?.url||"");
1372
- button.textContent="הקישור הועתק";
1373
- }catch{
1374
- const input=sheet.querySelector(".mx-phone-url");
1375
- input?.focus();input?.select();
1376
- button.textContent="בחרו והעתיקו את הקישור";
1377
- }
1378
- }
1379
-
1380
- async function pairPhone(){
1381
- cancelAdvance();stopPhone();
1382
- if(!cur)back=document.activeElement;
1383
- cur="phone";
1384
- const pair=phonePair={timer:null,qr:null,url:""};
1385
- sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div><div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1386
- <div class="mx-shtop"><h3 id="mxTitle">צילום בטלפון, פתרון במחשב</h3><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1387
- <div class="mx-pair-content"></div><p class="mx-pair-status" role="status" aria-live="polite">מכינים קישור לטלפון…</p></div>`;
1388
- sheet.hidden=false;sheet.querySelector(".mx-x").focus({preventScroll:true});
1389
- const content=sheet.querySelector(".mx-pair-content"),message=sheet.querySelector(".mx-pair-status");
1390
- try{
1391
- const response=await api("/api/math/photo-sessions",{method:"POST"}),data=await response.json();
1392
- if(!response.ok)throw Error(data.detail||"לא הצלחנו ליצור קישור.");
1393
- if(phonePair!==pair)return;
1394
- pair.url=`${location.origin}/phone#${data.token}`;
1395
- content.innerHTML=`<p>סרקו בטלפון וצלמו את התרגיל. התשובה והפתרון יופיעו כאן. הקישור תקף ל־15 דקות ולתרגיל אחד.</p>
1396
- <div class="mx-qr-slot"></div><label>אפשר גם לפתוח בטלפון את הקישור:<input class="mx-phone-url" readonly dir="ltr" value="${esc(pair.url)}" aria-label="קישור לצילום בטלפון"></label>
1397
- <button class="mx-btn" data-act="copy-phone">העתקת קישור</button>
1398
- ${["localhost","127.0.0.1","[::1]"].includes(location.hostname)?'<p class="mx-bad">כדי שהטלפון יתחבר, פתחו במחשב את האתר בכתובת רשת שנגישה גם מהטלפון, ואז צרו קישור חדש.</p>':""}`;
1399
- message.textContent="ממתינים לתמונה מהטלפון…";
1400
- // Start polling independently so a QR failure still leaves a working link.
1401
- const poll=async()=>{
1402
- if(phonePair!==pair)return;
1403
- try{
1404
- const res=await api(`/api/math/photo-sessions/${data.token}`,{cache:"no-store"}),result=await res.json();
1405
- if(phonePair!==pair)return;
1406
- if(!res.ok){
1407
- if(res.status>=400&&res.status<500){
1408
- message.textContent=result.detail||"החיבור הופסק. פתחו קישור חדש.";
1409
- stopPhone();return;
1410
- }
1411
- throw Error(result.detail||"החיבור נקטע לרגע. מנסים שוב…");
1412
- }
1413
- if(result.state==="done"){
1414
- stopPhone();cur="photo";
1415
- if(PH?.url)URL.revokeObjectURL(PH.url);
1416
- PH={url:null,data:result.result,error:"",busy:false,shown:1,works:new Set(),all:true,fresh:true};
1417
- drawPhoto();return;
1418
- }
1419
- message.textContent=result.state==="solving"?"התמונה התקבלה! קוראים ופותרים…":result.state==="error"?result.error+" אפשר לנסות שוב בטלפון.":"ממתינים לתמונה מהטלפון…";
1420
- }catch(error){
1421
- if(phonePair!==pair)return;
1422
- message.textContent="החיבור נקטע לרגע. מנסים שוב…";
1423
- }
1424
- pair.timer=setTimeout(poll,2000);
1425
- };
1426
- poll();
1427
- try{
1428
- const qr=await api("/api/math/photo-qr",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:pair.url})});
1429
- if(!qr.ok)throw Error("qr");
1430
- const blob=await qr.blob();if(phonePair!==pair)return;
1431
- pair.qr=URL.createObjectURL(blob);
1432
- content.querySelector(".mx-qr-slot").innerHTML=`<img class="mx-phone-qr" src="${pair.qr}" alt="סרקו בטלפון כדי לצלם תרגיל">`;
1433
- }catch{if(phonePair===pair)content.querySelector(".mx-qr-slot").textContent="הקוד לא נטען. אפשר להעתיק את הקישור לטלפון.";}
1434
- }catch(error){if(phonePair===pair)message.textContent=error.message;}
1435
- }
1436
-
1437
  /* Lesson resources (app/unit_resources.py). A teacher taps a unit's title on the path, photographs an exercise or an
1438
  explanation, says what it shows (a new exercise, a theory explanation, or other words) and, if they like, which of
1439
  the unit's topics it belongs to. Images come through the API with the teacher's sign-in, so each is a blob URL. */
 
33
  let nav=null, FIRST=true, COMBO=0, REV=null, QZ=null, UQ=null; // UQ: the unit quiz on the sheet (see unit quiz below) // QZ: the practice exam in progress (see quiz below) // the tab bar; whether the shown question is still at its first try; first-try answers in a row
34
  let picker=null, PH=null, resPicker=null, RES=null; // RES: the open lesson-resources sheet (see openResources()) // the photo file picker, and the photographed problem on the sheet (see photo())
35
  let advanceTimer=null;
 
36
  function stopPhone(){
37
  window.MathlyAnswers?.closeQuestion();
 
38
  }
39
  function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
40
 
 
986
  case "cal-prev": return showStreak(-1);
987
  case "cal-next": return showStreak(1);
988
  case "sound": Sky.setSound(!Sky.soundOn()); Sky.sound.tap(); return draw();
 
989
  case "resources": return openResources(t.dataset.unit);
990
  case "res-take": case "res-retake": return resPicker.click();
991
  case "res-cancel": RES.photo=null; RES.msg=""; return drawResources();
992
  case "res-delete": return deleteResource(t.dataset.res);
 
993
  case "photo": case "retake": return picker.click();
994
  case "hint": PH.shown++; return drawPhoto();
995
  case "work": PH.works.add(+t.dataset.i); return drawPhoto();
996
  case "all": PH.all=true; return drawPhoto();
997
  case "close": return closeSheet();
998
+ case "out": return Promise.resolve(window.MathlyAnswers?.forgetPush?.()).finally(()=>{window.MathlyAnswers?.reset();closeSheet();hide();return client?.auth.signOut().then(()=>location.reload());});
999
  }
1000
  }
1001
 
 
1362
  if(PH.fresh){ PH.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
1363
  }
1364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1365
  /* Lesson resources (app/unit_resources.py). A teacher taps a unit's title on the path, photographs an exercise or an
1366
  explanation, says what it shows (a new exercise, a theory explanation, or other words) and, if they like, which of
1367
  the unit's topics it belongs to. Images come through the API with the teacher's sign-in, so each is a blob URL. */
static/service-worker.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Mathly's service worker: push notifications only (app/push.py). It has no fetch handler and keeps no cache.
2
+ An older version served /static/ from its own cache forever; activating this one deletes those caches. */
3
+ self.addEventListener("install", () => self.skipWaiting());
4
+ self.addEventListener("activate", event => event.waitUntil((async () => {
5
+ await Promise.all((await caches.keys()).map(key => caches.delete(key)));
6
+ await self.clients.claim();
7
+ })()));
8
+
9
+ // a photo request from the computer (or any later message): shown even when the app is closed
10
+ self.addEventListener("push", event => {
11
+ let msg = {};
12
+ try { msg = event.data ? event.data.json() : {}; } catch { msg = { body: event.data && event.data.text() }; }
13
+ event.waitUntil(self.registration.showNotification(msg.title || "לומדים בכיף", {
14
+ body: msg.body || "", tag: msg.tag || "mathly", renotify: true, requireInteraction: false,
15
+ lang: "he", dir: "rtl",
16
+ icon: "/static/icons/icon-192.png", badge: "/static/icons/icon-192.png",
17
+ data: { url: msg.url || "/" }
18
+ }));
19
+ });
20
+
21
+ // a tap: the app already open is brought forward and told to look for requests now; otherwise it is opened
22
+ self.addEventListener("notificationclick", event => {
23
+ event.notification.close();
24
+ const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
25
+ event.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(wins => {
26
+ const app = wins.find(w => new URL(w.url).origin === self.location.origin && !new URL(w.url).pathname.startsWith("/static/"));
27
+ if (app) { app.postMessage({ type: "notified", url }); return app.focus(); }
28
+ return self.clients.openWindow(url);
29
+ }));
30
+ });
supabase/migrations/20261103180000_math_push_subs.sql ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Push subscriptions of phones (app/push.py): a phone is notified when the computer asks for a photo of an answer.
2
+ -- Additive: one new table; nothing existing changes. Only the server's secret key reads or writes it.
3
+ begin;
4
+ create table public.math_push_subs (
5
+ endpoint text primary key check (endpoint like 'https://%' and length(endpoint) <= 1000),
6
+ user_id uuid not null references auth.users(id) on delete cascade,
7
+ p256dh text not null check (length(p256dh) between 1 and 200),
8
+ auth text not null check (length(auth) between 1 and 100),
9
+ created_at timestamptz not null default now()
10
+ );
11
+ create index math_push_subs_user on public.math_push_subs(user_id);
12
+ alter table public.math_push_subs enable row level security;
13
+ revoke all on public.math_push_subs from public, anon, authenticated;
14
+ grant select, insert, update, delete on public.math_push_subs to service_role;
15
+ commit;
tests/test_phone_browser.py CHANGED
@@ -1,5 +1,4 @@
1
  """Optional browser regression checks: pip install playwright && playwright install chromium."""
2
- import base64
3
  import socket
4
  import threading
5
  import time
@@ -40,64 +39,6 @@ def phone_server(monkeypatch):
40
  thread.join(timeout=5)
41
 
42
 
43
- def test_phone_refresh_upload_and_desktop_network_recovery(phone_server):
44
- with playwright.sync_playwright() as p:
45
- browser = p.chromium.launch()
46
- try:
47
- computer = browser.new_page()
48
- computer.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
49
- polls = []
50
-
51
- def flaky_network(route):
52
- polls.append(route.request.url)
53
- if len(polls) == 1:
54
- route.abort()
55
- else:
56
- route.continue_()
57
-
58
- computer.route("**/api/math/photo-sessions/*", flaky_network)
59
- computer.goto(phone_server)
60
- computer.locator('[data-act="phone"]').click()
61
- qr = computer.locator(".mx-phone-qr")
62
- playwright.expect(qr).to_be_visible()
63
- assert qr.evaluate("img => img.complete && img.naturalWidth > 0")
64
- url = computer.locator(".mx-phone-url").input_value()
65
-
66
- # LAN HTTP pages have no Clipboard API. The link must still be selectable.
67
- computer.evaluate('Object.defineProperty(navigator, "clipboard", {value: undefined})')
68
- computer.locator('[data-act="copy-phone"]').click()
69
- playwright.expect(computer.locator(".mx-phone-url")).to_be_focused()
70
-
71
- phone = browser.new_context(is_mobile=True, viewport={"width": 390, "height": 844}).new_page()
72
- phone.goto(url)
73
- assert "#" not in phone.url
74
- phone.reload()
75
- playwright.expect(phone.locator("#take")).to_be_enabled()
76
- phone.locator("#camera").set_input_files({"name": "bad.heic", "mimeType": "image/heic", "buffer": b"undecodable"})
77
- playwright.expect(phone.locator("#status")).to_contain_text("JPEG, PNG")
78
- playwright.expect(phone.locator("#send")).to_be_hidden()
79
- # Exercise the fallback used when camera photos cannot use createImageBitmap.
80
- phone.evaluate("window.createImageBitmap = undefined")
81
- encoded = computer.evaluate("""() => {
82
- const canvas = document.createElement('canvas');
83
- canvas.width = canvas.height = 10;
84
- return canvas.toDataURL('image/png').split(',')[1];
85
- }""")
86
- png = base64.b64decode(encoded)
87
- phone.locator("#camera").set_input_files({"name": "exercise.png", "mimeType": "image/png", "buffer": png})
88
- # A proxy can return HTML on failure. Keep the photo ready for another attempt.
89
- phone.route("**/api/math/phone-photo", lambda route: route.fulfill(status=503, content_type="text/html", body="Unavailable"), times=1)
90
- phone.locator("#send").click()
91
- playwright.expect(phone.locator("#status")).to_have_text("לא הצלחנו לשלוח. נסו שוב.")
92
- playwright.expect(phone.locator("#send")).to_be_enabled()
93
- phone.locator("#send").click()
94
- playwright.expect(phone.locator("#status")).to_have_text("הפתרון מוכן במחשב!")
95
- playwright.expect(computer.locator(".mx-ok")).to_contain_text("10 cm", timeout=10000)
96
- assert len(polls) >= 2
97
- finally:
98
- browser.close()
99
-
100
-
101
  @pytest.mark.parametrize("manager", [True, False])
102
  def test_class_management_geometry_and_permissions(phone_server, manager):
103
  import json
 
1
  """Optional browser regression checks: pip install playwright && playwright install chromium."""
 
2
  import socket
3
  import threading
4
  import time
 
39
  thread.join(timeout=5)
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  @pytest.mark.parametrize("manager", [True, False])
43
  def test_class_management_geometry_and_permissions(phone_server, manager):
44
  import json
tests/test_push.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import urllib.parse
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from app import push as pushes
11
+ from app.answer_scans import scan_code
12
+ from app.auth import AuthUnavailable
13
+ from app.main import create_app
14
+ from scan_support import OTHER, OWNER, ScanAuth, answer_store, postgres_server # noqa: F401
15
+
16
+ KEY = pushes.new_private_key()
17
+ SUB = {"endpoint": "https://push.example/abc", "keys": {"p256dh": "p" * 87, "auth": "a" * 22}}
18
+
19
+
20
+ class PushAuth(ScanAuth):
21
+ """ScanAuth with an in-memory math_push_subs behind the PostgREST calls Push makes."""
22
+
23
+ def __init__(self):
24
+ self.rows: dict[str, dict] = {}
25
+
26
+ def _admin(self, path, method="GET", body=None, prefer=""):
27
+ table, _, query = path.partition("?")
28
+ assert table == "/rest/v1/math_push_subs"
29
+ q = dict(urllib.parse.parse_qsl(query))
30
+ match = lambda r: all(r[k] == v.removeprefix("eq.") for k, v in q.items() if k in ("endpoint", "user_id"))
31
+ if method == "POST":
32
+ self.rows[body["endpoint"]] = dict(body)
33
+ return None
34
+ if method == "DELETE":
35
+ for e in [e for e, r in self.rows.items() if match(r)]:
36
+ del self.rows[e]
37
+ return None
38
+ return [r for r in self.rows.values() if match(r)]
39
+
40
+
41
+ def scan(state="waiting", minutes=10, sid="7d9c2f0e-3c1a-4c55-9a1e-2b3c4d5e6f70"):
42
+ end = datetime.now(timezone.utc) + timedelta(minutes=minutes)
43
+ return {"attempt": {"id": "11111111-1111-1111-1111-111111111111"},
44
+ "scan": {"id": sid, "state": state, "expires_at": end.isoformat()}}
45
+
46
+
47
+ @pytest.fixture
48
+ def sent(monkeypatch):
49
+ calls = []
50
+
51
+ def webpush(sub, data, **kwargs):
52
+ calls.append({"endpoint": sub["endpoint"], "data": json.loads(data), **kwargs})
53
+
54
+ import pywebpush
55
+ monkeypatch.setattr(pywebpush, "webpush", webpush)
56
+ return calls
57
+
58
+
59
+ def test_payload_has_code_and_no_question_and_replaces_by_attempt():
60
+ result = scan()
61
+ msg = pushes.scan_payload(result)
62
+ assert scan_code(result["scan"]["id"]) in msg["body"]
63
+ assert msg["tag"] == "scan-11111111-1111-1111-1111-111111111111"
64
+ assert msg["url"] == "/?from=push-scan"
65
+ assert 590 <= pushes.seconds_left(result) <= 600
66
+ assert pushes.seconds_left(scan(minutes=-1)) == 0
67
+ assert pushes.seconds_left({"scan": {"expires_at": None}}) == 900
68
+
69
+
70
+ def test_keys_round_trip_and_off_reasons():
71
+ assert len(pushes.public_key(KEY)) == 87 # 65 bytes, base64url
72
+ assert pushes.Push(PushAuth(), "").why == "VAPID_PRIVATE_KEY is not set"
73
+ assert pushes.Push(PushAuth(), "not a key!").why == "VAPID_PRIVATE_KEY is not a valid key"
74
+ p = pushes.Push(PushAuth(), KEY, "mailto:a@test.invalid")
75
+ assert p.on and p.public == pushes.public_key(KEY)
76
+
77
+
78
+ def test_request_goes_once_to_the_accounts_phones_and_gone_ones_are_deleted(sent, monkeypatch):
79
+ auth = PushAuth()
80
+ p = pushes.Push(auth, KEY, "mailto:a@test.invalid")
81
+ p.save(OWNER, SUB)
82
+ p.save(OWNER, {**SUB, "endpoint": "https://push.example/gone"})
83
+ p.save(OTHER, {**SUB, "endpoint": "https://push.example/other"})
84
+ with pytest.raises(ValueError):
85
+ p.save(OWNER, {"endpoint": "http://insecure", "keys": SUB["keys"]})
86
+
87
+ import pywebpush
88
+ real = pywebpush.webpush
89
+
90
+ def webpush(sub, data, **kwargs):
91
+ if sub["endpoint"].endswith("/gone"):
92
+ raise pywebpush.WebPushException("gone", response=type("R", (), {"status_code": 410})())
93
+ return real(sub, data, **kwargs)
94
+
95
+ monkeypatch.setattr(pywebpush, "webpush", webpush)
96
+ assert p.scan_requested(OWNER, scan()) == 1
97
+ assert [c["endpoint"] for c in sent] == [SUB["endpoint"]]
98
+ assert sent[0]["headers"] == {"Urgency": "high"} and 0 < sent[0]["ttl"] <= 600
99
+ assert "https://push.example/gone" not in auth.rows and "https://push.example/other" in auth.rows
100
+ assert p.scan_requested(OWNER, scan()) == 0 # the same request again: no second ring
101
+ assert p.scan_requested(OWNER, scan("received", sid="22222222-2222-2222-2222-222222222222")) == 0
102
+ assert p.scan_requested(OWNER, scan(minutes=-1, sid="33333333-3333-3333-3333-333333333333")) == 0
103
+ assert len(sent) == 1
104
+
105
+
106
+ def test_missing_table_does_not_break_the_request(sent):
107
+ class NoTable(PushAuth):
108
+ def _admin(self, *a, **k):
109
+ raise AuthUnavailable("supabase answered 404: relation math_push_subs does not exist")
110
+
111
+ assert pushes.Push(NoTable(), KEY, "mailto:a@test.invalid").scan_requested(OWNER, scan()) == 0
112
+
113
+
114
+ def test_subscribe_api_moves_a_phone_between_accounts(monkeypatch):
115
+ monkeypatch.setenv("VAPID_PRIVATE_KEY", KEY)
116
+ auth = PushAuth()
117
+ app = create_app(auth=auth)
118
+
119
+ async def check():
120
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
121
+ assert (await c.get("/api/push")).status_code == 401
122
+ owner, other = ({"Authorization": f"Bearer {u}"} for u in (OWNER, OTHER))
123
+ assert (await c.get("/api/push", headers=owner)).json()["key"] == pushes.public_key(KEY)
124
+ assert (await c.post("/api/push/subscribe", json={"endpoint": "x"}, headers=owner)).status_code == 400
125
+ assert (await c.post("/api/push/subscribe", json=SUB, headers=owner)).json() == {"ok": True}
126
+ assert auth.rows[SUB["endpoint"]]["user_id"] == OWNER
127
+ assert (await c.post("/api/push/subscribe", json=SUB, headers=other)).status_code == 200
128
+ assert auth.rows[SUB["endpoint"]]["user_id"] == OTHER # one row per phone: the account signed in now
129
+ await c.post("/api/push/unsubscribe", json={"endpoint": SUB["endpoint"]}, headers=owner)
130
+ assert SUB["endpoint"] in auth.rows # another account cannot remove it
131
+ await c.post("/api/push/unsubscribe", json={"endpoint": SUB["endpoint"]}, headers=other)
132
+ assert not auth.rows
133
+ sw = await c.get("/service-worker.js")
134
+ assert "showNotification" in sw.text and 'addEventListener("fetch"' not in sw.text and sw.headers["cache-control"] == "no-store"
135
+
136
+ asyncio.run(check())
137
+
138
+
139
+ def test_push_off_without_key(monkeypatch):
140
+ monkeypatch.delenv("VAPID_PRIVATE_KEY", raising=False)
141
+ app = create_app(auth=PushAuth())
142
+
143
+ async def check():
144
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
145
+ headers={"Authorization": f"Bearer {OWNER}"}) as c:
146
+ assert (await c.get("/api/push")).json() == {"key": ""}
147
+ assert (await c.post("/api/push/subscribe", json=SUB)).status_code == 503
148
+
149
+ asyncio.run(check())
150
+
151
+
152
+ def test_computer_request_pushes_to_the_phone(answer_store, sent, monkeypatch):
153
+ monkeypatch.setenv("VAPID_PRIVATE_KEY", KEY)
154
+ auth = PushAuth()
155
+ app = create_app(auth=auth)
156
+ app.state.answers = answer_store
157
+ app.state.push.save(OWNER, SUB)
158
+ aid = answer_store.action(OWNER, "resume", question_id="q", question_version="v1",
159
+ snapshot={"question": "Prove it", "type": "open-ended"})["attempt"]["id"]
160
+
161
+ async def check():
162
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
163
+ headers={"Authorization": f"Bearer {OWNER}"}) as c:
164
+ first = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={})).json()
165
+ again = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={})).json()
166
+ assert again["scan"]["id"] == first["scan"]["id"]
167
+ renewed = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={"renew": True})).json()
168
+ return first, renewed
169
+
170
+ first, renewed = asyncio.run(check())
171
+ assert len(sent) == 2 # the repeated request did not ring again; the renewed one did
172
+ assert first["scan"]["code"] in sent[0]["data"]["body"] and renewed["scan"]["code"] in sent[1]["data"]["body"]
173
+ assert sent[0]["data"]["tag"] == sent[1]["data"]["tag"] == f"scan-{aid}"
174
+ assert "Prove it" not in json.dumps(sent)
175
+
176
+
177
+ def test_migration_keeps_subscriptions_from_browsers(postgres_server):
178
+ import uuid
179
+
180
+ import psycopg
181
+ database = "push_" + uuid.uuid4().hex
182
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
183
+ c.execute("create database " + database)
184
+ try:
185
+ with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
186
+ c.execute("create schema auth; create table auth.users(id uuid primary key);"
187
+ "grant usage on schema public to anon, authenticated, service_role;")
188
+ c.execute(Path("supabase/migrations/20261103180000_math_push_subs.sql").read_text())
189
+ c.execute("insert into auth.users values(%s)", (OWNER,))
190
+ c.execute("set role service_role")
191
+ c.execute("insert into math_push_subs(endpoint,user_id,p256dh,auth) values(%s,%s,'p','a')", (SUB["endpoint"], OWNER))
192
+ c.execute("reset role; set role authenticated")
193
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
194
+ c.execute("select * from math_push_subs")
195
+ c.execute("reset role")
196
+ with pytest.raises(psycopg.errors.CheckViolation):
197
+ c.execute("insert into math_push_subs(endpoint,user_id,p256dh,auth) values('http://x',%s,'p','a')", (OWNER,))
198
+ finally:
199
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
200
+ c.execute("drop database " + database + " with (force)")