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:
@@ -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>
|
||||
|
||||
49
browser/templates/_types/order/_ticket_items.html
Normal file
49
browser/templates/_types/order/_ticket_items.html
Normal 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 %}
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user