From 755313bd29b67a7e11b3da4f7a8302e07ca8a48a Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 19:16:39 +0000 Subject: [PATCH] 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 --- market/bp/page_admin/routes.py | 65 ++++++++++++++++++++++++++++++++-- market/sx/admin.sx | 40 +++++++++++++++++++++ market/sx/sx_components.py | 63 ++++++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 market/sx/admin.sx diff --git a/market/bp/page_admin/routes.py b/market/bp/page_admin/routes.py index 3cf2ccf..18cc2cc 100644 --- a/market/bp/page_admin/routes.py +++ b/market/bp/page_admin/routes.py @@ -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("//") + @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 diff --git a/market/sx/admin.sx b/market/sx/admin.sx new file mode 100644 index 0000000..6ecd2ce --- /dev/null +++ b/market/sx/admin.sx @@ -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"))))) diff --git a/market/sx/sx_components.py b/market/sx/sx_components.py index 450b96e..c40596d 100644 --- a/market/sx/sx_components.py +++ b/market/sx/sx_components.py @@ -1516,6 +1516,63 @@ def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") # Page admin (//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)