Replace every direct cross-app services.* call with HTTP-based communication: call_action() for writes, fetch_data() for reads. Each app now registers only its own domain service. Infrastructure: - shared/infrastructure/actions.py — POST client for /internal/actions/ - shared/infrastructure/data_client.py — GET client for /internal/data/ - shared/contracts/dtos.py — dto_to_dict/dto_from_dict serialization Action endpoints (writes): - events: 8 handlers (ticket adjust, claim/confirm, toggle, adopt) - market: 2 handlers (create/soft-delete marketplace) - cart: 1 handler (adopt cart for user) Data endpoints (reads): - blog: 4 (post-by-slug/id, posts-by-ids, search-posts) - events: 10 (pending entries/tickets, entries/tickets for page/order, entry-ids, associated-entries, calendars, visible-entries-for-period) - market: 1 (marketplaces-for-container) - cart: 1 (cart-summary) Service registration cleanup: - blog→blog+federation, events→calendar+federation, market→market+federation, cart→cart only, federation→federation only, account→nothing - Stubs reduced to minimal StubFederationService Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
10 KiB
Python
314 lines
10 KiB
Python
"""
|
|
Tickets blueprint — user-facing ticket views and QR codes.
|
|
|
|
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
|
|
|
|
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.infrastructure.cart_identity import current_cart_identity
|
|
from shared.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,
|
|
get_sold_ticket_count,
|
|
get_user_reserved_count,
|
|
cancel_latest_reserved_ticket,
|
|
)
|
|
|
|
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 shared.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("/<code>/")
|
|
async def ticket_detail(code: str):
|
|
"""View a single ticket with QR code."""
|
|
from shared.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)
|
|
|
|
@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,
|
|
)
|
|
|
|
# Compute cart count for OOB mini-cart update
|
|
from shared.infrastructure.data_client import fetch_data
|
|
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
|
summary_params = {}
|
|
if ident["user_id"] is not None:
|
|
summary_params["user_id"] = ident["user_id"]
|
|
if ident["session_id"] is not None:
|
|
summary_params["session_id"] = ident["session_id"]
|
|
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
|
|
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
|
|
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
|
|
|
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,
|
|
cart_count=cart_count,
|
|
)
|
|
|
|
return await make_response(html, 200)
|
|
|
|
return bp
|