Phase 6: Replace render_template() with s-expression rendering in all GET routes
Migrate ~52 GET route handlers across all 7 services from Jinja render_template() to s-expression component rendering. Each service gets a sexp_components.py with page/oob/cards render functions. - Add per-service sexp_components.py (account, blog, cart, events, federation, market, orders) with full page, OOB, and pagination card rendering - Add shared/sexp/helpers.py with call_url, root_header_html, full_page, oob_page utilities - Update all GET routes to use get_template_context() + render fns - Fix get_template_context() to inject Jinja globals (URL helpers) - Add qs_filter to base_context for sexp filter URL building - Mount sexp_components.py in docker-compose.dev.yml for all services - Import sexp_components in app.py for Hypercorn --reload watching - Fix route_prefix import (shared.utils not shared.infrastructure.urls) - Fix federation choose-username missing actor in context - Fix market page_markets missing post in context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
|
||||
@@ -47,14 +47,17 @@ def register(url_prefix="/"):
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_account_page, render_account_oob
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/"))
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/auth/index.html")
|
||||
html = await render_account_page(ctx)
|
||||
else:
|
||||
html = await render_template("_types/auth/_oob_elements.html")
|
||||
html = await render_account_oob(ctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -86,20 +89,14 @@ def register(url_prefix="/"):
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_newsletters_page, render_newsletters_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/auth/index.html",
|
||||
oob=nl_oob,
|
||||
newsletter_list=newsletter_list,
|
||||
)
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/auth/_oob_elements.html",
|
||||
oob=nl_oob,
|
||||
newsletter_list=newsletter_list,
|
||||
)
|
||||
html = await render_newsletters_oob(ctx, newsletter_list)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@@ -149,20 +146,14 @@ def register(url_prefix="/"):
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
|
||||
w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_fragment_page, render_fragment_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
"_types/auth/index.html",
|
||||
oob=w_oob,
|
||||
page_fragment_html=fragment_html,
|
||||
)
|
||||
html = await render_fragment_page(ctx, fragment_html)
|
||||
else:
|
||||
html = await render_template(
|
||||
"_types/auth/_oob_elements.html",
|
||||
oob=w_oob,
|
||||
page_fragment_html=fragment_html,
|
||||
)
|
||||
html = await render_fragment_oob(ctx, fragment_html)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@@ -275,7 +275,11 @@ def register(url_prefix="/auth"):
|
||||
if g.get("user"):
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
return await render_template("auth/login.html")
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
|
||||
@rate_limit(
|
||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
@@ -688,8 +692,11 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/")
|
||||
async def device_form():
|
||||
"""Browser form where user enters the code displayed in terminal."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_device_page
|
||||
code = request.args.get("code", "")
|
||||
return await render_template("auth/device.html", code=code)
|
||||
ctx = await get_template_context(code=code)
|
||||
return await render_device_page(ctx)
|
||||
|
||||
@auth_bp.post("/device")
|
||||
@auth_bp.post("/device/")
|
||||
@@ -739,6 +746,9 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/complete/")
|
||||
async def device_complete():
|
||||
"""Post-login redirect — completes approval after magic link auth."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp_components import render_device_page, render_device_approved_page
|
||||
|
||||
device_code = request.args.get("code", "")
|
||||
|
||||
if not device_code:
|
||||
@@ -750,11 +760,12 @@ def register(url_prefix="/auth"):
|
||||
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
return await render_template(
|
||||
"auth/device.html",
|
||||
ctx = await get_template_context(
|
||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||
), 400
|
||||
)
|
||||
return await render_device_page(ctx), 400
|
||||
|
||||
return await render_template("auth/device_approved.html")
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
|
||||
return auth_bp
|
||||
|
||||
379
account/sexp_components.py
Normal file
379
account/sexp_components.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||
auth pages. Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.sexp.jinja_bridge import sexp
|
||||
from shared.sexp.helpers import (
|
||||
call_url, root_header_html, search_desktop_html,
|
||||
search_mobile_html, full_page, oob_page,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_nav_html(ctx: dict) -> str:
|
||||
"""Auth section desktop nav items."""
|
||||
html = sexp(
|
||||
'(~nav-link :href h :label "newsletters" :select-colours sc)',
|
||||
h=call_url(ctx, "account_url", "/newsletters/"),
|
||||
sc=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row."""
|
||||
return sexp(
|
||||
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
|
||||
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
|
||||
' :nav-html nh :child-id "auth-header-child" :oob oob)',
|
||||
lh=call_url(ctx, "account_url", "/"),
|
||||
nh=_auth_nav_html(ctx),
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_nav_mobile_html(ctx: dict) -> str:
|
||||
"""Mobile nav menu for auth section."""
|
||||
html = sexp(
|
||||
'(~nav-link :href h :label "newsletters" :select-colours sc)',
|
||||
h=call_url(ctx, "account_url", "/newsletters/"),
|
||||
sc=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account dashboard (GET /)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_main_panel_html(ctx: dict) -> str:
|
||||
"""Account info panel with user details and logout."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
error = ctx.get("error", "")
|
||||
|
||||
parts = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
|
||||
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">']
|
||||
|
||||
if error:
|
||||
parts.append(
|
||||
f'<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">{error}</div>'
|
||||
)
|
||||
|
||||
# Account header with logout
|
||||
parts.append('<div class="flex items-center justify-between"><div>')
|
||||
parts.append('<h1 class="text-xl font-semibold tracking-tight">Account</h1>')
|
||||
if user:
|
||||
parts.append(f'<p class="text-sm text-stone-500 mt-1">{user.email}</p>')
|
||||
if user.name:
|
||||
parts.append(f'<p class="text-sm text-stone-600">{user.name}</p>')
|
||||
parts.append('</div>')
|
||||
parts.append(
|
||||
f'<form action="/auth/logout/" method="post">'
|
||||
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||
f'<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition">'
|
||||
f'<i class="fa-solid fa-right-from-bracket text-xs"></i> Sign out</button></form>'
|
||||
)
|
||||
parts.append('</div>')
|
||||
|
||||
# Labels
|
||||
if user and hasattr(user, "labels") and user.labels:
|
||||
parts.append('<div><h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>')
|
||||
parts.append('<div class="flex flex-wrap gap-2">')
|
||||
for label in user.labels:
|
||||
parts.append(
|
||||
f'<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">'
|
||||
f'{label.name}</span>'
|
||||
)
|
||||
parts.append('</div></div>')
|
||||
|
||||
parts.append('</div></div>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletters (GET /newsletters/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||
"""Render a single newsletter toggle switch."""
|
||||
nid = un.newsletter_id
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
if un.subscribed:
|
||||
bg = "bg-emerald-500"
|
||||
translate = "translate-x-6"
|
||||
checked = "true"
|
||||
else:
|
||||
bg = "bg-stone-300"
|
||||
translate = "translate-x-1"
|
||||
checked = "false"
|
||||
return (
|
||||
f'<div id="nl-{nid}" class="flex items-center">'
|
||||
f'<button hx-post="{toggle_url}"'
|
||||
f' hx-headers=\'{{"X-CSRFToken": "{csrf_token}"}}\''
|
||||
f' hx-target="#nl-{nid}" hx-swap="outerHTML"'
|
||||
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
|
||||
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}"'
|
||||
f' role="switch" aria-checked="{checked}">'
|
||||
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}"></span>'
|
||||
f'</button></div>'
|
||||
)
|
||||
|
||||
|
||||
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Newsletters management panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
account_url_fn = ctx.get("account_url")
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
parts = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
|
||||
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">',
|
||||
'<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>']
|
||||
|
||||
if newsletter_list:
|
||||
parts.append('<div class="divide-y divide-stone-100">')
|
||||
for item in newsletter_list:
|
||||
nl = item["newsletter"]
|
||||
un = item.get("un")
|
||||
parts.append('<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">')
|
||||
parts.append(f'<div class="min-w-0 flex-1"><p class="text-sm font-medium text-stone-800">{nl.name}</p>')
|
||||
if nl.description:
|
||||
parts.append(f'<p class="text-xs text-stone-500 mt-0.5 truncate">{nl.description}</p>')
|
||||
parts.append('</div><div class="ml-4 flex-shrink-0">')
|
||||
|
||||
if un:
|
||||
parts.append(_newsletter_toggle_html(un, account_url_fn, csrf))
|
||||
else:
|
||||
# No subscription yet — show off toggle
|
||||
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||
parts.append(
|
||||
f'<div id="nl-{nl.id}" class="flex items-center">'
|
||||
f'<button hx-post="{toggle_url}"'
|
||||
f' hx-headers=\'{{"X-CSRFToken": "{csrf}"}}\''
|
||||
f' hx-target="#nl-{nl.id}" hx-swap="outerHTML"'
|
||||
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
|
||||
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
|
||||
f' role="switch" aria-checked="false">'
|
||||
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>'
|
||||
f'</button></div>'
|
||||
)
|
||||
parts.append('</div></div>')
|
||||
parts.append('</div>')
|
||||
else:
|
||||
parts.append('<p class="text-sm text-stone-500">No newsletters available.</p>')
|
||||
|
||||
parts.append('</div></div>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_page_content(ctx: dict) -> str:
|
||||
"""Login form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
|
||||
parts = ['<div class="py-8 max-w-md mx-auto">',
|
||||
'<h1 class="text-2xl font-bold mb-6">Sign in</h1>']
|
||||
if error:
|
||||
parts.append(
|
||||
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
|
||||
)
|
||||
action = url_for("auth.start_login")
|
||||
parts.append(
|
||||
f'<form method="post" action="{action}" class="space-y-4">'
|
||||
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
|
||||
f'<input type="email" name="email" id="email" value="{email}" required autofocus'
|
||||
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
|
||||
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
|
||||
f'Send magic link</button></form>'
|
||||
)
|
||||
parts.append('</div>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _device_page_content(ctx: dict) -> str:
|
||||
"""Device authorization form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
|
||||
parts = ['<div class="py-8 max-w-md mx-auto">',
|
||||
'<h1 class="text-2xl font-bold mb-6">Authorize device</h1>',
|
||||
'<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>']
|
||||
if error:
|
||||
parts.append(
|
||||
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
|
||||
)
|
||||
action = url_for("auth.device_submit")
|
||||
parts.append(
|
||||
f'<form method="post" action="{action}" class="space-y-4">'
|
||||
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||
f'<div><label for="code" class="block text-sm font-medium mb-1">Device code</label>'
|
||||
f'<input type="text" name="code" id="code" value="{code}" placeholder="XXXX-XXXX"'
|
||||
f' required autofocus maxlength="9" autocomplete="off" spellcheck="false"'
|
||||
f' class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
|
||||
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
|
||||
f'Authorize</button></form>'
|
||||
)
|
||||
parts.append('</div>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _device_approved_content() -> str:
|
||||
"""Device approved success content."""
|
||||
return (
|
||||
'<div class="py-8 max-w-md mx-auto text-center">'
|
||||
'<h1 class="text-2xl font-bold mb-4">Device authorized</h1>'
|
||||
'<p class="text-stone-600">You can close this window and return to your terminal.</p>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_account_page(ctx: dict) -> str:
|
||||
"""Full page: account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += sexp(
|
||||
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||
a=_auth_header_html(ctx),
|
||||
)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_account_oob(ctx: dict) -> str:
|
||||
"""OOB response for account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += sexp(
|
||||
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||
a=_auth_header_html(ctx),
|
||||
)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||
"""OOB response for newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""Full page: fragment-provided content."""
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += sexp(
|
||||
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||
a=_auth_header_html(ctx),
|
||||
)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_login_page_content(ctx),
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_page_content(ctx),
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_approved_content(),
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
Reference in New Issue
Block a user