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,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 %}
<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">
<p class="font-medium">Your payment was not completed.</p>

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

View File

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

View File

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

View File

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

View File

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

View File

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