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 @@
from .visiblity import get_visible_entries_for_period

View File

@@ -0,0 +1,24 @@
from sqlalchemy import select, update
from models.calendars import CalendarEntry
from sqlalchemy import func
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
if not session_id:
return
# (Optional) Mark any existing entries for this user as deleted to avoid duplicates
await session.execute(
update(CalendarEntry)
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
.values(deleted_at=func.now())
)
# Reassign anonymous entries to the user
result = await session.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id
)
)
anon_entries = result.scalars().all()
for entry in anon_entries:
entry.user_id = user_id
# No commit here; caller will commit

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from models.calendars import Calendar
from ...calendars.services.calendars import CalendarError
async def update_calendar_config(sess, calendar_id: int, *, description: str | None, slots: list | None):
"""Update description and slots for a calendar."""
cal = await sess.get(Calendar, calendar_id)
if not cal:
raise CalendarError(f"Calendar {calendar_id} not found.")
cal.description = (description or '').strip() or None
# Validate slots shape a bit
norm_slots: list[dict] = []
if slots:
for s in slots:
if not isinstance(s, dict):
continue
norm_slots.append({
"days": str(s.get("days", ""))[:7].lower(),
"time_from": str(s.get("time_from", ""))[:5],
"time_to": str(s.get("time_to", ""))[:5],
"cost_name": (s.get("cost_name") or "")[:64],
"description": (s.get("description") or "")[:255],
})
cal.slots = norm_slots or None
await sess.flush()
return cal

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import calendar as pycalendar
from quart import request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, with_loader_criteria
from models.calendars import Calendar, CalendarSlot
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
"""Parse an integer query parameter from the request."""
val = request.args.get(name, "").strip()
if not val:
return default
try:
return int(val)
except ValueError:
return default
def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
"""Add (or subtract) months to a given year/month, handling year overflow."""
new_month = month + delta
new_year = year + (new_month - 1) // 12
new_month = ((new_month - 1) % 12) + 1
return new_year, new_month
def build_calendar_weeks(year: int, month: int) -> list[list[dict]]:
"""
Build a calendar grid for the given year and month.
Returns a list of weeks, where each week is a list of 7 day dictionaries.
"""
today = datetime.now(timezone.utc).date()
cal = pycalendar.Calendar(firstweekday=0) # 0 = Monday
weeks: list[list[dict]] = []
for week in cal.monthdatescalendar(year, month):
week_days = []
for d in week:
week_days.append(
{
"date": d,
"in_month": (d.month == month),
"is_today": (d == today),
}
)
weeks.append(week_days)
return weeks
async def get_calendar_by_post_and_slug(
session: AsyncSession,
post_id: int,
calendar_slug: str,
) -> Optional[Calendar]:
"""
Fetch a calendar by post_id and slug, with slots eagerly loaded.
Returns None if not found.
"""
result = await session.execute(
select(Calendar)
.options(
selectinload(Calendar.slots),
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
)
.where(
Calendar.post_id == post_id,
Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def get_calendar_by_slug(
session: AsyncSession,
calendar_slug: str,
) -> Optional[Calendar]:
"""
Fetch a calendar by slug only (for standalone events service).
With slots eagerly loaded. Returns None if not found.
"""
result = await session.execute(
select(Calendar)
.options(
selectinload(Calendar.slots),
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
)
.where(
Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def update_calendar_description(
calendar: Calendar,
description: Optional[str],
) -> None:
"""Update calendar description (in-place on the calendar object)."""
calendar.description = description or None

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import time
from typing import Sequence
from sqlalchemy import select
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 list_slots(sess: AsyncSession, calendar_id: int) -> Sequence[CalendarSlot]:
res = await sess.execute(
select(CalendarSlot)
.where(CalendarSlot.calendar_id == calendar_id, CalendarSlot.deleted_at.is_(None))
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
return res.scalars().all()
async def create_slot(sess: AsyncSession, calendar_id: int, *, name: str, description: str | None,
days: dict, time_start: time, time_end: time, cost: float | None):
if not name:
raise SlotError("name is required")
if not time_start or not time_end or time_end <= time_start:
raise SlotError("time range invalid")
slot = CalendarSlot(
calendar_id=calendar_id,
name=name,
description=(description or None),
mon=_b(days.get("mon")), tue=_b(days.get("tue")), wed=_b(days.get("wed")),
thu=_b(days.get("thu")), fri=_b(days.get("fri")), sat=_b(days.get("sat")), sun=_b(days.get("sun")),
time_start=time_start, time_end=time_end, cost=cost,
)
sess.add(slot)
await sess.flush()
return slot
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

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry
@dataclass
class VisibleEntries:
"""
Result of applying calendar visibility rules for a given period.
"""
user_entries: list[CalendarEntry]
confirmed_entries: list[CalendarEntry]
admin_other_entries: list[CalendarEntry]
merged_entries: list[CalendarEntry] # sorted, deduped
async def get_visible_entries_for_period(
sess: AsyncSession,
calendar_id: int,
period_start: datetime,
period_end: datetime,
user: Optional[object],
session_id: Optional[str],
) -> VisibleEntries:
"""
Visibility rules (same as your fixed month view):
- Non-admin:
- sees all *confirmed* entries in the period (any user)
- sees all entries for current user/session in the period (any state)
- Admin:
- sees all confirmed + provisional + ordered entries in the period (all users)
- sees pending only for current user/session
"""
user_id = user.id if user else None
is_admin = bool(user and getattr(user, "is_admin", False))
# --- Entries for current user/session (any state, in period) ---
user_entries: list[CalendarEntry] = []
if user_id or session_id:
conditions_user = [
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
]
if user_id:
conditions_user.append(CalendarEntry.user_id == user_id)
elif session_id:
conditions_user.append(CalendarEntry.session_id == session_id)
result_user = await sess.execute(select(CalendarEntry).where(*conditions_user))
user_entries = result_user.scalars().all()
# --- Confirmed entries for everyone in period ---
result_conf = await sess.execute(
select(CalendarEntry).where(
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.state == "confirmed",
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
)
)
confirmed_entries = result_conf.scalars().all()
# --- For admins: ordered + provisional for everyone in period ---
admin_other_entries: list[CalendarEntry] = []
if is_admin:
result_admin = await sess.execute(
select(CalendarEntry).where(
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.state.in_(("ordered", "provisional")),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
)
)
admin_other_entries = result_admin.scalars().all()
# --- Merge with de-duplication and keep chronological order ---
entries_by_id: dict[int, CalendarEntry] = {}
# Everyone's confirmed
for e in confirmed_entries:
entries_by_id[e.id] = e
# Admin-only: everyone's ordered/provisional
if is_admin:
for e in admin_other_entries:
entries_by_id[e.id] = e
# Always include current user/session entries (includes their pending)
for e in user_entries:
entries_by_id[e.id] = e
merged_entries = sorted(
entries_by_id.values(),
key=lambda e: e.start_at or period_start,
)
return VisibleEntries(
user_entries=user_entries,
confirmed_entries=confirmed_entries,
admin_other_entries=admin_other_entries,
merged_entries=merged_entries,
)