Allows event owners to grant individual invitees edit permission via a toggle in the invitee list. Invited editors can modify event details (title, description, time, location) but cannot change calendars, manage invitees, delete events, or bulk-edit recurring series (scope restricted to "this" only). The can_modify flag resets on decline to prevent silent re-grant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
Python
308 lines
10 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 sqlalchemy.orm import selectinload
|
|
from app.schemas.event_invitation import (
|
|
EventInvitationCreate,
|
|
EventInvitationRespond,
|
|
EventInvitationOverrideCreate,
|
|
UpdateCanModify,
|
|
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.put("/{invitation_id}/can-modify")
|
|
async def update_can_modify(
|
|
body: UpdateCanModify,
|
|
invitation_id: int = Path(ge=1, le=2147483647),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Toggle can_modify on an invitation. Owner-only."""
|
|
inv_result = await db.execute(
|
|
select(EventInvitation)
|
|
.options(selectinload(EventInvitation.event))
|
|
.where(EventInvitation.id == invitation_id)
|
|
)
|
|
invitation = inv_result.scalar_one_or_none()
|
|
if not invitation:
|
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
|
|
# Only the calendar owner can toggle can_modify (W-03)
|
|
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
|
|
if perm != "owner":
|
|
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
|
|
|
|
invitation.can_modify = body.can_modify
|
|
|
|
response_data = {
|
|
"id": invitation.id,
|
|
"event_id": invitation.event_id,
|
|
"can_modify": invitation.can_modify,
|
|
}
|
|
|
|
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
|