This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
events/bp/calendar_entries/services/entries.py
giles 3c0fa45f8c
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat: initialize events app with calendars, slots, tickets, and internal API
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>
2026-02-09 23:16:32 +00:00

258 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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