UMBRA/backend/app/routers/event_invitations.py
Kyle Pope a68ec0e23e Add display calendar support: model, router, service, types, visibility filter
Previously unstaged changes required for the display calendar feature:
- EventInvitation model: display_calendar_id column
- Event invitations router: display-calendar PUT endpoint
- Event invitation service: display calendar update logic
- CalendarPage: respect display_calendar_id in visibility filter
- Types: display_calendar_id on CalendarEvent interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:03:22 +08:00

272 lines
9.1 KiB
Python

"""
Event invitation endpoints — invite users to events, respond, override per-occurrence, leave.
Two routers:
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
- router: mounted at /api/event-invitations for respond/override/delete/pending
"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.event_invitation import (
EventInvitationCreate,
EventInvitationRespond,
EventInvitationOverrideCreate,
UpdateDisplayCalendar,
)
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
from app.services.event_invitation import (
send_event_invitations,
respond_to_invitation,
override_occurrence_status,
dismiss_invitation,
dismiss_invitation_by_owner,
get_event_invitations,
get_pending_invitations,
)
# Mounted at /api/events — event-scoped invitation endpoints
events_router = APIRouter()
# Mounted at /api/event-invitations — invitation-scoped endpoints
router = APIRouter()
async def _get_event_with_access_check(
db: AsyncSession, event_id: int, user_id: int
) -> CalendarEvent:
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check calendar access
perm = await get_user_permission(db, event.calendar_id, user_id)
if perm is not None:
return event
# Check if invitee (also check parent for recurring children)
event_ids_to_check = [event_id]
if event.parent_event_id:
event_ids_to_check.append(event.parent_event_id)
inv_result = await db.execute(
select(EventInvitation.id).where(
EventInvitation.event_id.in_(event_ids_to_check),
EventInvitation.user_id == user_id,
)
)
if inv_result.first() is not None:
return event
raise HTTPException(status_code=404, detail="Event not found")
# ── Event-scoped endpoints (mounted at /api/events) ──
@events_router.post("/{event_id}/invitations", status_code=201)
async def invite_to_event(
body: EventInvitationCreate,
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Permission check: owner or create_modify+
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")
if perm not in ("owner", "create_modify", "full_access"):
raise HTTPException(status_code=403, detail="Insufficient permission")
# For recurring child events, invite to the parent (series)
target_event_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await send_event_invitations(
db=db,
event_id=target_event_id,
user_ids=body.user_ids,
invited_by=current_user.id,
)
await db.commit()
return {"invited": len(invitations), "event_id": target_event_id}
@events_router.get("/{event_id}/invitations")
async def list_event_invitations(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all invitees and their statuses for an event."""
event = await _get_event_with_access_check(db, event_id, current_user.id)
# For recurring children, also fetch parent's invitations
target_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await get_event_invitations(db, target_id)
return invitations
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
@router.get("/pending")
async def my_pending_invitations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all pending event invitations for the current user."""
return await get_pending_invitations(db, current_user.id)
@router.put("/{invitation_id}/respond")
async def respond_invitation(
body: EventInvitationRespond,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept, tentative, or decline an event invitation."""
invitation = await respond_to_invitation(
db=db,
invitation_id=invitation_id,
user_id=current_user.id,
status=body.status,
)
# Build response before commit (ORM objects expire after commit)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"status": invitation.status,
"responded_at": invitation.responded_at,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/respond/{occurrence_id}")
async def override_occurrence(
body: EventInvitationOverrideCreate,
invitation_id: int = Path(ge=1, le=2147483647),
occurrence_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Override invitation status for a specific occurrence of a recurring event."""
override = await override_occurrence_status(
db=db,
invitation_id=invitation_id,
occurrence_id=occurrence_id,
user_id=current_user.id,
status=body.status,
)
response_data = {
"invitation_id": override.invitation_id,
"occurrence_id": override.occurrence_id,
"status": override.status,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/display-calendar")
async def update_display_calendar(
body: UpdateDisplayCalendar,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the display calendar for an accepted/tentative invitation."""
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == current_user.id,
)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.status not in ("accepted", "tentative"):
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
# Verify calendar is accessible to this user
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
if body.calendar_id not in accessible_ids:
raise HTTPException(status_code=404, detail="Calendar not found")
invitation.display_calendar_id = body.calendar_id
# Extract response before commit (ORM expiry rule)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"display_calendar_id": invitation.display_calendar_id,
}
await db.commit()
return response_data
@router.delete("/{invitation_id}", status_code=204)
async def leave_or_revoke_invitation(
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Leave an event (invitee) or revoke an invitation (event owner).
Invitees can only delete their own invitations.
Event owners can delete any invitation for their events.
"""
inv_result = await db.execute(
select(EventInvitation).where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.user_id == current_user.id:
# Invitee leaving
await dismiss_invitation(db, invitation_id, current_user.id)
else:
# Check if current user is the event owner
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == invitation.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 != "owner":
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
await dismiss_invitation_by_owner(db, invitation_id)
await db.commit()
return None