From 7ee8638d6e41de1f58aadd1f108cd7de8e920d07 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 19 Feb 2026 21:32:30 +0000 Subject: [PATCH] Add ticket-to-cart integration Reserved tickets now flow through the cart and checkout pipeline: - TicketDTO gains price, entry_id, order_id, calendar_container_id - CartSummaryDTO gains ticket_count, ticket_total - 6 new CalendarService methods for ticket lifecycle - cart_summary includes tickets; login adoption migrates tickets - New _ticket_items.html template for checkout return page Co-Authored-By: Claude Opus 4.6 --- .../_types/cart/checkout_return.html | 4 +- .../templates/_types/order/_ticket_items.html | 49 +++++++ contracts/dtos.py | 6 + contracts/protocols.py | 25 ++++ events/handlers/login_handlers.py | 3 +- services/calendar_impl.py | 137 ++++++++++++++++++ services/cart_impl.py | 19 +++ services/stubs.py | 31 ++++ 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 browser/templates/_types/order/_ticket_items.html diff --git a/browser/templates/_types/cart/checkout_return.html b/browser/templates/_types/cart/checkout_return.html index 326a469..b08a09d 100644 --- a/browser/templates/_types/cart/checkout_return.html +++ b/browser/templates/_types/cart/checkout_return.html @@ -47,8 +47,8 @@ {% endif %} {% include '_types/order/_items.html' %} {% include '_types/order/_calendar_items.html' %} - - + {% include '_types/order/_ticket_items.html' %} + {% if order.status == 'failed' and order %}

Your payment was not completed.

diff --git a/browser/templates/_types/order/_ticket_items.html b/browser/templates/_types/order/_ticket_items.html new file mode 100644 index 0000000..ef06c0b --- /dev/null +++ b/browser/templates/_types/order/_ticket_items.html @@ -0,0 +1,49 @@ +{# --- Tickets in this order --- #} + {% if order and order_tickets %} +
+

+ Event tickets in this order +

+ +
    + {% for tk in order_tickets %} +
  • +
    +
    + {{ tk.entry_name }} + {# Small status pill #} + + {{ tk.state|replace('_', ' ')|capitalize }} + +
    + {% if tk.ticket_type_name %} +
    {{ tk.ticket_type_name }}
    + {% endif %} +
    + {{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }} + {% if tk.entry_end_at %} + – {{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }} + {% endif %} +
    +
    + {{ tk.code }} +
    +
    +
    + £{{ "%.2f"|format(tk.price or 0) }} +
    +
  • + {% endfor %} +
+
+ {% endif %} \ No newline at end of file diff --git a/contracts/dtos.py b/contracts/dtos.py index b6dad4c..af1c8eb 100644 --- a/contracts/dtos.py +++ b/contracts/dtos.py @@ -55,6 +55,10 @@ class TicketDTO: calendar_name: str | None = None created_at: datetime | None = None checked_in_at: datetime | None = None + entry_id: int | None = None + price: Decimal | None = None + order_id: int | None = None + calendar_container_id: int | None = None @dataclass(frozen=True, slots=True) @@ -127,3 +131,5 @@ class CartSummaryDTO: calendar_count: int = 0 calendar_total: Decimal = Decimal("0") items: list[CartItemDTO] = field(default_factory=list) + ticket_count: int = 0 + ticket_total: Decimal = Decimal("0") diff --git a/contracts/protocols.py b/contracts/protocols.py index d5b2370..7580d0e 100644 --- a/contracts/protocols.py +++ b/contracts/protocols.py @@ -82,6 +82,31 @@ class CalendarService(Protocol): self, session: AsyncSession, post_ids: list[int], ) -> dict[int, list[CalendarEntryDTO]]: ... + async def pending_tickets( + self, session: AsyncSession, *, user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: ... + + async def tickets_for_page( + self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: ... + + async def claim_tickets_for_order( + self, session: AsyncSession, order_id: int, user_id: int | None, + session_id: str | None, page_post_id: int | None, + ) -> None: ... + + async def confirm_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> None: ... + + async def get_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> list[TicketDTO]: ... + + async def adopt_tickets_for_user( + self, session: AsyncSession, user_id: int, session_id: str, + ) -> None: ... + @runtime_checkable class MarketService(Protocol): diff --git a/events/handlers/login_handlers.py b/events/handlers/login_handlers.py index e798ef6..17ef493 100644 --- a/events/handlers/login_handlers.py +++ b/events/handlers/login_handlers.py @@ -16,9 +16,10 @@ async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None: if services.has("cart"): await services.cart.adopt_cart_for_user(session, user_id, session_id) - # Adopt calendar entries (if calendar service is registered) + # Adopt calendar entries and tickets (if calendar service is registered) if services.has("calendar"): await services.calendar.adopt_entries_for_user(session, user_id, session_id) + await services.calendar.adopt_tickets_for_user(session, user_id, session_id) register_handler("user.logged_in", on_user_logged_in) diff --git a/services/calendar_impl.py b/services/calendar_impl.py index 048d39d..1f749b9 100644 --- a/services/calendar_impl.py +++ b/services/calendar_impl.py @@ -5,6 +5,8 @@ calendar-domain tables on behalf of other domains. """ from __future__ import annotations +from decimal import Decimal + from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -51,6 +53,12 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO: entry = getattr(ticket, "entry", None) tt = getattr(ticket, "ticket_type", None) cal = getattr(entry, "calendar", None) if entry else None + # Price: ticket type cost if available, else entry ticket_price + price = None + if tt and tt.cost is not None: + price = tt.cost + elif entry and entry.ticket_price is not None: + price = entry.ticket_price return TicketDTO( id=ticket.id, code=ticket.code, @@ -62,6 +70,10 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO: calendar_name=cal.name if cal else None, created_at=ticket.created_at, checked_in_at=ticket.checked_in_at, + entry_id=entry.id if entry else None, + price=price, + order_id=ticket.order_id, + calendar_container_id=cal.container_id if cal else None, ) @@ -356,3 +368,128 @@ class SqlCalendarService: .where(*filters) .values(state="provisional") ) + + # -- ticket methods ------------------------------------------------------- + + def _ticket_query_options(self): + return [ + selectinload(Ticket.entry).selectinload(CalendarEntry.calendar), + selectinload(Ticket.ticket_type), + ] + + async def pending_tickets( + self, session: AsyncSession, *, user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: + """Reserved tickets for the given identity (cart line items).""" + filters = [Ticket.state == "reserved"] + if user_id is not None: + filters.append(Ticket.user_id == user_id) + elif session_id is not None: + filters.append(Ticket.session_id == session_id) + else: + return [] + + result = await session.execute( + select(Ticket) + .where(*filters) + .order_by(Ticket.created_at.asc()) + .options(*self._ticket_query_options()) + ) + return [_ticket_to_dto(t) for t in result.scalars().all()] + + async def tickets_for_page( + self, session: AsyncSession, page_id: int, *, + user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: + """Reserved tickets scoped to a page (via entry → calendar → container_id).""" + cal_ids = select(Calendar.id).where( + Calendar.container_type == "page", + Calendar.container_id == page_id, + Calendar.deleted_at.is_(None), + ).scalar_subquery() + + entry_ids = select(CalendarEntry.id).where( + CalendarEntry.calendar_id.in_(cal_ids), + CalendarEntry.deleted_at.is_(None), + ).scalar_subquery() + + filters = [ + Ticket.state == "reserved", + Ticket.entry_id.in_(entry_ids), + ] + if user_id is not None: + filters.append(Ticket.user_id == user_id) + elif session_id is not None: + filters.append(Ticket.session_id == session_id) + else: + return [] + + result = await session.execute( + select(Ticket) + .where(*filters) + .order_by(Ticket.created_at.asc()) + .options(*self._ticket_query_options()) + ) + return [_ticket_to_dto(t) for t in result.scalars().all()] + + async def claim_tickets_for_order( + self, session: AsyncSession, order_id: int, user_id: int | None, + session_id: str | None, page_post_id: int | None, + ) -> None: + """Set order_id on reserved tickets at checkout.""" + filters = [Ticket.state == "reserved"] + if user_id is not None: + filters.append(Ticket.user_id == user_id) + elif session_id is not None: + filters.append(Ticket.session_id == 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() + entry_ids = select(CalendarEntry.id).where( + CalendarEntry.calendar_id.in_(cal_ids), + CalendarEntry.deleted_at.is_(None), + ).scalar_subquery() + filters.append(Ticket.entry_id.in_(entry_ids)) + + await session.execute( + update(Ticket).where(*filters).values(order_id=order_id) + ) + + async def confirm_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> None: + """Reserved → confirmed on payment.""" + await session.execute( + update(Ticket) + .where(Ticket.order_id == order_id, Ticket.state == "reserved") + .values(state="confirmed") + ) + + async def get_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> list[TicketDTO]: + """Tickets for a given order (checkout return display).""" + result = await session.execute( + select(Ticket) + .where(Ticket.order_id == order_id) + .order_by(Ticket.created_at.asc()) + .options(*self._ticket_query_options()) + ) + return [_ticket_to_dto(t) for t in result.scalars().all()] + + async def adopt_tickets_for_user( + self, session: AsyncSession, user_id: int, session_id: str, + ) -> None: + """Migrate anonymous reserved tickets to user on login.""" + result = await session.execute( + select(Ticket).where( + Ticket.session_id == session_id, + Ticket.state == "reserved", + ) + ) + for ticket in result.scalars().all(): + ticket.user_id = user_id diff --git a/services/cart_impl.py b/services/cart_impl.py index 84dd40f..1438bfa 100644 --- a/services/cart_impl.py +++ b/services/cart_impl.py @@ -93,6 +93,23 @@ class SqlCartService: calendar_count = len(cal_entries) calendar_total = sum(Decimal(str(e.cost or 0)) for e in cal_entries if e.cost is not None) + # --- tickets --- + if page_post_id is not None: + tickets = await services.calendar.tickets_for_page( + session, page_post_id, + user_id=user_id, + session_id=session_id, + ) + else: + tickets = await services.calendar.pending_tickets( + session, + user_id=user_id, + session_id=session_id, + ) + + ticket_count = len(tickets) + ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets) + items = [_item_to_dto(ci) for ci in cart_items] return CartSummaryDTO( @@ -101,6 +118,8 @@ class SqlCartService: calendar_count=calendar_count, calendar_total=calendar_total, items=items, + ticket_count=ticket_count, + ticket_total=ticket_total, ) async def cart_items( diff --git a/services/stubs.py b/services/stubs.py index 1f4c9f7..db3b38c 100644 --- a/services/stubs.py +++ b/services/stubs.py @@ -98,6 +98,37 @@ class StubCalendarService: ) -> dict[int, list[CalendarEntryDTO]]: return {} + async def pending_tickets( + self, session: AsyncSession, *, user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: + return [] + + async def tickets_for_page( + self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None, + ) -> list[TicketDTO]: + return [] + + async def claim_tickets_for_order( + self, session: AsyncSession, order_id: int, user_id: int | None, + session_id: str | None, page_post_id: int | None, + ) -> None: + pass + + async def confirm_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> None: + pass + + async def get_tickets_for_order( + self, session: AsyncSession, order_id: int, + ) -> list[TicketDTO]: + return [] + + async def adopt_tickets_for_user( + self, session: AsyncSession, user_id: int, session_id: str, + ) -> None: + pass + class StubMarketService: async def marketplaces_for_container(