feat(booth): asks — a multiple-choice question a session poses in a booth, answered by the operator as a radio form + notes, written back as an answer sidecar

- booth/asks.py (stdlib): <stem>.ask.json question / <stem>.answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden
- POST /b/<name>/answer: validates choice against the ask (400), unknown stem 404, re-answer overwrites
- booth.html asks panel above the gallery; amber open / green answered; JS-off form POST; index card + booth header badge for open asks
- CLI: booth ask / asks / answer [--wait [SECS]]; remote sessions poll <stem>.answer.json over HTTP
- ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9
This commit is contained in:
vh
2026-09-09 07:24:16 -07:00
parent 8060f8cb9a
commit 97589dd062
9 changed files with 721 additions and 6 deletions
+47 -2
View File
@@ -75,6 +75,16 @@ KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.asks import ( # noqa: E402
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
is_answer_file,
is_ask_file,
list_asks,
valid_stem,
write_answer,
)
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
@@ -215,7 +225,14 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None
for f in files:
@@ -234,6 +251,8 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child),
"asks_total": len(asks),
"asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
}
@@ -249,7 +268,12 @@ def build_gallery(child: Path) -> list[dict]:
next to `a.png`) is consumed as that item's caption rather than shown itself —
the natural way to label an A/B pair.
"""
all_files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
all_files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
# `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
caption: dict[str, str] = {}
sidecars: set[str] = set()
@@ -620,11 +644,32 @@ def create_app(
)
if (booth / LINKS_FILE).is_file() else []
),
# Asks: multiple-choice questions a session left for the
# operator, rendered as forms above the gallery (open ones)
# or as their recorded answer. See booth/asks.py.
"asks": list_asks(booth),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
)
@app.post("/b/{name}/answer")
def booth_answer(request: Request, name: str, ask: str = Form(...), choice: str = Form(...),
notes: str = Form("")):
"""Record the operator's answer to one ask: validates the choice
against the ask's options and writes `<stem>.answer.json` atomically.
Re-submitting overwrites — the sidecar is the current answer. 404 for
an unknown/invalid stem, 400 for a choice the ask does not offer."""
booth = resolve_booth(name)
if not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
raise HTTPException(status_code=404, detail="no such ask")
who = request.client.host if request.client else ""
try:
write_answer(booth, ask, choice, notes, who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/b/{quote(name, safe='')}/#ask-{quote(ask, safe='')}", status_code=303)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
booth = resolve_booth(name)