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 __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.authz import require_admin
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
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():
|
def register():
|
||||||
@@ -20,8 +38,51 @@ def register():
|
|||||||
html = await render_page_admin_page(tctx)
|
html = await render_page_admin_page(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
else:
|
else:
|
||||||
from shared.sx.helpers import sx_response
|
|
||||||
sx_src = await render_page_admin_oob(tctx)
|
sx_src = await render_page_admin_oob(tctx)
|
||||||
return sx_response(sx_src)
|
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
|
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
|
# 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:
|
async def render_page_admin_page(ctx: dict) -> str:
|
||||||
"""Full page: page-level market admin."""
|
"""Full page: page-level market admin."""
|
||||||
slug = (ctx.get("post") or {}).get("slug", "")
|
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)
|
hdr = root_header_sx(ctx)
|
||||||
child = "(<> " + _post_header_sx(ctx) + " " + admin_hdr + ")"
|
child = "(<> " + _post_header_sx(ctx) + " " + admin_hdr + ")"
|
||||||
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
|
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)
|
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 = "(<> " + post_admin_header_sx(ctx, slug, oob=True, selected="markets") + " "
|
||||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||||
"post-admin-row", "post-admin-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)
|
return oob_page_sx(oobs=oobs, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user