This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
events/events_api.py
giles 1bab546dfc
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat: ticket purchase flow, QR display, and admin check-in
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/<code>/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 <noreply@anthropic.com>
2026-02-10 00:00:35 +00:00

199 lines
6.5 KiB
Python

"""
Internal JSON API for the events app.
These endpoints are called by other apps (cart) over HTTP.
They are CSRF-exempt because they are server-to-server calls.
"""
from __future__ import annotations
from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Calendar, Ticket
from suma_browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("events_api", __name__, url_prefix="/internal/events")
@bp.get("/calendar-entries")
@csrf_exempt
async def calendar_entries():
"""
Return pending calendar entries for a user/session.
Used by the cart app to display calendar items in the cart.
Query params: user_id, session_id, state (default: pending)
"""
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
state = request.args.get("state", "pending")
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == state,
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id:
filters.append(CalendarEntry.session_id == session_id)
else:
return jsonify([])
result = await g.s.execute(
select(CalendarEntry)
.where(*filters)
.options(selectinload(CalendarEntry.calendar))
.order_by(CalendarEntry.start_at.asc())
)
entries = result.scalars().all()
return jsonify([
{
"id": e.id,
"name": e.name,
"cost": float(e.cost) if e.cost else 0,
"state": e.state,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
"calendar_name": e.calendar.name if e.calendar else None,
"calendar_slug": e.calendar.slug if e.calendar else None,
}
for e in entries
])
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous calendar entries for a user.
Called by the cart app after login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# Soft-delete existing user entries
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.user_id == user_id,
)
.values(deleted_at=func.now())
)
# Adopt anonymous entries
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
@bp.get("/entry/<int:entry_id>")
@csrf_exempt
async def entry_detail(entry_id: int):
"""
Return entry details for order display.
Called by the cart app when showing order items.
"""
result = await g.s.execute(
select(CalendarEntry)
.where(CalendarEntry.id == entry_id)
.options(selectinload(CalendarEntry.calendar))
)
entry = result.scalar_one_or_none()
if not entry:
return jsonify(None), 404
return jsonify({
"id": entry.id,
"name": entry.name,
"cost": float(entry.cost) if entry.cost else 0,
"state": entry.state,
"start_at": entry.start_at.isoformat() if entry.start_at else None,
"end_at": entry.end_at.isoformat() if entry.end_at else None,
"calendar_name": entry.calendar.name if entry.calendar else None,
"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/<code>/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