From 1bab546dfcb8519001aa0d2707e9cbc809a4d2a9 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 10 Feb 2026 00:00:35 +0000 Subject: [PATCH] feat: ticket purchase flow, QR display, and admin check-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket purchase: - tickets blueprint with routes for my tickets list, ticket detail with QR - Buy tickets form on entry detail page (HTMX-powered) - Ticket services: create, query, availability checking Admin check-in: - ticket_admin blueprint with dashboard, lookup, and check-in routes - QR scanner/lookup interface with real-time search - Per-entry ticket list view - Check-in transitions ticket state to checked_in Internal API: - GET /internal/events/tickets endpoint for cross-app queries - POST /internal/events/tickets//checkin for programmatic check-in Template fixes: - All templates updated: blog.post.calendars.* → calendars.* - Removed slug=post.slug parameters (standalone events service) Co-Authored-By: Claude Opus 4.6 --- app.py | 8 + bp/calendar_entry/routes.py | 6 + bp/ticket_admin/__init__.py | 0 bp/ticket_admin/routes.py | 166 ++++++++++++ bp/ticket_admin/services/__init__.py | 0 bp/tickets/__init__.py | 0 bp/tickets/routes.py | 181 +++++++++++++ bp/tickets/services/__init__.py | 0 bp/tickets/services/tickets.py | 238 ++++++++++++++++++ events_api.py | 68 ++++- templates/_types/calendar/_main_panel.html | 30 +-- templates/_types/calendar/_nav.html | 2 - .../_types/calendar/admin/_description.html | 3 +- .../calendar/admin/_description_edit.html | 6 +- .../_types/calendar/admin/_main_panel.html | 1 - .../_types/calendar/admin/header/_header.html | 1 - templates/_types/calendar/header/_header.html | 1 - .../_types/calendars/_calendars_list.html | 2 - templates/_types/calendars/_main_panel.html | 4 +- .../_types/calendars/header/_header.html | 2 +- templates/_types/day/_add.html | 6 +- templates/_types/day/_add_button.html | 3 +- templates/_types/day/_nav.html | 6 +- templates/_types/day/_row.html | 6 +- .../_types/day/admin/_nav_entries_oob.html | 3 +- .../_types/day/admin/header/_header.html | 3 +- templates/_types/day/header/_header.html | 3 +- templates/_types/entry/_edit.html | 6 +- templates/_types/entry/_main_panel.html | 6 +- templates/_types/entry/_nav.html | 3 +- templates/_types/entry/_options.html | 9 +- .../_types/entry/_post_search_results.html | 6 +- templates/_types/entry/_posts.html | 6 +- templates/_types/entry/_tickets.html | 3 +- templates/_types/entry/admin/_nav.html | 3 +- .../_types/entry/admin/header/_header.html | 3 +- templates/_types/entry/header/_header.html | 3 +- .../_types/post_entries/_main_panel.html | 1 - templates/_types/slot/_edit.html | 6 +- templates/_types/slot/_main_panel.html | 3 +- templates/_types/slot/header/_header.html | 1 - templates/_types/slots/_add.html | 6 +- templates/_types/slots/_add_button.html | 3 +- templates/_types/slots/_row.html | 4 +- templates/_types/slots/header/_header.html | 1 - .../_types/ticket_admin/_checkin_result.html | 39 +++ .../_types/ticket_admin/_entry_tickets.html | 75 ++++++ .../_types/ticket_admin/_lookup_result.html | 82 ++++++ .../_types/ticket_admin/_main_panel.html | 148 +++++++++++ templates/_types/ticket_admin/index.html | 8 + templates/_types/ticket_type/_edit.html | 6 +- templates/_types/ticket_type/_main_panel.html | 3 +- .../_types/ticket_type/header/_header.html | 3 +- templates/_types/ticket_types/_add.html | 6 +- .../_types/ticket_types/_add_button.html | 3 +- templates/_types/ticket_types/_row.html | 6 +- .../_types/ticket_types/header/_header.html | 3 +- templates/_types/tickets/_buy_form.html | 98 ++++++++ templates/_types/tickets/_buy_result.html | 39 +++ templates/_types/tickets/_detail_panel.html | 124 +++++++++ templates/_types/tickets/_main_panel.html | 65 +++++ templates/_types/tickets/detail.html | 8 + templates/_types/tickets/index.html | 8 + 63 files changed, 1421 insertions(+), 125 deletions(-) create mode 100644 bp/ticket_admin/__init__.py create mode 100644 bp/ticket_admin/routes.py create mode 100644 bp/ticket_admin/services/__init__.py create mode 100644 bp/tickets/__init__.py create mode 100644 bp/tickets/routes.py create mode 100644 bp/tickets/services/__init__.py create mode 100644 bp/tickets/services/tickets.py create mode 100644 templates/_types/ticket_admin/_checkin_result.html create mode 100644 templates/_types/ticket_admin/_entry_tickets.html create mode 100644 templates/_types/ticket_admin/_lookup_result.html create mode 100644 templates/_types/ticket_admin/_main_panel.html create mode 100644 templates/_types/ticket_admin/index.html create mode 100644 templates/_types/tickets/_buy_form.html create mode 100644 templates/_types/tickets/_buy_result.html create mode 100644 templates/_types/tickets/_detail_panel.html create mode 100644 templates/_types/tickets/_main_panel.html create mode 100644 templates/_types/tickets/detail.html create mode 100644 templates/_types/tickets/index.html diff --git a/app.py b/app.py index 14fd995..3720b16 100644 --- a/app.py +++ b/app.py @@ -46,6 +46,14 @@ def create_app() -> "Quart": url_prefix="/calendars", ) + # Tickets blueprint — user-facing ticket views and QR codes + from .bp.tickets.routes import register as register_tickets + app.register_blueprint(register_tickets()) + + # Ticket admin — check-in interface (admin only) + from .bp.ticket_admin.routes import register as register_ticket_admin + app.register_blueprint(register_ticket_admin()) + # Internal API (server-to-server, CSRF-exempt) from .events_api import register as register_events_api app.register_blueprint(register_events_api()) diff --git a/bp/calendar_entry/routes.py b/bp/calendar_entry/routes.py index 35ecd07..9ccea07 100644 --- a/bp/calendar_entry/routes.py +++ b/bp/calendar_entry/routes.py @@ -160,10 +160,13 @@ def register(): @bp.context_processor async def inject_root(): + from ..tickets.services.tickets import get_available_ticket_count + view_args = getattr(request, "view_args", {}) or {} entry_id = view_args.get("entry_id") calendar_entry = None entry_posts = [] + ticket_remaining = None stmt = ( select(CalendarEntry) @@ -185,10 +188,13 @@ def register(): await g.s.refresh(calendar_entry, ['slot']) # Fetch associated posts 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) return { "entry": calendar_entry, "entry_posts": entry_posts, + "ticket_remaining": ticket_remaining, } @bp.get("/") @require_admin diff --git a/bp/ticket_admin/__init__.py b/bp/ticket_admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/ticket_admin/routes.py b/bp/ticket_admin/routes.py new file mode 100644 index 0000000..7f3905c --- /dev/null +++ b/bp/ticket_admin/routes.py @@ -0,0 +1,166 @@ +""" +Ticket admin blueprint — check-in interface and ticket management. + +Routes: + GET /admin/tickets/ — Ticket dashboard (scan + list) + GET /admin/tickets/entry// — Tickets for a specific entry + POST /admin/tickets//checkin — Check in a ticket + GET /admin/tickets// — Ticket admin detail +""" +from __future__ import annotations + +import logging + +from quart import ( + Blueprint, g, request, render_template, make_response, jsonify, +) +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from models.calendars import CalendarEntry, Ticket, TicketType +from suma_browser.app.authz import require_admin +from suma_browser.app.redis_cacher import clear_cache + +from ..tickets.services.tickets import ( + get_ticket_by_code, + get_tickets_for_entry, + checkin_ticket, +) + +logger = logging.getLogger(__name__) + + +def register() -> Blueprint: + bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets") + + @bp.get("/") + @require_admin + async def dashboard(): + """Ticket admin dashboard with QR scanner and recent tickets.""" + from suma_browser.app.utils.htmx import is_htmx_request + + # Get recent tickets + result = await g.s.execute( + select(Ticket) + .options( + selectinload(Ticket.entry).selectinload(CalendarEntry.calendar), + selectinload(Ticket.ticket_type), + ) + .order_by(Ticket.created_at.desc()) + .limit(50) + ) + tickets = result.scalars().all() + + # Stats + total = await g.s.scalar(select(func.count(Ticket.id))) + confirmed = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "confirmed") + ) + checked_in = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "checked_in") + ) + reserved = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "reserved") + ) + + stats = { + "total": total or 0, + "confirmed": confirmed or 0, + "checked_in": checked_in or 0, + "reserved": reserved or 0, + } + + if not is_htmx_request(): + html = await render_template( + "_types/ticket_admin/index.html", + tickets=tickets, + stats=stats, + ) + else: + html = await render_template( + "_types/ticket_admin/_main_panel.html", + tickets=tickets, + stats=stats, + ) + + return await make_response(html, 200) + + @bp.get("/entry//") + @require_admin + async def entry_tickets(entry_id: int): + """List all tickets for a specific calendar entry.""" + from suma_browser.app.utils.htmx import is_htmx_request + + entry = await g.s.scalar( + select(CalendarEntry) + .where( + CalendarEntry.id == entry_id, + CalendarEntry.deleted_at.is_(None), + ) + .options(selectinload(CalendarEntry.calendar)) + ) + if not entry: + return await make_response("Entry not found", 404) + + tickets = await get_tickets_for_entry(g.s, entry_id) + + html = await render_template( + "_types/ticket_admin/_entry_tickets.html", + entry=entry, + tickets=tickets, + ) + return await make_response(html, 200) + + @bp.get("/lookup/") + @require_admin + async def lookup(): + """Look up a ticket by code (used by scanner).""" + code = request.args.get("code", "").strip() + if not code: + return await make_response( + '
Enter a ticket code
', + 200, + ) + + ticket = await get_ticket_by_code(g.s, code) + if not ticket: + html = await render_template( + "_types/ticket_admin/_lookup_result.html", + ticket=None, + error="Ticket not found", + ) + return await make_response(html, 200) + + html = await render_template( + "_types/ticket_admin/_lookup_result.html", + ticket=ticket, + error=None, + ) + return await make_response(html, 200) + + @bp.post("//checkin/") + @require_admin + @clear_cache(tag="calendars", tag_scope="all") + async def do_checkin(code: str): + """Check in a ticket by its code.""" + success, error = await checkin_ticket(g.s, code) + + if not success: + html = await render_template( + "_types/ticket_admin/_checkin_result.html", + success=False, + error=error, + ticket=None, + ) + return await make_response(html, 200) + + ticket = await get_ticket_by_code(g.s, code) + html = await render_template( + "_types/ticket_admin/_checkin_result.html", + success=True, + error=None, + ticket=ticket, + ) + return await make_response(html, 200) + + return bp diff --git a/bp/ticket_admin/services/__init__.py b/bp/ticket_admin/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/tickets/__init__.py b/bp/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/tickets/routes.py b/bp/tickets/routes.py new file mode 100644 index 0000000..118203c --- /dev/null +++ b/bp/tickets/routes.py @@ -0,0 +1,181 @@ +""" +Tickets blueprint — user-facing ticket views and QR codes. + +Routes: + GET /tickets/ — My tickets list + GET /tickets// — Ticket detail with QR code + POST /tickets/buy/ — Purchase tickets for an entry +""" +from __future__ import annotations + +import logging + +from quart import ( + Blueprint, g, request, render_template, make_response, +) +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from models.calendars import CalendarEntry +from shared.cart_identity import current_cart_identity +from suma_browser.app.redis_cacher import clear_cache + +from .services.tickets import ( + create_ticket, + get_ticket_by_code, + get_user_tickets, + get_available_ticket_count, + get_tickets_for_entry, +) + +logger = logging.getLogger(__name__) + + +def register() -> Blueprint: + bp = Blueprint("tickets", __name__, url_prefix="/tickets") + + @bp.get("/") + async def my_tickets(): + """List all tickets for the current user/session.""" + from suma_browser.app.utils.htmx import is_htmx_request + + ident = current_cart_identity() + tickets = await get_user_tickets( + g.s, + user_id=ident["user_id"], + session_id=ident["session_id"], + ) + + if not is_htmx_request(): + html = await render_template( + "_types/tickets/index.html", + tickets=tickets, + ) + else: + html = await render_template( + "_types/tickets/_main_panel.html", + tickets=tickets, + ) + + return await make_response(html, 200) + + @bp.get("//") + async def ticket_detail(code: str): + """View a single ticket with QR code.""" + from suma_browser.app.utils.htmx import is_htmx_request + + ticket = await get_ticket_by_code(g.s, code) + if not ticket: + return await make_response("Ticket not found", 404) + + # Verify ownership + ident = current_cart_identity() + if ident["user_id"] is not None: + if ticket.user_id != ident["user_id"]: + return await make_response("Ticket not found", 404) + elif ident["session_id"] is not None: + if ticket.session_id != ident["session_id"]: + return await make_response("Ticket not found", 404) + else: + return await make_response("Ticket not found", 404) + + if not is_htmx_request(): + html = await render_template( + "_types/tickets/detail.html", + ticket=ticket, + ) + else: + html = await render_template( + "_types/tickets/_detail_panel.html", + ticket=ticket, + ) + + return await make_response(html, 200) + + @bp.post("/buy/") + @clear_cache(tag="calendars", tag_scope="all") + async def buy_tickets(): + """ + Purchase tickets for a calendar entry. + Creates ticket records with state='reserved' (awaiting payment). + + Form fields: + entry_id — the calendar entry ID + ticket_type_id (optional) — specific ticket type + quantity — number of tickets (default 1) + """ + 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) + + # Check availability + available = await get_available_ticket_count(g.s, entry_id) + quantity = int(form.get("quantity", 1)) + if quantity < 1: + quantity = 1 + + if available is not None and quantity > available: + return await make_response( + f"Only {available} ticket(s) remaining", 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 + + ident = current_cart_identity() + + # Create tickets + created = [] + for _ in range(quantity): + ticket = 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", + ) + created.append(ticket) + + # Re-check availability for display + remaining = await get_available_ticket_count(g.s, entry_id) + all_tickets = await get_tickets_for_entry(g.s, entry_id) + + html = await render_template( + "_types/tickets/_buy_result.html", + entry=entry, + created_tickets=created, + remaining=remaining, + all_tickets=all_tickets, + ) + return await make_response(html, 200) + + return bp diff --git a/bp/tickets/services/__init__.py b/bp/tickets/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/tickets/services/tickets.py b/bp/tickets/services/tickets.py new file mode 100644 index 0000000..b83bdca --- /dev/null +++ b/bp/tickets/services/tickets.py @@ -0,0 +1,238 @@ +""" +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_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 diff --git a/events_api.py b/events_api.py index 530f871..777777a 100644 --- a/events_api.py +++ b/events_api.py @@ -10,7 +10,7 @@ from quart import Blueprint, g, request, jsonify from sqlalchemy import select, update, func from sqlalchemy.orm import selectinload -from models.calendars import CalendarEntry, Calendar +from models.calendars import CalendarEntry, Calendar, Ticket from suma_browser.app.csrf import csrf_exempt @@ -129,4 +129,70 @@ def register() -> Blueprint: "calendar_slug": entry.calendar.slug if entry.calendar else None, }) + @bp.get("/tickets") + @csrf_exempt + async def tickets(): + """ + Return tickets for a user/session. + Query params: user_id, session_id, order_id, state + """ + user_id = request.args.get("user_id", type=int) + session_id = request.args.get("session_id") + order_id = request.args.get("order_id", type=int) + state = request.args.get("state") + + filters = [] + if order_id is not None: + filters.append(Ticket.order_id == order_id) + elif user_id is not None: + filters.append(Ticket.user_id == user_id) + elif session_id: + filters.append(Ticket.session_id == session_id) + else: + return jsonify([]) + + if state: + filters.append(Ticket.state == state) + + result = await g.s.execute( + select(Ticket) + .where(*filters) + .options( + selectinload(Ticket.entry).selectinload(CalendarEntry.calendar), + selectinload(Ticket.ticket_type), + ) + .order_by(Ticket.created_at.desc()) + ) + tix = result.scalars().all() + + return jsonify([ + { + "id": t.id, + "code": t.code, + "state": t.state, + "entry_name": t.entry.name if t.entry else None, + "entry_start_at": t.entry.start_at.isoformat() if t.entry and t.entry.start_at else None, + "calendar_name": t.entry.calendar.name if t.entry and t.entry.calendar else None, + "ticket_type_name": t.ticket_type.name if t.ticket_type else None, + "ticket_type_cost": float(t.ticket_type.cost) if t.ticket_type and t.ticket_type.cost else None, + "checked_in_at": t.checked_in_at.isoformat() if t.checked_in_at else None, + } + for t in tix + ]) + + @bp.post("/tickets//checkin") + @csrf_exempt + async def checkin(code: str): + """ + Check in a ticket by code. + Used by admin check-in interface. + """ + from .bp.tickets.services.tickets import checkin_ticket + + success, error = await checkin_ticket(g.s, code) + if not success: + return jsonify({"ok": False, "error": error}), 400 + + return jsonify({"ok": True}) + return bp diff --git a/templates/_types/calendar/_main_panel.html b/templates/_types/calendar/_main_panel.html index f2f781a..7c0ffde 100644 --- a/templates/_types/calendar/_main_panel.html +++ b/templates/_types/calendar/_main_panel.html @@ -6,13 +6,11 @@ {# Outer left: -1 year #} {% import 'macros/links.html' as links %} {% call links.link( - url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug), hx_select_search, select_colours, aclass=styles.nav_button @@ -13,5 +12,4 @@ {% endcall %} {% if g.rights.admin %} {% from 'macros/admin_nav.html' import admin_nav_item %} - {{admin_nav_item(url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug))}} {% endif %} \ No newline at end of file diff --git a/templates/_types/calendar/admin/_description.html b/templates/_types/calendar/admin/_description.html index 2759d7a..46d99cb 100644 --- a/templates/_types/calendar/admin/_description.html +++ b/templates/_types/calendar/admin/_description.html @@ -13,8 +13,7 @@ type="button" class="mt-2 text-xs underline" hx-get="{{ url_for( - 'blog.post.calendars.calendar.admin.calendar_description_edit', - slug=post.slug, + 'calendars.calendar.admin.calendar_description_edit', calendar_slug=calendar.slug, ) }}" hx-target="#calendar-description" diff --git a/templates/_types/calendar/admin/_description_edit.html b/templates/_types/calendar/admin/_description_edit.html index 87b31c5..4ab7a7b 100644 --- a/templates/_types/calendar/admin/_description_edit.html +++ b/templates/_types/calendar/admin/_description_edit.html @@ -1,8 +1,7 @@
diff --git a/templates/_types/calendars/_calendars_list.html b/templates/_types/calendars/_calendars_list.html index 92d0fd2..35a8d1e 100644 --- a/templates/_types/calendars/_calendars_list.html +++ b/templates/_types/calendars/_calendars_list.html @@ -3,7 +3,6 @@
- {% set calendar_href = url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%} - {% if has_access('blog.post.calendars.create_calendar') %} + {% if has_access('calendars.create_calendar') %}
Calendars diff --git a/templates/_types/day/_add.html b/templates/_types/day/_add.html index 1fde06f..ed08280 100644 --- a/templates/_types/day/_add.html +++ b/templates/_types/day/_add.html @@ -3,8 +3,7 @@ {% call links.link( url_for( - 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get', - slug=post.slug, + 'calendars.calendar.day.calendar_entries.calendar_entry.get', calendar_slug=calendar.slug, day=day, month=month, @@ -24,8 +23,7 @@
{% call links.link( url_for( - 'blog.post.calendars.calendar.slots.slot.get', - slug=post.slug, + 'calendars.calendar.slots.slot.get', calendar_slug=calendar.slug, slot_id=entry.slot.id ), diff --git a/templates/_types/day/admin/_nav_entries_oob.html b/templates/_types/day/admin/_nav_entries_oob.html index 957246b..c8be72c 100644 --- a/templates/_types/day/admin/_nav_entries_oob.html +++ b/templates/_types/day/admin/_nav_entries_oob.html @@ -9,8 +9,7 @@ {% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
@@ -108,9 +111,8 @@ type="button" class="{{styles.pre_action_button}}" hx-get="{{ url_for( - 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get_edit', + 'calendars.calendar.day.calendar_entries.calendar_entry.get_edit', entry_id=entry.id, - slug=post.slug, calendar_slug=calendar.slug, day=day, month=month, diff --git a/templates/_types/entry/_nav.html b/templates/_types/entry/_nav.html index ea42b12..f6457b2 100644 --- a/templates/_types/entry/_nav.html +++ b/templates/_types/entry/_nav.html @@ -28,8 +28,7 @@ {% from 'macros/admin_nav.html' import admin_nav_item %} {{admin_nav_item( url_for( - 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin', - slug=post.slug, + 'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin', calendar_slug=calendar.slug, day=day, month=month, diff --git a/templates/_types/entry/_options.html b/templates/_types/entry/_options.html index b240699..d33ae4c 100644 --- a/templates/_types/entry/_options.html +++ b/templates/_types/entry/_options.html @@ -2,8 +2,7 @@ {% if entry.state == 'provisional' %}
Loading calendar...
diff --git a/templates/_types/slot/_edit.html b/templates/_types/slot/_edit.html index 79b18cf..e591e74 100644 --- a/templates/_types/slot/_edit.html +++ b/templates/_types/slot/_edit.html @@ -3,8 +3,7 @@
diff --git a/templates/_types/slots/_add.html b/templates/_types/slots/_add.html index f2959d8..8a0f6df 100644 --- a/templates/_types/slots/_add.html +++ b/templates/_types/slots/_add.html @@ -1,6 +1,5 @@
{% call links.link( - url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=s.id), hx_select_search, aclass=styles.pill ) %} @@ -46,8 +45,7 @@ data-confirm-confirm-text="Yes, delete it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-delete="{{ url_for('blog.post.calendars.calendar.slots.slot.slot_delete', - slug=post.slug, + hx-delete="{{ url_for('calendars.calendar.slots.slot.slot_delete', calendar_slug=calendar.slug, slot_id=s.id) }}" hx-target="#slots-table" diff --git a/templates/_types/slots/header/_header.html b/templates/_types/slots/header/_header.html index f0221e4..eb34edb 100644 --- a/templates/_types/slots/header/_header.html +++ b/templates/_types/slots/header/_header.html @@ -2,7 +2,6 @@ {% macro header_row(oob=False) %} {% call links.menu_row(id='slots-row', oob=oob) %} {% call links.link( - url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug), hx_select_search, ) %} diff --git a/templates/_types/ticket_admin/_checkin_result.html b/templates/_types/ticket_admin/_checkin_result.html new file mode 100644 index 0000000..4d6447e --- /dev/null +++ b/templates/_types/ticket_admin/_checkin_result.html @@ -0,0 +1,39 @@ +{# Check-in result — replaces ticket row or action area #} +{% if success and ticket %} + + + {{ ticket.code[:12] }}... + + +
{{ ticket.entry.name if ticket.entry else '—' }}
+ {% if ticket.entry and ticket.entry.start_at %} +
+ {{ ticket.entry.start_at.strftime('%d %b %Y, %H:%M') }} +
+ {% endif %} + + + {{ ticket.ticket_type.name if ticket.ticket_type else '—' }} + + + + Checked in + + + + + + {% if ticket.checked_in_at %} + {{ ticket.checked_in_at.strftime('%H:%M') }} + {% else %} + Just now + {% endif %} + + + +{% elif not success %} +
+ + {{ error or 'Check-in failed' }} +
+{% endif %} diff --git a/templates/_types/ticket_admin/_entry_tickets.html b/templates/_types/ticket_admin/_entry_tickets.html new file mode 100644 index 0000000..6599b2a --- /dev/null +++ b/templates/_types/ticket_admin/_entry_tickets.html @@ -0,0 +1,75 @@ +{# Tickets for a specific calendar entry — admin view #} +
+
+

+ Tickets for: {{ entry.name }} +

+ + {{ tickets|length }} ticket{{ 's' if tickets|length != 1 else '' }} + +
+ + {% if tickets %} +
+ + + + + + + + + + + {% for ticket in tickets %} + + + + + + + {% endfor %} + +
CodeTypeStateActions
{{ ticket.code[:12] }}...{{ ticket.ticket_type.name if ticket.ticket_type else '—' }} + + {{ ticket.state|replace('_', ' ')|capitalize }} + + + {% if ticket.state in ('confirmed', 'reserved') %} + + + + + {% elif ticket.state == 'checked_in' %} + + + {% if ticket.checked_in_at %}{{ ticket.checked_in_at.strftime('%H:%M') }}{% endif %} + + {% endif %} +
+
+ {% else %} +
+ No tickets for this entry +
+ {% endif %} +
diff --git a/templates/_types/ticket_admin/_lookup_result.html b/templates/_types/ticket_admin/_lookup_result.html new file mode 100644 index 0000000..5ea17eb --- /dev/null +++ b/templates/_types/ticket_admin/_lookup_result.html @@ -0,0 +1,82 @@ +{# Ticket lookup result — rendered into #lookup-result #} +{% if error %} +
+ + {{ error }} +
+{% elif ticket %} +
+
+
+
+ {{ ticket.entry.name if ticket.entry else 'Unknown event' }} +
+ {% if ticket.ticket_type %} +
{{ ticket.ticket_type.name }}
+ {% endif %} + {% if ticket.entry and ticket.entry.start_at %} +
+ {{ ticket.entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }} +
+ {% endif %} + {% if ticket.entry and ticket.entry.calendar %} +
+ {{ ticket.entry.calendar.name }} +
+ {% endif %} +
+ + {{ ticket.state|replace('_', ' ')|capitalize }} + + {{ ticket.code }} +
+ {% if ticket.checked_in_at %} +
+ Checked in: {{ ticket.checked_in_at.strftime('%B %d, %Y at %H:%M') }} +
+ {% endif %} +
+ +
+ {% if ticket.state in ('confirmed', 'reserved') %} +
+ + +
+ {% elif ticket.state == 'checked_in' %} +
+ +
Checked In
+
+ {% elif ticket.state == 'cancelled' %} +
+ +
Cancelled
+
+ {% endif %} +
+
+
+{% endif %} diff --git a/templates/_types/ticket_admin/_main_panel.html b/templates/_types/ticket_admin/_main_panel.html new file mode 100644 index 0000000..43f367b --- /dev/null +++ b/templates/_types/ticket_admin/_main_panel.html @@ -0,0 +1,148 @@ +
+

Ticket Admin

+ + {# Stats row #} +
+
+
{{ stats.total }}
+
Total
+
+
+
{{ stats.confirmed }}
+
Confirmed
+
+
+
{{ stats.checked_in }}
+
Checked In
+
+
+
{{ stats.reserved }}
+
Reserved
+
+
+ + {# Scanner section #} +
+

+ + Scan / Look Up Ticket +

+ +
+ + +
+ +
+
+ Enter a ticket code to look it up +
+
+
+ + {# Recent tickets table #} +
+

+ Recent Tickets +

+ + {% if tickets %} +
+ + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + {% endfor %} + +
CodeEventTypeStateActions
+ {{ ticket.code[:12] }}... + +
{{ ticket.entry.name if ticket.entry else '—' }}
+ {% if ticket.entry and ticket.entry.start_at %} +
+ {{ ticket.entry.start_at.strftime('%d %b %Y, %H:%M') }} +
+ {% endif %} +
+ {{ ticket.ticket_type.name if ticket.ticket_type else '—' }} + + + {{ ticket.state|replace('_', ' ')|capitalize }} + + + {% if ticket.state in ('confirmed', 'reserved') %} +
+ + +
+ {% elif ticket.state == 'checked_in' %} + + + {% if ticket.checked_in_at %} + {{ ticket.checked_in_at.strftime('%H:%M') }} + {% endif %} + + {% endif %} +
+
+ {% else %} +
+ No tickets yet +
+ {% endif %} +
+
diff --git a/templates/_types/ticket_admin/index.html b/templates/_types/ticket_admin/index.html new file mode 100644 index 0000000..47ecb0a --- /dev/null +++ b/templates/_types/ticket_admin/index.html @@ -0,0 +1,8 @@ +{% extends '_types/root/index.html' %} + +{% block _main_mobile_menu %} +{% endblock %} + +{% block content %} +{% include '_types/ticket_admin/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/ticket_type/_edit.html b/templates/_types/ticket_type/_edit.html index 857bdc8..67cec9e 100644 --- a/templates/_types/ticket_type/_edit.html +++ b/templates/_types/ticket_type/_edit.html @@ -3,8 +3,7 @@
{% call links.link( url_for( - 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get', - slug=post.slug, + 'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get', calendar_slug=calendar.slug, year=year, month=month, @@ -36,8 +35,7 @@ data-confirm-confirm-text="Yes, delete it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-delete="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete', - slug=post.slug, + hx-delete="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete', calendar_slug=calendar.slug, year=year, month=month, diff --git a/templates/_types/ticket_types/header/_header.html b/templates/_types/ticket_types/header/_header.html index 84165a1..2a95316 100644 --- a/templates/_types/ticket_types/header/_header.html +++ b/templates/_types/ticket_types/header/_header.html @@ -2,8 +2,7 @@ {% macro header_row(oob=False) %} {% call links.menu_row(id='ticket_types-row', oob=oob) %} {% call links.link(url_for( - 'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get', - slug=post.slug, + 'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get', calendar_slug=calendar.slug, entry_id=entry.id, year=year, diff --git a/templates/_types/tickets/_buy_form.html b/templates/_types/tickets/_buy_form.html new file mode 100644 index 0000000..8f8be15 --- /dev/null +++ b/templates/_types/tickets/_buy_form.html @@ -0,0 +1,98 @@ +{# Ticket purchase form — shown on entry detail when tickets are available #} +{% if entry.ticket_price is not none and entry.state == 'confirmed' %} +
+

+ + Buy Tickets +

+ + {% if entry.ticket_types %} + {# Multiple ticket types #} +
+ {% for tt in entry.ticket_types %} + {% if tt.deleted_at is none %} +
+
+
{{ tt.name }}
+
+ £{{ '%.2f'|format(tt.cost) }} +
+
+ + + + + + + +
+ {% endif %} + {% endfor %} +
+ {% else %} + {# Simple ticket (single price) #} +
+
+ + £{{ '%.2f'|format(entry.ticket_price) }} + + per ticket +
+ {% if ticket_remaining is not none %} + + {{ ticket_remaining }} remaining + + {% endif %} +
+ +
+ + + + + +
+ {% endif %} +
+{% elif entry.ticket_price is not none %} + {# Tickets configured but entry not confirmed yet #} +
+ + Tickets available once this event is confirmed. +
+{% endif %} diff --git a/templates/_types/tickets/_buy_result.html b/templates/_types/tickets/_buy_result.html new file mode 100644 index 0000000..25e0b32 --- /dev/null +++ b/templates/_types/tickets/_buy_result.html @@ -0,0 +1,39 @@ +{# Shown after ticket purchase — replaces the buy form #} +
diff --git a/templates/_types/tickets/_detail_panel.html b/templates/_types/tickets/_detail_panel.html new file mode 100644 index 0000000..75cde1a --- /dev/null +++ b/templates/_types/tickets/_detail_panel.html @@ -0,0 +1,124 @@ +
+ + {# Back link #} + + + Back to my tickets + + + {# Ticket card #} +
+ {# Header with state #} +
+
+

+ {{ ticket.entry.name if ticket.entry else 'Ticket' }} +

+ + {{ ticket.state|replace('_', ' ')|capitalize }} + +
+ {% if ticket.ticket_type %} +
+ {{ ticket.ticket_type.name }} +
+ {% endif %} +
+ + {# QR Code #} +
+
+ {# QR code rendered via JavaScript #} +
+

+ {{ ticket.code }} +

+
+ + {# Event details #} +
+ {% if ticket.entry %} +
+ +
+
+ {{ ticket.entry.start_at.strftime('%A, %B %d, %Y') }} +
+
+ {{ ticket.entry.start_at.strftime('%H:%M') }} + {% if ticket.entry.end_at %} + – {{ ticket.entry.end_at.strftime('%H:%M') }} + {% endif %} +
+
+
+ + {% if ticket.entry.calendar %} +
+ +
+ {{ ticket.entry.calendar.name }} +
+
+ {% endif %} + {% endif %} + + {% if ticket.ticket_type and ticket.ticket_type.cost %} +
+ +
+ {{ ticket.ticket_type.name }} — £{{ '%.2f'|format(ticket.ticket_type.cost) }} +
+
+ {% endif %} + + {% if ticket.checked_in_at %} +
+ +
+ Checked in: {{ ticket.checked_in_at.strftime('%B %d, %Y at %H:%M') }} +
+
+ {% endif %} +
+
+ + {# QR code generation script #} + + +
diff --git a/templates/_types/tickets/_main_panel.html b/templates/_types/tickets/_main_panel.html new file mode 100644 index 0000000..15b40d9 --- /dev/null +++ b/templates/_types/tickets/_main_panel.html @@ -0,0 +1,65 @@ +
+

My Tickets

+ + {% if tickets %} + + {% else %} +
+ +

No tickets yet

+

Tickets will appear here after you purchase them.

+
+ {% endif %} +
diff --git a/templates/_types/tickets/detail.html b/templates/_types/tickets/detail.html new file mode 100644 index 0000000..31c9319 --- /dev/null +++ b/templates/_types/tickets/detail.html @@ -0,0 +1,8 @@ +{% extends '_types/root/index.html' %} + +{% block _main_mobile_menu %} +{% endblock %} + +{% block content %} +{% include '_types/tickets/_detail_panel.html' %} +{% endblock %} diff --git a/templates/_types/tickets/index.html b/templates/_types/tickets/index.html new file mode 100644 index 0000000..908be8b --- /dev/null +++ b/templates/_types/tickets/index.html @@ -0,0 +1,8 @@ +{% extends '_types/root/index.html' %} + +{% block _main_mobile_menu %} +{% endblock %} + +{% block content %} +{% include '_types/tickets/_main_panel.html' %} +{% endblock %}