import json from fastapi import APIRouter, Depends, HTTPException, 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_session from app.models.settings import Settings from app.services.recurrence import generate_occurrences 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) -> int: """Return the id of the default calendar, raising 500 if not found.""" result = await db.execute(select(Calendar).where(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 @router.get("/", response_model=None) async def get_events( start: Optional[date] = Query(None), end: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session), ) -> 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. """ query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) ) # 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 Birthdays calendar; only generate virtual events if visible bday_result = await db.execute( select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) ) bday_calendar = bday_result.scalar_one_or_none() if bday_calendar and bday_calendar.is_visible: people_result = await db.execute(select(Person).where(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: Settings = Depends(get_current_session), ): 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 default if not provided if not data.get("calendar_id"): data["calendar_id"] = await _get_default_calendar_id(db) # Serialize RecurrenceRule object to JSON string for DB storage rule_obj = data.pop("recurrence_rule", None) rule_json: Optional[str] = json.dumps(rule_obj) 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) 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) 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, db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session), ): result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where(CalendarEvent.id == event_id) ) 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, event_update: CalendarEventUpdate, db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session), ): result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where(CalendarEvent.id == event_id) ) event = result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Calendar event not found") 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(rule_obj) if rule_obj else None start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if 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 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, ) ) # 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 # 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) # 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) 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, scope: Optional[Literal["this", "this_and_future"]] = Query(None), db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session), ): 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="Calendar event not found") 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