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

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from quart import (
render_template, make_response, Blueprint
)
from suma_browser.app.authz import require_admin
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def admin(entry_id: int, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/entry/admin/index.html")
else:
html = await render_template("_types/entry/admin/_oob_elements.html")
return await make_response(html)
return bp

574
bp/calendar_entry/routes.py Normal file
View File

@@ -0,0 +1,574 @@
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():
view_args = getattr(request, "view_args", {}) or {}
entry_id = view_args.get("entry_id")
calendar_entry = None
entry_posts = []
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)
return {
"entry": calendar_entry,
"entry_posts": entry_posts,
}
@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

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost
from models.ghost_content import Post
async def add_post_to_entry(
session: AsyncSession,
entry_id: int,
post_id: int
) -> tuple[bool, str | None]:
"""
Associate a post with a calendar entry.
Returns (success, error_message).
"""
# Check if entry exists
entry = await session.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None)
)
)
if not entry:
return False, "Calendar entry not found"
# Check if post exists
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
return False, "Post not found"
# Check if association already exists
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
if existing:
return False, "Post is already associated with this entry"
# Create association
association = CalendarEntryPost(
entry_id=entry_id,
post_id=post_id
)
session.add(association)
await session.flush()
return True, None
async def remove_post_from_entry(
session: AsyncSession,
entry_id: int,
post_id: int
) -> tuple[bool, str | None]:
"""
Remove a post association from a calendar entry (soft delete).
Returns (success, error_message).
"""
# Find the association
association = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
if not association:
return False, "Association not found"
# Soft delete
association.deleted_at = func.now()
await session.flush()
return True, None
async def get_entry_posts(
session: AsyncSession,
entry_id: int
) -> list[Post]:
"""
Get all posts associated with a calendar entry.
"""
result = await session.execute(
select(Post)
.join(CalendarEntryPost)
.where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.deleted_at.is_(None)
)
.order_by(Post.title)
)
return list(result.scalars().all())
async def search_posts(
session: AsyncSession,
query: str,
page: int = 1,
per_page: int = 10
) -> tuple[list[Post], int]:
"""
Search for posts by title with pagination.
If query is empty, returns all posts in published order.
Returns (posts, total_count).
"""
# Build base query
if query:
# Search by title
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
# All posts in published order (newest first)
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
# Count total
count_result = await session.execute(count_stmt)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
posts_stmt.limit(per_page).offset(offset)
)
return list(result.scalars().all()), total

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Optional
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry
async def update_ticket_config(
session: AsyncSession,
entry_id: int,
ticket_price: Optional[Decimal],
ticket_count: Optional[int],
) -> tuple[bool, Optional[str]]:
"""
Update ticket configuration for a calendar entry.
Args:
session: Database session
entry_id: Calendar entry ID
ticket_price: Price per ticket (None = no tickets)
ticket_count: Total available tickets (None = unlimited)
Returns:
(success, error_message)
"""
# Get the entry
entry = await session.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None)
)
)
if not entry:
return False, "Calendar entry not found"
# Validate inputs
if ticket_price is not None and ticket_price < 0:
return False, "Ticket price cannot be negative"
if ticket_count is not None and ticket_count < 0:
return False, "Ticket count cannot be negative"
# Update ticket configuration
entry.ticket_price = ticket_price
entry.ticket_count = ticket_count
return True, None
async def get_available_tickets(
session: AsyncSession,
entry_id: int,
) -> tuple[Optional[int], Optional[str]]:
"""
Get the number of available tickets for a calendar entry.
Returns:
(available_count, error_message)
- available_count is None if unlimited tickets
- available_count is the remaining count if limited
"""
entry = await session.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None)
)
)
if not entry:
return None, "Calendar entry not found"
# If no ticket configuration, return None (unlimited)
if entry.ticket_price is None:
return None, None
# If ticket_count is None, unlimited tickets
if entry.ticket_count is None:
return None, None
# TODO: Subtract booked tickets when ticket booking is implemented
# For now, just return the total count
return entry.ticket_count, None