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 446bbf74b4
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Inline federation publication for calendar entries
Replace emit_event("calendar_entry.created") with direct try_publish().
Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:55:52 +00:00

278 lines
8.2 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 shared.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()
# Publish to federation inline
if entry.user_id:
from shared.services.federation_publish import try_publish
await try_publish(
sess,
user_id=entry.user_id,
activity_type="Create",
object_type="Event",
object_data={
"name": entry.name or "",
"startTime": entry.start_at.isoformat() if entry.start_at else "",
"endTime": entry.end_at.isoformat() if entry.end_at else "",
},
source_type="CalendarEntry",
source_id=entry.id,
)
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.container_type == "page",
Calendar.container_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