UMBRA/backend/app/routers/shared_calendars.py
Kyle Pope 206144d20d Fix 2 pentest findings: unlock permission check + permanent lock preservation
SC-01: unlock_event now verifies caller has access to the calendar before
revealing lock state. Previously any authenticated user could probe event
existence via 404/204/403 response differences.

SC-02: acquire_lock no longer overwrites permanent locks. If the owner holds
a permanent lock and clicks Edit, the existing lock is returned as-is instead
of being downgraded to a 5-minute temporary lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:37:05 +08:00

855 lines
28 KiB
Python

"""
Shared calendars router — invites, membership, locks, sync.
All endpoints live under /api/shared-calendars.
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.routers.auth import get_current_user
from app.schemas.shared_calendar import (
CalendarInviteResponse,
CalendarMemberResponse,
InviteMemberRequest,
LockStatusResponse,
RespondInviteRequest,
SyncResponse,
UpdateLocalColorRequest,
UpdateMemberRequest,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.calendar_sharing import (
PERMISSION_RANK,
acquire_lock,
get_user_permission,
release_lock,
require_permission,
)
from app.services.notification import create_notification
router = APIRouter()
logger = logging.getLogger(__name__)
PENDING_INVITE_CAP = 10
# -- Helpers ---------------------------------------------------------------
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
return result.scalar_one_or_none()
def _build_member_response(member: CalendarMember) -> dict:
return {
"id": member.id,
"calendar_id": member.calendar_id,
"user_id": member.user_id,
"umbral_name": member.user.umbral_name if member.user else "",
"preferred_name": None,
"permission": member.permission,
"can_add_others": member.can_add_others,
"local_color": member.local_color,
"status": member.status,
"invited_at": member.invited_at,
"accepted_at": member.accepted_at,
}
# -- GET / — List accepted memberships ------------------------------------
@router.get("/")
async def list_shared_calendars(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List calendars the current user has accepted membership in."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
.options(selectinload(CalendarMember.calendar))
.order_by(CalendarMember.accepted_at.desc())
)
members = result.scalars().all()
return [
{
"id": m.id,
"calendar_id": m.calendar_id,
"calendar_name": m.calendar.name if m.calendar else "",
"calendar_color": m.calendar.color if m.calendar else "",
"local_color": m.local_color,
"permission": m.permission,
"can_add_others": m.can_add_others,
"is_owner": False,
}
for m in members
]
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
@router.post("/{cal_id}/invite", status_code=201)
async def invite_member(
body: InviteMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite a connected user to a shared calendar."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
if not calendar:
raise HTTPException(status_code=404, detail="Calendar not found")
is_owner = calendar.user_id == current_user.id
inviter_perm = "owner" if is_owner else None
if not is_owner:
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Calendar not found")
if not member.can_add_others:
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
inviter_perm = member.permission
# Permission ceiling
if inviter_perm != "owner":
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
raise HTTPException(
status_code=403,
detail="Cannot grant a permission level higher than your own",
)
# Resolve connection_id -> connected user
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.id == body.connection_id,
UserConnection.user_id == current_user.id,
)
)
connection = conn_result.scalar_one_or_none()
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
target_user_id = connection.connected_user_id
if target_user_id == calendar.user_id:
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
target_result = await db.execute(
select(User).where(User.id == target_user_id)
)
target = target_result.scalar_one_or_none()
if not target or not target.is_active:
raise HTTPException(status_code=404, detail="Target user not found or inactive")
existing = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == target_user_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="User already invited or is a member")
pending_count = await db.scalar(
select(func.count())
.select_from(CalendarMember)
.where(
CalendarMember.calendar_id == cal_id,
CalendarMember.status == "pending",
)
) or 0
if pending_count >= PENDING_INVITE_CAP:
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
if not calendar.is_shared:
calendar.is_shared = True
new_member = CalendarMember(
calendar_id=cal_id,
user_id=target_user_id,
invited_by=current_user.id,
permission=body.permission,
can_add_others=body.can_add_others,
status="pending",
)
db.add(new_member)
await db.flush()
inviter_settings = await _get_settings_for_user(db, current_user.id)
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
cal_name = calendar.name
await create_notification(
db,
user_id=target_user_id,
type="calendar_invite",
title="Calendar Invite",
message=f"{inviter_display} invited you to '{cal_name}'",
data={"calendar_id": cal_id, "calendar_name": cal_name},
source_type="calendar_invite",
source_id=new_member.id,
)
await log_audit_event(
db,
action="calendar.invite_sent",
actor_id=current_user.id,
target_id=target_user_id,
detail={
"calendar_id": cal_id,
"calendar_name": cal_name,
"permission": body.permission,
},
ip=get_client_ip(request),
)
response = {
"message": "Invite sent",
"member_id": new_member.id,
"calendar_id": cal_id,
}
await db.commit()
return response
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
@router.put("/invites/{invite_id}/respond")
async def respond_to_invite(
body: RespondInviteRequest,
request: Request,
invite_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept or reject a calendar invite."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.id == invite_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(selectinload(CalendarMember.calendar))
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
inviter_id = invite.invited_by
if body.action == "accept":
invite.status = "accepted"
invite.accepted_at = datetime.now()
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_accepted",
title="Invite Accepted",
message=f"{display} accepted your invite to '{calendar_name}'",
data={"calendar_id": invite.calendar_id},
source_type="calendar_invite",
source_id=invite.id,
)
await log_audit_event(
db,
action="calendar.invite_accepted",
actor_id=current_user.id,
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Invite accepted"}
else:
member_id = invite.id
calendar_id = invite.calendar_id
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_rejected",
title="Invite Rejected",
message=f"{display} declined your invite to '{calendar_name}'",
data={"calendar_id": calendar_id},
source_type="calendar_invite",
source_id=member_id,
)
await log_audit_event(
db,
action="calendar.invite_rejected",
actor_id=current_user.id,
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.delete(invite)
await db.commit()
return {"message": "Invite rejected"}
# -- GET /invites/incoming — Pending invites -------------------------------
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
async def get_incoming_invites(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending calendar invites for the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(
selectinload(CalendarMember.calendar),
selectinload(CalendarMember.inviter),
)
.order_by(CalendarMember.invited_at.desc())
.offset(offset)
.limit(per_page)
)
invites = result.scalars().all()
# Batch-fetch owner names to avoid N+1
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
if owner_ids:
owner_result = await db.execute(
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
)
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
else:
owner_names = {}
responses = []
for inv in invites:
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
responses.append(CalendarInviteResponse(
id=inv.id,
calendar_id=inv.calendar_id,
calendar_name=inv.calendar.name if inv.calendar else "",
calendar_color=inv.calendar.color if inv.calendar else "",
owner_umbral_name=owner_name,
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
permission=inv.permission,
invited_at=inv.invited_at,
))
return responses
# -- GET /{cal_id}/members — Member list -----------------------------------
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
async def list_members(
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all members of a shared calendar. Requires membership or ownership."""
perm = await get_user_permission(db, cal_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Calendar not found")
result = await db.execute(
select(CalendarMember)
.where(CalendarMember.calendar_id == cal_id)
.options(selectinload(CalendarMember.user))
.order_by(CalendarMember.invited_at.asc())
)
members = result.scalars().all()
user_ids = [m.user_id for m in members]
if user_ids:
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name)
.where(Settings.user_id.in_(user_ids))
)
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
else:
pref_names = {}
responses = []
for m in members:
resp = _build_member_response(m)
resp["preferred_name"] = pref_names.get(m.user_id)
responses.append(resp)
return responses
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
@router.put("/{cal_id}/members/{member_id}")
async def update_member(
body: UpdateMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a member permission or can_add_others. Owner only."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
for key, value in update_data.items():
setattr(member, key, value)
await log_audit_event(
db,
action="calendar.member_updated",
actor_id=current_user.id,
target_id=member.user_id,
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Member updated"}
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
async def remove_member(
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a member or leave a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
is_self = member.user_id == current_user.id
is_owner = calendar and calendar.user_id == current_user.id
if not is_self and not is_owner:
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
target_user_id = member.user_id
await db.execute(
delete(EventLock).where(
EventLock.locked_by == target_user_id,
EventLock.event_id.in_(
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
),
)
)
await db.delete(member)
remaining = await db.execute(
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
)
if not remaining.scalar_one_or_none() and calendar:
calendar.is_shared = False
action = "calendar.member_left" if is_self else "calendar.member_removed"
await log_audit_event(
db,
action=action,
actor_id=current_user.id,
target_id=target_user_id,
detail={"calendar_id": cal_id, "member_id": member_id},
ip=get_client_ip(request),
)
await db.commit()
return None
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
@router.put("/{cal_id}/members/me/color")
async def update_local_color(
body: UpdateLocalColorRequest,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user local color for a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Membership not found")
member.local_color = body.local_color
await db.commit()
return {"message": "Color updated"}
# -- GET /sync — Sync endpoint ---------------------------------------------
@router.get("/sync", response_model=SyncResponse)
async def sync_shared_calendars(
since: datetime = Query(...),
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Sync events and member changes since a given timestamp. Cap 500 events."""
MAX_EVENTS = 500
cal_id_list: list[int] = []
if calendar_ids:
for part in calendar_ids.split(","):
part = part.strip()
if part.isdigit():
cal_id_list.append(int(part))
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
owned_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
owned_ids = {row[0] for row in owned_ids_result.all()}
member_ids_result = await db.execute(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member_ids = {row[0] for row in member_ids_result.all()}
accessible = owned_ids | member_ids
if cal_id_list:
accessible = accessible & set(cal_id_list)
if not accessible:
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
accessible_list = list(accessible)
events_result = await db.execute(
select(CalendarEvent)
.where(
CalendarEvent.calendar_id.in_(accessible_list),
CalendarEvent.updated_at >= since,
)
.options(selectinload(CalendarEvent.calendar))
.order_by(CalendarEvent.updated_at.desc())
.limit(MAX_EVENTS + 1)
)
events = events_result.scalars().all()
truncated = len(events) > MAX_EVENTS
events = events[:MAX_EVENTS]
event_dicts = []
for e in events:
event_dicts.append({
"id": e.id,
"title": e.title,
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
"all_day": e.all_day,
"calendar_id": e.calendar_id,
"calendar_name": e.calendar.name if e.calendar else "",
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
"updated_by": e.updated_by,
})
members_result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.calendar_id.in_(accessible_list),
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
)
.options(selectinload(CalendarMember.user))
)
member_changes = members_result.scalars().all()
member_dicts = []
for m in member_changes:
member_dicts.append({
"id": m.id,
"calendar_id": m.calendar_id,
"user_id": m.user_id,
"umbral_name": m.user.umbral_name if m.user else "",
"permission": m.permission,
"status": m.status,
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
})
return SyncResponse(
events=event_dicts,
member_changes=member_dicts,
server_time=datetime.now(),
truncated=truncated,
)
# -- Event Lock Endpoints --------------------------------------------------
@router.post("/events/{event_id}/lock")
async def lock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Acquire a 5-minute editing lock on an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
lock = await acquire_lock(db, event_id, current_user.id)
# Build response BEFORE commit — ORM objects expire after commit
response = {
"locked": True,
"locked_by_name": current_user.umbral_name,
"expires_at": lock.expires_at,
"is_permanent": lock.is_permanent,
}
await db.commit()
return response
@router.delete("/events/{event_id}/lock", status_code=204)
async def unlock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Release a lock. Only the holder or calendar owner can release."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# SC-01: Verify caller has access to this calendar before revealing lock state
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
lock = lock_result.scalar_one_or_none()
if not lock:
return None
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
calendar = cal_result.scalar_one_or_none()
is_owner = calendar and calendar.user_id == current_user.id
if lock.locked_by != current_user.id and not is_owner:
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
await db.delete(lock)
await db.commit()
return None
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
async def get_lock_status(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the lock status of an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock)
.where(EventLock.event_id == event_id)
.options(selectinload(EventLock.holder))
)
lock = lock_result.scalar_one_or_none()
if not lock:
return LockStatusResponse(locked=False)
now = datetime.now()
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
await db.delete(lock)
await db.commit()
return LockStatusResponse(locked=False)
return LockStatusResponse(
locked=True,
locked_by_name=lock.holder.umbral_name if lock.holder else None,
expires_at=lock.expires_at,
is_permanent=lock.is_permanent,
)
@router.post("/events/{event_id}/owner-lock")
async def set_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Set a permanent lock on an event. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
now = datetime.now()
await db.execute(
text("""
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
VALUES (:event_id, :user_id, :now, NULL, true)
ON CONFLICT (event_id)
DO UPDATE SET
locked_by = :user_id,
locked_at = :now,
expires_at = NULL,
is_permanent = true
"""),
{"event_id": event_id, "user_id": current_user.id, "now": now},
)
await db.commit()
return {"message": "Permanent lock set", "is_permanent": True}
@router.delete("/events/{event_id}/owner-lock", status_code=204)
async def remove_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a permanent lock. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
await db.execute(
delete(EventLock).where(
EventLock.event_id == event_id,
EventLock.is_permanent == True,
)
)
await db.commit()
return None