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/bp/calendar_entry/routes.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

581 lines
20 KiB
Python

from __future__ import annotations
from sqlalchemy import select, update
from models.calendars import CalendarEntry, CalendarSlot
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from sqlalchemy import select
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
)
from ..calendar_entries.services.entries import (
svc_update_entry,
CalendarError, # <-- add this if you want to catch it explicitly
)
from .services.post_associations import (
add_post_to_entry,
remove_post_from_entry,
get_entry_posts,
search_posts as svc_search_posts,
)
from datetime import datetime, timezone
import math
import logging
from ..ticket_types.routes import register as register_ticket_types
from .admin.routes import register as register_admin
logger = logging.getLogger(__name__)
def register():
bp = Blueprint("calendar_entry", __name__, url_prefix='/<int:entry_id>')
# Register tickets blueprint
bp.register_blueprint(
register_ticket_types()
)
bp.register_blueprint(
register_admin()
)
@bp.before_request
async def load_entry():
"""Load the calendar entry from the URL parameter."""
entry_id = request.view_args.get("entry_id")
if entry_id:
result = await g.s.execute(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None)
)
)
g.entry = result.scalar_one_or_none()
@bp.context_processor
async def inject_entry():
"""Make entry and date parameters available to all templates in this blueprint."""
return {
"entry": getattr(g, "entry", None),
"year": request.view_args.get("year"),
"month": request.view_args.get("month"),
"day": request.view_args.get("day"),
}
async def get_day_nav_oob(year: int, month: int, day: int):
"""Helper to generate OOB update for day entries nav"""
from datetime import datetime, timezone, date, timedelta
from ..calendar.services import get_visible_entries_for_period
from quart import session as qsession
# Get the calendar from g
calendar = getattr(g, "calendar", None)
if not calendar:
return ""
# Build day date
try:
day_date = date(year, month, day)
except (ValueError, TypeError):
return ""
# Period: this day only
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
# Identity
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
# Get confirmed entries for this day
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
# Render OOB template
nav_oob = await render_template(
"_types/day/admin/_nav_entries_oob.html",
confirmed_entries=visible.confirmed_entries,
post=g.post_data["post"],
calendar=calendar,
day_date=day_date,
)
return nav_oob
async def get_post_nav_oob(entry_id: int):
"""Helper to generate OOB update for post entries nav when entry state changes"""
# Get the entry to find associated posts
entry = await g.s.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None)
)
)
if not entry:
return ""
# Get all posts associated with this entry
from .services.post_associations import get_entry_posts
entry_posts = await get_entry_posts(g.s, entry_id)
# Generate OOB updates for each post's nav
nav_oobs = []
for post in entry_posts:
# Get associated entries for this post
from ..post.services.entry_associations import get_associated_entries
associated_entries = await get_associated_entries(g.s, post.id)
# Load calendars for this post
from models.calendars import Calendar
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == post.id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Render OOB template for this post's nav
nav_oob = await render_template(
"_types/post/admin/_nav_entries_oob.html",
associated_entries=associated_entries,
calendars=calendars,
post=post,
)
nav_oobs.append(nav_oob)
return "".join(nav_oobs)
@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)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
# Optional: also ensure it belongs to the current calendar, if g.calendar is set
if calendar_entry is not None and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
# Refresh slot relationship if we have a valid entry
if calendar_entry is not None:
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
async def get(entry_id: int, **rest):
from suma_browser.app.utils.htmx import is_htmx_request
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
# For now, render full template for both HTMX and normal requests
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/entry/index.html",
)
else:
html = await render_template(
"_types/entry/_oob_elements.html",
)
return await make_response(html, 200)
@bp.get("/edit/")
@require_admin
async def get_edit(entry_id: int, **rest):
html = await render_template("_types/entry/_edit.html")
return await make_response(html, 200)
@bp.put("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def put(year: int, month: int, day: int, entry_id: int, **rest):
form = await request.form
def parse_time_to_dt(value: str | None, year: int, month: int, day: int):
"""
'HH:MM' + (year, month, day) -> aware datetime in UTC.
Returns None if empty/invalid.
"""
if not value:
return None
try:
hour_str, minute_str = value.split(":", 1)
hour = int(hour_str)
minute = int(minute_str)
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
except Exception:
return None
name = (form.get("name") or "").strip()
start_at = parse_time_to_dt(form.get("start_at"), year, month, day)
end_at = parse_time_to_dt(form.get("end_at"), year, month, day)
# NEW: slot_id
slot_id_raw = (form.get("slot_id") or "").strip()
slot_id = int(slot_id_raw) if slot_id_raw else None
# Ticket configuration
ticket_price_str = (form.get("ticket_price") or "").strip()
ticket_price = None
if ticket_price_str:
try:
from decimal import Decimal
ticket_price = Decimal(ticket_price_str)
except Exception:
pass # Will be validated below if needed
ticket_count_str = (form.get("ticket_count") or "").strip()
ticket_count = None
if ticket_count_str:
try:
ticket_count = int(ticket_count_str)
except Exception:
pass # Will be validated below if needed
field_errors: dict[str, list[str]] = {}
# --- Basic validation (slot-style) -------------------------
if not name:
field_errors.setdefault("name", []).append(
"Please enter a name for the entry."
)
# Check slot first before validating times
slot = None
if slot_id is not None:
result = await g.s.execute(
select(CalendarSlot).where(
CalendarSlot.id == slot_id,
CalendarSlot.calendar_id == g.calendar.id,
CalendarSlot.deleted_at.is_(None),
)
)
slot = result.scalar_one_or_none()
if slot is None:
field_errors.setdefault("slot_id", []).append(
"Selected slot is no longer available."
)
else:
# For inflexible slots, override the times with slot times
if not slot.flexible:
# Replace start/end with slot times
start_at = datetime(year, month, day,
slot.time_start.hour,
slot.time_start.minute,
tzinfo=timezone.utc)
if slot.time_end:
end_at = datetime(year, month, day,
slot.time_end.hour,
slot.time_end.minute,
tzinfo=timezone.utc)
else:
# Flexible: validate times are within slot band
# Only validate if times were provided
if not start_at:
field_errors.setdefault("start_at", []).append(
"Please select a start time."
)
if not end_at:
field_errors.setdefault("end_at", []).append(
"Please select an end time."
)
if start_at and end_at:
s_time = start_at.timetz()
e_time = end_at.timetz()
slot_start = slot.time_start
slot_end = slot.time_end
if s_time.replace(tzinfo=None) < slot_start:
field_errors.setdefault("start_at", []).append(
f"Start time must be at or after {slot_start.strftime('%H:%M')}."
)
if slot_end is not None and e_time.replace(tzinfo=None) > slot_end:
field_errors.setdefault("end_at", []).append(
f"End time must be at or before {slot_end.strftime('%H:%M')}."
)
else:
field_errors.setdefault("slot_id", []).append(
"Please select a slot."
)
# Time ordering check (only if we have times and no slot override)
if start_at and end_at and end_at < start_at:
field_errors.setdefault("end_at", []).append(
"End time must be after the start time."
)
if field_errors:
return jsonify(
{
"message": "Please fix the highlighted fields.",
"errors": field_errors,
}
), 422
# --- Service call & safety net for extra validation -------
try:
entry = await svc_update_entry(
g.s,
entry_id,
name=name,
start_at=start_at,
end_at=end_at,
slot_id=slot_id, # Pass slot_id to service
)
# Update ticket configuration
entry.ticket_price = ticket_price
entry.ticket_count = ticket_count
except CalendarError as e:
# If the service still finds something wrong, surface it nicely.
msg = str(e)
return jsonify(
{
"message": "There was a problem updating the entry.",
"errors": {"__all__": [msg]},
}
), 422
# --- Success: re-render the entry block -------------------
# Get nav OOB update
nav_oob = await get_day_nav_oob(year, month, day)
html = await render_template(
"_types/entry/index.html",
#entry=entry,
)
return await make_response(html + nav_oob, 200)
@bp.post("/confirm/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def confirm_entry(entry_id: int, year: int, month: int, day: int, **rest):
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "provisional",
)
.values(state="confirmed")
)
await g.s.flush()
# Get nav OOB updates (both day and post navs)
day_nav_oob = await get_day_nav_oob(year, month, day)
post_nav_oob = await get_post_nav_oob(entry_id)
# redirect back to calendar admin or order page as you prefer
html = await render_template("_types/entry/_optioned.html")
return await make_response(html + day_nav_oob + post_nav_oob, 200)
@bp.post("/decline/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def decline_entry(entry_id: int, year: int, month: int, day: int, **rest):
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "provisional",
)
.values(state="declined")
)
await g.s.flush()
# Get nav OOB updates (both day and post navs)
day_nav_oob = await get_day_nav_oob(year, month, day)
post_nav_oob = await get_post_nav_oob(entry_id)
# redirect back to calendar admin or order page as you prefer
html = await render_template("_types/entry/_optioned.html")
return await make_response(html + day_nav_oob + post_nav_oob, 200)
@bp.post("/provisional/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def provisional_entry(entry_id: int, year: int, month: int, day: int, **rest):
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed",
)
.values(state="provisional")
)
await g.s.flush()
# Get nav OOB updates (both day and post navs)
day_nav_oob = await get_day_nav_oob(year, month, day)
post_nav_oob = await get_post_nav_oob(entry_id)
# redirect back to calendar admin or order page as you prefer
html = await render_template("_types/entry/_optioned.html")
return await make_response(html + day_nav_oob + post_nav_oob, 200)
@bp.post("/tickets/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def update_tickets(entry_id: int, **rest):
"""Update ticket configuration for a calendar entry"""
from .services.ticket_operations import update_ticket_config
from decimal import Decimal
form = await request.form
# Parse ticket price
ticket_price_str = (form.get("ticket_price") or "").strip()
ticket_price = None
if ticket_price_str:
try:
ticket_price = Decimal(ticket_price_str)
except Exception:
return await make_response("Invalid ticket price", 400)
# Parse ticket count
ticket_count_str = (form.get("ticket_count") or "").strip()
ticket_count = None
if ticket_count_str:
try:
ticket_count = int(ticket_count_str)
except Exception:
return await make_response("Invalid ticket count", 400)
# Update ticket configuration
success, error = await update_ticket_config(
g.s, entry_id, ticket_price, ticket_count
)
if not success:
return await make_response(error, 400)
await g.s.flush()
# Return updated entry view
html = await render_template("_types/entry/index.html")
return await make_response(html, 200)
@bp.get("/posts/search/")
@require_admin
async def search_posts(entry_id: int, **rest):
"""Search for posts to associate with this entry"""
query = request.args.get("q", "").strip()
page = int(request.args.get("page", 1))
per_page = 10
search_posts, total = await svc_search_posts(g.s, query, page, per_page)
total_pages = math.ceil(total / per_page) if total > 0 else 0
html = await render_template(
"_types/entry/_post_search_results.html",
search_posts=search_posts,
search_query=query,
page=page,
total_pages=total_pages,
)
return await make_response(html, 200)
@bp.post("/posts/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def add_post(entry_id: int, **rest):
"""Add a post association to this entry"""
form = await request.form
post_id = form.get("post_id")
if not post_id:
return await make_response("Post ID is required", 400)
try:
post_id = int(post_id)
except ValueError:
return await make_response("Invalid post ID", 400)
success, error = await add_post_to_entry(g.s, entry_id, post_id)
if not success:
return await make_response(error, 400)
await g.s.flush()
# Reload entry_posts for nav update
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
html = await render_template("_types/entry/_posts.html")
nav_oob = await render_template(
"_types/entry/admin/_nav_posts_oob.html",
entry_posts=entry_posts,
)
return await make_response(html + nav_oob, 200)
@bp.delete("/posts/<int:post_id>/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def remove_post(entry_id: int, post_id: int, **rest):
"""Remove a post association from this entry"""
success, error = await remove_post_from_entry(g.s, entry_id, post_id)
if not success:
return await make_response(error or "Association not found", 404)
await g.s.flush()
# Reload entry_posts for nav update
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
html = await render_template("_types/entry/_posts.html")
nav_oob = await render_template(
"_types/entry/admin/_nav_posts_oob.html",
entry_posts=entry_posts,
)
return await make_response(html + nav_oob, 200)
return bp