""" 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 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_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. """ cal = await db.execute( select(Calendar).where(Calendar.id == calendar_id) ) calendar = cal.scalar_one_or_none() if not calendar: return None if calendar.user_id == user_id: return "owner" member = await db.execute( select(CalendarMember).where( CalendarMember.calendar_id == calendar_id, CalendarMember.user_id == user_id, CalendarMember.status == "accepted", ) ) row = member.scalar_one_or_none() return row.permission if row else None 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. Returns the lock or raises 423 Locked. """ 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}, ) # Reset is_shared on calendars with no remaining members all_cal_ids = a_cal_ids + b_cal_ids for cal_id in all_cal_ids: remaining = await db.execute( select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) ) if not remaining.scalar_one_or_none(): cal_result = await db.execute( select(Calendar).where(Calendar.id == cal_id) ) cal = cal_result.scalar_one_or_none() if cal: cal.is_shared = False