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, ) -> 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, } 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) 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, )) # 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