From e0679f81000382ff7bc6e1b7fdeb10c3d73eb2cf Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 10 Feb 2026 23:45:07 +0000 Subject: [PATCH] feat: add markets and payments management pages - New markets blueprint at //markets/ with create/delete - New payments blueprint at //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 --- app.py | 23 ++++- bp/__init__.py | 2 + bp/calendars/services/calendars.py | 7 -- bp/markets/__init__.py | 0 bp/markets/routes.py | 68 +++++++++++++++ bp/markets/services/__init__.py | 0 bp/markets/services/markets.py | 85 +++++++++++++++++++ bp/payments/__init__.py | 0 bp/payments/routes.py | 81 ++++++++++++++++++ templates/_types/markets/_main_panel.html | 25 ++++++ templates/_types/markets/_markets_list.html | 37 ++++++++ templates/_types/markets/_nav.html | 2 + templates/_types/markets/_oob_elements.html | 19 +++++ templates/_types/markets/header/_header.html | 14 +++ templates/_types/markets/index.html | 21 +++++ templates/_types/payments/_main_panel.html | 70 +++++++++++++++ templates/_types/payments/_nav.html | 2 + templates/_types/payments/_oob_elements.html | 19 +++++ templates/_types/payments/header/_header.html | 14 +++ templates/_types/payments/index.html | 21 +++++ 20 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 bp/markets/__init__.py create mode 100644 bp/markets/routes.py create mode 100644 bp/markets/services/__init__.py create mode 100644 bp/markets/services/markets.py create mode 100644 bp/payments/__init__.py create mode 100644 bp/payments/routes.py create mode 100644 templates/_types/markets/_main_panel.html create mode 100644 templates/_types/markets/_markets_list.html create mode 100644 templates/_types/markets/_nav.html create mode 100644 templates/_types/markets/_oob_elements.html create mode 100644 templates/_types/markets/header/_header.html create mode 100644 templates/_types/markets/index.html create mode 100644 templates/_types/payments/_main_panel.html create mode 100644 templates/_types/payments/_nav.html create mode 100644 templates/_types/payments/_oob_elements.html create mode 100644 templates/_types/payments/header/_header.html create mode 100644 templates/_types/payments/index.html diff --git a/app.py b/app.py index 8c0b434..4309243 100644 --- a/app.py +++ b/app.py @@ -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="//calendars", ) + # Markets nested under post slug: //markets/... + app.register_blueprint( + register_markets(), + url_prefix="//markets", + ) + + # Payments nested under post slug: //payments/... + app.register_blueprint( + register_payments(), + url_prefix="//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 diff --git a/bp/__init__.py b/bp/__init__.py index 351d697..5b4924b 100644 --- a/bp/__init__.py +++ b/bp/__init__.py @@ -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 diff --git a/bp/calendars/services/calendars.py b/bp/calendars/services/calendars.py index 1bcabcd..4b6fc62 100644 --- a/bp/calendars/services/calendars.py +++ b/bp/calendars/services/calendars.py @@ -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) diff --git a/bp/markets/__init__.py b/bp/markets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/markets/routes.py b/bp/markets/routes.py new file mode 100644 index 0000000..86471dc --- /dev/null +++ b/bp/markets/routes.py @@ -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'
{e}
', 422) + + html = await render_template("_types/markets/index.html") + return await make_response(html) + + @bp.delete("//") + @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 diff --git a/bp/markets/services/__init__.py b/bp/markets/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/markets/services/markets.py b/bp/markets/services/markets.py new file mode 100644 index 0000000..269b895 --- /dev/null +++ b/bp/markets/services/markets.py @@ -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 diff --git a/bp/payments/__init__.py b/bp/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/payments/routes.py b/bp/payments/routes.py new file mode 100644 index 0000000..8820103 --- /dev/null +++ b/bp/payments/routes.py @@ -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 diff --git a/templates/_types/markets/_main_panel.html b/templates/_types/markets/_main_panel.html new file mode 100644 index 0000000..7168712 --- /dev/null +++ b/templates/_types/markets/_main_panel.html @@ -0,0 +1,25 @@ +
+ {% if has_access('markets.create_market') %} +
+ +
+ +
+ + +
+ +
+ {% endif %} +
+ {% include "_types/markets/_markets_list.html" %} +
+
diff --git a/templates/_types/markets/_markets_list.html b/templates/_types/markets/_markets_list.html new file mode 100644 index 0000000..2ac5143 --- /dev/null +++ b/templates/_types/markets/_markets_list.html @@ -0,0 +1,37 @@ + {% for m in markets %} +
+
+ + {% set market_href = market_url('/' + post.slug + '/' + m.slug + '/') %} + +

{{ m.name }}

+

/{{ m.slug }}/

+
+ + + +
+
+ {% else %} +

No markets yet. Create one above.

+ {% endfor %} diff --git a/templates/_types/markets/_nav.html b/templates/_types/markets/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/markets/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/markets/_oob_elements.html b/templates/_types/markets/_oob_elements.html new file mode 100644 index 0000000..93ec6d7 --- /dev/null +++ b/templates/_types/markets/_oob_elements.html @@ -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 %} diff --git a/templates/_types/markets/header/_header.html b/templates/_types/markets/header/_header.html new file mode 100644 index 0000000..6ae008d --- /dev/null +++ b/templates/_types/markets/header/_header.html @@ -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) %} + +
+ Markets +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/markets/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/markets/index.html b/templates/_types/markets/index.html new file mode 100644 index 0000000..d81e3d5 --- /dev/null +++ b/templates/_types/markets/index.html @@ -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 %} diff --git a/templates/_types/payments/_main_panel.html b/templates/_types/payments/_main_panel.html new file mode 100644 index 0000000..42f4141 --- /dev/null +++ b/templates/_types/payments/_main_panel.html @@ -0,0 +1,70 @@ +
+
+

+ + SumUp Payment +

+

+ Configure per-page SumUp credentials. Leave blank to use the global merchant account. +

+ +
+ + +
+ + +
+ +
+ + + {% if sumup_configured %} +

Key is set. Leave blank to keep current key.

+ {% endif %} +
+ +
+ + +
+ + + + {% if sumup_configured %} + + Connected + + {% endif %} +
+
+
diff --git a/templates/_types/payments/_nav.html b/templates/_types/payments/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/payments/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/payments/_oob_elements.html b/templates/_types/payments/_oob_elements.html new file mode 100644 index 0000000..5232f7e --- /dev/null +++ b/templates/_types/payments/_oob_elements.html @@ -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 %} diff --git a/templates/_types/payments/header/_header.html b/templates/_types/payments/header/_header.html new file mode 100644 index 0000000..282aac6 --- /dev/null +++ b/templates/_types/payments/header/_header.html @@ -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) %} + +
+ Payments +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/payments/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/payments/index.html b/templates/_types/payments/index.html new file mode 100644 index 0000000..31a1002 --- /dev/null +++ b/templates/_types/payments/index.html @@ -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 %}