""" Calendar sharing service — permission checks, lock management, disconnect cascade. All functions accept an AsyncSession and do NOT commit — callers manage transactions. """ import logging from datetime import datetime, timedelta from fastapi import HTTPException from sqlalchemy import delete, literal_column, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.calendar import Calendar from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock logger = logging.getLogger(__name__) PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3} LOCK_DURATION_MINUTES = 5 async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[int]: """Return all calendar IDs the user can access (owned + accepted shared memberships).""" result = await db.execute( select(Calendar.id).where(Calendar.user_id == user_id) .union( select(CalendarMember.calendar_id).where( CalendarMember.user_id == user_id, CalendarMember.status == "accepted", ) ) ) return [r[0] for r in result.all()] async def get_accessible_event_scope( user_id: int, db: AsyncSession ) -> tuple[list[int], list[int]]: """ Returns (calendar_ids, invited_parent_event_ids) in a single DB round-trip. calendar_ids: all calendars the user can access (owned + accepted shared). invited_parent_event_ids: event IDs where the user has a non-declined invitation. """ from app.models.event_invitation import EventInvitation result = await db.execute( select(literal_column("'c'").label("kind"), Calendar.id.label("val")) .where(Calendar.user_id == user_id) .union_all( select(literal_column("'c'"), CalendarMember.calendar_id) .where( CalendarMember.user_id == user_id, CalendarMember.status == "accepted", ) ) .union_all( select(literal_column("'i'"), EventInvitation.event_id) .where( EventInvitation.user_id == user_id, EventInvitation.status != "declined", ) ) ) cal_ids: list[int] = [] inv_ids: list[int] = [] for kind, val in result.all(): if kind == "c": cal_ids.append(val) else: inv_ids.append(val) return cal_ids, inv_ids async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None: """ Returns "owner" if the user owns the calendar, the permission string if they are an accepted member, or None if they have no access. AW-5: Single query with LEFT JOIN instead of 2 sequential queries. """ result = await db.execute( select( Calendar.user_id, CalendarMember.permission, ) .outerjoin( CalendarMember, (CalendarMember.calendar_id == Calendar.id) & (CalendarMember.user_id == user_id) & (CalendarMember.status == "accepted"), ) .where(Calendar.id == calendar_id) ) row = result.one_or_none() if not row: return None owner_id, member_permission = row.tuple() if owner_id == user_id: return "owner" return member_permission async def require_permission( db: AsyncSession, calendar_id: int, user_id: int, min_level: str ) -> str: """ Raises 403 if the user lacks at least min_level permission. Returns the actual permission string (or "owner"). """ perm = await get_user_permission(db, calendar_id, user_id) if perm is None: raise HTTPException(status_code=404, detail="Calendar not found") if perm == "owner": return "owner" if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0): raise HTTPException(status_code=403, detail="Insufficient permission on this calendar") return perm async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock: """ Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event. Only succeeds if no unexpired lock exists or the existing lock is held by the same user. Permanent locks are never overwritten — if the same user holds one, it is returned as-is. Returns the lock or raises 423 Locked. """ # Check for existing permanent lock first existing = await db.execute( select(EventLock).where(EventLock.event_id == event_id) ) existing_lock = existing.scalar_one_or_none() if existing_lock and existing_lock.is_permanent: if existing_lock.locked_by == user_id: # Owner holds permanent lock — return it without downgrading return existing_lock raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") now = datetime.now() expires = now + timedelta(minutes=LOCK_DURATION_MINUTES) result = await db.execute( text(""" INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) VALUES (:event_id, :user_id, :now, :expires, false) ON CONFLICT (event_id) DO UPDATE SET locked_by = :user_id, locked_at = :now, expires_at = :expires, is_permanent = false WHERE event_locks.expires_at < :now OR event_locks.locked_by = :user_id RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent """), {"event_id": event_id, "user_id": user_id, "now": now, "expires": expires}, ) row = result.first() if not row: raise HTTPException(status_code=423, detail="Event is locked by another user") lock_result = await db.execute( select(EventLock).where(EventLock.id == row.id) ) return lock_result.scalar_one() async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None: """Delete the lock only if held by this user.""" await db.execute( delete(EventLock).where( EventLock.event_id == event_id, EventLock.locked_by == user_id, ) ) async def check_lock_for_edit( db: AsyncSession, event_id: int, user_id: int, calendar_id: int ) -> None: """ For shared calendars: verify no active lock by another user blocks this edit. For personal (non-shared) calendars: no-op. """ cal_result = await db.execute( select(Calendar.is_shared).where(Calendar.id == calendar_id) ) is_shared = cal_result.scalar_one_or_none() if not is_shared: return lock_result = await db.execute( select(EventLock).where(EventLock.event_id == event_id) ) lock = lock_result.scalar_one_or_none() if not lock: return now = datetime.now() if lock.is_permanent and lock.locked_by != user_id: raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now): raise HTTPException(status_code=423, detail="Event is locked by another user") async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None: """ When a connection is severed: 1. Delete CalendarMember rows where one user is a member of the other's calendars 2. Delete EventLock rows held by the disconnected user on affected calendars 3. Reset is_shared=False on calendars with no remaining members """ # Find calendars owned by each user a_cal_ids_result = await db.execute( select(Calendar.id).where(Calendar.user_id == user_a_id) ) a_cal_ids = [row[0] for row in a_cal_ids_result.all()] b_cal_ids_result = await db.execute( select(Calendar.id).where(Calendar.user_id == user_b_id) ) b_cal_ids = [row[0] for row in b_cal_ids_result.all()] # Delete user_b's memberships on user_a's calendars + locks if a_cal_ids: await db.execute( delete(CalendarMember).where( CalendarMember.calendar_id.in_(a_cal_ids), CalendarMember.user_id == user_b_id, ) ) await db.execute( text(""" DELETE FROM event_locks WHERE locked_by = :user_id AND event_id IN ( SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) ) """), {"user_id": user_b_id, "cal_ids": a_cal_ids}, ) # Delete user_a's memberships on user_b's calendars + locks if b_cal_ids: await db.execute( delete(CalendarMember).where( CalendarMember.calendar_id.in_(b_cal_ids), CalendarMember.user_id == user_a_id, ) ) await db.execute( text(""" DELETE FROM event_locks WHERE locked_by = :user_id AND event_id IN ( SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) ) """), {"user_id": user_a_id, "cal_ids": b_cal_ids}, ) # Clean up event invitations between the two users from app.services.event_invitation import cascade_event_invitations_on_disconnect await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id) # AC-5: Single aggregation query instead of N per-calendar checks all_cal_ids = a_cal_ids + b_cal_ids if all_cal_ids: # Find which calendars still have members has_members_result = await db.execute( select(CalendarMember.calendar_id) .where(CalendarMember.calendar_id.in_(all_cal_ids)) .group_by(CalendarMember.calendar_id) ) cals_with_members = {row[0] for row in has_members_result.all()} # Reset is_shared on calendars with no remaining members empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members] if empty_cal_ids: await db.execute( update(Calendar) .where(Calendar.id.in_(empty_cal_ids)) .values(is_shared=False) )