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

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