feat: add markets and payments management pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s

- New markets blueprint at /<slug>/markets/ with create/delete
- New payments blueprint at /<slug>/payments/ with SumUp config
- Register both in events app with context processor for markets
- Remove PageConfig feature flag check from calendar creation
  (feature toggles replaced by direct management pages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-10 23:45:07 +00:00
parent 0255c937dd
commit e0679f8100
20 changed files with 502 additions and 8 deletions

23
app.py
View File

@@ -9,7 +9,7 @@ from sqlalchemy import select
from shared.factory import create_base_app
from suma_browser.app.bp import register_calendars
from suma_browser.app.bp import register_calendars, register_markets, register_payments
async def events_context() -> dict:
@@ -43,6 +43,7 @@ async def events_context() -> dict:
def create_app() -> "Quart":
from models.ghost_content import Post
from models.calendars import Calendar
from models.market_place import MarketPlace
app = create_base_app("events", context_fn=events_context)
@@ -59,6 +60,18 @@ def create_app() -> "Quart":
url_prefix="/<slug>/calendars",
)
# Markets nested under post slug: /<slug>/markets/...
app.register_blueprint(
register_markets(),
url_prefix="/<slug>/markets",
)
# Payments nested under post slug: /<slug>/payments/...
app.register_blueprint(
register_payments(),
url_prefix="/<slug>/payments",
)
# --- Auto-inject slug into url_for() calls ---
@app.url_value_preprocessor
def pull_slug(endpoint, values):
@@ -109,9 +122,17 @@ def create_app() -> "Quart":
.order_by(Calendar.name.asc())
)
).scalars().all()
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.post_id == post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
return {
**post_data,
"calendars": calendars,
"markets": markets,
}
# Tickets blueprint — user-facing ticket views and QR codes

View File

@@ -1 +1,3 @@
from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets
from .payments.routes import register as register_payments

View File

@@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar
from models.ghost_content import Post # for FK existence checks
from models.page_config import PageConfig
import unicodedata
import re
@@ -88,12 +87,6 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
if not post.is_page:
raise CalendarError("Calendars can only be created on pages, not posts.")
pc = (await sess.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
if pc is None or not (pc.features or {}).get("calendar"):
raise CalendarError("Calendar feature is not enabled for this page. Enable it in page settings first.")
# Look for existing (including soft-deleted)
q = await sess.execute(
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name)

0
bp/markets/__init__.py Normal file
View File

68
bp/markets/routes.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
)
from sqlalchemy import select
from models.market_place import MarketPlace
from .services.markets import (
create_market as svc_create_market,
soft_delete as svc_soft_delete,
)
from suma_browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("markets", __name__, url_prefix='/markets')
@bp.context_processor
async def inject_root():
return {}
@bp.get("/")
async def home(**kwargs):
if not is_htmx_request():
html = await render_template("_types/markets/index.html")
else:
html = await render_template("_types/markets/_oob_elements.html")
return await make_response(html)
@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:
post_id = form.get("post_id")
if post_id:
post_id = int(post_id)
try:
await svc_create_market(g.s, post_id, name)
except Exception as e:
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422)
html = await render_template("_types/markets/index.html")
return await make_response(html)
@bp.delete("/<market_slug>/")
@require_admin
async def delete_market(market_slug: str, **kwargs):
post_slug = getattr(g, "post_slug", None)
deleted = await svc_soft_delete(g.s, post_slug, market_slug)
if not deleted:
return await make_response("Market not found", 404)
html = await render_template("_types/markets/index.html")
return await make_response(html)
return bp

View File

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import re
import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace
from models.ghost_content import Post
from suma_browser.app.utils import utcnow
class MarketError(ValueError):
"""Base error for market service operations."""
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"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace:
"""
Create a market for a page. Name must be unique per page.
If a market with the same (post_id, slug) exists but is soft-deleted,
it will be revived.
"""
name = (name or "").strip()
if not name:
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
if not post:
raise MarketError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
# Look for existing (including soft-deleted)
existing = (await sess.execute(
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
)).scalar_one_or_none()
if existing:
if existing.deleted_at is not None:
existing.deleted_at = None
existing.name = name
await sess.flush()
return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug)
sess.add(market)
await sess.flush()
return market
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
market = (
await sess.execute(
select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id)
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
return False
market.deleted_at = utcnow()
await sess.flush()
return True

0
bp/payments/__init__.py Normal file
View File

81
bp/payments/routes.py Normal file
View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from quart import (
render_template, make_response, Blueprint, g, request
)
from sqlalchemy import select
from models.page_config import PageConfig
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("payments", __name__, url_prefix='/payments')
@bp.context_processor
async def inject_root():
return {}
async def _load_payment_ctx():
"""Load PageConfig SumUp data for the current page."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return {}
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
return {
"sumup_configured": bool(pc and pc.sumup_api_key),
"sumup_merchant_code": (pc.sumup_merchant_code or "") if pc else "",
"sumup_checkout_prefix": (pc.sumup_checkout_prefix or "") if pc else "",
}
@bp.get("/")
@require_admin
async def home(**kwargs):
ctx = await _load_payment_ctx()
if not is_htmx_request():
html = await render_template("_types/payments/index.html", **ctx)
else:
html = await render_template("_types/payments/_oob_elements.html", **ctx)
return await make_response(html)
@bp.put("/")
@require_admin
async def update_sumup(**kwargs):
"""Update SumUp credentials for this page."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None
pc.sumup_checkout_prefix = checkout_prefix or None
if api_key:
pc.sumup_api_key = api_key
await g.s.flush()
ctx = await _load_payment_ctx()
html = await render_template("_types/payments/_main_panel.html", **ctx)
return await make_response(html)
return bp

View File

@@ -0,0 +1,25 @@
<section class="p-4">
{% if has_access('markets.create_market') %}
<div id="market-create-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="mt-4 flex gap-2 items-end"
hx-post="{{ url_for('markets.create_market') }}"
hx-target="#markets-list"
hx-select="#markets-list"
hx-swap="outerHTML"
hx-on::before-request="document.querySelector('#market-create-errors').textContent='';"
hx-on::response-error="document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex-1">
<label class="block text-sm text-gray-600">Name</label>
<input name="name" type="text" required class="w-full border rounded px-3 py-2" placeholder="e.g. Farm Shop, Bakery" />
</div>
<button type="submit" class="border rounded px-3 py-2">Add market</button>
</form>
{% endif %}
<div id="markets-list" class="mt-6">
{% include "_types/markets/_markets_list.html" %}
</div>
</section>

View File

@@ -0,0 +1,37 @@
{% for m in markets %}
<div class="mt-6 border rounded-lg p-4">
<div class="flex items-center justify-between gap-3">
{% set market_href = market_url('/' + post.slug + '/' + m.slug + '/') %}
<a
class="flex items-baseline gap-3"
href="{{ market_href }}"
>
<h3 class="font-semibold">{{ m.name }}</h3>
<h4 class="text-gray-500">/{{ m.slug }}/</h4>
</a>
<button
class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
data-confirm
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"
hx-delete="{{ url_for('markets.delete_market', market_slug=m.slug) }}"
hx-trigger="confirmed"
hx-target="#markets-list"
hx-select="#markets-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
{% else %}
<p class="text-gray-500 mt-4">No markets yet. Create one above.</p>
{% endfor %}

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'markets-header-child', '_types/markets/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/markets/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/markets/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='markets-row', oob=oob) %}
{% call links.link(url_for('markets.home'), hx_select_search) %}
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>
Markets
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/markets/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,21 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% call index_row('markets-header-child', '_types/markets/header/_header.html') %}
{% block markets_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/markets/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/markets/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,70 @@
<section class="p-4 max-w-lg mx-auto">
<div id="payments-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">
<h3 class="text-lg font-semibold text-stone-800">
<i class="fa fa-credit-card text-purple-600 mr-1"></i>
SumUp Payment
</h3>
<p class="text-xs text-stone-400">
Configure per-page SumUp credentials. Leave blank to use the global merchant account.
</p>
<form
hx-put="{{ url_for('payments.update_sumup') }}"
hx-target="#payments-panel"
hx-swap="outerHTML"
hx-select="#payments-panel"
class="space-y-3"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>
<input
type="text"
name="merchant_code"
value="{{ sumup_merchant_code }}"
placeholder="e.g. ME4J6100"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>
<input
type="password"
name="api_key"
value=""
placeholder="{{ '--------' if sumup_configured else 'sup_sk_...' }}"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
{% if sumup_configured %}
<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>
{% endif %}
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>
<input
type="text"
name="checkout_prefix"
value="{{ sumup_checkout_prefix }}"
placeholder="e.g. ROSE-"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
</div>
<button
type="submit"
class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
>
Save SumUp Settings
</button>
{% if sumup_configured %}
<span class="ml-2 text-xs text-green-600">
<i class="fa fa-check-circle"></i> Connected
</span>
{% endif %}
</form>
</div>
</section>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'payments-header-child', '_types/payments/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/payments/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/payments/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='payments-row', oob=oob) %}
{% call links.link(url_for('payments.home'), hx_select_search) %}
<i class="fa fa-credit-card" aria-hidden="true"></i>
<div>
Payments
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/payments/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,21 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% call index_row('payments-header-child', '_types/payments/header/_header.html') %}
{% block payments_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/payments/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/payments/_main_panel.html' %}
{% endblock %}