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>
258 lines
7.6 KiB
Python
258 lines
7.6 KiB
Python
from __future__ import annotations
|
||
from datetime import datetime
|
||
from typing import Optional, Sequence
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select, and_, or_
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from models.calendars import Calendar, CalendarEntry
|
||
from datetime import datetime
|
||
|
||
from suma_browser.app.errors import AppError
|
||
|
||
class CalendarError(AppError):
|
||
"""Base error for calendar service operations."""
|
||
status_code = 422
|
||
|
||
|
||
|
||
async def add_entry(
|
||
sess: AsyncSession,
|
||
calendar_id: int,
|
||
name: str,
|
||
start_at: Optional[datetime],
|
||
end_at: Optional[datetime],
|
||
user_id: int | None = None,
|
||
session_id: str | None = None,
|
||
slot_id: int | None = None, # NEW: accept slot_id
|
||
cost: Optional[Decimal] = None, # NEW: accept cost
|
||
) -> CalendarEntry:
|
||
"""
|
||
Add an entry to a calendar.
|
||
|
||
Collects *all* validation errors and raises CalendarError([...])
|
||
so the HTMX handler can show them as a list.
|
||
"""
|
||
errors: list[str] = []
|
||
|
||
# Normalise
|
||
name = (name or "").strip()
|
||
|
||
# Name validation
|
||
if not name:
|
||
errors.append("Entry name must not be empty.")
|
||
|
||
# start_at validation
|
||
if start_at is None:
|
||
errors.append("Start time is required.")
|
||
elif not isinstance(start_at, datetime):
|
||
errors.append("Start time is invalid.")
|
||
|
||
# end_at validation
|
||
if end_at is not None and not isinstance(end_at, datetime):
|
||
errors.append("End time is invalid.")
|
||
|
||
# Time ordering (only if we have sensible datetimes)
|
||
if isinstance(start_at, datetime) and isinstance(end_at, datetime):
|
||
if end_at < start_at:
|
||
errors.append("End time must be greater than or equal to the start time.")
|
||
|
||
# If we have any validation errors, bail out now
|
||
if errors:
|
||
raise CalendarError(errors, status_code=422)
|
||
|
||
# Calendar existence (this is more of a 404 than a validation issue)
|
||
cal = (
|
||
await sess.execute(
|
||
select(Calendar).where(
|
||
Calendar.id == calendar_id,
|
||
Calendar.deleted_at.is_(None),
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
if not cal:
|
||
# Single-message CalendarError – still handled by the same error handler
|
||
raise CalendarError(
|
||
f"Calendar {calendar_id} does not exist or has been deleted.",
|
||
status_code=404,
|
||
)
|
||
|
||
# All good, create the entry
|
||
entry = CalendarEntry(
|
||
calendar_id=calendar_id,
|
||
name=name,
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
user_id=user_id,
|
||
session_id=session_id,
|
||
slot_id=slot_id, # NEW: save slot_id
|
||
state="pending",
|
||
cost=cost if cost is not None else Decimal('10'), # Use provided cost or default
|
||
)
|
||
sess.add(entry)
|
||
await sess.flush()
|
||
return entry
|
||
|
||
|
||
async def list_entries(
|
||
sess: AsyncSession,
|
||
post_id: int,
|
||
calendar_slug: str,
|
||
from_: Optional[datetime] = None,
|
||
to: Optional[datetime] = None,
|
||
) -> Sequence[CalendarEntry]:
|
||
"""
|
||
List entries for a given post's calendar by name.
|
||
- Respects soft-deletes (only non-deleted calendar / entries).
|
||
- If a time window is provided, returns entries that overlap the window:
|
||
- If only from_ is given: entries where end_at is NULL or end_at >= from_
|
||
- If only to is given: entries where start_at <= to
|
||
- If both given: entries where [start_at, end_at or +inf] overlaps [from_, to]
|
||
- Sorted by start_at ascending.
|
||
"""
|
||
calendar_slug = (calendar_slug or "").strip()
|
||
if not calendar_slug:
|
||
raise CalendarError("calendar_slug must not be empty.")
|
||
|
||
cal = (
|
||
await sess.execute(
|
||
select(Calendar.id)
|
||
.where(
|
||
Calendar.post_id == post_id,
|
||
Calendar.slug == calendar_slug,
|
||
Calendar.deleted_at.is_(None),
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
if not cal:
|
||
# Return empty list instead of raising, so callers can treat absence as "no entries"
|
||
return []
|
||
|
||
# Base filter: not soft-deleted entries of this calendar
|
||
filters = [CalendarEntry.calendar_id == cal, CalendarEntry.deleted_at.is_(None)]
|
||
|
||
# Time window logic
|
||
if from_ and to:
|
||
# Overlap condition: start <= to AND (end is NULL OR end >= from_)
|
||
filters.append(CalendarEntry.start_at <= to)
|
||
filters.append(or_(CalendarEntry.end_at.is_(None), CalendarEntry.end_at >= from_))
|
||
elif from_:
|
||
# Anything that hasn't ended before from_
|
||
filters.append(or_(CalendarEntry.end_at.is_(None), CalendarEntry.end_at >= from_))
|
||
elif to:
|
||
# Anything that has started by 'to'
|
||
filters.append(CalendarEntry.start_at <= to)
|
||
|
||
stmt = (
|
||
select(CalendarEntry)
|
||
.where(and_(*filters))
|
||
.order_by(CalendarEntry.start_at.asc(), CalendarEntry.id.asc())
|
||
)
|
||
|
||
result = await sess.execute(stmt)
|
||
entries = list(result.scalars())
|
||
|
||
# Eagerly load slot relationships
|
||
for entry in entries:
|
||
await sess.refresh(entry, ['slot'])
|
||
|
||
return entries
|
||
|
||
|
||
async def svc_update_entry(
|
||
sess: AsyncSession,
|
||
entry_id: int,
|
||
*,
|
||
name: str | None = None,
|
||
start_at: datetime | None = None,
|
||
end_at: datetime | None = None,
|
||
user_id: int | None = None,
|
||
session_id: str | None = None,
|
||
slot_id: int | None = None, # NEW: accept slot_id
|
||
cost: Decimal | None = None, # NEW: accept cost
|
||
) -> CalendarEntry:
|
||
"""
|
||
Update an existing CalendarEntry.
|
||
|
||
- Performs the same validations as add_entry()
|
||
- Returns the updated CalendarEntry
|
||
- Raises CalendarError([...]) on validation issues
|
||
- Raises CalendarError(...) if entry does not exist
|
||
"""
|
||
|
||
# Fetch entry
|
||
entry = (
|
||
await sess.execute(
|
||
select(CalendarEntry).where(
|
||
CalendarEntry.id == entry_id,
|
||
CalendarEntry.deleted_at.is_(None),
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
if not entry:
|
||
raise CalendarError(
|
||
f"Entry {entry_id} does not exist or has been deleted.",
|
||
status_code=404,
|
||
)
|
||
|
||
errors: list[str] = []
|
||
|
||
# ----- Validation ----- #
|
||
|
||
# Name validation only if updating it
|
||
if name is not None:
|
||
name = name.strip()
|
||
if not name:
|
||
errors.append("Entry name must not be empty.")
|
||
|
||
# start_at type validation only if provided
|
||
if start_at is not None and not isinstance(start_at, datetime):
|
||
errors.append("Start time is invalid.")
|
||
|
||
# end_at type validation
|
||
if end_at is not None and not isinstance(end_at, datetime):
|
||
errors.append("End time is invalid.")
|
||
|
||
# Time ordering
|
||
effective_start = start_at if start_at is not None else entry.start_at
|
||
effective_end = end_at if end_at is not None else entry.end_at
|
||
|
||
if isinstance(effective_start, datetime) and isinstance(effective_end, datetime):
|
||
if effective_end < effective_start:
|
||
errors.append("End time must be greater than or equal to the start time.")
|
||
|
||
# Validation failures?
|
||
if errors:
|
||
raise CalendarError(errors, status_code=422)
|
||
|
||
# ----- Apply Updates ----- #
|
||
|
||
if name is not None:
|
||
entry.name = name
|
||
|
||
if start_at is not None:
|
||
entry.start_at = start_at
|
||
|
||
if end_at is not None:
|
||
entry.end_at = end_at
|
||
|
||
if user_id is not None:
|
||
entry.user_id = user_id
|
||
|
||
if session_id is not None:
|
||
entry.session_id = session_id
|
||
|
||
if slot_id is not None: # NEW: update slot_id
|
||
entry.slot_id = slot_id
|
||
|
||
if cost is not None: # NEW: update cost
|
||
entry.cost = cost
|
||
|
||
entry.updated_at = datetime.utcnow()
|
||
|
||
await sess.flush()
|
||
return entry |