UMBRA/backend/app/routers/events.py
Kyle Pope e6e81c59e7 Phase 2: Shared calendars backend core + QA fixes
Router: invite/accept/reject flow, membership CRUD, event locking
(timed + permanent), sync endpoint, local color override.
Services: permission hierarchy, atomic lock acquisition, disconnect cascade.
Events: shared calendar scoping, permission/lock enforcement, updated_by tracking.
Admin: sharing-stats endpoint. nginx: rate limits for invite + sync.

QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future),
W-01 (pre-commit response build), W-02 (owned calendar short-circuit),
W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 04:46:17 +08:00

514 lines
19 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
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.models.calendar_member import CalendarMember
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, require_permission
router = APIRouter()
def _event_to_dict(event: CalendarEvent) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
return {
"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,
}
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
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
)
# 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())
result = await db.execute(query)
events = result.scalars().all()
response: List[dict] = [_event_to_dict(e) for e in events]
# 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)
for child in children:
db.add(child)
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),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
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")
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),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
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
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
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)
for child in children:
db.add(child)
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)
for child in children:
db.add(child)
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),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
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
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
# Ensure the target event itself is deleted (edge case: original_start fallback mismatch)
existing = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
target = existing.scalar_one_or_none()
if target:
await db.delete(target)
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