"""SQL-backed CalendarService implementation. Queries ``shared.models.calendars.*`` — only this module may write to calendar-domain tables on behalf of other domains. """ from __future__ import annotations from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO def _cal_to_dto(cal: Calendar) -> CalendarDTO: return CalendarDTO( id=cal.id, container_type=cal.container_type, container_id=cal.container_id, name=cal.name, slug=cal.slug, description=cal.description, ) def _entry_to_dto(entry: CalendarEntry) -> CalendarEntryDTO: cal = getattr(entry, "calendar", None) return CalendarEntryDTO( id=entry.id, calendar_id=entry.calendar_id, name=entry.name, start_at=entry.start_at, state=entry.state, cost=entry.cost, end_at=entry.end_at, user_id=entry.user_id, session_id=entry.session_id, order_id=entry.order_id, slot_id=entry.slot_id, ticket_price=entry.ticket_price, ticket_count=entry.ticket_count, calendar_name=cal.name if cal else None, calendar_slug=cal.slug if cal else None, calendar_container_id=cal.container_id if cal else None, calendar_container_type=cal.container_type if cal else None, ) class SqlCalendarService: # -- reads ---------------------------------------------------------------- async def calendars_for_container( self, session: AsyncSession, container_type: str, container_id: int, ) -> list[CalendarDTO]: result = await session.execute( select(Calendar).where( Calendar.container_type == container_type, Calendar.container_id == container_id, Calendar.deleted_at.is_(None), ).order_by(Calendar.name.asc()) ) return [_cal_to_dto(c) for c in result.scalars().all()] async def pending_entries( self, session: AsyncSession, *, user_id: int | None, session_id: str | None, ) -> list[CalendarEntryDTO]: filters = [ CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "pending", ] if user_id is not None: filters.append(CalendarEntry.user_id == user_id) elif session_id is not None: filters.append(CalendarEntry.session_id == session_id) else: return [] result = await session.execute( select(CalendarEntry) .where(*filters) .order_by(CalendarEntry.start_at.asc()) .options(selectinload(CalendarEntry.calendar)) ) return [_entry_to_dto(e) for e in result.scalars().all()] async def entries_for_page( self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None, ) -> list[CalendarEntryDTO]: cal_ids = select(Calendar.id).where( Calendar.container_type == "page", Calendar.container_id == page_id, Calendar.deleted_at.is_(None), ).scalar_subquery() filters = [ CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "pending", CalendarEntry.calendar_id.in_(cal_ids), ] if user_id is not None: filters.append(CalendarEntry.user_id == user_id) elif session_id is not None: filters.append(CalendarEntry.session_id == session_id) else: return [] result = await session.execute( select(CalendarEntry) .where(*filters) .order_by(CalendarEntry.start_at.asc()) .options(selectinload(CalendarEntry.calendar)) ) return [_entry_to_dto(e) for e in result.scalars().all()] async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None: entry = ( await session.execute( select(CalendarEntry) .where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None)) .options(selectinload(CalendarEntry.calendar)) ) ).scalar_one_or_none() return _entry_to_dto(entry) if entry else None async def entry_ids_for_content( self, session: AsyncSession, content_type: str, content_id: int, ) -> set[int]: """Get entry IDs associated with a content item (e.g. post).""" result = await session.execute( select(CalendarEntryPost.entry_id).where( CalendarEntryPost.content_type == content_type, CalendarEntryPost.content_id == content_id, CalendarEntryPost.deleted_at.is_(None), ) ) return set(result.scalars().all()) async def associated_entries( self, session: AsyncSession, content_type: str, content_id: int, page: int, ) -> tuple[list[CalendarEntryDTO], bool]: """Get paginated confirmed entries associated with a content item.""" per_page = 10 entry_ids_result = await session.execute( select(CalendarEntryPost.entry_id).where( CalendarEntryPost.content_type == content_type, CalendarEntryPost.content_id == content_id, CalendarEntryPost.deleted_at.is_(None), ) ) entry_ids = set(entry_ids_result.scalars().all()) if not entry_ids: return [], False offset = (page - 1) * per_page result = await session.execute( select(CalendarEntry) .where( CalendarEntry.id.in_(entry_ids), CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "confirmed", ) .order_by(CalendarEntry.start_at.desc()) .limit(per_page) .offset(offset) .options(selectinload(CalendarEntry.calendar)) ) entries = result.scalars().all() has_more = len(entries) == per_page return [_entry_to_dto(e) for e in entries], has_more async def toggle_entry_post( self, session: AsyncSession, entry_id: int, content_type: str, content_id: int, ) -> bool: """Toggle association; returns True if now associated, False if removed.""" existing = await session.scalar( select(CalendarEntryPost).where( CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.content_type == content_type, CalendarEntryPost.content_id == content_id, CalendarEntryPost.deleted_at.is_(None), ) ) if existing: existing.deleted_at = func.now() await session.flush() return False else: assoc = CalendarEntryPost( entry_id=entry_id, content_type=content_type, content_id=content_id, ) session.add(assoc) await session.flush() return True async def get_entries_for_order( self, session: AsyncSession, order_id: int, ) -> list[CalendarEntryDTO]: result = await session.execute( select(CalendarEntry) .where( CalendarEntry.order_id == order_id, CalendarEntry.deleted_at.is_(None), ) .options(selectinload(CalendarEntry.calendar)) ) return [_entry_to_dto(e) for e in result.scalars().all()] # -- batch reads (not in protocol — convenience for blog service) --------- async def confirmed_entries_for_posts( self, session: AsyncSession, post_ids: list[int], ) -> dict[int, list[CalendarEntryDTO]]: """Return confirmed entries grouped by post_id for a batch of posts.""" if not post_ids: return {} result = await session.execute( select(CalendarEntry, CalendarEntryPost.content_id) .join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id) .options(selectinload(CalendarEntry.calendar)) .where( CalendarEntryPost.content_type == "post", CalendarEntryPost.content_id.in_(post_ids), CalendarEntryPost.deleted_at.is_(None), CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "confirmed", ) .order_by(CalendarEntry.start_at.asc()) ) entries_by_post: dict[int, list[CalendarEntryDTO]] = {} for entry, post_id in result: entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry)) return entries_by_post # -- writes (absorb glue lifecycle) --------------------------------------- async def adopt_entries_for_user( self, session: AsyncSession, user_id: int, session_id: str, ) -> None: """Adopt anonymous calendar entries for a logged-in user.""" await session.execute( update(CalendarEntry) .where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id) .values(deleted_at=func.now()) ) cal_result = await session.execute( select(CalendarEntry).where( CalendarEntry.deleted_at.is_(None), CalendarEntry.session_id == session_id, ) ) for entry in cal_result.scalars().all(): entry.user_id = user_id async def claim_entries_for_order( self, session: AsyncSession, order_id: int, user_id: int | None, session_id: str | None, page_post_id: int | None, ) -> None: """Mark pending CalendarEntries as 'ordered' and set order_id.""" filters = [ CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "pending", ] if user_id is not None: filters.append(CalendarEntry.user_id == user_id) elif session_id is not None: filters.append(CalendarEntry.session_id == session_id) if page_post_id is not None: cal_ids = select(Calendar.id).where( Calendar.container_type == "page", Calendar.container_id == page_post_id, Calendar.deleted_at.is_(None), ).scalar_subquery() filters.append(CalendarEntry.calendar_id.in_(cal_ids)) await session.execute( update(CalendarEntry) .where(*filters) .values(state="ordered", order_id=order_id) ) async def confirm_entries_for_order( self, session: AsyncSession, order_id: int, user_id: int | None, session_id: str | None, ) -> None: """Mark ordered CalendarEntries as 'provisional'.""" filters = [ CalendarEntry.deleted_at.is_(None), CalendarEntry.state == "ordered", CalendarEntry.order_id == order_id, ] if user_id is not None: filters.append(CalendarEntry.user_id == user_id) elif session_id is not None: filters.append(CalendarEntry.session_id == session_id) await session.execute( update(CalendarEntry) .where(*filters) .values(state="provisional") )