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

182
bp/slot/routes.py Normal file
View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
)
from sqlalchemy.exc import IntegrityError
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from .services.slot import (
update_slot as svc_update_slot,
soft_delete_slot as svc_delete_slot,
get_slot as svc_get_slot,
)
from ..slots.services.slots import (
list_slots as svc_list_slots,
)
from suma_browser.app.utils import (
parse_time,
parse_cost
)
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def get(slot_id: int, **kwargs):
slot = await svc_get_slot(g.s, slot_id)
if not slot:
return await make_response("Not found", 404)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/slot/index.html",
slot=slot,
)
else:
html = await render_template(
"_types/slot/_oob_elements.html",
slot=slot,
)
return await make_response(html)
@bp.get("/edit/")
@require_admin
async def get_edit(slot_id: int, **kwargs):
slot = await svc_get_slot(g.s, slot_id)
if not slot:
return await make_response("Not found", 404)
html = await render_template(
"_types/slot/_edit.html",
slot=slot,
#post=g.post_data['post'],
#calendar=g.calendar,
)
return await make_response(html)
@bp.get("/view/")
@require_admin
async def get_view(slot_id: int, **kwargs):
slot = await svc_get_slot(g.s, slot_id)
if not slot:
return await make_response("Not found", 404)
html = await render_template(
"_types/slot/_main_panel.html",
slot=slot,
#post=g.post_data['post'],
#calendar=g.calendar,
)
return await make_response(html)
@bp.delete("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def slot_delete(slot_id: int, **kwargs):
await svc_delete_slot(g.s, slot_id)
slots = await svc_list_slots(g.s, g.calendar.id)
html = await render_template("_types/slots/_man_panel.html", calendar=g.calendar, slots=slots)
return await make_response(html)
@bp.put("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def put(slot_id: int, **kwargs):
form = await request.form
name = (form.get("name") or "").strip()
description = (form.get("description") or "").strip() or None
days = {k: form.get(k) for k in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
time_start = parse_time(form.get("time_start"))
time_end = parse_time(form.get("time_end"))
cost = parse_cost(form.get("cost"))
# NEW
flexible = bool(form.get("flexible"))
field_errors: dict[str, list[str]] = {}
# Basic validation...
if not name:
field_errors.setdefault("name", []).append("Please enter a name for the slot.")
if not time_start:
field_errors.setdefault("time_start", []).append("Please select a start time.")
if not time_end:
field_errors.setdefault("time_end", []).append("Please select an end time.")
if time_start and time_end and time_end <= time_start:
field_errors.setdefault("time_end", []).append(
"End time must be after the start time."
)
if not any(form.get(d) for d in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]):
field_errors.setdefault("days", []).append(
"Please select at least one day."
)
if field_errors:
return jsonify(
{
"message": "Please fix the highlighted fields.",
"errors": field_errors,
}
), 422
# DB update + friendly duplicate handling
try:
slot = await svc_update_slot(
g.s,
slot_id,
name=name,
description=description,
days=days,
time_start=time_start,
time_end=time_end,
cost=cost,
flexible=flexible, # <--- NEW
)
except IntegrityError as e:
msg = str(e.orig) if getattr(e, "orig", None) else str(e)
if "uq_calendar_slots_unique_band" in msg or "duplicate key value" in msg:
field_errors = {
"name": [f'A slot called “{name}” already exists on this calendar.']
}
return jsonify(
{
"message": "That slot name is already in use.",
"errors": field_errors,
}
), 422
return jsonify(
{
"message": "An unexpected error occurred while updating the slot.",
"errors": {"__all__": [msg]},
}
), 422
html = await render_template(
"_types/slot/_main_panel.html",
slot=slot,
oob=True,
)
return await make_response(html)
return bp

90
bp/slot/services/slot.py Normal file
View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from datetime import time
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot
class SlotError(ValueError):
pass
def _b(v):
if isinstance(v, bool):
return v
s = str(v).lower()
return s in {"1","true","t","yes","y","on"}
async def update_slot(
sess: AsyncSession,
slot_id: int,
*,
name: str | None = None,
description: str | None = None,
days: dict | None = None,
time_start: time | None = None,
time_end: time | None = None,
cost: float | None = None,
flexible: bool | None = None, # NEW
):
slot = await sess.get(CalendarSlot, slot_id)
if not slot or slot.deleted_at is not None:
raise SlotError("slot not found")
if name is not None:
slot.name = name
if description is not None:
slot.description = description or None
if days is not None:
slot.mon = _b(days.get("mon", slot.mon))
slot.tue = _b(days.get("tue", slot.tue))
slot.wed = _b(days.get("wed", slot.wed))
slot.thu = _b(days.get("thu", slot.thu))
slot.fri = _b(days.get("fri", slot.fri))
slot.sat = _b(days.get("sat", slot.sat))
slot.sun = _b(days.get("sun", slot.sun))
if time_start is not None:
slot.time_start = time_start
if time_end is not None:
slot.time_end = time_end
if (time_start or time_end) and slot.time_end <= slot.time_start:
raise SlotError("time range invalid")
if cost is not None:
slot.cost = cost
# NEW: update flexible flag only if explicitly provided
if flexible is not None:
slot.flexible = flexible
await sess.flush()
return slot
async def soft_delete_slot(sess: AsyncSession, slot_id: int):
slot = await sess.get(CalendarSlot, slot_id)
if not slot or slot.deleted_at is not None:
return
from datetime import datetime, timezone
slot.deleted_at = datetime.now(timezone.utc)
await sess.flush()
async def get_slot(sess: AsyncSession, slot_id: int) -> CalendarSlot | None:
return await sess.get(CalendarSlot, slot_id)
async def update_slot_description(
sess: AsyncSession,
slot_id: int,
description: str | None,
) -> CalendarSlot:
slot = await sess.get(CalendarSlot, slot_id)
if not slot:
raise SlotError("slot not found")
slot.description = description or None
await sess.flush()
return slot