feat: add markets and payments management pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
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:
@@ -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
|
||||
|
||||
@@ -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
0
bp/markets/__init__.py
Normal file
68
bp/markets/routes.py
Normal file
68
bp/markets/routes.py
Normal 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
|
||||
0
bp/markets/services/__init__.py
Normal file
0
bp/markets/services/__init__.py
Normal file
85
bp/markets/services/markets.py
Normal file
85
bp/markets/services/markets.py
Normal 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
0
bp/payments/__init__.py
Normal file
81
bp/payments/routes.py
Normal file
81
bp/payments/routes.py
Normal 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
|
||||
Reference in New Issue
Block a user