The _build_member_response helper tried to access member.user.preferred_name
but User model has no preferred_name field (it's on Settings). With lazy="raise"
this caused a 500 on GET /shared-calendars/{id}/members. Reverted to None —
the list_members endpoint already patches preferred_name from Settings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
872 lines
29 KiB
Python
872 lines
29 KiB
Python
"""
|
|
Shared calendars router — invites, membership, locks, sync.
|
|
|
|
All endpoints live under /api/shared-calendars.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
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
|
|
|
|
# W-03: Verify bidirectional connection still active
|
|
reverse_conn = await db.execute(
|
|
select(UserConnection.id).where(
|
|
UserConnection.user_id == target_user_id,
|
|
UserConnection.connected_user_id == current_user.id,
|
|
)
|
|
)
|
|
if not reverse_conn.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="Connection is no longer active")
|
|
|
|
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")
|
|
|
|
if "permission" in update_data:
|
|
member.permission = update_data["permission"]
|
|
if "can_add_others" in update_data:
|
|
member.can_add_others = update_data["can_add_others"]
|
|
|
|
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
|
|
|
|
# Cap since to 7 days ago to prevent unbounded scans
|
|
floor = datetime.now() - timedelta(days=7)
|
|
if since < floor:
|
|
since = floor
|
|
|
|
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
|