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:
+47
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user