""" 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, 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_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}, ) # 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) )