All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
- Entry page shows tickets sold count, remaining, and "in basket" count - Replace numeric input + Buy button with add-to-basket / +/- controls - New POST /tickets/adjust/ route creates/cancels tickets to target count - Keep buy form active after adding (no confirmation replacement) - New service functions: get_sold_ticket_count, get_user_reserved_count, cancel_latest_reserved_ticket Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
8.6 KiB
Python
314 lines
8.6 KiB
Python
"""
|
|
Ticket service layer — create, query, and manage tickets.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select, update, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from models.calendars import Ticket, TicketType, CalendarEntry
|
|
|
|
|
|
|
|
async def create_ticket(
|
|
session: AsyncSession,
|
|
*,
|
|
entry_id: int,
|
|
ticket_type_id: Optional[int] = None,
|
|
user_id: Optional[int] = None,
|
|
session_id: Optional[str] = None,
|
|
order_id: Optional[int] = None,
|
|
state: str = "reserved",
|
|
) -> Ticket:
|
|
"""Create a single ticket with a unique code."""
|
|
ticket = Ticket(
|
|
entry_id=entry_id,
|
|
ticket_type_id=ticket_type_id,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
order_id=order_id,
|
|
code=uuid.uuid4().hex,
|
|
state=state,
|
|
)
|
|
session.add(ticket)
|
|
await session.flush()
|
|
return ticket
|
|
|
|
|
|
async def create_tickets_for_order(
|
|
session: AsyncSession,
|
|
order_id: int,
|
|
user_id: Optional[int],
|
|
session_id: Optional[str],
|
|
) -> list[Ticket]:
|
|
"""
|
|
Create ticket records for all calendar entries in an order
|
|
that have ticket_price configured.
|
|
Called during checkout after calendar entries are transitioned to 'ordered'.
|
|
"""
|
|
# Find all ordered entries for this order that have ticket pricing
|
|
result = await session.execute(
|
|
select(CalendarEntry)
|
|
.where(
|
|
CalendarEntry.order_id == order_id,
|
|
CalendarEntry.deleted_at.is_(None),
|
|
CalendarEntry.ticket_price.isnot(None),
|
|
)
|
|
.options(selectinload(CalendarEntry.ticket_types))
|
|
)
|
|
entries = result.scalars().all()
|
|
|
|
tickets = []
|
|
for entry in entries:
|
|
if entry.ticket_types:
|
|
# Entry has specific ticket types — create one ticket per type
|
|
# (quantity handling can be added later)
|
|
for tt in entry.ticket_types:
|
|
if tt.deleted_at is None:
|
|
ticket = await create_ticket(
|
|
session,
|
|
entry_id=entry.id,
|
|
ticket_type_id=tt.id,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
order_id=order_id,
|
|
state="reserved",
|
|
)
|
|
tickets.append(ticket)
|
|
else:
|
|
# Simple ticket — one per entry
|
|
ticket = await create_ticket(
|
|
session,
|
|
entry_id=entry.id,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
order_id=order_id,
|
|
state="reserved",
|
|
)
|
|
tickets.append(ticket)
|
|
|
|
return tickets
|
|
|
|
|
|
async def confirm_tickets_for_order(
|
|
session: AsyncSession,
|
|
order_id: int,
|
|
) -> int:
|
|
"""
|
|
Transition tickets from reserved → confirmed when payment succeeds.
|
|
Returns the number of tickets confirmed.
|
|
"""
|
|
result = await session.execute(
|
|
update(Ticket)
|
|
.where(
|
|
Ticket.order_id == order_id,
|
|
Ticket.state == "reserved",
|
|
)
|
|
.values(state="confirmed")
|
|
)
|
|
return result.rowcount
|
|
|
|
|
|
async def get_ticket_by_code(
|
|
session: AsyncSession,
|
|
code: str,
|
|
) -> Optional[Ticket]:
|
|
"""Look up a ticket by its unique code."""
|
|
result = await session.execute(
|
|
select(Ticket)
|
|
.where(Ticket.code == code)
|
|
.options(
|
|
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
|
selectinload(Ticket.ticket_type),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_user_tickets(
|
|
session: AsyncSession,
|
|
user_id: Optional[int] = None,
|
|
session_id: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
) -> list[Ticket]:
|
|
"""Get all tickets for a user or session."""
|
|
filters = []
|
|
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 []
|
|
|
|
if state:
|
|
filters.append(Ticket.state == state)
|
|
else:
|
|
# Exclude cancelled by default
|
|
filters.append(Ticket.state != "cancelled")
|
|
|
|
result = await session.execute(
|
|
select(Ticket)
|
|
.where(*filters)
|
|
.options(
|
|
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
|
selectinload(Ticket.ticket_type),
|
|
)
|
|
.order_by(Ticket.created_at.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def get_tickets_for_entry(
|
|
session: AsyncSession,
|
|
entry_id: int,
|
|
) -> list[Ticket]:
|
|
"""Get all non-cancelled tickets for a calendar entry."""
|
|
result = await session.execute(
|
|
select(Ticket)
|
|
.where(
|
|
Ticket.entry_id == entry_id,
|
|
Ticket.state != "cancelled",
|
|
)
|
|
.options(
|
|
selectinload(Ticket.ticket_type),
|
|
)
|
|
.order_by(Ticket.created_at.asc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def get_sold_ticket_count(
|
|
session: AsyncSession,
|
|
entry_id: int,
|
|
) -> int:
|
|
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
|
|
result = await session.scalar(
|
|
select(func.count(Ticket.id)).where(
|
|
Ticket.entry_id == entry_id,
|
|
Ticket.state != "cancelled",
|
|
)
|
|
)
|
|
return result or 0
|
|
|
|
|
|
async def get_user_reserved_count(
|
|
session: AsyncSession,
|
|
entry_id: int,
|
|
user_id: Optional[int] = None,
|
|
session_id: Optional[str] = None,
|
|
ticket_type_id: Optional[int] = None,
|
|
) -> int:
|
|
"""Count reserved tickets for a specific user/session + entry + optional type."""
|
|
filters = [
|
|
Ticket.entry_id == entry_id,
|
|
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 0
|
|
if ticket_type_id is not None:
|
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
|
result = await session.scalar(
|
|
select(func.count(Ticket.id)).where(*filters)
|
|
)
|
|
return result or 0
|
|
|
|
|
|
async def cancel_latest_reserved_ticket(
|
|
session: AsyncSession,
|
|
entry_id: int,
|
|
user_id: Optional[int] = None,
|
|
session_id: Optional[str] = None,
|
|
ticket_type_id: Optional[int] = None,
|
|
) -> bool:
|
|
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
|
|
filters = [
|
|
Ticket.entry_id == entry_id,
|
|
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 False
|
|
if ticket_type_id is not None:
|
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
|
|
|
ticket = await session.scalar(
|
|
select(Ticket)
|
|
.where(*filters)
|
|
.order_by(Ticket.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
if ticket:
|
|
ticket.state = "cancelled"
|
|
await session.flush()
|
|
return True
|
|
return False
|
|
|
|
|
|
async def get_available_ticket_count(
|
|
session: AsyncSession,
|
|
entry_id: int,
|
|
) -> Optional[int]:
|
|
"""
|
|
Get number of remaining tickets for an entry.
|
|
Returns None if unlimited.
|
|
"""
|
|
entry = await session.scalar(
|
|
select(CalendarEntry).where(
|
|
CalendarEntry.id == entry_id,
|
|
CalendarEntry.deleted_at.is_(None),
|
|
)
|
|
)
|
|
if not entry or entry.ticket_price is None:
|
|
return None
|
|
if entry.ticket_count is None:
|
|
return None # Unlimited
|
|
|
|
# Count non-cancelled tickets
|
|
sold = await session.scalar(
|
|
select(func.count(Ticket.id)).where(
|
|
Ticket.entry_id == entry_id,
|
|
Ticket.state != "cancelled",
|
|
)
|
|
)
|
|
return max(0, entry.ticket_count - (sold or 0))
|
|
|
|
|
|
async def checkin_ticket(
|
|
session: AsyncSession,
|
|
code: str,
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Check in a ticket by its code.
|
|
Returns (success, error_message).
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
ticket = await get_ticket_by_code(session, code)
|
|
if not ticket:
|
|
return False, "Ticket not found"
|
|
|
|
if ticket.state == "checked_in":
|
|
return False, "Ticket already checked in"
|
|
|
|
if ticket.state == "cancelled":
|
|
return False, "Ticket is cancelled"
|
|
|
|
if ticket.state not in ("confirmed", "reserved"):
|
|
return False, f"Ticket in unexpected state: {ticket.state}"
|
|
|
|
ticket.state = "checked_in"
|
|
ticket.checked_in_at = datetime.now(timezone.utc)
|
|
return True, None
|