From 093bceed06f9194ae37bb4618c9cbad404bb079a Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sat, 21 Feb 2026 19:07:35 +0800 Subject: [PATCH] Add multi-calendar backend support with virtual birthday events - New Calendar model and calendars table with system/default flags - Alembic migration 006: creates calendars, seeds Personal+Birthdays, migrates existing events - CalendarEvent model gains calendar_id FK and calendar_name/calendar_color properties - Updated CalendarEventCreate/Response schemas to include calendar fields - New /api/calendars CRUD router (blocks system calendar deletion/rename) - Events router: selectinload on all queries, default-calendar assignment on POST, virtual birthday event generation from People with birthdays when Birthdays calendar is visible Co-Authored-By: Claude Opus 4.6 --- backend/alembic/versions/006_add_calendars.py | 89 +++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/calendar.py | 20 ++ backend/app/models/calendar_event.py | 11 +- backend/app/routers/calendars.py | 102 +++++++++++ backend/app/routers/events.py | 172 +++++++++++++++--- backend/app/schemas/calendar.py | 27 +++ backend/app/schemas/calendar_event.py | 6 + 9 files changed, 409 insertions(+), 23 deletions(-) create mode 100644 backend/alembic/versions/006_add_calendars.py create mode 100644 backend/app/models/calendar.py create mode 100644 backend/app/routers/calendars.py create mode 100644 backend/app/schemas/calendar.py diff --git a/backend/alembic/versions/006_add_calendars.py b/backend/alembic/versions/006_add_calendars.py new file mode 100644 index 0000000..d6a0b21 --- /dev/null +++ b/backend/alembic/versions/006_add_calendars.py @@ -0,0 +1,89 @@ +"""Add calendars table and calendar_id to calendar_events + +Revision ID: 006 +Revises: 005 +Create Date: 2026-02-21 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column + + +# revision identifiers, used by Alembic. +revision: str = '006' +down_revision: Union[str, None] = '005' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create the calendars table + op.create_table( + 'calendars', + sa.Column('id', sa.Integer(), primary_key=True, index=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('color', sa.String(20), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('is_system', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('is_visible', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()), + ) + + # 2. Seed the two default calendars + calendars_table = table( + 'calendars', + column('name', sa.String), + column('color', sa.String), + column('is_default', sa.Boolean), + column('is_system', sa.Boolean), + column('is_visible', sa.Boolean), + ) + + op.bulk_insert(calendars_table, [ + { + 'name': 'Personal', + 'color': '#3b82f6', + 'is_default': True, + 'is_system': False, + 'is_visible': True, + }, + { + 'name': 'Birthdays', + 'color': '#ec4899', + 'is_default': False, + 'is_system': True, + 'is_visible': True, + }, + ]) + + # 3. Add calendar_id as nullable first so existing rows don't violate NOT NULL + op.add_column('calendar_events', sa.Column('calendar_id', sa.Integer(), nullable=True)) + + # 4. Set all existing events to the Personal (default) calendar + op.execute( + """ + UPDATE calendar_events + SET calendar_id = (SELECT id FROM calendars WHERE is_default = true LIMIT 1) + WHERE calendar_id IS NULL + """ + ) + + # 5. Alter to NOT NULL and add FK constraint + op.alter_column('calendar_events', 'calendar_id', nullable=False) + op.create_foreign_key( + 'fk_calendar_events_calendar_id', + 'calendar_events', + 'calendars', + ['calendar_id'], + ['id'], + ) + + +def downgrade() -> None: + op.drop_constraint('fk_calendar_events_calendar_id', 'calendar_events', type_='foreignkey') + op.drop_column('calendar_events', 'calendar_id') + op.drop_table('calendars') diff --git a/backend/app/main.py b/backend/app/main.py index 823a07f..1f055d4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from app.database import engine -from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard, weather +from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather @asynccontextmanager @@ -32,6 +32,7 @@ app.add_middleware( app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(todos.router, prefix="/api/todos", tags=["Todos"]) app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"]) +app.include_router(calendars.router, prefix="/api/calendars", tags=["Calendars"]) app.include_router(reminders.router, prefix="/api/reminders", tags=["Reminders"]) app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) app.include_router(people.router, prefix="/api/people", tags=["People"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9ca212b..c8d3e31 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.settings import Settings from app.models.todo import Todo +from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.models.reminder import Reminder from app.models.project import Project @@ -10,6 +11,7 @@ from app.models.location import Location __all__ = [ "Settings", "Todo", + "Calendar", "CalendarEvent", "Reminder", "Project", diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py new file mode 100644 index 0000000..60de5f7 --- /dev/null +++ b/backend/app/models/calendar.py @@ -0,0 +1,20 @@ +from sqlalchemy import String, Boolean, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import List +from app.database import Base + + +class Calendar(Base): + __tablename__ = "calendars" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6") + is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar") diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index 7cce5f4..3fec35e 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -18,8 +18,17 @@ class CalendarEvent(Base): location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) - # Relationships location: Mapped[Optional["Location"]] = relationship(back_populates="events") + calendar: Mapped["Calendar"] = relationship(back_populates="events") + + @property + def calendar_name(self) -> str: + return self.calendar.name if self.calendar else "" + + @property + def calendar_color(self) -> str: + return self.calendar.color if self.calendar else "" diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py new file mode 100644 index 0000000..ba0c292 --- /dev/null +++ b/backend/app/routers/calendars.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from typing import List + +from app.database import get_db +from app.models.calendar import Calendar +from app.models.calendar_event import CalendarEvent +from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[CalendarResponse]) +async def get_calendars( + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) + return result.scalars().all() + + +@router.post("/", response_model=CalendarResponse, status_code=201) +async def create_calendar( + calendar: CalendarCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + new_calendar = Calendar( + name=calendar.name, + color=calendar.color, + is_default=False, + is_system=False, + is_visible=True, + ) + db.add(new_calendar) + await db.commit() + await db.refresh(new_calendar) + return new_calendar + + +@router.put("/{calendar_id}", response_model=CalendarResponse) +async def update_calendar( + calendar_id: int, + calendar_update: CalendarUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + calendar = result.scalar_one_or_none() + + if not calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + update_data = calendar_update.model_dump(exclude_unset=True) + + # System calendars: allow visibility toggle but block name changes + if calendar.is_system and "name" in update_data: + raise HTTPException(status_code=400, detail="Cannot rename system calendars") + + for key, value in update_data.items(): + setattr(calendar, key, value) + + await db.commit() + await db.refresh(calendar) + return calendar + + +@router.delete("/{calendar_id}", status_code=204) +async def delete_calendar( + calendar_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + calendar = result.scalar_one_or_none() + + if not calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + if calendar.is_system: + raise HTTPException(status_code=400, detail="Cannot delete system calendars") + + if calendar.is_default: + raise HTTPException(status_code=400, detail="Cannot delete the default calendar") + + # Reassign all events on this calendar to the default calendar + default_result = await db.execute(select(Calendar).where(Calendar.is_default == True)) + default_calendar = default_result.scalar_one_or_none() + + if default_calendar: + await db.execute( + update(CalendarEvent) + .where(CalendarEvent.calendar_id == calendar_id) + .values(calendar_id=default_calendar.id) + ) + + await db.delete(calendar) + await db.commit() + return None diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 1eaf69a..4b1dc2c 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,11 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from typing import Optional, List -from datetime import date +from sqlalchemy.orm import selectinload +from typing import Optional, List, Any +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 @@ -13,19 +16,107 @@ from app.models.settings import Settings router = APIRouter() -@router.get("/", response_model=List[CalendarEventResponse]) +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, + "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 + # Generate for each year the birthday falls in the range + 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, + "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) -): - """Get all calendar events with optional date range filtering.""" - query = select(CalendarEvent) +) -> List[Any]: + """Get all calendar events with optional date range filtering, including virtual birthday events.""" + query = ( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + ) if start: query = query.where(CalendarEvent.end_datetime >= start) - if end: query = query.where(CalendarEvent.start_datetime <= end) @@ -34,7 +125,28 @@ async def get_events( result = await db.execute(query) events = result.scalars().all() - return events + response: List[dict] = [_event_to_dict(e) for e in events] + + # Fetch Birthdays calendar; only generate virtual events if it exists and is 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) @@ -43,16 +155,26 @@ async def create_event( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Create a new calendar event.""" if event.end_datetime < event.start_datetime: raise HTTPException(status_code=400, detail="End datetime must be after start datetime") - new_event = CalendarEvent(**event.model_dump()) + 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) + + new_event = CalendarEvent(**data) db.add(new_event) await db.commit() - await db.refresh(new_event) - return new_event + # Re-fetch with relationship eagerly loaded + 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) @@ -61,8 +183,11 @@ async def get_event( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Get a specific calendar event by ID.""" - result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == event_id) + ) event = result.scalar_one_or_none() if not event: @@ -78,8 +203,11 @@ async def update_event( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Update a calendar event.""" - result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == event_id) + ) event = result.scalar_one_or_none() if not event: @@ -87,7 +215,6 @@ async def update_event( update_data = event_update.model_dump(exclude_unset=True) - # Validate datetime order if both are being updated start = update_data.get("start_datetime", event.start_datetime) end = update_data.get("end_datetime", event.end_datetime) @@ -98,9 +225,14 @@ async def update_event( setattr(event, key, value) await db.commit() - await db.refresh(event) - return event + # Re-fetch to ensure relationship is fresh after commit + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == event_id) + ) + return result.scalar_one() @router.delete("/{event_id}", status_code=204) @@ -109,7 +241,6 @@ async def delete_event( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Delete a calendar event.""" result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) event = result.scalar_one_or_none() @@ -118,5 +249,4 @@ async def delete_event( await db.delete(event) await db.commit() - return None diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py new file mode 100644 index 0000000..d2eafd4 --- /dev/null +++ b/backend/app/schemas/calendar.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional + + +class CalendarCreate(BaseModel): + name: str + color: str = "#3b82f6" + + +class CalendarUpdate(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + is_visible: Optional[bool] = None + + +class CalendarResponse(BaseModel): + id: int + name: str + color: str + is_default: bool + is_system: bool + is_visible: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index ffa6d66..469dbc5 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -13,6 +13,7 @@ class CalendarEventCreate(BaseModel): location_id: Optional[int] = None recurrence_rule: Optional[str] = None is_starred: bool = False + calendar_id: Optional[int] = None # If None, server assigns default calendar class CalendarEventUpdate(BaseModel): @@ -25,6 +26,7 @@ class CalendarEventUpdate(BaseModel): location_id: Optional[int] = None recurrence_rule: Optional[str] = None is_starred: Optional[bool] = None + calendar_id: Optional[int] = None class CalendarEventResponse(BaseModel): @@ -38,6 +40,10 @@ class CalendarEventResponse(BaseModel): location_id: Optional[int] recurrence_rule: Optional[str] is_starred: bool + calendar_id: int + calendar_name: str + calendar_color: str + is_virtual: bool = False created_at: datetime updated_at: datetime