feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.
The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.
Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".
Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.
data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.
Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.
Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.
Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).
Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
This commit is contained in:
+154
-173
@@ -158,11 +158,6 @@ from booth.marks import ( # noqa: E402
|
||||
set_flag,
|
||||
write_note,
|
||||
)
|
||||
from booth.inline import ( # noqa: E402
|
||||
form_id as ask_form_id,
|
||||
has_placeholders,
|
||||
place as place_asks,
|
||||
)
|
||||
from booth.manifest import ( # noqa: E402
|
||||
MANIFEST_FILE,
|
||||
SERVICE_HANDLE,
|
||||
@@ -545,119 +540,77 @@ def _zip_filename(name: str) -> str:
|
||||
return f"{safe or 'booth'}.zip"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper -------------------------------------------
|
||||
# ---- the declared embed seam (U3) ------------------------------------------
|
||||
|
||||
# Mirror of base.html's favicon (the app templates set it there; this is the copy
|
||||
# injected into a booth's *verbatim* index.html so a raw page inherits the same
|
||||
# icon). Keep the two in sync if the Booth's icon ever changes.
|
||||
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||||
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
|
||||
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||
FAVICON_HREF = (
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
||||
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
||||
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
||||
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
||||
)
|
||||
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
|
||||
|
||||
# A self-contained floating "back to all booths" chip injected into verbatim
|
||||
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
|
||||
# page without touching its layout; hidden in print so downloaded reports stay clean.
|
||||
_BACK_CHIP = (
|
||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
||||
# top-right: empty on left-aligned report layouts (a top-left chip clips the
|
||||
# page title), and consistent with the zoom view's top-right back affordance.
|
||||
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
|
||||
"@media print{.booth-nav-home{display:none}}</style>"
|
||||
)
|
||||
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||
# the Booth appends it only when the page has not declared it itself.
|
||||
EMBED_SRC = "/_booth/embed.js"
|
||||
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||||
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||||
|
||||
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
|
||||
# the auto-gallery template — can never appear on it. Without this chip an ask
|
||||
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
|
||||
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
|
||||
# showed nothing). Same injection mechanism as the back chip; it links to the
|
||||
# standalone /asks page, which renders the real forms.
|
||||
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
|
||||
if open_count < 1:
|
||||
return ""
|
||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
||||
href = href or f"/b/{quote(name, safe='')}/asks"
|
||||
return (
|
||||
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
|
||||
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}"
|
||||
"@media print{.booth-nav-asks{display:none}}</style>"
|
||||
)
|
||||
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||||
# and each requires `src=` immediately before the path.
|
||||
#
|
||||
# The bare path was the first draft and it was wrong in the dangerous
|
||||
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||||
# sample, a comment, a sentence about this very feature — would have been read
|
||||
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||||
# Booth's own design reports are exactly the pages that would quote it.
|
||||
#
|
||||
# These tests fail in the harmless direction instead. An unusual spelling
|
||||
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||||
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||||
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||||
# costs a duplicate tag; a false one costs the operator his chrome.
|
||||
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||||
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
||||
def declares_embed(html: str) -> bool:
|
||||
"""Whether a verbatim page already asks for the Booth's chrome.
|
||||
|
||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
||||
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.start()] + snippet + html[m.start() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.end()] + snippet + html[m.end() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
|
||||
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
|
||||
declares none — into a booth's verbatim index.html, without altering the page's
|
||||
rendered content.
|
||||
|
||||
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
|
||||
<style>…content`, no explicit head/body). The two hard constraints:
|
||||
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
|
||||
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
|
||||
So the favicon lands at the first head-ish seam (before </head>, else after
|
||||
<html>, else right after the doctype — a ~250B link keeps charset in range), and
|
||||
the fixed-position chip is appended at the END of the document (before </body> /
|
||||
</html> or appended), which renders top-left regardless and disturbs nothing.
|
||||
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||||
six regular expressions run against arbitrary author HTML — and the other
|
||||
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||||
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||||
harmless duplicate rather than toward silently withholding the chrome.
|
||||
"""
|
||||
if favicon_link and not _ICON_RE.search(html):
|
||||
for inserter, pat in (
|
||||
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
|
||||
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
|
||||
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
|
||||
):
|
||||
html, done = inserter(html, pat, favicon_link)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = favicon_link + html # bare fragment, no doctype: safe to prepend
|
||||
return any(d in html for d in _EMBED_DECLARATIONS)
|
||||
|
||||
chips = _BACK_CHIP + (extra or "")
|
||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
||||
html, done = _insert_before(html, pat, chips)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = html + chips # no </body>/</html>: append to the end
|
||||
return html
|
||||
|
||||
def embed_verbatim(html: str) -> str:
|
||||
"""The ONLY thing the Booth does to a verbatim report.
|
||||
|
||||
Appended, never inserted, and never prepended. That is what retires both of
|
||||
the old wrapper's hard constraints rather than satisfying them more
|
||||
carefully: nothing can displace a leading doctype into quirks mode and
|
||||
nothing can push the charset <meta> out of its first-1024-byte detection
|
||||
window, because nothing in front of them moves. Content after `</html>` is
|
||||
parsed into the body by every browser, so there is no seam to find.
|
||||
"""
|
||||
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||
|
||||
|
||||
def ask_form_id(stem: str) -> str:
|
||||
"""The shared `<form>` id a pick's scattered question groups bind to with
|
||||
the HTML5 `form=` attribute.
|
||||
|
||||
Moved here from `booth/inline.py` when U3 deleted that module: it is not
|
||||
placement machinery, it is what makes four radio groups spread down a report
|
||||
submit as ONE POST, which is what a multi-question ask requires.
|
||||
"""
|
||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||
|
||||
|
||||
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||
@@ -765,6 +718,13 @@ def create_app(
|
||||
env.filters["dur"] = human_dur
|
||||
templates = Jinja2Templates(env=env)
|
||||
|
||||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||||
# kind of thing this repo serves, and the only one that would otherwise be
|
||||
# free to hot-reload from the deployment root — which is the skew that put
|
||||
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
|
||||
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
|
||||
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = None
|
||||
@@ -855,6 +815,20 @@ def create_app(
|
||||
def healthz():
|
||||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
||||
|
||||
@app.get(EMBED_SRC)
|
||||
def embed_script():
|
||||
"""The declared seam's one static asset.
|
||||
|
||||
Served from the startup read, with an ETag over its content so a
|
||||
browser revalidates instead of holding a stale copy across a restart —
|
||||
`no-cache` here means "ask me", not "do not store".
|
||||
"""
|
||||
return Response(
|
||||
content=embed_js,
|
||||
media_type="text/javascript; charset=utf-8",
|
||||
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}", include_in_schema=False)
|
||||
def booth_redirect(name: str):
|
||||
resolve_booth(name)
|
||||
@@ -877,18 +851,17 @@ def create_app(
|
||||
)
|
||||
own_index = booth / "index.html"
|
||||
if own_index.is_file():
|
||||
# Serve the operator's verbatim report, but inject a floating
|
||||
# back-to-booths chip + the Booth favicon (if it declares none) so a
|
||||
# raw page still has a way home. Small HTML -> read + wrap in memory;
|
||||
# a pathological large file falls back to serving raw, unwrapped.
|
||||
# The operator's verbatim report. U3: the page declares the seam and
|
||||
# the Booth mounts into it — so a page carrying the script tag is
|
||||
# served exactly as written, and one that is not gets that single
|
||||
# line appended. Nothing is parsed, matched or inserted.
|
||||
#
|
||||
# The read is still bounded: a pathological file falls back to
|
||||
# serving raw, which costs it the chrome exactly as it did before.
|
||||
try:
|
||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||
raw = own_index.read_text(encoding="utf-8", errors="replace")
|
||||
# Asks render INLINE, where the report author put them (or
|
||||
# appended, if they marked nothing) — a question about an
|
||||
# artifact belongs beside that artifact, not on another page.
|
||||
body, tail = inject_asks(name, booth, raw)
|
||||
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
|
||||
return HTMLResponse(embed_verbatim(raw))
|
||||
except OSError:
|
||||
pass
|
||||
return FileResponse(str(own_index), media_type="text/html")
|
||||
@@ -1098,73 +1071,81 @@ def create_app(
|
||||
|
||||
_frag = templates.env.get_template("_ask_inline.html").module
|
||||
|
||||
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
|
||||
"""(body, tail) for a verbatim booth: placeholders substituted in place,
|
||||
and whatever still has to be appended before </body>.
|
||||
def _pick_fragments(name: str, mark) -> dict:
|
||||
"""One pick, rendered into the pieces a page can mount independently.
|
||||
|
||||
Marked-up pages get each fragment exactly where the author put it. An
|
||||
unmarked page gets the whole ask appended — an ask is NEVER invisible,
|
||||
which is the guarantee; markup only moves it somewhere better. A stem
|
||||
whose questions were placed but whose submit block was not gets that
|
||||
block appended, so a scattered form is always submittable.
|
||||
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||||
is exactly ONE renderer of an ask. embed.js places these; it never
|
||||
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||||
written to stop after the zoom view re-derived an item and lost its
|
||||
captions doing it.
|
||||
"""
|
||||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
||||
if not picks:
|
||||
return html, ""
|
||||
url = quote(name, safe="")
|
||||
fid = ask_form_id(mark.id)
|
||||
if mark.error:
|
||||
# `whole` renders the broken-ask box. A question the session
|
||||
# believes it posted has to be visible; the pieces of a pick that
|
||||
# could not be read do not exist to offer.
|
||||
return {"id": mark.id, "error": mark.error,
|
||||
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||||
"questions": []}
|
||||
return {
|
||||
"id": mark.id,
|
||||
"error": None,
|
||||
"whole": str(_frag.whole(mark, fid, url)),
|
||||
"submit": str(_frag.submit(mark, fid, url)),
|
||||
# A LIST, not an object keyed by question key: a single-question
|
||||
# pick normalizes to one question whose key is None, which JSON
|
||||
# would write as the string "null" and so invent a name. The list
|
||||
# also carries declaration order in the format itself.
|
||||
"questions": [
|
||||
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
|
||||
for q in mark.questions
|
||||
],
|
||||
}
|
||||
|
||||
seen: set[str] = set()
|
||||
@app.get("/b/{name}/embed.json")
|
||||
def booth_embed_json(name: str):
|
||||
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||||
|
||||
def render(kind: str, mark, key: str | None) -> str:
|
||||
fid = ask_form_id(mark.id)
|
||||
if kind == "whole":
|
||||
frag = str(_frag.whole(mark, fid, url))
|
||||
elif kind == "submit":
|
||||
frag = str(_frag.submit(mark, fid, url))
|
||||
else:
|
||||
q = next(q for q in mark.questions if q.get("key") == key)
|
||||
frag = str(_frag.question(mark, q, fid, url))
|
||||
# An anchor on the FIRST fragment of each pick, wherever it landed,
|
||||
# so the floating chip can jump to it on a long report. Computed
|
||||
# here rather than in the macros because only the caller knows
|
||||
# which fragment came first.
|
||||
if mark.id not in seen:
|
||||
seen.add(mark.id)
|
||||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
||||
return frag
|
||||
The READ half of the declared seam. `embed.js` fetches this and places
|
||||
what comes back; every decision — what a mark says, whether it is still
|
||||
open, what order the marks come in — is made here and never re-derived
|
||||
on the page.
|
||||
|
||||
tail = [str(_frag.styles())]
|
||||
if has_placeholders(html):
|
||||
html, placed, submitted = place_asks(html, picks, render)
|
||||
for m in picks:
|
||||
keys = placed.get(m.id)
|
||||
if keys is None:
|
||||
tail.append(render("whole", m, None)) # unmarked: never dropped
|
||||
continue
|
||||
if m.error:
|
||||
continue
|
||||
if None not in keys:
|
||||
# Partially marked up: append every question the author did
|
||||
# NOT place. A multi-question pick needs all of them or the
|
||||
# POST is a 400 — met only after the operator fills it in.
|
||||
for q in m.questions:
|
||||
if q.get("key") not in keys:
|
||||
tail.append(render("question", m, q.get("key")))
|
||||
if m.id not in submitted:
|
||||
tail.append(render("submit", m, None)) # scattered but submittable
|
||||
else:
|
||||
for m in picks:
|
||||
tail.append(render("whole", m, None))
|
||||
Marks are ordered `(created, id)`, which is what both readers below
|
||||
sort by. Questions are in declaration order. `open` is `open_marks`,
|
||||
the ONE openness predicate, so a half-answered multi-question pick
|
||||
counts as open here exactly as it does on the index badge.
|
||||
|
||||
# The chip is a JUMP LINK to the inline block, not a way out to a
|
||||
# separate page: on a long report the question can be well below the
|
||||
# fold, and "there is a question waiting" still has to be visible at
|
||||
# first paint.
|
||||
still_open = open_marks(picks) # INV-2: not re-derived here
|
||||
if still_open:
|
||||
tail.append(asks_chip(name, len(still_open),
|
||||
href=f'#bk-ask-{still_open[0].id}-top'))
|
||||
return html, "".join(tail)
|
||||
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
|
||||
early returns; counting a script's fetch of the page it is already on
|
||||
would reset the TTL on machinery rather than on the operator.
|
||||
|
||||
The read is LENIENT and the status stays 200, copied from
|
||||
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
|
||||
operator's report. That is the v0.2.2 lesson.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
if read_err is not None:
|
||||
marks = marks_for(booth)
|
||||
picks = [m for m in marks if m.shape == "pick"]
|
||||
body = {
|
||||
"booth": name,
|
||||
"home": "/",
|
||||
"favicon": FAVICON_HREF,
|
||||
# Picks only. It is also what keeps a flag's `flag:<target>` id —
|
||||
# the one mark id containing the separator an anchor spec splits
|
||||
# on — out of a payload whose specs split on the first colon.
|
||||
"marks": [_pick_fragments(name, m) for m in picks],
|
||||
"open": [m.id for m in open_marks(picks)],
|
||||
}
|
||||
if read_err is not None:
|
||||
body["marks"] = []
|
||||
body["error"] = "this booth's .marks.json cannot be read"
|
||||
body["detail"] = read_err
|
||||
return JSONResponse(body)
|
||||
|
||||
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||
def booth_marks_page(request: Request, name: str):
|
||||
|
||||
Reference in New Issue
Block a user