UMBRA/backend/app/services/calendar_sharing.py
Kyle Pope e6e81c59e7 Phase 2: Shared calendars backend core + QA fixes
Router: invite/accept/reject flow, membership CRUD, event locking
(timed + permanent), sync endpoint, local color override.
Services: permission hierarchy, atomic lock acquisition, disconnect cascade.
Events: shared calendar scoping, permission/lock enforcement, updated_by tracking.
Admin: sharing-stats endpoint. nginx: rate limits for invite + sync.

QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future),
W-01 (pre-commit response build), W-02 (owned calendar short-circuit),
W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 04:46:17 +08:00

206 lines
7.2 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, 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