UMBRA/backend/app/services/calendar_sharing.py
Kyle Pope 0401a71fce Fix CompoundSelect chaining: use standalone union_all()
SQLAlchemy 2.0's select().union_all() returns a CompoundSelect which
cannot chain another .union_all(). Use the standalone union_all()
function to combine all three queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:39:40 +08:00

282 lines
10 KiB
Python

"""
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, union_all, 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(
union_all(
select(literal_column("'c'").label("kind"), Calendar.id.label("val"))
.where(Calendar.user_id == user_id),
select(literal_column("'c'"), CalendarMember.calendar_id)
.where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
),
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)
)