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 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 21:32:30 +00:00
parent 71729ffb28
commit 7ee8638d6e
8 changed files with 271 additions and 3 deletions

View File

@@ -47,7 +47,7 @@
{% endif %} {% endif %}
{% include '_types/order/_items.html' %} {% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %} {% include '_types/order/_calendar_items.html' %}
{% include '_types/order/_ticket_items.html' %}
{% if order.status == 'failed' and order %} {% if order.status == 'failed' and order %}
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"> <div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">

View File

@@ -0,0 +1,49 @@
{# --- Tickets in this order --- #}
{% if order and order_tickets %}
<section class="mt-6 space-y-3">
<h2 class="text-base sm:text-lg font-semibold">
Event tickets in this order
</h2>
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
{% for tk in order_tickets %}
<li class="px-4 py-3 flex items-start justify-between text-sm">
<div>
<div class="font-medium flex items-center gap-2">
{{ tk.entry_name }}
{# Small status pill #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if tk.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif tk.state == 'reserved' %}
bg-amber-100 text-amber-800
{% elif tk.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ tk.state|replace('_', ' ')|capitalize }}
</span>
</div>
{% if tk.ticket_type_name %}
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
{% endif %}
<div class="text-xs text-stone-500">
{{ 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 %}
</div>
<div class="text-xs text-stone-400 font-mono mt-0.5">
{{ tk.code }}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(tk.price or 0) }}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -55,6 +55,10 @@ class TicketDTO:
calendar_name: str | None = None calendar_name: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
checked_in_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) @dataclass(frozen=True, slots=True)
@@ -127,3 +131,5 @@ class CartSummaryDTO:
calendar_count: int = 0 calendar_count: int = 0
calendar_total: Decimal = Decimal("0") calendar_total: Decimal = Decimal("0")
items: list[CartItemDTO] = field(default_factory=list) items: list[CartItemDTO] = field(default_factory=list)
ticket_count: int = 0
ticket_total: Decimal = Decimal("0")

View File

@@ -82,6 +82,31 @@ class CalendarService(Protocol):
self, session: AsyncSession, post_ids: list[int], self, session: AsyncSession, post_ids: list[int],
) -> dict[int, list[CalendarEntryDTO]]: ... ) -> 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 @runtime_checkable
class MarketService(Protocol): class MarketService(Protocol):

View File

@@ -16,9 +16,10 @@ async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None:
if services.has("cart"): if services.has("cart"):
await services.cart.adopt_cart_for_user(session, user_id, session_id) 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"): if services.has("calendar"):
await services.calendar.adopt_entries_for_user(session, user_id, session_id) 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) register_handler("user.logged_in", on_user_logged_in)

View File

@@ -5,6 +5,8 @@ calendar-domain tables on behalf of other domains.
""" """
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
from sqlalchemy import select, update, func from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -51,6 +53,12 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
entry = getattr(ticket, "entry", None) entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None) tt = getattr(ticket, "ticket_type", None)
cal = getattr(entry, "calendar", None) if entry else 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( return TicketDTO(
id=ticket.id, id=ticket.id,
code=ticket.code, code=ticket.code,
@@ -62,6 +70,10 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
calendar_name=cal.name if cal else None, calendar_name=cal.name if cal else None,
created_at=ticket.created_at, created_at=ticket.created_at,
checked_in_at=ticket.checked_in_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) .where(*filters)
.values(state="provisional") .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

View File

@@ -93,6 +93,23 @@ class SqlCartService:
calendar_count = len(cal_entries) 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) 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] items = [_item_to_dto(ci) for ci in cart_items]
return CartSummaryDTO( return CartSummaryDTO(
@@ -101,6 +118,8 @@ class SqlCartService:
calendar_count=calendar_count, calendar_count=calendar_count,
calendar_total=calendar_total, calendar_total=calendar_total,
items=items, items=items,
ticket_count=ticket_count,
ticket_total=ticket_total,
) )
async def cart_items( async def cart_items(

View File

@@ -98,6 +98,37 @@ class StubCalendarService:
) -> dict[int, list[CalendarEntryDTO]]: ) -> dict[int, list[CalendarEntryDTO]]:
return {} 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: class StubMarketService:
async def marketplaces_for_container( async def marketplaces_for_container(