From 256eb390b0c1c4d37cd4ff687371ab22ad5fbd70 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 21 Feb 2026 08:53:12 +0000 Subject: [PATCH] Replace ticket qty input with +/- buttons, show sold/basket counts - 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 --- bp/calendar_entry/routes.py | 34 ++- bp/tickets/routes.py | 119 ++++++++++ bp/tickets/services/tickets.py | 74 ++++++ .../_types/tickets/_adjust_response.html | 4 + templates/_types/tickets/_buy_form.html | 216 +++++++++++++----- 5 files changed, 392 insertions(+), 55 deletions(-) create mode 100644 templates/_types/tickets/_adjust_response.html diff --git a/bp/calendar_entry/routes.py b/bp/calendar_entry/routes.py index b119dae..53e675e 100644 --- a/bp/calendar_entry/routes.py +++ b/bp/calendar_entry/routes.py @@ -160,13 +160,22 @@ def register(): @bp.context_processor async def inject_root(): - from ..tickets.services.tickets import get_available_ticket_count + from ..tickets.services.tickets import ( + get_available_ticket_count, + get_sold_ticket_count, + get_user_reserved_count, + ) + from shared.infrastructure.cart_identity import current_cart_identity + from sqlalchemy.orm import selectinload view_args = getattr(request, "view_args", {}) or {} entry_id = view_args.get("entry_id") calendar_entry = None entry_posts = [] ticket_remaining = None + ticket_sold_count = 0 + user_ticket_count = 0 + user_ticket_counts_by_type = {} stmt = ( select(CalendarEntry) @@ -174,6 +183,7 @@ def register(): CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None), ) + .options(selectinload(CalendarEntry.ticket_types)) ) result = await g.s.execute(stmt) calendar_entry = result.scalar_one_or_none() @@ -190,11 +200,33 @@ def register(): entry_posts = await get_entry_posts(g.s, calendar_entry.id) # Get ticket availability ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id) + # Get sold count + ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id) + # Get current user's reserved count + ident = current_cart_identity() + user_ticket_count = await get_user_reserved_count( + g.s, calendar_entry.id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ) + # Per-type counts for multi-type entries + if calendar_entry.ticket_types: + for tt in calendar_entry.ticket_types: + if tt.deleted_at is None: + user_ticket_counts_by_type[tt.id] = await get_user_reserved_count( + g.s, calendar_entry.id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ticket_type_id=tt.id, + ) return { "entry": calendar_entry, "entry_posts": entry_posts, "ticket_remaining": ticket_remaining, + "ticket_sold_count": ticket_sold_count, + "user_ticket_count": user_ticket_count, + "user_ticket_counts_by_type": user_ticket_counts_by_type, } @bp.get("/") @require_admin diff --git a/bp/tickets/routes.py b/bp/tickets/routes.py index ecedeae..2125126 100644 --- a/bp/tickets/routes.py +++ b/bp/tickets/routes.py @@ -5,6 +5,7 @@ Routes: GET /tickets/ — My tickets list GET /tickets// — Ticket detail with QR code POST /tickets/buy/ — Purchase tickets for an entry + POST /tickets/adjust/ — Adjust ticket quantity (+/-) """ from __future__ import annotations @@ -26,6 +27,9 @@ from .services.tickets import ( get_user_tickets, get_available_ticket_count, get_tickets_for_entry, + get_sold_ticket_count, + get_user_reserved_count, + cancel_latest_reserved_ticket, ) logger = logging.getLogger(__name__) @@ -178,4 +182,119 @@ def register() -> Blueprint: ) return await make_response(html, 200) + @bp.post("/adjust/") + @clear_cache(tag="calendars", tag_scope="all") + async def adjust_quantity(): + """ + Adjust ticket quantity for a calendar entry (+/- pattern). + Creates or cancels tickets to reach the target count. + + Form fields: + entry_id — the calendar entry ID + ticket_type_id — (optional) specific ticket type + count — target quantity of reserved tickets + """ + form = await request.form + + entry_id_raw = form.get("entry_id", "").strip() + if not entry_id_raw: + return await make_response("Entry ID required", 400) + try: + entry_id = int(entry_id_raw) + except ValueError: + return await make_response("Invalid entry ID", 400) + + # Load entry + entry = await g.s.scalar( + select(CalendarEntry) + .where( + CalendarEntry.id == entry_id, + CalendarEntry.deleted_at.is_(None), + ) + .options(selectinload(CalendarEntry.ticket_types)) + ) + if not entry: + return await make_response("Entry not found", 404) + if entry.ticket_price is None: + return await make_response("Tickets not available for this entry", 400) + + # Ticket type (optional) + ticket_type_id = None + tt_raw = form.get("ticket_type_id", "").strip() + if tt_raw: + try: + ticket_type_id = int(tt_raw) + except ValueError: + pass + + target = max(int(form.get("count", 0)), 0) + ident = current_cart_identity() + + current = await get_user_reserved_count( + g.s, entry_id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ticket_type_id=ticket_type_id, + ) + + if target > current: + # Need to add tickets + to_add = target - current + available = await get_available_ticket_count(g.s, entry_id) + if available is not None and to_add > available: + return await make_response( + f"Only {available} ticket(s) remaining", 400 + ) + for _ in range(to_add): + await create_ticket( + g.s, + entry_id=entry_id, + ticket_type_id=ticket_type_id, + user_id=ident["user_id"], + session_id=ident["session_id"], + state="reserved", + ) + elif target < current: + # Need to remove tickets + to_remove = current - target + for _ in range(to_remove): + await cancel_latest_reserved_ticket( + g.s, entry_id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ticket_type_id=ticket_type_id, + ) + + # Build context for re-rendering the buy form + ticket_remaining = await get_available_ticket_count(g.s, entry_id) + ticket_sold_count = await get_sold_ticket_count(g.s, entry_id) + user_ticket_count = await get_user_reserved_count( + g.s, entry_id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ) + + # Per-type counts for multi-type entries + user_ticket_counts_by_type = {} + if entry.ticket_types: + for tt in entry.ticket_types: + if tt.deleted_at is None: + user_ticket_counts_by_type[tt.id] = await get_user_reserved_count( + g.s, entry_id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ticket_type_id=tt.id, + ) + + html = await render_template( + "_types/tickets/_adjust_response.html", + entry=entry, + ticket_remaining=ticket_remaining, + ticket_sold_count=ticket_sold_count, + user_ticket_count=user_ticket_count, + user_ticket_counts_by_type=user_ticket_counts_by_type, + ) + + return await make_response(html, 200) + return bp diff --git a/bp/tickets/services/tickets.py b/bp/tickets/services/tickets.py index 3f398d2..dab250c 100644 --- a/bp/tickets/services/tickets.py +++ b/bp/tickets/services/tickets.py @@ -182,6 +182,80 @@ async def get_tickets_for_entry( 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, diff --git a/templates/_types/tickets/_adjust_response.html b/templates/_types/tickets/_adjust_response.html new file mode 100644 index 0000000..fdbbe02 --- /dev/null +++ b/templates/_types/tickets/_adjust_response.html @@ -0,0 +1,4 @@ +{# Response for ticket adjust — buy form + OOB cart-mini update #} +{% from '_types/cart/_mini.html' import mini %} +{{ mini(oob='true') }} +{% include '_types/tickets/_buy_form.html' %} diff --git a/templates/_types/tickets/_buy_form.html b/templates/_types/tickets/_buy_form.html index 8f8be15..3cb981a 100644 --- a/templates/_types/tickets/_buy_form.html +++ b/templates/_types/tickets/_buy_form.html @@ -3,14 +3,31 @@

- Buy Tickets + Tickets

+ {# Sold / remaining info #} +
+ {% if ticket_sold_count is defined and ticket_sold_count %} + {{ ticket_sold_count }} sold + {% endif %} + {% if ticket_remaining is not none %} + {{ ticket_remaining }} remaining + {% endif %} + {% if user_ticket_count is defined and user_ticket_count %} + + + {{ user_ticket_count }} in basket + + {% endif %} +
+ {% if entry.ticket_types %} {# Multiple ticket types #} -
+
{% for tt in entry.ticket_types %} {% if tt.deleted_at is none %} + {% set type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type is defined else 0 %}
{{ tt.name }}
@@ -18,34 +35,83 @@ £{{ '%.2f'|format(tt.cost) }}
-
- - - - - -
+ + + + + + + {% else %} + {# +/- controls #} +
+
+ + + + + +
+ + + + + + + {{ type_count }} + + + + + +
+ + + + + +
+
+ {% endif %}
{% endif %} {% endfor %}
+ {% else %} {# Simple ticket (single price) #}
@@ -55,38 +121,80 @@ per ticket
- {% if ticket_remaining is not none %} - - {{ ticket_remaining }} remaining - - {% endif %}
-
- - - - - -
+ + + + + + {% else %} + {# +/- controls #} +
+
+ + + + +
+ + + + + + + {{ qty }} + + + + + +
+ + + + +
+
+ {% endif %} {% endif %} {% elif entry.ticket_price is not none %}