Add market admin CRUD: list, create, and delete marketplaces

Replaces placeholder "Market admin" text with a functional admin panel
that lists marketplaces for a page and supports create/delete via sx,
mirroring the events calendar admin pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 19:16:39 +00:00
parent 01a67029f0
commit 755313bd29
3 changed files with 164 additions and 4 deletions

View File

@@ -1,9 +1,27 @@
from __future__ import annotations
from quart import make_response, Blueprint
import re
import unicodedata
from quart import make_response, request, g, Blueprint
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.services.registry import services
from shared.sx.helpers import sx_response
def _slugify(value: str, max_len: int = 255) -> str:
if value is None:
value = ""
value = unicodedata.normalize("NFKD", value)
value = value.encode("ascii", "ignore").decode("ascii")
value = value.lower()
value = value.replace("/", "-")
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
value = value.strip("-")[:max_len].strip("-")
return value or "market"
def register():
@@ -20,8 +38,51 @@ def register():
html = await render_page_admin_page(tctx)
return await make_response(html)
else:
from shared.sx.helpers import sx_response
sx_src = await render_page_admin_oob(tctx)
return sx_response(sx_src)
@bp.post("/new/")
@require_admin
async def create_market(**kwargs):
form = await request.form
name = (form.get("name") or "").strip()
post_data = getattr(g, "post_data", None)
post_id = (post_data.get("post") or {}).get("id") if post_data else None
if not post_id:
return await make_response("No page context", 400)
slug = _slugify(name)
try:
await services.market.create_marketplace(g.s, "page", post_id, name, slug)
except Exception as e:
from shared.sx.jinja_bridge import render as render_comp
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_admin_list_panel
ctx = await get_template_context()
html = await render_markets_admin_list_panel(ctx)
return sx_response(html)
@bp.delete("/<market_slug>/")
@require_admin
async def delete_market(**kwargs):
market_slug = g.get("market_slug", "")
post_data = getattr(g, "post_data", None)
post_id = (post_data.get("post") or {}).get("id") if post_data else None
if not post_id:
return await make_response("No page context", 400)
await services.market.soft_delete_marketplace(g.s, "page", post_id, market_slug)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_admin_list_panel
ctx = await get_template_context()
html = await render_markets_admin_list_panel(ctx)
return sx_response(html)
return bp

40
market/sx/admin.sx Normal file
View File

@@ -0,0 +1,40 @@
;; Market admin panel components (page-level admin for markets)
(defcomp ~market-admin-create-form (&key create-url csrf)
(<>
(div :id "market-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
:sx-target "#markets-list" :sx-select "#markets-list" :sx-swap "outerHTML"
:sx-on:beforeRequest "document.querySelector('#market-create-errors').textContent='';"
:sx-on:responseError "document.querySelector('#market-create-errors').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "flex-1"
(label :class "block text-sm text-gray-600" "Name")
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
:placeholder "e.g. Suma, Craft Fair"))
(button :type "submit" :class "border rounded px-3 py-2" "Add market"))))
(defcomp ~market-admin-panel (&key form list)
(section :class "p-4"
form
(div :id "markets-list" :class "mt-6" list)))
(defcomp ~market-admin-empty ()
(p :class "text-gray-500 mt-4" "No markets yet. Create one above."))
(defcomp ~market-admin-item (&key href name slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
(h3 :class "font-semibold" name)
(h4 :class "text-gray-500" (str "/" slug "/")))
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
:data-confirm true :data-confirm-title "Delete market?"
:data-confirm-text "Products will be hidden (soft delete)"
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:sx-delete del-url :sx-trigger "confirmed"
:sx-target "#markets-list" :sx-select "#markets-list" :sx-swap "outerHTML"
:sx-headers csrf-hdr
(i :class "fa-solid fa-trash")))))

View File

@@ -1516,6 +1516,63 @@ def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "")
# Page admin (/<slug>/admin/) — post-level admin for markets
# ---------------------------------------------------------------------------
async def _markets_admin_panel_sx(ctx: dict) -> str:
"""Render the markets list + create form panel."""
from quart import g, url_for
from shared.services.registry import services
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
has_access = ctx.get("has_access")
can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
post_id = post.get("id")
markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
form_html = ""
if can_create:
create_url = url_for("page_admin.create_market")
form_html = sx_call("market-admin-create-form",
create_url=create_url, csrf=csrf)
list_html = _markets_admin_list_sx(ctx, markets)
return sx_call("market-admin-panel",
form=SxExpr(form_html), list=SxExpr(list_html))
def _markets_admin_list_sx(ctx: dict, markets: list) -> str:
"""Render the markets list items."""
from quart import url_for
from shared.utils import route_prefix
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
prefix = route_prefix()
if not markets:
return sx_call("market-admin-empty")
parts = []
for m in markets:
m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
post_slug = (ctx.get("post") or {}).get("slug", "")
href = prefix + f"/{post_slug}/{m_slug}/"
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
parts.append(sx_call("market-admin-item",
href=href, name=m_name, slug=m_slug,
del_url=del_url, csrf_hdr=csrf_hdr))
return "".join(parts)
async def render_markets_admin_list_panel(ctx: dict) -> str:
"""Render the markets admin panel HTML for POST/DELETE response."""
return await _markets_admin_panel_sx(ctx)
async def render_page_admin_page(ctx: dict) -> str:
"""Full page: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
@@ -1523,7 +1580,8 @@ async def render_page_admin_page(ctx: dict) -> str:
hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + admin_hdr + ")"
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))'
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return full_page_sx(ctx, header_rows=hdr, content=content)
@@ -1533,7 +1591,8 @@ async def render_page_admin_oob(ctx: dict) -> str:
oobs = "(<> " + post_admin_header_sx(ctx, slug, oob=True, selected="markets") + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child") + ")"
content = '(div :id "main-panel" (div :class "p-4 text-stone-500" "Market admin"))'
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return oob_page_sx(oobs=oobs, content=content)