Adds has_active_invitees flag to the events GET response. The Users icon now appears on the owner's calendar view when an event has accepted or tentative invitees, giving visual feedback that the event is actively shared. Single batch query with set lookup — no N+1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
676 lines
27 KiB
Python
676 lines
27 KiB
Python
import json
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import false as sa_false, 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,
|
|
display_calendar_id: int | None = None,
|
|
display_calendar_name: str | None = None,
|
|
display_calendar_color: str | None = None,
|
|
can_modify: bool = False,
|
|
has_active_invitees: bool = False,
|
|
) -> dict:
|
|
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
|
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
|
|
if is_invited:
|
|
if display_calendar_name:
|
|
cal_name = display_calendar_name
|
|
cal_color = display_calendar_color or "#6B7280"
|
|
else:
|
|
cal_name = "Invited"
|
|
cal_color = "#6B7280"
|
|
else:
|
|
cal_name = event.calendar.name if event.calendar else ""
|
|
cal_color = event.calendar.color if event.calendar else ""
|
|
|
|
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": cal_name,
|
|
"calendar_color": cal_color,
|
|
"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,
|
|
"display_calendar_id": display_calendar_id,
|
|
"can_modify": can_modify,
|
|
"has_active_invitees": has_active_invitees,
|
|
}
|
|
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 sa_false(),
|
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_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, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
|
|
if invited_event_ids:
|
|
inv_result = await db.execute(
|
|
select(
|
|
EventInvitation.event_id,
|
|
EventInvitation.status,
|
|
EventInvitation.id,
|
|
EventInvitation.display_calendar_id,
|
|
EventInvitation.can_modify,
|
|
).where(
|
|
EventInvitation.user_id == current_user.id,
|
|
EventInvitation.event_id.in_(invited_event_ids),
|
|
)
|
|
)
|
|
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
|
|
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
|
|
|
|
# Batch-fetch display calendars for invited events
|
|
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
|
|
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
|
|
if display_cal_ids:
|
|
cal_result = await db.execute(
|
|
select(Calendar.id, Calendar.name, Calendar.color).where(
|
|
Calendar.id.in_(display_cal_ids),
|
|
Calendar.id.in_(all_calendar_ids),
|
|
)
|
|
)
|
|
for cal_id, cal_name, cal_color in cal_result.all():
|
|
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
|
|
|
|
# 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)
|
|
|
|
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
|
|
active_invitee_set: set[int] = set()
|
|
if all_event_ids:
|
|
active_inv_result = await db.execute(
|
|
select(EventInvitation.event_id).where(
|
|
EventInvitation.event_id.in_(all_event_ids),
|
|
EventInvitation.status.in_(["accepted", "tentative"]),
|
|
).distinct()
|
|
)
|
|
active_invitee_set = {r[0] for r in active_inv_result.all()}
|
|
# Also mark parent events: if a parent has active invitees, all its children should show the icon
|
|
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
|
|
if parent_ids:
|
|
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
|
|
|
|
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
|
|
disp_cal_id = None
|
|
disp_cal_name = None
|
|
disp_cal_color = None
|
|
inv_can_modify = False
|
|
if is_invited and parent_id in invitation_map:
|
|
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
|
|
# Check for per-occurrence override
|
|
if e.id in override_map:
|
|
inv_status = override_map[e.id]
|
|
# Resolve display calendar info
|
|
if disp_cal_id and disp_cal_id in display_cal_map:
|
|
disp_cal_name = display_cal_map[disp_cal_id]["name"]
|
|
disp_cal_color = display_cal_map[disp_cal_id]["color"]
|
|
response.append(_event_to_dict(
|
|
e,
|
|
is_invited=is_invited,
|
|
invitation_status=inv_status,
|
|
invitation_id=inv_id,
|
|
display_calendar_id=disp_cal_id,
|
|
display_calendar_name=disp_cal_name,
|
|
display_calendar_color=disp_cal_color,
|
|
can_modify=inv_can_modify,
|
|
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
|
|
))
|
|
|
|
# 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 sa_false(),
|
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_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),
|
|
):
|
|
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
|
|
# Event invitees can VIEW events but must NOT be able to edit them
|
|
# UNLESS they have can_modify=True (checked in fallback path below).
|
|
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
|
is_invited_editor = False
|
|
|
|
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:
|
|
# Fallback: check if user has can_modify invitation for this event
|
|
# Must check both event_id (direct) and parent_event_id (recurring child)
|
|
# because invitations are stored against the parent event
|
|
target_event_result = await db.execute(
|
|
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
|
|
)
|
|
target_row = target_event_result.one_or_none()
|
|
if not target_row:
|
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
|
candidate_ids = [event_id]
|
|
if target_row[0] is not None:
|
|
candidate_ids.append(target_row[0])
|
|
|
|
inv_result = await db.execute(
|
|
select(EventInvitation).where(
|
|
EventInvitation.event_id.in_(candidate_ids),
|
|
EventInvitation.user_id == current_user.id,
|
|
EventInvitation.can_modify == True,
|
|
EventInvitation.status.in_(["accepted", "tentative"]),
|
|
)
|
|
)
|
|
inv = inv_result.scalar_one_or_none()
|
|
if not inv:
|
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
|
|
|
# Load the event directly (bypassing calendar filter)
|
|
event_result = await db.execute(
|
|
select(CalendarEvent)
|
|
.options(selectinload(CalendarEvent.calendar))
|
|
.where(CalendarEvent.id == event_id)
|
|
)
|
|
event = event_result.scalar_one_or_none()
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
|
is_invited_editor = True
|
|
|
|
update_data = event_update.model_dump(exclude_unset=True)
|
|
|
|
if is_invited_editor:
|
|
# Invited editor restrictions — enforce BEFORE any data mutation
|
|
scope_peek = update_data.get("edit_scope")
|
|
# Block all bulk-scope edits on recurring events (C-01/F-01)
|
|
if event.is_recurring and scope_peek != "this":
|
|
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
|
|
# Block calendar moves (C-02)
|
|
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
|
|
raise HTTPException(status_code=403, detail="Invited editors cannot move events between calendars")
|
|
else:
|
|
# Standard calendar-access path: require create_modify+ permission
|
|
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
|
|
|
# Lock check applies to both paths (uses owner's calendar_id)
|
|
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
|
|
|
# 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
|
|
|
|
if not is_invited_editor:
|
|
# 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),
|
|
):
|
|
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
|
|
# Event invitees can VIEW events but must NOT be able to delete them.
|
|
# Invitees use DELETE /api/event-invitations/{id} to leave instead.
|
|
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
|