3v324v23 commited on
Commit
cb7f2ec
ยท
1 Parent(s): 3418adf

feat: implement prompter functionality with save, edit, and delete features, including API routes and tests

Browse files
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-text{width:100%;box-sizing:border-box;min-height:220px;padding:12px;border:1px solid var(--mx-line,#ccc);border-radius:12px;font:inherit;font-size:17px;line-height:1.5;resize:vertical;background:var(--mx-card,#fff);color:inherit;margin-block:10px}
 
 
 
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('[data-act="prompter-go"]').click()
 
 
 
 
 
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-text").hover()
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=5000)
 
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)")