Replace ticket qty input with +/- buttons, show sold/basket counts
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>
This commit is contained in:
giles
2026-02-21 08:53:12 +00:00
parent 13064c3772
commit 256eb390b0
5 changed files with 392 additions and 55 deletions

View File

@@ -5,6 +5,7 @@ Routes:
GET /tickets/ — My tickets list
GET /tickets/<code>/ — 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

View File

@@ -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,