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