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:
1
bp/calendar/services/__init__.py
Normal file
1
bp/calendar/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .visiblity import get_visible_entries_for_period
|
||||
24
bp/calendar/services/adopt_session_entries_for_user.py
Normal file
24
bp/calendar/services/adopt_session_entries_for_user.py
Normal 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
|
||||
28
bp/calendar/services/calendar.py
Normal file
28
bp/calendar/services/calendar.py
Normal 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
|
||||
109
bp/calendar/services/calendar_view.py
Normal file
109
bp/calendar/services/calendar_view.py
Normal 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
|
||||
117
bp/calendar/services/slots.py
Normal file
117
bp/calendar/services/slots.py
Normal 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
|
||||
115
bp/calendar/services/visiblity.py
Normal file
115
bp/calendar/services/visiblity.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user