Phase 5: Replace cross-domain writes with glue services, emit events
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s

- checkout.py: use claim_entries_for_order(), emit order.created
- check_sumup_status.py: use confirm_entries_for_order(), emit order.paid
- global_routes.py: use get_entries_for_order() instead of relationship
- order.py: remove calendar_entries relationship
- api.py: remove /adopt endpoint (replaced by event-driven adoption)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-14 17:35:43 +00:00
parent cd332b2544
commit d407957928
5 changed files with 23 additions and 104 deletions

View File

@@ -7,7 +7,7 @@ They are CSRF-exempt because they are server-to-server calls.
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, jsonify from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from market.models.market import CartItem from market.models.market import CartItem
@@ -120,57 +120,4 @@ def register() -> Blueprint:
} }
) )
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous cart items + calendar entries for a user.
Called by the coop app after successful login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# --- adopt cart items ---
anon_result = await g.s.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = anon_result.scalars().all()
if anon_items:
# Soft-delete existing user cart
await g.s.execute(
update(CartItem)
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
.values(deleted_at=func.now())
)
for ci in anon_items:
ci.user_id = user_id
# --- adopt calendar entries ---
await g.s.execute(
update(CalendarEntry)
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
.values(deleted_at=func.now())
)
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
return bp return bp

View File

@@ -7,6 +7,7 @@ from sqlalchemy import select
from models.order import Order from models.order import Order
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from glue.services.order_lifecycle import get_entries_for_order
from .services import ( from .services import (
current_cart_identity, current_cart_identity,
get_cart, get_cart,
@@ -187,7 +188,7 @@ def register(url_prefix: str) -> Blueprint:
except Exception: except Exception:
status = status or "pending" status = status or "pending"
calendar_entries = order.calendar_entries or [] calendar_entries = await get_entries_for_order(g.s, order.id)
await g.s.flush() await g.s.flush()
html = await render_template( html = await render_template(

View File

@@ -1,6 +1,6 @@
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 sqlalchemy import update from shared.events import emit_event
from events.models.calendars import CalendarEntry from glue.services.order_lifecycle import confirm_entries_for_order
async def check_sumup_status(session, order): async def check_sumup_status(session, order):
@@ -13,21 +13,13 @@ async def check_sumup_status(session, order):
if sumup_status == "PAID": if sumup_status == "PAID":
if order.status != "paid": if order.status != "paid":
order.status = "paid" order.status = "paid"
filters = [ await confirm_entries_for_order(
CalendarEntry.deleted_at.is_(None), session, order.id, order.user_id, order.session_id
CalendarEntry.state == "ordered",
CalendarEntry.order_id==order.id,
]
if order.user_id is not None:
filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
filters.append(CalendarEntry.session_id == order.session_id)
await session.execute(
update(CalendarEntry)
.where(*filters)
.values(state="provisional")
) )
await emit_event(session, "order.paid", "order", order.id, {
"order_id": order.id,
"user_id": order.user_id,
})
elif sumup_status == "FAILED": elif sumup_status == "FAILED":
order.status = "failed" order.status = "failed"
else: else:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from sqlalchemy import select, update 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
@@ -13,6 +13,8 @@ from events.models.calendars import CalendarEntry, Calendar
from models.page_config import PageConfig from models.page_config import PageConfig
from market.models.market_place import MarketPlace from market.models.market_place import MarketPlace
from shared.config import config from shared.config import config
from shared.events import emit_event
from glue.services.order_lifecycle import claim_entries_for_order
async def find_or_create_cart_item( async def find_or_create_cart_item(
@@ -148,34 +150,17 @@ async def create_order_from_cart(
) )
session.add(oi) session.add(oi)
# Update calendar entries to reference this order # Mark pending calendar entries as "ordered" via glue service
calendar_filters = [ await claim_entries_for_order(
CalendarEntry.deleted_at.is_(None), session, order.id, user_id, session_id, page_post_id
CalendarEntry.state == "pending",
]
if order.user_id is not None:
calendar_filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
calendar_filters.append(CalendarEntry.session_id == order.session_id)
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.container_type == "page",
Calendar.container_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids))
await session.execute(
update(CalendarEntry)
.where(*calendar_filters)
.values(
state="ordered",
order_id=order.id,
)
) )
await emit_event(session, "order.created", "order", order.id, {
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
})
return order return order
@@ -234,7 +219,6 @@ async def get_order_with_details(session: AsyncSession, order_id: int) -> Option
select(Order) select(Order)
.options( .options(
selectinload(Order.items).selectinload(OrderItem.product), selectinload(Order.items).selectinload(OrderItem.product),
selectinload(Order.calendar_entries),
) )
.where(Order.id == order_id) .where(Order.id == order_id)
) )

View File

@@ -69,11 +69,6 @@ class Order(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
calendar_entries: Mapped[List["CalendarEntry"]] = relationship(
"CalendarEntry",
back_populates="order",
lazy="selectin",
)
page_config: Mapped[Optional["PageConfig"]] = relationship( page_config: Mapped[Optional["PageConfig"]] = relationship(
"PageConfig", "PageConfig",
foreign_keys=[page_config_id], foreign_keys=[page_config_id],