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

@@ -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)