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

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