Add tickets & bookings to account page

Add TicketDTO, user_tickets/user_bookings to CalendarService protocol
and SqlCalendarService implementation, plus nav links and panel
templates for the auth account sub-pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 16:06:21 +00:00
parent 98c3df860b
commit dfc324b1be
7 changed files with 180 additions and 2 deletions

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -2,6 +2,12 @@
{% call links.link(coop_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% call links.link(coop_url('/auth/tickets/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
tickets
{% endcall %}
{% call links.link(coop_url('/auth/bookings/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
bookings
{% endcall %}
<div class="relative nav-group">
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}" data-hx-disable>
orders

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -43,6 +43,20 @@ class CalendarDTO:
description: str | None = None
@dataclass(frozen=True, slots=True)
class TicketDTO:
id: int
code: str
state: str
entry_name: str
entry_start_at: datetime
entry_end_at: datetime | None = None
ticket_type_name: str | None = None
calendar_name: str | None = None
created_at: datetime | None = None
checked_in_at: datetime | None = None
@dataclass(frozen=True, slots=True)
class CalendarEntryDTO:
id: int

View File

@@ -13,6 +13,7 @@ from .dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
TicketDTO,
MarketPlaceDTO,
ProductDTO,
CartItemDTO,
@@ -69,6 +70,14 @@ class CalendarService(Protocol):
self, session: AsyncSession, order_id: int,
) -> list[CalendarEntryDTO]: ...
async def user_tickets(
self, session: AsyncSession, *, user_id: int,
) -> list[TicketDTO]: ...
async def user_bookings(
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]: ...
@runtime_checkable
class MarketService(Protocol):

View File

@@ -9,8 +9,8 @@ from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost, Ticket
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO, TicketDTO
def _cal_to_dto(cal: Calendar) -> CalendarDTO:
@@ -47,6 +47,24 @@ def _entry_to_dto(entry: CalendarEntry) -> CalendarEntryDTO:
)
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
return TicketDTO(
id=ticket.id,
code=ticket.code,
state=ticket.state,
entry_name=entry.name if entry else "",
entry_start_at=entry.start_at if entry else ticket.created_at,
entry_end_at=entry.end_at if entry else None,
ticket_type_name=tt.name if tt else None,
calendar_name=cal.name if cal else None,
created_at=ticket.created_at,
checked_in_at=ticket.checked_in_at,
)
class SqlCalendarService:
# -- reads ----------------------------------------------------------------
@@ -210,6 +228,38 @@ class SqlCalendarService:
)
return [_entry_to_dto(e) for e in result.scalars().all()]
async def user_tickets(
self, session: AsyncSession, *, user_id: int,
) -> list[TicketDTO]:
result = await session.execute(
select(Ticket)
.where(
Ticket.user_id == user_id,
Ticket.state != "cancelled",
)
.order_by(Ticket.created_at.desc())
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
)
return [_ticket_to_dto(t) for t in result.scalars().all()]
async def user_bookings(
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]:
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.user_id == user_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state.in_(("ordered", "provisional", "confirmed")),
)
.order_by(CalendarEntry.start_at.desc())
.options(selectinload(CalendarEntry.calendar))
)
return [_entry_to_dto(e) for e in result.scalars().all()]
# -- batch reads (not in protocol — convenience for blog service) ---------
async def confirmed_entries_for_posts(

View File

@@ -13,6 +13,7 @@ from shared.contracts.dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
TicketDTO,
MarketPlaceDTO,
ProductDTO,
CartItemDTO,
@@ -82,6 +83,16 @@ class StubCalendarService:
) -> list[CalendarEntryDTO]:
return []
async def user_tickets(
self, session: AsyncSession, *, user_id: int,
) -> list[TicketDTO]:
return []
async def user_bookings(
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]:
return []
class StubMarketService:
async def marketplaces_for_container(