UMBRA/backend/app/routers/events.py
Kyle Pope 8652c9f2ce Implement event invitation feature (invite, RSVP, per-occurrence override, leave)
Full-stack implementation of event invitations allowing users to invite connected
contacts to calendar events. Invitees can respond Going/Tentative/Declined, with
per-occurrence overrides for recurring series. Invited events appear on the invitee's
calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events.

Backend: Migration 054 (2 tables + notification types), EventInvitation model with
lazy="raise", service layer, dual-router (events + event-invitations), cascade on
disconnect, events/dashboard queries extended with OR for invited events.

Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons +
invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar
eventContent render with Users icon for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 02:47:27 +08:00

550 lines
21 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, or_
from sqlalchemy.orm import selectinload
from typing import Optional, List, Any, Literal
from datetime import date, datetime, timedelta
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.person import Person
from app.schemas.calendar_event import (
CalendarEventCreate,
CalendarEventUpdate,
CalendarEventResponse,
)
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
from app.models.event_invitation import EventInvitation
router = APIRouter()
def _event_to_dict(
event: CalendarEvent,
is_invited: bool = False,
invitation_status: str | None = None,
invitation_id: int | None = None,
) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
d = {
"id": event.id,
"title": event.title,
"description": event.description,
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"location_id": event.location_id,
"recurrence_rule": event.recurrence_rule,
"is_starred": event.is_starred,
"calendar_id": event.calendar_id,
"calendar_name": event.calendar.name if event.calendar else "",
"calendar_color": event.calendar.color if event.calendar else "",
"is_virtual": False,
"parent_event_id": event.parent_event_id,
"is_recurring": event.is_recurring,
"original_start": event.original_start,
"created_at": event.created_at,
"updated_at": event.updated_at,
"is_invited": is_invited,
"invitation_status": invitation_status,
"invitation_id": invitation_id,
}
return d
def _birthday_events_for_range(
people: list,
bday_calendar_id: int,
bday_calendar_color: str,
start: Optional[date],
end: Optional[date],
) -> list[dict]:
"""Generate virtual all-day birthday events for each person within the date range."""
virtual_events = []
now = datetime.now()
range_start = start or date(now.year - 1, 1, 1)
range_end = end or date(now.year + 2, 12, 31)
for person in people:
if not person.birthday:
continue
bday: date = person.birthday
for year in range(range_start.year, range_end.year + 1):
try:
bday_this_year = bday.replace(year=year)
except ValueError:
# Feb 29 on non-leap year — skip
continue
if bday_this_year < range_start or bday_this_year > range_end:
continue
start_dt = datetime(bday_this_year.year, bday_this_year.month, bday_this_year.day, 0, 0, 0)
end_dt = start_dt + timedelta(days=1)
age = year - bday.year
virtual_events.append({
"id": f"bday-{person.id}-{year}",
"title": f"{person.name}'s Birthday" + (f" ({age})" if age > 0 else ""),
"description": None,
"start_datetime": start_dt,
"end_datetime": end_dt,
"all_day": True,
"color": bday_calendar_color,
"location_id": None,
"recurrence_rule": None,
"is_starred": False,
"calendar_id": bday_calendar_id,
"calendar_name": "Birthdays",
"calendar_color": bday_calendar_color,
"is_virtual": True,
"parent_event_id": None,
"is_recurring": False,
"original_start": None,
"created_at": start_dt,
"updated_at": start_dt,
})
return virtual_events
async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int:
"""Return the id of the user's default calendar, raising 500 if not found."""
result = await db.execute(
select(Calendar).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
)
)
default = result.scalar_one_or_none()
if not default:
raise HTTPException(status_code=500, detail="No default calendar configured")
return default.id
async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None:
"""Raise 404 if calendar_id does not belong to user_id (SEC-04)."""
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Calendar not found")
@router.get("/", response_model=None)
async def get_events(
start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Any]:
"""
Get all calendar events with optional date range filtering.
Parent template rows (is_recurring=True, parent_event_id IS NULL,
recurrence_rule IS NOT NULL) are excluded — their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership + shared memberships + invitations
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
)
)
)
# Exclude parent template rows — they are not directly rendered
query = query.where(
~(
(CalendarEvent.is_recurring == True)
& (CalendarEvent.parent_event_id == None)
& (CalendarEvent.recurrence_rule != None)
)
)
if start:
query = query.where(CalendarEvent.end_datetime >= start)
if end:
query = query.where(CalendarEvent.start_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc()).limit(2000)
result = await db.execute(query)
events = result.scalars().all()
# Build invitation lookup for the current user
invited_event_id_set = set(invited_event_ids)
invitation_map: dict[int, tuple[str, int]] = {} # event_id -> (status, invitation_id)
if invited_event_ids:
inv_result = await db.execute(
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.id).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(invited_event_ids),
)
)
for eid, status, inv_id in inv_result.all():
invitation_map[eid] = (status, inv_id)
# Get per-occurrence overrides for invited events
all_event_ids = [e.id for e in events]
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
response: List[dict] = []
for e in events:
# Determine if this event is from an invitation
parent_id = e.parent_event_id or e.id
is_invited = parent_id in invited_event_id_set
inv_status = None
inv_id = None
if is_invited and parent_id in invitation_map:
inv_status, inv_id = invitation_map[parent_id]
# Check for per-occurrence override
if e.id in override_map:
inv_status = override_map[e.id]
response.append(_event_to_dict(e, is_invited=is_invited, invitation_status=inv_status, invitation_id=inv_id))
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute(
select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.name == "Birthdays",
Calendar.is_system == True,
)
)
bday_calendar = bday_result.scalar_one_or_none()
if bday_calendar and bday_calendar.is_visible:
# Scope birthday people to this user
people_result = await db.execute(
select(Person).where(
Person.user_id == current_user.id,
Person.birthday.isnot(None),
)
)
people = people_result.scalars().all()
virtual = _birthday_events_for_range(
people=people,
bday_calendar_id=bday_calendar.id,
bday_calendar_color=bday_calendar.color,
start=start,
end=end,
)
response.extend(virtual)
return response
@router.post("/", response_model=CalendarEventResponse, status_code=201)
async def create_event(
event: CalendarEventCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
data = event.model_dump()
# Resolve calendar_id to user's default if not provided
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
else:
# SEC-04: verify ownership OR shared calendar permission
cal_ownership_result = await db.execute(
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
)
if not cal_ownership_result.scalar_one_or_none():
# Not owned — check shared calendar permission
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
# Serialize RecurrenceRule object to JSON string for DB storage
# Exclude None values so defaults in recurrence service work correctly
rule_obj = data.pop("recurrence_rule", None)
rule_json: Optional[str] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
if rule_json:
# Parent template: is_recurring=True, no parent_event_id
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
db.add(parent)
await db.flush() # assign parent.id before generating children
children = generate_occurrences(parent)
db.add_all(children)
await db.commit()
# Return the first child so the caller sees a concrete occurrence
# (The parent template is intentionally hidden from list views)
if children:
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == children[0].id)
)
return result.scalar_one()
# Fallback: no children generated (e.g. bad rule), return parent
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == parent.id)
)
return result.scalar_one()
else:
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
db.add(new_event)
await db.commit()
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == new_event.id)
)
return result.scalar_one()
@router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
invited_set = set(invited_event_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
),
)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
return event
@router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event(
event_id: int = Path(ge=1, le=2147483647),
event_update: CalendarEventUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Shared calendar: require create_modify+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
update_data = event_update.model_dump(exclude_unset=True)
# Extract scope before applying fields to the model
scope: Optional[str] = update_data.pop("edit_scope", None)
# Serialize RecurrenceRule → JSON string if present in update payload
rule_obj = update_data.pop("recurrence_rule", None)
if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
# Only verify ownership when the calendar is actually changing — members submitting
# an unchanged calendar_id must not be rejected just because they aren't the owner.
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
# M-01: Block non-owners from moving events off shared calendars
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
source_cal = source_cal_result.scalar_one_or_none()
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Only the calendar owner can move events between calendars",
)
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt is not None and end_dt < start:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
if scope == "this":
# Update only this occurrence and detach it from the series
for key, value in update_data.items():
setattr(event, key, value)
# Detach from parent so it's an independent event going forward
event.parent_event_id = None
event.is_recurring = False
event.updated_by = current_user.id
await db.commit()
elif scope == "this_and_future":
# Delete all future siblings (same parent, original_start >= this event's original_start)
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Fetch the parent's recurrence_rule before deleting siblings
parent_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == parent_id)
)
parent_event = parent_result.scalar_one_or_none()
parent_rule = parent_event.recurrence_rule if parent_event else None
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
CalendarEvent.id != event_id,
)
)
# Update this event with the new data, making it a new parent
for key, value in update_data.items():
setattr(event, key, value)
event.parent_event_id = None
event.is_recurring = True
event.original_start = None
event.updated_by = current_user.id
# Inherit parent's recurrence_rule if none was provided in update
if not event.recurrence_rule and parent_rule:
event.recurrence_rule = parent_rule
# Regenerate children from this point
if event.recurrence_rule:
await db.flush()
children = generate_occurrences(event)
db.add_all(children)
else:
# This IS a parent — update it and regenerate all children
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
# Delete all existing children and regenerate
if event.recurrence_rule:
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == event.id
)
)
await db.flush()
children = generate_occurrences(event)
db.add_all(children)
await db.commit()
else:
# No scope — plain update (non-recurring events or full-series metadata)
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
await db.commit()
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
updated = result.scalar_one_or_none()
if not updated:
# Event was deleted as part of this_and_future; 404 is appropriate
raise HTTPException(status_code=404, detail="Event no longer exists after update")
return updated
@router.delete("/{event_id}", status_code=204)
async def delete_event(
event_id: int = Path(ge=1, le=2147483647),
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Shared calendar: require full_access+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "full_access")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
if scope == "this":
# Delete just this one occurrence
await db.delete(event)
elif scope == "this_and_future":
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Delete this + all future siblings (original_start is always set on children)
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
else:
# This event IS the parent — delete it and all children (CASCADE handles children)
await db.delete(event)
else:
# Plain delete (non-recurring; or delete entire series if parent)
await db.delete(event)
await db.commit()
return None