- C-02: flush invitations before creating notifications so invitation_id is available in notification data; eliminates extra pending fetch - C-03: skip RSVP notification when status hasn't changed - C-01: add defensive comments on update/delete endpoints - W-01: add ge=1, le=2147483647 per-element validation on user_ids - W-04: deduplicate invited_event_ids query via get_invited_event_ids() - W-06: replace Python False with sa_false() in or_() clauses - Frontend: extract resolveInvitationId helper, prefer data.invitation_id Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
261 lines
9.4 KiB
Python
261 lines
9.4 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, 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).
|
|
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.services.event_invitation import get_invited_event_ids
|
|
|
|
cal_ids = await get_accessible_calendar_ids(user_id, db)
|
|
invited_event_ids = await get_invited_event_ids(db, user_id)
|
|
return cal_ids, invited_event_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)
|
|
)
|