feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract events/calendar functionality into standalone microservice:
- app.py and events_api.py from apps/events/
- Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type)
- Templates for all calendar/event views including admin
- Dockerfile (APP_MODULE=app:app, IMAGE=events)
- entrypoint.sh (no Alembic - migrations managed by blog app)
- Gitea CI workflow for build and deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:16:32 +00:00
commit 3c0fa45f8c
119 changed files with 7163 additions and 0 deletions

159
bp/ticket_type/routes.py Normal file
View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
)
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from .services.ticket import (
get_ticket_type as svc_get_ticket_type,
update_ticket_type as svc_update_ticket_type,
soft_delete_ticket_type as svc_delete_ticket_type,
)
from ..ticket_types.services.tickets import (
list_ticket_types as svc_list_ticket_types,
)
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.get("/")
@require_admin
async def get(ticket_type_id: int, **kwargs):
"""View a single ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/ticket_type/index.html",
ticket_type=ticket_type,
)
else:
html = await render_template(
"_types/ticket_type/_oob_elements.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.get("/edit/")
@require_admin
async def get_edit(ticket_type_id: int, **kwargs):
"""Show the edit form for a ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
html = await render_template(
"_types/ticket_type/_edit.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.get("/view/")
@require_admin
async def get_view(ticket_type_id: int, **kwargs):
"""Show the view for a ticket type."""
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
if not ticket_type:
return await make_response("Not found", 404)
html = await render_template(
"_types/ticket_type/_main_panel.html",
ticket_type=ticket_type,
)
return await make_response(html)
@bp.put("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def put(ticket_type_id: int, **kwargs):
"""Update a ticket type."""
form = await request.form
name = (form.get("name") or "").strip()
cost_str = (form.get("cost") or "").strip()
count_str = (form.get("count") or "").strip()
field_errors: dict[str, list[str]] = {}
# Validate name
if not name:
field_errors.setdefault("name", []).append("Please enter a ticket type name.")
# Validate cost
cost = None
if not cost_str:
field_errors.setdefault("cost", []).append("Please enter a cost.")
else:
try:
cost = float(cost_str)
if cost < 0:
field_errors.setdefault("cost", []).append("Cost must be positive.")
except ValueError:
field_errors.setdefault("cost", []).append("Please enter a valid number.")
# Validate count
count = None
if not count_str:
field_errors.setdefault("count", []).append("Please enter a ticket count.")
else:
try:
count = int(count_str)
if count < 0:
field_errors.setdefault("count", []).append("Count must be positive.")
except ValueError:
field_errors.setdefault("count", []).append("Please enter a valid whole number.")
if field_errors:
return jsonify({
"message": "Please fix the highlighted fields.",
"errors": field_errors,
}), 422
# Update ticket type
ticket_type = await svc_update_ticket_type(
g.s,
ticket_type_id,
name=name,
cost=cost,
count=count,
)
if not ticket_type:
return await make_response("Not found", 404)
# Return updated view with OOB flag
html = await render_template(
"_types/ticket_type/_main_panel.html",
ticket_type=ticket_type,
oob=True,
)
return await make_response(html)
@bp.delete("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def delete(ticket_type_id: int, **kwargs):
"""Soft-delete a ticket type."""
success = await svc_delete_ticket_type(g.s, ticket_type_id)
if not success:
return await make_response("Not found", 404)
# Re-render the ticket types list
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
html = await render_template(
"_types/ticket_types/_main_panel.html",
ticket_types=ticket_types
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import TicketType
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
async def get_ticket_type(session: AsyncSession, ticket_type_id: int) -> TicketType | None:
"""Get a single ticket type by ID (only if not soft-deleted)."""
result = await session.execute(
select(TicketType)
.where(
TicketType.id == ticket_type_id,
TicketType.deleted_at.is_(None)
)
)
return result.scalar_one_or_none()
async def update_ticket_type(
session: AsyncSession,
ticket_type_id: int,
*,
name: str,
cost: float,
count: int,
) -> TicketType | None:
"""Update an existing ticket type."""
ticket_type = await get_ticket_type(session, ticket_type_id)
if not ticket_type:
return None
ticket_type.name = name
ticket_type.cost = cost
ticket_type.count = count
ticket_type.updated_at = utcnow()
await session.flush()
return ticket_type
async def soft_delete_ticket_type(session: AsyncSession, ticket_type_id: int) -> bool:
"""Soft-delete a ticket type."""
ticket_type = await get_ticket_type(session, ticket_type_id)
if not ticket_type:
return False
ticket_type.deleted_at = utcnow()
await session.flush()
return True