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>
206 lines
7.2 KiB
Python
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
|