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 +21 -3
- README.md +4 -2
- app/answer_routes.py +7 -2
- app/main.py +7 -78
- app/push.py +228 -0
- openspec/changes/add-open-question-phone-scan/proposal.md +1 -1
- requirements.txt +1 -1
- static/answer-scans.js +85 -6
- static/math.css +2 -3
- static/math.js +1 -73
- static/service-worker.js +30 -0
- supabase/migrations/20261103180000_math_push_subs.sql +15 -0
- tests/test_phone_browser.py +0 -59
- tests/test_push.py +200 -0
.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 |
-
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 111 |
-
#
|
| 112 |
-
#
|
| 113 |
-
|
| 114 |
-
|
| 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 |
-
|
| 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/
|
| 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)")
|