feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
251
bp/calendar/routes.py
Normal file
251
bp/calendar/routes.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||
)
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.calendars import Calendar
|
||||
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
from suma_browser.app.authz import require_admin
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
from .services import get_visible_entries_for_period
|
||||
from .services.calendar_view import (
|
||||
parse_int_arg,
|
||||
add_months,
|
||||
build_calendar_weeks,
|
||||
get_calendar_by_post_and_slug,
|
||||
get_calendar_by_slug,
|
||||
update_calendar_description,
|
||||
)
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from ..slots.routes import register as register_slots
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
from suma_browser.app.bp.calendars.services.calendars import soft_delete
|
||||
|
||||
from suma_browser.app.bp.day.routes import register as register_day
|
||||
|
||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
import calendar as pycalendar
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("calendar", __name__, url_prefix='/<calendar_slug>')
|
||||
|
||||
bp.register_blueprint(
|
||||
register_admin(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_slots(),
|
||||
)
|
||||
bp.register_blueprint(
|
||||
register_day()
|
||||
)
|
||||
|
||||
@bp.url_value_preprocessor
|
||||
def pull(endpoint, values):
|
||||
g.calendar_slug = values.get("calendar_slug")
|
||||
|
||||
@bp.before_request
|
||||
async def hydrate_calendar_data():
|
||||
calendar_slug = getattr(g, "calendar_slug", None)
|
||||
|
||||
# Standalone mode (events app): no post context
|
||||
post_data = getattr(g, "post_data", None)
|
||||
if post_data:
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
|
||||
else:
|
||||
cal = await get_calendar_by_slug(g.s, calendar_slug)
|
||||
|
||||
if not cal:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
g.calendar = cal
|
||||
|
||||
@bp.context_processor
|
||||
async def inject_root():
|
||||
|
||||
return {
|
||||
"calendar": getattr(g, "calendar", None),
|
||||
}
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@cache_page(tag="calendars")
|
||||
async def get(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Show a month-view calendar for this calendar.
|
||||
|
||||
- One month at a time
|
||||
- Outer arrows: +/- 1 year
|
||||
- Inner arrows: +/- 1 month
|
||||
"""
|
||||
|
||||
# --- Determine year & month from query params ---
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
month = parse_int_arg("month")
|
||||
year = parse_int_arg("year")
|
||||
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None or not (1 <= month <= 12):
|
||||
month = today.month
|
||||
|
||||
# --- Helpers to move between months ---
|
||||
prev_month_year, prev_month = add_months(year, month, -1)
|
||||
next_month_year, next_month = add_months(year, month, +1)
|
||||
prev_year = year - 1
|
||||
next_year = year + 1
|
||||
|
||||
# --- Build weeks grid (list of weeks, each week = 7 days) ---
|
||||
weeks = build_calendar_weeks(year, month)
|
||||
month_name = pycalendar.month_name[month]
|
||||
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
|
||||
|
||||
# --- Period boundaries for this calendar view ---
|
||||
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
next_y, next_m = add_months(year, month, +1)
|
||||
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
||||
|
||||
# --- Identity & admin flag ---
|
||||
user = getattr(g, "user", None)
|
||||
session_id = qsession.get("calendar_sid")
|
||||
|
||||
visible = await get_visible_entries_for_period(
|
||||
sess=g.s,
|
||||
calendar_id=g.calendar.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
month_entries = visible.merged_entries
|
||||
user_entries = visible.user_entries
|
||||
confirmed_entries = visible.confirmed_entries
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
"_types/calendar/index.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
else:
|
||||
|
||||
html = await render_template(
|
||||
"_types/calendar/_oob_elements.html",
|
||||
qsession=qsession,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
user_entries=user_entries,
|
||||
confirmed_entries=confirmed_entries,
|
||||
month_entries=month_entries,
|
||||
)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
@bp.put("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def put(calendar_slug: str, **kwargs):
|
||||
"""
|
||||
Idempotent update for calendar configuration.
|
||||
Accepts HTMX form (POST/PUT) and optional JSON.
|
||||
"""
|
||||
# Try JSON first
|
||||
data = await request.get_json(silent=True)
|
||||
description = None
|
||||
|
||||
if data and isinstance(data, dict):
|
||||
description = (data.get("description") or "").strip()
|
||||
else:
|
||||
form = await request.form
|
||||
description = (form.get("description") or "").strip()
|
||||
|
||||
await update_calendar_description(g.calendar, description)
|
||||
html = await render_template("_types/calendar/admin/index.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
@bp.delete("/")
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
async def delete(calendar_slug: str, **kwargs):
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
cal = g.calendar
|
||||
cal.deleted_at = datetime.now(timezone.utc)
|
||||
await g.s.flush()
|
||||
|
||||
# If we have post context (blog-embedded mode), update nav
|
||||
post_data = getattr(g, "post_data", None)
|
||||
html = await render_template("_types/calendars/index.html")
|
||||
|
||||
if post_data:
|
||||
from ..post.services.entry_associations import get_associated_entries
|
||||
|
||||
post_id = (post_data.get("post") or {}).get("id")
|
||||
cals = (
|
||||
await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
|
||||
nav_oob = await render_template(
|
||||
"_types/post/admin/_nav_entries_oob.html",
|
||||
associated_entries=associated_entries,
|
||||
calendars=cals,
|
||||
post=post_data["post"],
|
||||
)
|
||||
html = html + nav_oob
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
|
||||
return bp
|
||||
Reference in New Issue
Block a user