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:
@@ -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
40
market/sx/admin.sx
Normal 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")))))
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user