Decouple PageConfig cross-domain queries + merge cart into db_market
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m11s

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_blog;
CREATE DATABASE db_market;
CREATE DATABASE db_cart;
CREATE DATABASE db_market; -- also houses cart tables (commerce bounded context)
CREATE DATABASE db_events;
CREATE DATABASE db_federation;

View File

@@ -64,14 +64,14 @@ DB_TABLES[db_market]="
link_errors
link_externals
subcategory_redirects
"
DB_TABLES[db_cart]="
cart_items
orders
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]="
calendars
calendar_slots
@@ -99,7 +99,7 @@ DB_TABLES[db_federation]="
# ── 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]}"
table_list=""
for t in $tables; do
@@ -120,7 +120,7 @@ done
echo ""
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
psql -q "$target_db" <<'SQL'
CREATE TABLE IF NOT EXISTS alembic_version (
@@ -144,7 +144,7 @@ echo ""
echo "Per-app DATABASE_URL values:"
echo " blog: postgresql+asyncpg://postgres:change-me@db:5432/db_blog"
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 " federation: postgresql+asyncpg://postgres:change-me@db:5432/db_federation"
echo " account: postgresql+asyncpg://postgres:change-me@db:5432/db_account"

View File

@@ -17,6 +17,7 @@ from bp import (
register_snippets,
register_fragments,
register_data,
register_actions,
)
@@ -105,6 +106,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
app.register_blueprint(register_data())
app.register_blueprint(register_actions())
# --- KV admin endpoints ---
@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 .fragments import register_fragments
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
await sess.flush()
# rebuild post_authors (dedup to avoid composite PK conflicts from Ghost dupes)
await sess.execute(delete(PostAuthor).where(PostAuthor.post_id == obj.id))
await sess.flush()
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))
# Rebuild post_authors + post_tags inside no_autoflush to prevent
# premature INSERT from query-invoked autoflush.
async with sess.no_autoflush:
# Delete + re-add post_authors (dedup for Ghost duplicate authors)
await sess.execute(
delete(PostAuthor).where(PostAuthor.post_id == obj.id),
execution_options={"synchronize_session": "fetch"},
)
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()
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
if obj.is_page:

View File

@@ -7,9 +7,25 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
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:
@@ -71,4 +87,56 @@ def register() -> Blueprint:
_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

View File

@@ -4,9 +4,10 @@ import path_setup # noqa: F401 # adds shared/ to sys.path
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
@@ -114,8 +115,12 @@ async def cart_context() -> dict:
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":
from shared.models.page_config import PageConfig
from shared.services.registry import services
from services import register_domain_services
@@ -168,14 +173,12 @@ def create_app() -> "Quart":
if not post or not post.is_page:
abort(404)
g.page_post = post
g.page_config = (
await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post.id,
)
)
).scalar_one_or_none()
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post.id},
required=False,
)
g.page_config = _make_page_config(raw_pc) if raw_pc else None
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
@@ -211,10 +214,10 @@ def create_app() -> "Quart":
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy import select as sel
from shared.db.session import get_session
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
log = logging.getLogger("cart.reconcile")
@@ -222,17 +225,14 @@ def create_app() -> "Quart":
try:
async with get_session() as sess:
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)
result = await sess.execute(
select(Order)
sel(Order)
.where(
Order.status == "pending",
Order.sumup_checkout_id.isnot(None),
Order.created_at < cutoff,
)
.options(selectinload(Order.page_config))
.limit(50)
)
stale_orders = result.scalars().all()
@@ -243,7 +243,17 @@ def create_app() -> "Quart":
log.info("Reconciling %d stale pending orders", len(stale_orders))
for order in stale_orders:
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(
"Order %d reconciled: %s",
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.events import emit_activity
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
async def check_sumup_status(session, order):
# Use order's page_config for per-page SumUp credentials
page_config = getattr(order, "page_config", None)
async def check_sumup_status(session, order, *, page_config=None):
# Auto-fetch page_config from blog if order has one and caller didn't provide it
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)
order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper()

View File

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

View File

@@ -9,12 +9,13 @@ from __future__ import annotations
from collections import defaultdict
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.page_config import PageConfig
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict
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
})
posts_by_id: dict[int, object] = {}
configs_by_post: dict[int, PageConfig] = {}
configs_by_post: dict[int, object] = {}
if post_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)
posts_by_id[p.id] = p
pc_result = await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id.in_(post_ids),
)
)
for pc in pc_result.scalars().all():
raw_pcs = await fetch_data("blog", "page-configs-batch",
params={"container_type": "page",
"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_pc in raw_pcs:
pc = SimpleNamespace(**raw_pc)
configs_by_post[pc.container_id] = pc
# Build result list (markets with pages first, orphan last)

View File

@@ -87,7 +87,7 @@ services:
dockerfile: cart/Dockerfile
environment:
<<: *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
DATABASE_HOST: db
DATABASE_PORT: "5432"

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
from types import SimpleNamespace
from quart import (
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.utils.htmx import is_htmx_request
@@ -19,20 +20,23 @@ def register():
return {}
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_id = post.get("id")
if not post_id:
return {}
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False,
)
pc = SimpleNamespace(**raw_pc) if raw_pc else 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 "",
"sumup_configured": bool(pc and getattr(pc, "sumup_api_key", None)),
"sumup_merchant_code": (getattr(pc, "sumup_merchant_code", None) or "") if pc else "",
"sumup_checkout_prefix": (getattr(pc, "sumup_checkout_prefix", None) or "") if pc else "",
}
@bp.get("/")
@@ -48,31 +52,27 @@ def register():
@bp.put("/")
@require_admin
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_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.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
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
payload = {
"container_type": "page",
"container_id": post_id,
"sumup_merchant_code": merchant_code,
"sumup_checkout_prefix": checkout_prefix,
}
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()
html = await render_template("_types/payments/_main_panel.html", **ctx)

View File

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