Decouple PageConfig cross-domain queries + merge cart into db_market

PageConfig (db_blog) decoupling:
- Blog: add page-config, page-config-by-id, page-configs-batch data endpoints
- Blog: add update-page-config action endpoint for events payment admin
- Cart: hydrate_page, resolve_page_config, get_cart_grouped_by_page all
  fetch PageConfig from blog via HTTP instead of direct DB query
- Cart: check_sumup_status auto-fetches page_config from blog when needed
- Events: payment routes read/write PageConfig via blog HTTP endpoints
- Order model: remove cross-domain page_config ORM relationship (keep column)

Cart + Market DB merge:
- Cart tables (cart_items, orders, order_items) moved into db_market
- Cart app DATABASE_URL now points to db_market (same bounded context)
- CartItem.product / CartItem.market_place relationships work again
  (same database, no cross-domain join issues)
- Updated split-databases.sh, init-databases.sql, docker-compose.yml

Ghost sync fix:
- Wrap PostAuthor/PostTag delete+re-add in no_autoflush block
- Use synchronize_session="fetch" to keep identity map consistent
- Prevents query-invoked autoflush IntegrityError on composite PK

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-25 11:59:35 +00:00
parent 3be287532d
commit 3053cb321d
15 changed files with 270 additions and 96 deletions

View File

@@ -6,7 +6,6 @@
CREATE DATABASE db_account; CREATE DATABASE db_account;
CREATE DATABASE db_blog; CREATE DATABASE db_blog;
CREATE DATABASE db_market; CREATE DATABASE db_market; -- also houses cart tables (commerce bounded context)
CREATE DATABASE db_cart;
CREATE DATABASE db_events; CREATE DATABASE db_events;
CREATE DATABASE db_federation; CREATE DATABASE db_federation;

View File

@@ -64,14 +64,14 @@ DB_TABLES[db_market]="
link_errors link_errors
link_externals link_externals
subcategory_redirects subcategory_redirects
"
DB_TABLES[db_cart]="
cart_items cart_items
orders orders
order_items order_items
" "
# db_cart merged into db_market — cart and market share the same bounded context
# (commerce). Cart needs direct read access to products/market_places.
DB_TABLES[db_events]=" DB_TABLES[db_events]="
calendars calendars
calendar_slots calendar_slots
@@ -99,7 +99,7 @@ DB_TABLES[db_federation]="
# ── Migrate each domain ──────────────────────────────────────────────────── # ── Migrate each domain ────────────────────────────────────────────────────
for target_db in db_account db_blog db_market db_cart db_events db_federation; do for target_db in db_account db_blog db_market db_events db_federation; do
tables="${DB_TABLES[$target_db]}" tables="${DB_TABLES[$target_db]}"
table_list="" table_list=""
for t in $tables; do for t in $tables; do
@@ -120,7 +120,7 @@ done
echo "" echo ""
echo "=== Stamping Alembic head in each DB ===" echo "=== Stamping Alembic head in each DB ==="
for target_db in db_account db_blog db_market db_cart db_events db_federation; do for target_db in db_account db_blog db_market db_events db_federation; do
# Create alembic_version table and stamp current head # Create alembic_version table and stamp current head
psql -q "$target_db" <<'SQL' psql -q "$target_db" <<'SQL'
CREATE TABLE IF NOT EXISTS alembic_version ( CREATE TABLE IF NOT EXISTS alembic_version (
@@ -144,7 +144,7 @@ echo ""
echo "Per-app DATABASE_URL values:" echo "Per-app DATABASE_URL values:"
echo " blog: postgresql+asyncpg://postgres:change-me@db:5432/db_blog" echo " blog: postgresql+asyncpg://postgres:change-me@db:5432/db_blog"
echo " market: postgresql+asyncpg://postgres:change-me@db:5432/db_market" echo " market: postgresql+asyncpg://postgres:change-me@db:5432/db_market"
echo " cart: postgresql+asyncpg://postgres:change-me@db:5432/db_cart" echo " cart: postgresql+asyncpg://postgres:change-me@db:5432/db_market (shared with market)"
echo " events: postgresql+asyncpg://postgres:change-me@db:5432/db_events" echo " events: postgresql+asyncpg://postgres:change-me@db:5432/db_events"
echo " federation: postgresql+asyncpg://postgres:change-me@db:5432/db_federation" echo " federation: postgresql+asyncpg://postgres:change-me@db:5432/db_federation"
echo " account: postgresql+asyncpg://postgres:change-me@db:5432/db_account" echo " account: postgresql+asyncpg://postgres:change-me@db:5432/db_account"

View File

@@ -17,6 +17,7 @@ from bp import (
register_snippets, register_snippets,
register_fragments, register_fragments,
register_data, register_data,
register_actions,
) )
@@ -105,6 +106,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_snippets()) app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments()) app.register_blueprint(register_fragments())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_actions())
# --- KV admin endpoints --- # --- KV admin endpoints ---
@app.get("/settings/kv/<key>") @app.get("/settings/kv/<key>")

View File

@@ -4,3 +4,4 @@ from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets from .snippets.routes import register as register_snippets
from .fragments import register_fragments from .fragments import register_fragments
from .data import register_data from .data import register_data
from .actions.routes import register as register_actions

View File

79
blog/bp/actions/routes.py Normal file
View File

@@ -0,0 +1,79 @@
"""Blog app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers via the internal action client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
result = await handler()
return jsonify(result or {"ok": True})
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (used by events payment admin)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
return bp

View File

@@ -185,25 +185,34 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
obj.user_id = user_id obj.user_id = user_id
await sess.flush() await sess.flush()
# rebuild post_authors (dedup to avoid composite PK conflicts from Ghost dupes) # Rebuild post_authors + post_tags inside no_autoflush to prevent
await sess.execute(delete(PostAuthor).where(PostAuthor.post_id == obj.id)) # premature INSERT from query-invoked autoflush.
await sess.flush() async with sess.no_autoflush:
seen_authors: set[int] = set() # Delete + re-add post_authors (dedup for Ghost duplicate authors)
for idx, a in enumerate(gp.get("authors") or []): await sess.execute(
aa = author_map[a["id"]] delete(PostAuthor).where(PostAuthor.post_id == obj.id),
if aa.id not in seen_authors: execution_options={"synchronize_session": "fetch"},
seen_authors.add(aa.id) )
sess.add(PostAuthor(post_id=obj.id, author_id=aa.id, sort_order=idx)) seen_authors: set[int] = set()
for idx, a in enumerate(gp.get("authors") or []):
aa = author_map[a["id"]]
if aa.id not in seen_authors:
seen_authors.add(aa.id)
sess.add(PostAuthor(post_id=obj.id, author_id=aa.id, sort_order=idx))
# Delete + re-add post_tags (dedup similarly)
await sess.execute(
delete(PostTag).where(PostTag.post_id == obj.id),
execution_options={"synchronize_session": "fetch"},
)
seen_tags: set[int] = set()
for idx, t in enumerate(gp.get("tags") or []):
tt = tag_map[t["id"]]
if tt.id not in seen_tags:
seen_tags.add(tt.id)
sess.add(PostTag(post_id=obj.id, tag_id=tt.id, sort_order=idx))
# rebuild post_tags (dedup similarly)
await sess.execute(delete(PostTag).where(PostTag.post_id == obj.id))
await sess.flush() await sess.flush()
seen_tags: set[int] = set()
for idx, t in enumerate(gp.get("tags") or []):
tt = tag_map[t["id"]]
if tt.id not in seen_tags:
seen_tags.add(tt.id)
sess.add(PostTag(post_id=obj.id, tag_id=tt.id, sort_order=idx))
# Auto-create PageConfig for pages # Auto-create PageConfig for pages
if obj.is_page: if obj.is_page:

View File

@@ -7,9 +7,25 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services from shared.services.registry import services
from shared.models.page_config import PageConfig
def _page_config_dict(pc: PageConfig) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}
def register() -> Blueprint: def register() -> Blueprint:
@@ -71,4 +87,56 @@ def register() -> Blueprint:
_handlers["search-posts"] = _search_posts _handlers["search-posts"] = _search_posts
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
return bp return bp

View File

@@ -4,9 +4,10 @@ import path_setup # noqa: F401 # adds shared/ to sys.path
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from quart import g, abort, request from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
@@ -114,8 +115,12 @@ async def cart_context() -> dict:
return ctx return ctx
def _make_page_config(raw: dict) -> SimpleNamespace:
"""Convert a page-config JSON dict to a namespace for SumUp helpers."""
return SimpleNamespace(**raw)
def create_app() -> "Quart": def create_app() -> "Quart":
from shared.models.page_config import PageConfig
from shared.services.registry import services from shared.services.registry import services
from services import register_domain_services from services import register_domain_services
@@ -168,14 +173,12 @@ def create_app() -> "Quart":
if not post or not post.is_page: if not post or not post.is_page:
abort(404) abort(404)
g.page_post = post g.page_post = post
g.page_config = ( raw_pc = await fetch_data(
await g.s.execute( "blog", "page-config",
select(PageConfig).where( params={"container_type": "page", "container_id": post.id},
PageConfig.container_type == "page", required=False,
PageConfig.container_id == post.id, )
) g.page_config = _make_page_config(raw_pc) if raw_pc else None
)
).scalar_one_or_none()
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
@@ -211,10 +214,10 @@ def create_app() -> "Quart":
""" """
import logging import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy import select from sqlalchemy import select as sel
from sqlalchemy.orm import selectinload
from shared.db.session import get_session from shared.db.session import get_session
from shared.models.order import Order from shared.models.order import Order
from shared.infrastructure.data_client import fetch_data
from bp.cart.services.check_sumup_status import check_sumup_status from bp.cart.services.check_sumup_status import check_sumup_status
log = logging.getLogger("cart.reconcile") log = logging.getLogger("cart.reconcile")
@@ -222,17 +225,14 @@ def create_app() -> "Quart":
try: try:
async with get_session() as sess: async with get_session() as sess:
async with sess.begin(): async with sess.begin():
# Orders that are pending, have a SumUp checkout, and are
# older than 2 minutes (avoid racing with in-flight checkouts)
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2) cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
result = await sess.execute( result = await sess.execute(
select(Order) sel(Order)
.where( .where(
Order.status == "pending", Order.status == "pending",
Order.sumup_checkout_id.isnot(None), Order.sumup_checkout_id.isnot(None),
Order.created_at < cutoff, Order.created_at < cutoff,
) )
.options(selectinload(Order.page_config))
.limit(50) .limit(50)
) )
stale_orders = result.scalars().all() stale_orders = result.scalars().all()
@@ -243,7 +243,17 @@ def create_app() -> "Quart":
log.info("Reconciling %d stale pending orders", len(stale_orders)) log.info("Reconciling %d stale pending orders", len(stale_orders))
for order in stale_orders: for order in stale_orders:
try: try:
await check_sumup_status(sess, order) # Fetch page_config from blog if order has one
pc = None
if order.page_config_id:
raw_pc = await fetch_data(
"blog", "page-config-by-id",
params={"id": order.page_config_id},
required=False,
)
if raw_pc:
pc = _make_page_config(raw_pc)
await check_sumup_status(sess, order, page_config=pc)
log.info( log.info(
"Order %d reconciled: %s", "Order %d reconciled: %s",
order.id, order.status, order.id, order.status,

View File

@@ -1,12 +1,23 @@
from types import SimpleNamespace
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_activity from shared.events import emit_activity
from shared.infrastructure.actions import call_action from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
from .clear_cart_for_order import clear_cart_for_order from .clear_cart_for_order import clear_cart_for_order
async def check_sumup_status(session, order): async def check_sumup_status(session, order, *, page_config=None):
# Use order's page_config for per-page SumUp credentials # Auto-fetch page_config from blog if order has one and caller didn't provide it
page_config = getattr(order, "page_config", None) if page_config is None and order.page_config_id:
raw_pc = await fetch_data(
"blog", "page-config-by-id",
params={"id": order.page_config_id},
required=False,
)
if raw_pc:
page_config = SimpleNamespace(**raw_pc)
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config) checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
order.sumup_status = checkout_data.get("status") or order.sumup_status order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper() sumup_status = (order.sumup_status or "").upper()

View File

@@ -3,18 +3,20 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from types import SimpleNamespace
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from shared.models.market import Product, CartItem from shared.models.market import Product, CartItem
from shared.models.order import Order, OrderItem from shared.models.order import Order, OrderItem
from shared.models.page_config import PageConfig
from shared.models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from shared.config import config from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_activity from shared.events import emit_activity
from shared.infrastructure.actions import call_action from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
async def find_or_create_cart_item( async def find_or_create_cart_item(
@@ -66,10 +68,10 @@ async def resolve_page_config(
cart: list[CartItem], cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO], calendar_entries: list[CalendarEntryDTO],
tickets=None, tickets=None,
) -> Optional["PageConfig"]: ) -> Optional[SimpleNamespace]:
"""Determine the PageConfig for this order. """Determine the PageConfig for this order.
Returns PageConfig or None (use global credentials). Returns a PageConfig namespace or None (use global credentials).
Raises ValueError if items span multiple pages. Raises ValueError if items span multiple pages.
""" """
post_ids: set[int] = set() post_ids: set[int] = set()
@@ -98,13 +100,12 @@ async def resolve_page_config(
return None # global credentials return None # global credentials
post_id = post_ids.pop() post_id = post_ids.pop()
pc = (await session.execute( raw_pc = await fetch_data(
select(PageConfig).where( "blog", "page-config",
PageConfig.container_type == "page", params={"container_type": "page", "container_id": post_id},
PageConfig.container_id == post_id, required=False,
) )
)).scalar_one_or_none() return SimpleNamespace(**raw_pc) if raw_pc else None
return pc
async def create_order_from_cart( async def create_order_from_cart(

View File

@@ -9,12 +9,13 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from types import SimpleNamespace
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from shared.models.market import CartItem from shared.models.market import CartItem
from shared.models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from shared.models.page_config import PageConfig
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict
from .identity import current_cart_identity from .identity import current_cart_identity
@@ -169,7 +170,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
if grp["post_id"] is not None if grp["post_id"] is not None
}) })
posts_by_id: dict[int, object] = {} posts_by_id: dict[int, object] = {}
configs_by_post: dict[int, PageConfig] = {} configs_by_post: dict[int, object] = {}
if post_ids: if post_ids:
raw_posts = await fetch_data("blog", "posts-by-ids", raw_posts = await fetch_data("blog", "posts-by-ids",
@@ -179,13 +180,12 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
p = dto_from_dict(PostDTO, raw_p) p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = p posts_by_id[p.id] = p
pc_result = await session.execute( raw_pcs = await fetch_data("blog", "page-configs-batch",
select(PageConfig).where( params={"container_type": "page",
PageConfig.container_type == "page", "ids": ",".join(str(i) for i in post_ids)},
PageConfig.container_id.in_(post_ids), required=False) or []
) for raw_pc in raw_pcs:
) pc = SimpleNamespace(**raw_pc)
for pc in pc_result.scalars().all():
configs_by_post[pc.container_id] = pc configs_by_post[pc.container_id] = pc
# Build result list (markets with pages first, orphan last) # Build result list (markets with pages first, orphan last)

View File

@@ -87,7 +87,7 @@ services:
dockerfile: cart/Dockerfile dockerfile: cart/Dockerfile
environment: environment:
<<: *app-env <<: *app-env
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_cart DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_market
REDIS_URL: redis://redis:6379/2 REDIS_URL: redis://redis:6379/2
DATABASE_HOST: db DATABASE_HOST: db
DATABASE_PORT: "5432" DATABASE_PORT: "5432"

View File

@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from quart import ( from quart import (
render_template, make_response, Blueprint, g, request render_template, make_response, Blueprint, g, request
) )
from sqlalchemy import select
from shared.models.page_config import PageConfig
from shared.infrastructure.data_client import fetch_data
from shared.infrastructure.actions import call_action
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
@@ -19,20 +20,23 @@ def register():
return {} return {}
async def _load_payment_ctx(): async def _load_payment_ctx():
"""Load PageConfig SumUp data for the current page.""" """Load PageConfig SumUp data for the current page (from blog)."""
post = (getattr(g, "post_data", None) or {}).get("post", {}) post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id") post_id = post.get("id")
if not post_id: if not post_id:
return {} return {}
pc = (await g.s.execute( raw_pc = await fetch_data(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id) "blog", "page-config",
)).scalar_one_or_none() params={"container_type": "page", "container_id": post_id},
required=False,
)
pc = SimpleNamespace(**raw_pc) if raw_pc else None
return { return {
"sumup_configured": bool(pc and pc.sumup_api_key), "sumup_configured": bool(pc and getattr(pc, "sumup_api_key", None)),
"sumup_merchant_code": (pc.sumup_merchant_code or "") if pc else "", "sumup_merchant_code": (getattr(pc, "sumup_merchant_code", None) or "") if pc else "",
"sumup_checkout_prefix": (pc.sumup_checkout_prefix or "") if pc else "", "sumup_checkout_prefix": (getattr(pc, "sumup_checkout_prefix", None) or "") if pc else "",
} }
@bp.get("/") @bp.get("/")
@@ -48,31 +52,27 @@ def register():
@bp.put("/") @bp.put("/")
@require_admin @require_admin
async def update_sumup(**kwargs): async def update_sumup(**kwargs):
"""Update SumUp credentials for this page.""" """Update SumUp credentials for this page (writes to blog's db_blog)."""
post = (getattr(g, "post_data", None) or {}).get("post", {}) post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id") post_id = post.get("id")
if not post_id: if not post_id:
return await make_response("Post not found", 404) return await make_response("Post not found", 404)
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
form = await request.form form = await request.form
merchant_code = (form.get("merchant_code") or "").strip() merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip() api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip() checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None payload = {
pc.sumup_checkout_prefix = checkout_prefix or None "container_type": "page",
"container_id": post_id,
"sumup_merchant_code": merchant_code,
"sumup_checkout_prefix": checkout_prefix,
}
if api_key: if api_key:
pc.sumup_api_key = api_key payload["sumup_api_key"] = api_key
await g.s.flush() await call_action("blog", "update-page-config", payload=payload)
ctx = await _load_payment_ctx() ctx = await _load_payment_ctx()
html = await render_template("_types/payments/_main_panel.html", **ctx) html = await render_template("_types/payments/_main_panel.html", **ctx)

View File

@@ -69,14 +69,8 @@ class Order(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
# Cross-domain relationship — explicit join, viewonly (no FK constraint) # page_config_id references PageConfig in db_blog (cross-domain).
page_config: Mapped[Optional["PageConfig"]] = relationship( # Fetch via HTTP: fetch_data("blog", "page-config-by-id", params={"id": ...})
"PageConfig",
primaryjoin="Order.page_config_id == PageConfig.id",
foreign_keys="[Order.page_config_id]",
viewonly=True,
lazy="selectin",
)
class OrderItem(Base): class OrderItem(Base):