feat: implement prompter functionality with save, edit, and delete features, including API routes and tests
Browse files- app/main.py +2 -0
- app/prompter.py +95 -0
- docs/project.prd +87 -0
- static/math.css +4 -1
- static/math.js +87 -12
- static/prompter.js +17 -3
- supabase/migrations/20261106130000_math_prompter_texts.sql +15 -0
- tests/test_phone_browser.py +33 -3
- tests/test_prompter.py +127 -0
app/main.py
CHANGED
|
@@ -28,6 +28,7 @@ from .class_report import install_class_report_routes, install_exam_topic_routes
|
|
| 28 |
from .teacher_review import install_teacher_review_routes
|
| 29 |
from .assignments import install_assignment_routes
|
| 30 |
from .progress import install_progress_routes
|
|
|
|
| 31 |
from .push import install_push_routes
|
| 32 |
from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
|
| 33 |
from .unit_resources import install_resource_routes
|
|
@@ -316,6 +317,7 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
|
|
| 316 |
return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
|
| 317 |
|
| 318 |
install_progress_routes(app, signed_in)
|
|
|
|
| 319 |
install_class_report_routes(app, signed_in, units_of)
|
| 320 |
install_exam_topic_routes(app, signed_in, units_of)
|
| 321 |
install_push_routes(app, signed_in)
|
|
|
|
| 28 |
from .teacher_review import install_teacher_review_routes
|
| 29 |
from .assignments import install_assignment_routes
|
| 30 |
from .progress import install_progress_routes
|
| 31 |
+
from .prompter import install_prompter_routes
|
| 32 |
from .push import install_push_routes
|
| 33 |
from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
|
| 34 |
from .unit_resources import install_resource_routes
|
|
|
|
| 317 |
return {**result, "left": DAILY_PHOTOS - photo_counts.get(counter, 0) if counter else None}
|
| 318 |
|
| 319 |
install_progress_routes(app, signed_in)
|
| 320 |
+
install_prompter_routes(app, signed_in)
|
| 321 |
install_class_report_routes(app, signed_in, units_of)
|
| 322 |
install_exam_topic_routes(app, signed_in, units_of)
|
| 323 |
install_push_routes(app, signed_in)
|
app/prompter.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The prompter's saved texts (ื ืืืื โ ืคืจืืืคืืจ, static/prompter.js), kept with the account so they are on every device.
|
| 2 |
+
|
| 3 |
+
For developers only (the management.access permission, like the rest of ื ืืืื). Each account sees only its own texts.
|
| 4 |
+
They are kept in the Supabase table `math_prompter_texts`, read and written with the server's secret key.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
import urllib.parse
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
from fastapi import Body, Depends, HTTPException
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from .access import has_permission
|
| 17 |
+
from .auth import AuthUnavailable
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger("mathly.prompter")
|
| 20 |
+
MIGRATION = "supabase/migrations/20261106130000_math_prompter_texts.sql"
|
| 21 |
+
TABLE = "/rest/v1/math_prompter_texts"
|
| 22 |
+
MAX_TITLE, MAX_BODY, MAX_TEXTS = 120, 100_000, 200
|
| 23 |
+
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
| 24 |
+
FIELDS = "id,title,body,updated_at"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def clean(body: dict) -> dict:
|
| 28 |
+
"""The title and text from a request, or HTTPException 400. An empty title becomes the text's first line."""
|
| 29 |
+
if not isinstance(body, dict):
|
| 30 |
+
raise HTTPException(400, "send {\"title\": ..., \"body\": ...}")
|
| 31 |
+
text, title = body.get("body"), body.get("title", "")
|
| 32 |
+
if not isinstance(text, str) or not isinstance(title, str):
|
| 33 |
+
raise HTTPException(400, "send {\"title\": ..., \"body\": ...}")
|
| 34 |
+
if not text.strip():
|
| 35 |
+
raise HTTPException(400, "empty_text")
|
| 36 |
+
if len(text) > MAX_BODY:
|
| 37 |
+
raise HTTPException(413, "text_too_long")
|
| 38 |
+
title = " ".join(title.split()) or " ".join(text.strip().splitlines()[0].split())
|
| 39 |
+
return {"title": title[:MAX_TITLE], "body": text}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def install_prompter_routes(app, signed_in):
|
| 43 |
+
"""GET/POST /api/prompter/texts and PUT/DELETE /api/prompter/texts/{id}: the developer's own saved texts."""
|
| 44 |
+
def developer(user=Depends(signed_in)) -> str:
|
| 45 |
+
if not user or not user.get("id"):
|
| 46 |
+
raise HTTPException(401, "sign_in_required")
|
| 47 |
+
if not has_permission(user, "management.access"):
|
| 48 |
+
raise HTTPException(403, "developers_only")
|
| 49 |
+
return user["id"]
|
| 50 |
+
|
| 51 |
+
def admin(path: str, method: str = "GET", body=None, prefer: str = "return=representation"):
|
| 52 |
+
try:
|
| 53 |
+
return app.state.auth._admin(path, method, body, prefer=prefer)
|
| 54 |
+
except AuthUnavailable as exc:
|
| 55 |
+
if "answered 404" in str(exc):
|
| 56 |
+
log.warning("the math_prompter_texts table is missing; apply %s", MIGRATION)
|
| 57 |
+
else:
|
| 58 |
+
log.error("prompter texts unavailable: %s", exc)
|
| 59 |
+
raise HTTPException(503, "prompter_unavailable") from exc
|
| 60 |
+
|
| 61 |
+
def one(text_id: str, user: str) -> str:
|
| 62 |
+
if not UUID.match(text_id):
|
| 63 |
+
raise HTTPException(404, "not_found")
|
| 64 |
+
return urllib.parse.urlencode({"id": f"eq.{text_id}", "user_id": f"eq.{user}"})
|
| 65 |
+
|
| 66 |
+
def now() -> str:
|
| 67 |
+
return datetime.now(timezone.utc).isoformat()
|
| 68 |
+
|
| 69 |
+
@app.get("/api/prompter/texts")
|
| 70 |
+
def list_texts(user: str = Depends(developer)):
|
| 71 |
+
rows = admin(f"{TABLE}?select={FIELDS}&order=updated_at.desc&limit={MAX_TEXTS}&"
|
| 72 |
+
+ urllib.parse.urlencode({"user_id": f"eq.{user}"})) or []
|
| 73 |
+
return JSONResponse({"texts": rows}, headers={"Cache-Control": "no-store"})
|
| 74 |
+
|
| 75 |
+
@app.post("/api/prompter/texts")
|
| 76 |
+
def add_text(body: dict = Body(...), user: str = Depends(developer)):
|
| 77 |
+
row = clean(body)
|
| 78 |
+
have = admin(f"{TABLE}?select=id&limit={MAX_TEXTS}&" + urllib.parse.urlencode({"user_id": f"eq.{user}"})) or []
|
| 79 |
+
if len(have) >= MAX_TEXTS:
|
| 80 |
+
raise HTTPException(409, "too_many_texts")
|
| 81 |
+
rows = admin(f"{TABLE}?select={FIELDS}", "POST", {**row, "user_id": user, "updated_at": now()}) or []
|
| 82 |
+
return {"text": rows[0] if rows else None}
|
| 83 |
+
|
| 84 |
+
@app.put("/api/prompter/texts/{text_id}")
|
| 85 |
+
def save_text(text_id: str, body: dict = Body(...), user: str = Depends(developer)):
|
| 86 |
+
row = clean(body)
|
| 87 |
+
rows = admin(f"{TABLE}?select={FIELDS}&" + one(text_id, user), "PATCH", {**row, "updated_at": now()}) or []
|
| 88 |
+
if not rows:
|
| 89 |
+
raise HTTPException(404, "not_found")
|
| 90 |
+
return {"text": rows[0]}
|
| 91 |
+
|
| 92 |
+
@app.delete("/api/prompter/texts/{text_id}")
|
| 93 |
+
def delete_text(text_id: str, user: str = Depends(developer)):
|
| 94 |
+
admin(f"{TABLE}?" + one(text_id, user), "DELETE", prefer="return=minimal")
|
| 95 |
+
return {"ok": True}
|
docs/project.prd
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Glossary:
|
| 2 |
+
|
| 3 |
+
- skill (ืืืืื ืืช): a specific topic like ืคืืจืืง ืืจืื ืื
|
| 4 |
+
- exercise (ืชืจืืื): a qustion on a certain topic, can be either open ended or multiple choice
|
| 5 |
+
- lesson (ืฉืืขืืจ): set of skills like ืืื ืืงื ืืืืืจืืช 1
|
| 6 |
+
- quiz (ืืืื): a test on a single lesson, can be retaken, best grade wins. each retake draws new questions
|
| 7 |
+
- exrcise test (ืืืื ืชืจืืื): a timed test on a set of a lessons, can be retaken, best grade wins
|
| 8 |
+
- simulation test (ืืืื ืกืืืืืฆืื): a timed test on a set of a lessons, one time only
|
| 9 |
+
- test: real life test, appears in app as a date + the lessons it covers. set by the teacher
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
Goals:
|
| 14 |
+
|
| 15 |
+
The app helps the student to:
|
| 16 |
+
1. master the skills - done by the path
|
| 17 |
+
2. get ready for the real life tests - done by the test countdown plan
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
Goal 1: Mastering the skills (the path)
|
| 22 |
+
|
| 23 |
+
Each skill goes through these stages:
|
| 24 |
+
1. worked example: the student sees a full solution and is asked "why this step?"
|
| 25 |
+
2. faded examples: steps are removed one at a time, the student fills them in
|
| 26 |
+
3. practice: immediate feedback that names the specific mistake.
|
| 27 |
+
after a wrong answer, the student gets a similar question (not the same one)
|
| 28 |
+
4. mastered: 3 correct answers in a row, without hints, across at least 2 different days
|
| 29 |
+
|
| 30 |
+
Skill states:
|
| 31 |
+
- not started
|
| 32 |
+
- learning
|
| 33 |
+
- mastered
|
| 34 |
+
- needs review: a mastered skill becomes "needs review" after some days without practice
|
| 35 |
+
|
| 36 |
+
Path rules:
|
| 37 |
+
- the lesson's quiz (ืืืื) unlocks the next lesson
|
| 38 |
+
- daily review: a short mixed set of "needs review" skills from different lessons
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
Goal 2: Getting ready for the real test
|
| 43 |
+
|
| 44 |
+
The teacher sets the test date and the lessons it covers.
|
| 45 |
+
The app plans backwards from the date:
|
| 46 |
+
|
| 47 |
+
- 3+ weeks before: finish the path for all the covered lessons
|
| 48 |
+
- 1-3 weeks before: exercise tests (ืืืื ืชืจืืื) - timed, mixing all the covered lessons.
|
| 49 |
+
each result points back to the weak skills in the path
|
| 50 |
+
- final week: simulation test (ืืืื ืกืืืืืฆืื) - required. one attempt,
|
| 51 |
+
same length and timing as the real test
|
| 52 |
+
- last days: targeted review of the skills that failed in the simulation,
|
| 53 |
+
light mixed review the day before
|
| 54 |
+
|
| 55 |
+
Readiness meter (per test):
|
| 56 |
+
- % of the covered skills that are mastered
|
| 57 |
+
- recent exercise test grades
|
| 58 |
+
- simulation test grade
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
Student Screen:
|
| 63 |
+
- home
|
| 64 |
+
- my assignments (ืืืฉืืืืช ืฉืื): homewords submitted by the teacher
|
| 65 |
+
- remider on the upcoming test: days left, readiness meter, today's task
|
| 66 |
+
-
|
| 67 |
+
|
| 68 |
+
- path
|
| 69 |
+
- grades
|
| 70 |
+
- collection
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
Teacher Screen:
|
| 75 |
+
- assignments
|
| 76 |
+
- give an assignment. load it from:
|
| 77 |
+
- the computer (file upload)
|
| 78 |
+
- the board
|
| 79 |
+
- a picture (camera)
|
| 80 |
+
- check assignments: review each student's submitted answers and give feedback/grade
|
| 81 |
+
- tests
|
| 82 |
+
- set a real life test: date + the lessons it covers
|
| 83 |
+
- see who took the required simulation test
|
| 84 |
+
- class heat map
|
| 85 |
+
- rows: students, columns: skills
|
| 86 |
+
- each cell is colored by the skill state (not started / learning / mastered / needs review)
|
| 87 |
+
- period filter: sep-dec, dec-jun
|
static/math.css
CHANGED
|
@@ -182,7 +182,10 @@ html.math-on, html.math-on body{overflow:hidden}
|
|
| 182 |
}
|
| 183 |
.mx-manage-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
| 184 |
.mx-manage-head h3{font-size:30px;color:var(--mx-accent)}
|
| 185 |
-
.mx-prompter-
|
|
|
|
|
|
|
|
|
|
| 186 |
.mx-manage-spacer{flex:1}
|
| 187 |
.mx-manage-state{min-height:18px;margin:0;color:var(--muted);font-size:13px}
|
| 188 |
.mx-manage-modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;padding:4px;border-radius:14px;background:var(--line)}
|
|
|
|
| 182 |
}
|
| 183 |
.mx-manage-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
| 184 |
.mx-manage-head h3{font-size:30px;color:var(--mx-accent)}
|
| 185 |
+
.mx-prompter-title{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid var(--line);border-radius:12px;font:inherit;font-size:17px;background:var(--bg);color:inherit;margin-top:10px}
|
| 186 |
+
.mx-prompter-acts,.mx-prompter-row{display:flex;flex-wrap:wrap;gap:8px}
|
| 187 |
+
.mx-prompter-list article[aria-current="true"]{outline:2px solid var(--mx-accent)}
|
| 188 |
+
.mx-prompter-text{width:100%;box-sizing:border-box;min-height:220px;padding:12px;border:1px solid var(--line);border-radius:12px;font:inherit;font-size:17px;line-height:1.5;resize:vertical;background:var(--bg);color:inherit;margin-block:10px}
|
| 189 |
.mx-manage-spacer{flex:1}
|
| 190 |
.mx-manage-state{min-height:18px;margin:0;color:var(--muted);font-size:13px}
|
| 191 |
.mx-manage-modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;padding:4px;border-radius:14px;background:var(--line)}
|
static/math.js
CHANGED
|
@@ -1751,6 +1751,9 @@ function onClick(e){
|
|
| 1751 |
if(t.dataset.comet) return showComet(t.dataset.comet);
|
| 1752 |
if(t.dataset.id) return open(t.dataset.id);
|
| 1753 |
if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
|
|
|
|
|
|
|
|
|
|
| 1754 |
if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
|
| 1755 |
if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass);
|
| 1756 |
if(t.dataset.reportStudent!=null) return reportOpenStudent(t.dataset.reportStudent);
|
|
@@ -1768,6 +1771,9 @@ function onClick(e){
|
|
| 1768 |
case "manage-lab": return showManageMode("lab");
|
| 1769 |
case "manage-prompter": return showManageMode("prompter");
|
| 1770 |
case "prompter-go": return window.MathlyPrompter?.open(sheet.querySelector("#mxPrompterText")?.value);
|
|
|
|
|
|
|
|
|
|
| 1771 |
case "manage-practice": return closeSheet();
|
| 1772 |
case "preview-on": return setPreview(true);
|
| 1773 |
case "preview-off": return setPreview(false);
|
|
@@ -1900,21 +1906,90 @@ function showManageMode(mode){
|
|
| 1900 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืืจืฉืืืช</h4><span>ืชืคืงืืืื ืืืืฉื</span></div><div class="mx-access-card"><small>ืืชืคืงืืืื ืฉืื</small><div class="mx-tag-row">${roles.length?roles.map(role=>`<span>${esc(role)}</span>`).join(""):'<span>ืื ืื/ืช</span>'}</div><small>ืืจืฉืืืช ืคืขืืืืช</small><div class="mx-tag-row">${permissions.map(permission=>`<span dir="ltr">${esc(permission)}</span>`).join("")}</div></div>`;
|
| 1901 |
return;
|
| 1902 |
}
|
| 1903 |
-
if(mode==="prompter")
|
| 1904 |
-
// a developer tool: the text is kept only in this browser, so it is still there the next time
|
| 1905 |
-
let saved=""; try{saved=localStorage.getItem("mathly.prompterText")||"";}catch{}
|
| 1906 |
-
box.innerHTML=`<div class="mx-manage-title"><h4>ืคืจืืืคืืจ</h4><span>ืืงืกื ืืงืจืืื ืืื ืืืฆืืื</span></div>
|
| 1907 |
-
<textarea id="mxPrompterText" class="mx-prompter-text" dir="auto" rows="12" placeholder="ืืืืืงื ืื ืืชืื ืืื ืืช ืืืงืกืโฆ" aria-label="ืืงืกื ืืคืจืืืคืืจ">${esc(saved)}</textarea>
|
| 1908 |
-
<p class="mx-note">ืจืืื: ืืคืขืื/ืขืฆืืจื ยท โ/โ: ืืืืจืืช ยท +/โ: ืืืื ยท M: ืืจืื ยท F: ืืกื ืืื ยท Esc: ืืฆืืื</p>
|
| 1909 |
-
<p class="mx-note">ืขืืืจ: ืืืืฆื โ ืืคืขืื/ืขืฆืืจื ยท ืืืืืช ืืืื ืจืืฆื โ ืืืืจืืช ยท ืืืฆื ืืื ื โ ืืื ืฉืืจืืช ืืืืจื ยท ืืืฆื ืืืฆืขื ืื "ืืืืจื" โ ืืืชืืื. ืฉืื ืืฆืืืช (Page Down/Up) ืขืืื ืื ืืื.</p>
|
| 1910 |
-
<button class="mx-btn solid" data-act="prompter-go">ืืฆืื ืืคืจืืืคืืจ</button>`;
|
| 1911 |
-
const area=box.querySelector("#mxPrompterText");
|
| 1912 |
-
area.addEventListener("input",()=>{try{localStorage.setItem("mathly.prompterText",area.value);}catch{}});
|
| 1913 |
-
area.focus(); return;
|
| 1914 |
-
}
|
| 1915 |
box.innerHTML='<div class="mx-manage-title"><h4>ืืขืืื</h4><span>ืืืื ืืืืืงื ืืคืืชืื</span></div><p class="mx-note">ืืื ืืืขืืื ืืืชืืืืงื ืืชืืืกืคื ืืื. ืืคืฉืจ ืืืืืจ ืืื ืืืืจ ื ืืืื ืืืืจืืืกืืืช ืืืขืื.</p>';
|
| 1916 |
}
|
| 1917 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1918 |
/* Monitoring (ื ืืืืจ): a snapshot of the system, open-answer activity, and the server's latest warnings.
|
| 1919 |
Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
|
| 1920 |
const MON_RANGES={day:["ืืื","24 ืืฉืขืืช ืืืืจืื ืืช","15 ืืงืืช"],week:["ืฉืืืข","7 ืืืืื ืืืืจืื ืื","ืฉืขื"],month:["ืืืืฉ","30 ืืืืื ืืืืจืื ืื","ืืื"]};
|
|
|
|
| 1751 |
if(t.dataset.comet) return showComet(t.dataset.comet);
|
| 1752 |
if(t.dataset.id) return open(t.dataset.id);
|
| 1753 |
if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
|
| 1754 |
+
if(t.dataset.prompterPlay) return window.MathlyPrompter?.open(PR.texts.find(x=>x.id===t.dataset.prompterPlay)?.body);
|
| 1755 |
+
if(t.dataset.prompterOpen) return prEdit(t.dataset.prompterOpen);
|
| 1756 |
+
if(t.dataset.prompterDel) return prDelete(t.dataset.prompterDel);
|
| 1757 |
if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
|
| 1758 |
if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass);
|
| 1759 |
if(t.dataset.reportStudent!=null) return reportOpenStudent(t.dataset.reportStudent);
|
|
|
|
| 1771 |
case "manage-lab": return showManageMode("lab");
|
| 1772 |
case "manage-prompter": return showManageMode("prompter");
|
| 1773 |
case "prompter-go": return window.MathlyPrompter?.open(sheet.querySelector("#mxPrompterText")?.value);
|
| 1774 |
+
case "prompter-save": return prSave(false);
|
| 1775 |
+
case "prompter-save-new": return prSave(true);
|
| 1776 |
+
case "prompter-new": return prNew();
|
| 1777 |
case "manage-practice": return closeSheet();
|
| 1778 |
case "preview-on": return setPreview(true);
|
| 1779 |
case "preview-off": return setPreview(false);
|
|
|
|
| 1906 |
box.innerHTML=`<div class="mx-manage-title"><h4>ืืจืฉืืืช</h4><span>ืชืคืงืืืื ืืืืฉื</span></div><div class="mx-access-card"><small>ืืชืคืงืืืื ืฉืื</small><div class="mx-tag-row">${roles.length?roles.map(role=>`<span>${esc(role)}</span>`).join(""):'<span>ืื ืื/ืช</span>'}</div><small>ืืจืฉืืืช ืคืขืืืืช</small><div class="mx-tag-row">${permissions.map(permission=>`<span dir="ltr">${esc(permission)}</span>`).join("")}</div></div>`;
|
| 1907 |
return;
|
| 1908 |
}
|
| 1909 |
+
if(mode==="prompter") return drawPrompter(box);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1910 |
box.innerHTML='<div class="mx-manage-title"><h4>ืืขืืื</h4><span>ืืืื ืืืืืงื ืืคืืชืื</span></div><p class="mx-note">ืืื ืืืขืืื ืืืชืืืืงื ืืชืืืกืคื ืืื. ืืคืฉืจ ืืืืืจ ืืื ืืืืจ ื ืืืื ืืืืจืืืกืืืช ืืืขืื.</p>';
|
| 1911 |
}
|
| 1912 |
|
| 1913 |
+
/* Prompter (ืคืจืืืคืืจ): a developer writes or picks a text and shows it full screen (static/prompter.js).
|
| 1914 |
+
Saved texts are kept with the account (app/prompter.py), so they are on every device; the text being edited is
|
| 1915 |
+
also kept in this browser, so nothing is lost before it is saved. */
|
| 1916 |
+
let PR={texts:[],id:null,loaded:false};
|
| 1917 |
+
function prDraft(){ try{return JSON.parse(localStorage.getItem("mathly.prompterDraft")||"null")||{title:"",body:localStorage.getItem("mathly.prompterText")||"",id:null};}catch{return {title:"",body:"",id:null};} }
|
| 1918 |
+
function prKeep(){
|
| 1919 |
+
const title=sheet.querySelector("#mxPrompterTitle"), body=sheet.querySelector("#mxPrompterText"); if(!title||!body) return;
|
| 1920 |
+
try{localStorage.setItem("mathly.prompterDraft",JSON.stringify({title:title.value,body:body.value,id:PR.id}));}catch{}
|
| 1921 |
+
}
|
| 1922 |
+
function prSay(text){ const el=sheet.querySelector(".mx-prompter-state"); if(el) el.textContent=text; }
|
| 1923 |
+
function prDate(at){ try{return new Date(at).toLocaleString("he-IL",{day:"numeric",month:"numeric",year:"2-digit",hour:"2-digit",minute:"2-digit"});}catch{return "";} }
|
| 1924 |
+
function drawPrompter(box){
|
| 1925 |
+
const draft=prDraft(); PR.id=draft.id||null;
|
| 1926 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>ืคืจืืืคืืจ</h4><span>ืืงืกื ืืงืจืืื ืืื ืืืฆืืื</span></div>
|
| 1927 |
+
<input id="mxPrompterTitle" class="mx-prompter-title" dir="auto" maxlength="120" placeholder="ืฉื ืืืงืกื (ืื ืืืื)" aria-label="ืฉื ืืืงืกื" value="${esc(draft.title||"")}">
|
| 1928 |
+
<textarea id="mxPrompterText" class="mx-prompter-text" dir="auto" rows="12" placeholder="ืืืืืงื ืื ืืชืื ๏ฟฝ๏ฟฝืื ืืช ืืืงืกืโฆ" aria-label="ืืงืกื ืืคืจืืืคืืจ">${esc(draft.body||"")}</textarea>
|
| 1929 |
+
<div class="mx-prompter-acts">
|
| 1930 |
+
<button class="mx-btn solid" data-act="prompter-go">ืืฆืื ืืคืจืืืคืืจ</button>
|
| 1931 |
+
<button class="mx-btn" data-act="prompter-save">ืฉืืืจื</button>
|
| 1932 |
+
<button class="mx-btn" data-act="prompter-save-new">ืฉืืืจื ืืืงืกื ืืืฉ</button>
|
| 1933 |
+
<button class="mx-btn" data-act="prompter-new">ืืงืกื ืืืฉ</button></div>
|
| 1934 |
+
<p class="mx-prompter-state mx-note" role="status" aria-live="polite"></p>
|
| 1935 |
+
<p class="mx-note">ืจืืื: ืืคืขืื/ืขืฆืืจื ยท โ/โ: ืืืืจืืช ยท +/โ: ืืืื ยท M: ืืจืื ยท F: ืืกื ืืื ยท Esc: ืืฆืืื</p>
|
| 1936 |
+
<p class="mx-note">ืขืืืจ: ืืืืฆื โ ืืคืขืื/ืขืฆืืจื ยท ืืืืืช ืืืื ืจืืฆื โ ืืืืจืืช ยท ืืืฆื ืืื ื โ ืืื ืฉืืจืืช ืืืืจื ยท ืืืฆื ืืืฆืขื ืื "ืืืืจื" โ ืืืชืืื. ืฉืื ืืฆืืืช (Page Down/Up) ืขืืื ืื ืืื.</p>
|
| 1937 |
+
<div class="mx-manage-title"><h4>ืืงืกืืื ืฉืืืจืื</h4><span class="mx-prompter-count"></span></div>
|
| 1938 |
+
<div class="mx-prompter-list mx-manage-list"><p class="mx-note">ืืืขื ืืโฆ</p></div>`;
|
| 1939 |
+
box.querySelectorAll("#mxPrompterTitle,#mxPrompterText").forEach(el=>el.addEventListener("input",prKeep));
|
| 1940 |
+
prMark(); box.querySelector("#mxPrompterText").focus();
|
| 1941 |
+
prLoad();
|
| 1942 |
+
}
|
| 1943 |
+
function prMark(){ prSay(PR.id?`ืขืืจืืื ืืช โ${PR.texts.find(t=>t.id===PR.id)?.title||"ืืืงืกื ืืฉืืืจ"}โ. โืฉืืืจืโ ืืขืืื ืช ืืืชื.`:""); }
|
| 1944 |
+
function prList(){
|
| 1945 |
+
const list=sheet.querySelector(".mx-prompter-list"); if(!list) return;
|
| 1946 |
+
sheet.querySelector(".mx-prompter-count").textContent=PR.texts.length?`${PR.texts.length} ืืงืกืืื`:"";
|
| 1947 |
+
list.innerHTML=PR.texts.length?PR.texts.map(t=>`<article${t.id===PR.id?' aria-current="true"':""}><b dir="auto">${esc(t.title)}</b><small>${esc(prDate(t.updated_at))} ยท ${t.body.length.toLocaleString("he-IL")} ืชืืืื</small>
|
| 1948 |
+
<span class="mx-prompter-row"><button class="mx-btn solid" data-prompter-play="${t.id}">ืืฆืื</button><button class="mx-btn" data-prompter-open="${t.id}">ืขืจืืื</button><button class="mx-btn" data-prompter-del="${t.id}">ืืืืงื</button></span></article>`).join("")
|
| 1949 |
+
:'<p class="mx-note">ืขืืืื ืืื ืืงืกืืื ืฉืืืจืื. ืืชืื ืืงืกื ืืืืฆื โืฉืืืจืโ.</p>';
|
| 1950 |
+
}
|
| 1951 |
+
async function prLoad(){
|
| 1952 |
+
try{
|
| 1953 |
+
const r=await api("/api/prompter/texts"); if(!r.ok) throw new Error();
|
| 1954 |
+
PR.texts=(await r.json()).texts||[]; PR.loaded=true;
|
| 1955 |
+
if(PR.id&&!PR.texts.some(t=>t.id===PR.id)) PR.id=null;
|
| 1956 |
+
prList(); prMark();
|
| 1957 |
+
}catch{ const list=sheet.querySelector(".mx-prompter-list"); if(list) list.innerHTML='<p class="mx-note">ืื ืืฆืืื ื ืืืขืื ืืช ืืืงืกืืื ืืฉืืืจืื. ืืืงืกื ืฉืืชืืชื ื ืฉืืจ ืืืคืืคื ืืื.</p>'; }
|
| 1958 |
+
}
|
| 1959 |
+
async function prSave(asNew){
|
| 1960 |
+
const title=sheet.querySelector("#mxPrompterTitle").value, body=sheet.querySelector("#mxPrompterText").value;
|
| 1961 |
+
if(!body.trim()) return prSay("ืืื ืืงืกื ืืฉืืืจ.");
|
| 1962 |
+
const id=asNew?null:PR.id;
|
| 1963 |
+
prSay("ืฉืืืจืืโฆ");
|
| 1964 |
+
try{
|
| 1965 |
+
const r=await api(id?`/api/prompter/texts/${id}`:"/api/prompter/texts",{method:id?"PUT":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({title,body})});
|
| 1966 |
+
if(r.status===409) return prSay("ืืฉ ืืืจ 200 ืืงืกืืื ืฉืืืจืื. ืืืงื ืืื ืืื ืืฉืืืจ ืขืื.");
|
| 1967 |
+
if(r.status===404&&id){ PR.id=null; return prSave(true); } // deleted on another device: keep it as a new one
|
| 1968 |
+
if(!r.ok) throw new Error();
|
| 1969 |
+
const {text}=await r.json();
|
| 1970 |
+
PR.texts=[text,...PR.texts.filter(t=>t.id!==text.id)]; PR.id=text.id;
|
| 1971 |
+
sheet.querySelector("#mxPrompterTitle").value=text.title; prKeep(); prList();
|
| 1972 |
+
prSay(`ื ืฉืืจ: โ${text.title}โ.`);
|
| 1973 |
+
}catch{ prSay("ืืฉืืืจื ื ืืฉืื. ืืืงืกื ืขืืืื ืืื ืืืืคืืคื ืืื; ื ืกื ืฉืื."); }
|
| 1974 |
+
}
|
| 1975 |
+
function prEdit(id){
|
| 1976 |
+
const t=PR.texts.find(x=>x.id===id); if(!t) return;
|
| 1977 |
+
PR.id=id; sheet.querySelector("#mxPrompterTitle").value=t.title; sheet.querySelector("#mxPrompterText").value=t.body;
|
| 1978 |
+
prKeep(); prList(); prMark(); sheet.querySelector("#mxPrompterText").focus();
|
| 1979 |
+
}
|
| 1980 |
+
function prNew(){
|
| 1981 |
+
PR.id=null; sheet.querySelector("#mxPrompterTitle").value=""; sheet.querySelector("#mxPrompterText").value="";
|
| 1982 |
+
prKeep(); prList(); prSay(""); sheet.querySelector("#mxPrompterTitle").focus();
|
| 1983 |
+
}
|
| 1984 |
+
async function prDelete(id){
|
| 1985 |
+
const t=PR.texts.find(x=>x.id===id); if(!t||!confirm(`ืืืืืง ืืช โ${t.title}โ?`)) return;
|
| 1986 |
+
try{
|
| 1987 |
+
const r=await api(`/api/prompter/texts/${id}`,{method:"DELETE"}); if(!r.ok) throw new Error();
|
| 1988 |
+
PR.texts=PR.texts.filter(x=>x.id!==id); if(PR.id===id){PR.id=null; prKeep();}
|
| 1989 |
+
prList(); prSay(`โ${t.title}โ ื ืืืง.`);
|
| 1990 |
+
}catch{ prSay("ืืืืืงื ื ืืฉืื. ื ืกื ืฉืื."); }
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
/* Monitoring (ื ืืืืจ): a snapshot of the system, open-answer activity, and the server's latest warnings.
|
| 1994 |
Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
|
| 1995 |
const MON_RANGES={day:["ืืื","24 ืืฉืขืืช ืืืืจืื ืืช","15 ืืงืืช"],week:["ืฉืืืข","7 ืืืืื ืืืืจืื ืื","ืฉืขื"],month:["ืืืืฉ","30 ืืืืื ืืืืจืื ืื","ืืื"]};
|
static/prompter.js
CHANGED
|
@@ -28,7 +28,7 @@ const CSS=`
|
|
| 28 |
.mxp-progress{height:3px;background:#222}.mxp-progress i{display:block;height:100%;width:0;background:#f5c518}
|
| 29 |
.mxp-count{position:absolute;inset:0;display:grid;place-items:center;font-size:30vh;font-weight:700;color:#f5c518;background:rgba(0,0,0,.6);pointer-events:none}
|
| 30 |
`;
|
| 31 |
-
let wheel=0,root,scroller,textEl,guide,bar,raf=0,last=0,playing=false,speed=40,size=64,mirror=false,acc=0,countTimer=0,back=null;
|
| 32 |
|
| 33 |
function save(){try{localStorage.setItem("mathly.prompter",JSON.stringify({speed,size,mirror}))}catch{}}
|
| 34 |
function load(){try{const s=JSON.parse(localStorage.getItem("mathly.prompter")||"{}");if(s.speed)speed=s.speed;if(s.size)size=s.size;mirror=!!s.mirror}catch{}}
|
|
@@ -70,6 +70,7 @@ function play(on){
|
|
| 70 |
|
| 71 |
function countdown(){
|
| 72 |
if(playing||root.querySelector(".mxp-count")) return play(false); // a second press during 3-2-1 cancels it
|
|
|
|
| 73 |
let n=3; const el=document.createElement("div"); el.className="mxp-count"; el.textContent=n; root.querySelector(".mxp-view").appendChild(el);
|
| 74 |
countTimer=setInterval(()=>{n--; if(n>0) el.textContent=n; else play(true);},700);
|
| 75 |
}
|
|
@@ -84,7 +85,7 @@ function act(p){
|
|
| 84 |
case "mirror": mirror=!mirror; break;
|
| 85 |
case "top": scroller.scrollTop=0; acc=0; progress(); break;
|
| 86 |
case "back": scroller.scrollTop-=size*1.45*3; acc=0; progress(); return;
|
| 87 |
-
case "full": if(document.fullscreenElement) document.exitFullscreen?.(); else root.requestFullscreen?.().catch(()=>{}); return;
|
| 88 |
case "close": return close();
|
| 89 |
}
|
| 90 |
save(); sync();
|
|
@@ -98,6 +99,14 @@ function onKey(e){
|
|
| 98 |
e.preventDefault(); e.stopImmediatePropagation(); act(p);
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
function open(text){
|
| 102 |
if(!String(text||"").trim()) return;
|
| 103 |
close(); style(); load(); back=document.activeElement;
|
|
@@ -135,12 +144,17 @@ function open(text){
|
|
| 135 |
view.addEventListener("mouseup",e=>{const p=extra[e.button]; if(p){e.preventDefault(); act(p);}});
|
| 136 |
document.body.appendChild(root);
|
| 137 |
document.addEventListener("keydown",onKey,true);
|
|
|
|
| 138 |
sync(); bar.querySelector(".mxp-play").focus();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
function close(){
|
| 142 |
if(!root) return;
|
| 143 |
-
play(false); document.removeEventListener("keydown",onKey,true);
|
| 144 |
if(document.fullscreenElement) document.exitFullscreen?.();
|
| 145 |
root.remove(); root=null; back?.focus?.(); back=null;
|
| 146 |
}
|
|
|
|
| 28 |
.mxp-progress{height:3px;background:#222}.mxp-progress i{display:block;height:100%;width:0;background:#f5c518}
|
| 29 |
.mxp-count{position:absolute;inset:0;display:grid;place-items:center;font-size:30vh;font-weight:700;color:#f5c518;background:rgba(0,0,0,.6);pointer-events:none}
|
| 30 |
`;
|
| 31 |
+
let wheel=0,keepOpen=false,root,scroller,textEl,guide,bar,raf=0,last=0,playing=false,speed=40,size=64,mirror=false,acc=0,countTimer=0,back=null;
|
| 32 |
|
| 33 |
function save(){try{localStorage.setItem("mathly.prompter",JSON.stringify({speed,size,mirror}))}catch{}}
|
| 34 |
function load(){try{const s=JSON.parse(localStorage.getItem("mathly.prompter")||"{}");if(s.speed)speed=s.speed;if(s.size)size=s.size;mirror=!!s.mirror}catch{}}
|
|
|
|
| 70 |
|
| 71 |
function countdown(){
|
| 72 |
if(playing||root.querySelector(".mxp-count")) return play(false); // a second press during 3-2-1 cancels it
|
| 73 |
+
if(scroller.scrollTop>0) return play(true); // 3-2-1 only from the start; after a pause it resumes at once
|
| 74 |
let n=3; const el=document.createElement("div"); el.className="mxp-count"; el.textContent=n; root.querySelector(".mxp-view").appendChild(el);
|
| 75 |
countTimer=setInterval(()=>{n--; if(n>0) el.textContent=n; else play(true);},700);
|
| 76 |
}
|
|
|
|
| 85 |
case "mirror": mirror=!mirror; break;
|
| 86 |
case "top": scroller.scrollTop=0; acc=0; progress(); break;
|
| 87 |
case "back": scroller.scrollTop-=size*1.45*3; acc=0; progress(); return;
|
| 88 |
+
case "full": if(document.fullscreenElement){keepOpen=true; document.exitFullscreen?.();} else root.requestFullscreen?.().catch(()=>{}); return;
|
| 89 |
case "close": return close();
|
| 90 |
}
|
| 91 |
save(); sync();
|
|
|
|
| 99 |
e.preventDefault(); e.stopImmediatePropagation(); act(p);
|
| 100 |
}
|
| 101 |
|
| 102 |
+
// in full screen the browser keeps Esc for itself (it leaves full screen and the page never sees the key), so leaving
|
| 103 |
+
// full screen closes the prompter too, unless it was F or the โถ button
|
| 104 |
+
function onFull(){
|
| 105 |
+
if(document.fullscreenElement||!root) return;
|
| 106 |
+
if(keepOpen){keepOpen=false; return;}
|
| 107 |
+
close();
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
function open(text){
|
| 111 |
if(!String(text||"").trim()) return;
|
| 112 |
close(); style(); load(); back=document.activeElement;
|
|
|
|
| 144 |
view.addEventListener("mouseup",e=>{const p=extra[e.button]; if(p){e.preventDefault(); act(p);}});
|
| 145 |
document.body.appendChild(root);
|
| 146 |
document.addEventListener("keydown",onKey,true);
|
| 147 |
+
document.addEventListener("fullscreenchange",onFull);
|
| 148 |
sync(); bar.querySelector(".mxp-play").focus();
|
| 149 |
+
// it opens full screen; this runs inside the click that opened it, which the browser requires. Where the browser
|
| 150 |
+
// does not allow it (an iPhone), it simply stays as a full-page layer.
|
| 151 |
+
const full=root.requestFullscreen||root.webkitRequestFullscreen;
|
| 152 |
+
try{ const p=full?.call(root); p?.catch?.(()=>{}); }catch{}
|
| 153 |
}
|
| 154 |
|
| 155 |
function close(){
|
| 156 |
if(!root) return;
|
| 157 |
+
play(false); document.removeEventListener("keydown",onKey,true); document.removeEventListener("fullscreenchange",onFull);
|
| 158 |
if(document.fullscreenElement) document.exitFullscreen?.();
|
| 159 |
root.remove(); root=null; back?.focus?.(); back=null;
|
| 160 |
}
|
supabase/migrations/20261106130000_math_prompter_texts.sql
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- The prompter's saved texts (app/prompter.py): a developer's scripts, kept with the account so they are on every device.
|
| 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_prompter_texts (
|
| 5 |
+
id uuid primary key default gen_random_uuid(),
|
| 6 |
+
user_id uuid not null references auth.users(id) on delete cascade,
|
| 7 |
+
title text not null check (char_length(title) between 1 and 120),
|
| 8 |
+
body text not null check (char_length(body) <= 100000),
|
| 9 |
+
updated_at timestamptz not null default now()
|
| 10 |
+
);
|
| 11 |
+
create index math_prompter_texts_user on public.math_prompter_texts(user_id, updated_at desc);
|
| 12 |
+
alter table public.math_prompter_texts enable row level security;
|
| 13 |
+
revoke all on public.math_prompter_texts from public, anon, authenticated;
|
| 14 |
+
grant select, insert, update, delete on public.math_prompter_texts to service_role;
|
| 15 |
+
commit;
|
tests/test_phone_browser.py
CHANGED
|
@@ -1171,6 +1171,8 @@ def test_the_teacher_opens_a_final_quiz_and_gives_one_student_another_sitting(ph
|
|
| 1171 |
|
| 1172 |
|
| 1173 |
def test_prompter_from_management(phone_server):
|
|
|
|
|
|
|
| 1174 |
with playwright.sync_playwright() as p:
|
| 1175 |
browser = p.chromium.launch()
|
| 1176 |
try:
|
|
@@ -1192,30 +1194,58 @@ def test_prompter_from_management(phone_server):
|
|
| 1192 |
"permissions": ["management.access"], "classes": []}))
|
| 1193 |
page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}}))
|
| 1194 |
page.route("**/api/classes", lambda route: route.fulfill(json={"classes": []}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1195 |
page.goto(phone_server)
|
| 1196 |
page.locator('[data-act="manage"]').click()
|
| 1197 |
page.locator('[data-act="manage-prompter"]').click()
|
| 1198 |
text = "ืฉืืื ืืืืื\nืื ืืงืกื ืืคืจืืืคืืจ" + "\nืขืื ืฉืืจื" * 60 # long enough to still be scrolling
|
| 1199 |
page.locator("#mxPrompterText").fill(text)
|
| 1200 |
-
page.locator(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1201 |
prompter = page.locator(".mxp")
|
|
|
|
| 1202 |
playwright.expect(prompter.locator(".mxp-text")).to_have_text(text)
|
| 1203 |
page.keyboard.press("m")
|
| 1204 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1205 |
page.keyboard.press(" ")
|
| 1206 |
playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=5000)
|
| 1207 |
speed = page.locator(".mxp-speed-v").inner_text()
|
| 1208 |
-
page.locator(".mxp-
|
| 1209 |
page.mouse.wheel(0, 120) # while playing, the wheel is the speed
|
| 1210 |
playwright.expect(page.locator(".mxp-speed-v")).not_to_have_text(speed)
|
| 1211 |
page.locator(".mxp-view").click() # a click pauses
|
| 1212 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1213 |
page.keyboard.press("PageDown") # a presentation clicker plays
|
| 1214 |
-
playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=
|
|
|
|
| 1215 |
page.locator(".mxp-view").click()
|
| 1216 |
top = page.locator(".mxp-scroll").evaluate("e=>e.scrollTop")
|
| 1217 |
page.locator(".mxp-view").click(button="right") # right-click goes back a few lines
|
| 1218 |
assert page.locator(".mxp-scroll").evaluate("e=>e.scrollTop") <= top
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
page.keyboard.press("Escape")
|
| 1220 |
playwright.expect(prompter).to_have_count(0)
|
| 1221 |
playwright.expect(page.locator(".mx-manage-sheet")).to_be_visible() # Esc closes only the prompter
|
|
|
|
| 1171 |
|
| 1172 |
|
| 1173 |
def test_prompter_from_management(phone_server):
|
| 1174 |
+
import json
|
| 1175 |
+
|
| 1176 |
with playwright.sync_playwright() as p:
|
| 1177 |
browser = p.chromium.launch()
|
| 1178 |
try:
|
|
|
|
| 1194 |
"permissions": ["management.access"], "classes": []}))
|
| 1195 |
page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}}))
|
| 1196 |
page.route("**/api/classes", lambda route: route.fulfill(json={"classes": []}))
|
| 1197 |
+
saved = []
|
| 1198 |
+
|
| 1199 |
+
def texts(route): # app/prompter.py, in memory
|
| 1200 |
+
req = route.request
|
| 1201 |
+
if req.method == "GET":
|
| 1202 |
+
return route.fulfill(json={"texts": saved})
|
| 1203 |
+
body = json.loads(req.post_data)
|
| 1204 |
+
row = {"id": "aaaaaaaa-aaaa-4aaa-8aaa-%012d" % len(saved), "title": body["title"] or body["body"].split("\n")[0],
|
| 1205 |
+
"body": body["body"], "updated_at": "2026-11-06T10:00:00Z"}
|
| 1206 |
+
saved.insert(0, row)
|
| 1207 |
+
route.fulfill(json={"text": row})
|
| 1208 |
+
page.route("**/api/prompter/texts", texts)
|
| 1209 |
page.goto(phone_server)
|
| 1210 |
page.locator('[data-act="manage"]').click()
|
| 1211 |
page.locator('[data-act="manage-prompter"]').click()
|
| 1212 |
text = "ืฉืืื ืืืืื\nืื ืืงืกื ืืคืจืืืคืืจ" + "\nืขืื ืฉืืจื" * 60 # long enough to still be scrolling
|
| 1213 |
page.locator("#mxPrompterText").fill(text)
|
| 1214 |
+
page.locator("#mxPrompterTitle").fill("ืคืชืืื")
|
| 1215 |
+
page.locator('[data-act="prompter-save"]').click()
|
| 1216 |
+
playwright.expect(page.locator(".mx-prompter-state")).to_have_text("ื ืฉืืจ: โืคืชืืืโ.")
|
| 1217 |
+
assert saved[0]["title"] == "ืคืชืืื" and saved[0]["body"] == text
|
| 1218 |
+
playwright.expect(page.locator(".mx-prompter-list article")).to_contain_text("ืคืชืืื")
|
| 1219 |
+
page.locator("[data-prompter-play]").click() # a saved text opens straight in the prompter
|
| 1220 |
prompter = page.locator(".mxp")
|
| 1221 |
+
page.wait_for_function("document.fullscreenElement?.classList.contains('mxp')") # it opens full screen
|
| 1222 |
playwright.expect(prompter.locator(".mxp-text")).to_have_text(text)
|
| 1223 |
page.keyboard.press("m")
|
| 1224 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1225 |
page.keyboard.press(" ")
|
| 1226 |
playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=5000)
|
| 1227 |
speed = page.locator(".mxp-speed-v").inner_text()
|
| 1228 |
+
page.locator(".mxp-view").hover()
|
| 1229 |
page.mouse.wheel(0, 120) # while playing, the wheel is the speed
|
| 1230 |
playwright.expect(page.locator(".mxp-speed-v")).not_to_have_text(speed)
|
| 1231 |
page.locator(".mxp-view").click() # a click pauses
|
| 1232 |
playwright.expect(prompter).to_have_class("mxp mirror")
|
| 1233 |
page.keyboard.press("PageDown") # a presentation clicker plays
|
| 1234 |
+
playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=500) # resumes with no 3-2-1
|
| 1235 |
+
playwright.expect(page.locator(".mxp-count")).to_have_count(0)
|
| 1236 |
page.locator(".mxp-view").click()
|
| 1237 |
top = page.locator(".mxp-scroll").evaluate("e=>e.scrollTop")
|
| 1238 |
page.locator(".mxp-view").click(button="right") # right-click goes back a few lines
|
| 1239 |
assert page.locator(".mxp-scroll").evaluate("e=>e.scrollTop") <= top
|
| 1240 |
+
page.keyboard.press("f") # F leaves full screen and keeps the prompter
|
| 1241 |
+
page.wait_for_function("!document.fullscreenElement")
|
| 1242 |
+
playwright.expect(prompter).to_have_count(1)
|
| 1243 |
+
page.keyboard.press("f")
|
| 1244 |
+
page.wait_for_function("!!document.fullscreenElement")
|
| 1245 |
+
page.evaluate("document.exitFullscreen()") # what the browser does with Esc in full screen
|
| 1246 |
+
playwright.expect(prompter).to_have_count(0)
|
| 1247 |
+
playwright.expect(page.locator(".mx-manage-sheet")).to_be_visible()
|
| 1248 |
+
page.locator('[data-act="prompter-go"]').click()
|
| 1249 |
page.keyboard.press("Escape")
|
| 1250 |
playwright.expect(prompter).to_have_count(0)
|
| 1251 |
playwright.expect(page.locator(".mx-manage-sheet")).to_be_visible() # Esc closes only the prompter
|
tests/test_prompter.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import urllib.parse
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.auth import AuthUnavailable
|
| 9 |
+
from app.main import create_app
|
| 10 |
+
from scan_support import OTHER, OWNER, ScanAuth, postgres_server # noqa: F401 (a fixture)
|
| 11 |
+
|
| 12 |
+
DEV = '33333333-3333-4333-8333-333333333333'
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class PrompterAuth(ScanAuth):
|
| 16 |
+
"""OWNER and DEV are developers, OTHER is a student; an in-memory math_prompter_texts behind the PostgREST calls."""
|
| 17 |
+
developers = frozenset({OWNER + '@test.invalid', DEV + '@test.invalid'})
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.rows: list[dict] = []
|
| 21 |
+
self.missing = False
|
| 22 |
+
|
| 23 |
+
def user(self, token):
|
| 24 |
+
return {'id': token, 'email': token + '@test.invalid'} if token in (OWNER, OTHER, DEV) else None
|
| 25 |
+
|
| 26 |
+
def _admin(self, path, method='GET', body=None, prefer=''):
|
| 27 |
+
if self.missing:
|
| 28 |
+
raise AuthUnavailable('supabase answered 404: relation does not exist')
|
| 29 |
+
table, _, query = path.partition('?')
|
| 30 |
+
assert table == '/rest/v1/math_prompter_texts'
|
| 31 |
+
q = dict(urllib.parse.parse_qsl(query))
|
| 32 |
+
match = [r for r in self.rows if all(r[k] == q[k].removeprefix('eq.') for k in ('id', 'user_id') if k in q)]
|
| 33 |
+
if method == 'POST':
|
| 34 |
+
row = {**body, 'id': str(uuid.uuid4())}
|
| 35 |
+
self.rows.append(row)
|
| 36 |
+
return [row]
|
| 37 |
+
if method == 'PATCH':
|
| 38 |
+
for r in match:
|
| 39 |
+
r.update(body)
|
| 40 |
+
return match
|
| 41 |
+
if method == 'DELETE':
|
| 42 |
+
self.rows = [r for r in self.rows if r not in match]
|
| 43 |
+
return None
|
| 44 |
+
return sorted(match, key=lambda r: r['updated_at'], reverse=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def call(app, user, method, url, **kw):
|
| 48 |
+
async def go():
|
| 49 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test',
|
| 50 |
+
headers={'Authorization': f'Bearer {user}'}) as c:
|
| 51 |
+
return await c.request(method, url, **kw)
|
| 52 |
+
return asyncio.run(go())
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.fixture
|
| 56 |
+
def app():
|
| 57 |
+
a = create_app()
|
| 58 |
+
a.state.auth = PrompterAuth()
|
| 59 |
+
return a
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_developer_saves_lists_updates_and_deletes(app):
|
| 63 |
+
r = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': '', 'body': ' ืฉืืื ืืืืื\nืฉืืจื ืฉื ืืื'})
|
| 64 |
+
assert r.status_code == 200
|
| 65 |
+
first = r.json()['text']
|
| 66 |
+
assert first['title'] == 'ืฉืืื ืืืืื' # no title: the first line
|
| 67 |
+
second = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ืคืชืืื', 'body': 'ืืงืกื'}).json()['text']
|
| 68 |
+
texts = call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']
|
| 69 |
+
assert [t['id'] for t in texts] == [second['id'], first['id']] # newest first
|
| 70 |
+
r = call(app, OWNER, 'PUT', f"/api/prompter/texts/{first['id']}", json={'title': 'ืืืฉ', 'body': 'ืืืฃ ืืืฉ'})
|
| 71 |
+
assert r.json()['text']['title'] == 'ืืืฉ' and r.json()['text']['body'] == 'ืืืฃ ืืืฉ'
|
| 72 |
+
assert call(app, OWNER, 'DELETE', f"/api/prompter/texts/{second['id']}").status_code == 200
|
| 73 |
+
assert [t['title'] for t in call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']] == ['ืืืฉ']
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_each_developer_sees_only_their_own(app):
|
| 77 |
+
mine = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ืฉืื', 'body': 'x'}).json()['text']
|
| 78 |
+
assert call(app, DEV, 'GET', '/api/prompter/texts').json()['texts'] == []
|
| 79 |
+
assert call(app, DEV, 'PUT', f"/api/prompter/texts/{mine['id']}", json={'title': 'a', 'body': 'b'}).status_code == 404
|
| 80 |
+
call(app, DEV, 'DELETE', f"/api/prompter/texts/{mine['id']}")
|
| 81 |
+
assert len(call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']) == 1
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_students_and_bad_input_are_refused(app):
|
| 85 |
+
assert call(app, OTHER, 'GET', '/api/prompter/texts').status_code == 403
|
| 86 |
+
assert call(app, OTHER, 'POST', '/api/prompter/texts', json={'title': 'a', 'body': 'b'}).status_code == 403
|
| 87 |
+
assert call(app, 'nobody', 'GET', '/api/prompter/texts').status_code == 401
|
| 88 |
+
assert call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'a', 'body': ' '}).status_code == 400
|
| 89 |
+
assert call(app, OWNER, 'POST', '/api/prompter/texts', json={'body': 'x' * 100_001}).status_code == 413
|
| 90 |
+
assert call(app, OWNER, 'PUT', '/api/prompter/texts/not-an-id', json={'body': 'x'}).status_code == 404
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_long_title_is_cut_and_missing_table_is_503(app):
|
| 94 |
+
r = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ื' * 300, 'body': 'x'})
|
| 95 |
+
assert len(r.json()['text']['title']) == 120
|
| 96 |
+
app.state.auth.missing = True
|
| 97 |
+
assert call(app, OWNER, 'GET', '/api/prompter/texts').status_code == 503
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_migration_on_real_postgres(postgres_server): # noqa: F811
|
| 101 |
+
"""The table exists, keeps its limits, and only the server's key may use it."""
|
| 102 |
+
psycopg = pytest.importorskip("psycopg")
|
| 103 |
+
from pathlib import Path
|
| 104 |
+
from scan_support import BOOTSTRAP
|
| 105 |
+
database = "prm_" + uuid.uuid4().hex
|
| 106 |
+
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
|
| 107 |
+
c.execute("create database " + database)
|
| 108 |
+
uri = postgres_server.get_uri(database=database)
|
| 109 |
+
try:
|
| 110 |
+
with psycopg.connect(uri, autocommit=True) as c:
|
| 111 |
+
c.execute(BOOTSTRAP)
|
| 112 |
+
c.execute(Path("supabase/migrations/20261106130000_math_prompter_texts.sql").read_text())
|
| 113 |
+
c.execute("insert into auth.users values(%s)", (OWNER,))
|
| 114 |
+
c.execute("set role service_role")
|
| 115 |
+
c.execute("insert into math_prompter_texts(user_id,title,body) values(%s,'a','b')", (OWNER,))
|
| 116 |
+
with pytest.raises(psycopg.errors.CheckViolation):
|
| 117 |
+
c.execute("insert into math_prompter_texts(user_id,title,body) values(%s,'','b')", (OWNER,))
|
| 118 |
+
c.execute("reset role")
|
| 119 |
+
c.execute("set role authenticated")
|
| 120 |
+
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
| 121 |
+
c.execute("select * from math_prompter_texts")
|
| 122 |
+
c.execute("reset role")
|
| 123 |
+
c.execute("delete from auth.users") # an account's texts go with it
|
| 124 |
+
assert c.execute("select count(*) from math_prompter_texts").fetchone()[0] == 0
|
| 125 |
+
finally:
|
| 126 |
+
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
|
| 127 |
+
c.execute("drop database " + database + " with (force)")
|