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