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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-21 19:07:35 +08:00
parent 7d4089762e
commit 093bceed06
9 changed files with 409 additions and 23 deletions

View File

@ -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')

View File

@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.database import engine 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 @asynccontextmanager
@ -32,6 +32,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(todos.router, prefix="/api/todos", tags=["Todos"]) app.include_router(todos.router, prefix="/api/todos", tags=["Todos"])
app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"]) 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(reminders.router, prefix="/api/reminders", tags=["Reminders"])
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
app.include_router(people.router, prefix="/api/people", tags=["People"]) app.include_router(people.router, prefix="/api/people", tags=["People"])

View File

@ -1,5 +1,6 @@
from app.models.settings import Settings from app.models.settings import Settings
from app.models.todo import Todo from app.models.todo import Todo
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.project import Project from app.models.project import Project
@ -10,6 +11,7 @@ from app.models.location import Location
__all__ = [ __all__ = [
"Settings", "Settings",
"Todo", "Todo",
"Calendar",
"CalendarEvent", "CalendarEvent",
"Reminder", "Reminder",
"Project", "Project",

View File

@ -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")

View File

@ -18,8 +18,17 @@ class CalendarEvent(Base):
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True) location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), 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") 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()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
location: Mapped[Optional["Location"]] = relationship(back_populates="events") 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 ""

View File

@ -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

View File

@ -1,11 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Optional, List from sqlalchemy.orm import selectinload
from datetime import date from typing import Optional, List, Any
from datetime import date, datetime, timedelta
from app.database import get_db from app.database import get_db
from app.models.calendar_event import CalendarEvent 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.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
from app.routers.auth import get_current_session from app.routers.auth import get_current_session
from app.models.settings import Settings from app.models.settings import Settings
@ -13,19 +16,107 @@ from app.models.settings import Settings
router = APIRouter() 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( async def get_events(
start: Optional[date] = Query(None), start: Optional[date] = Query(None),
end: Optional[date] = Query(None), end: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ) -> List[Any]:
"""Get all calendar events with optional date range filtering.""" """Get all calendar events with optional date range filtering, including virtual birthday events."""
query = select(CalendarEvent) query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
)
if start: if start:
query = query.where(CalendarEvent.end_datetime >= start) query = query.where(CalendarEvent.end_datetime >= start)
if end: if end:
query = query.where(CalendarEvent.start_datetime <= end) query = query.where(CalendarEvent.start_datetime <= end)
@ -34,7 +125,28 @@ async def get_events(
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() 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) @router.post("/", response_model=CalendarEventResponse, status_code=201)
@ -43,16 +155,26 @@ async def create_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Create a new calendar event."""
if event.end_datetime < event.start_datetime: if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after 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) db.add(new_event)
await db.commit() 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) @router.get("/{event_id}", response_model=CalendarEventResponse)
@ -61,8 +183,11 @@ async def get_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get a specific calendar event by ID.""" result = await db.execute(
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if not event: if not event:
@ -78,8 +203,11 @@ async def update_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Update a calendar event.""" result = await db.execute(
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if not event: if not event:
@ -87,7 +215,6 @@ async def update_event(
update_data = event_update.model_dump(exclude_unset=True) 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) start = update_data.get("start_datetime", event.start_datetime)
end = update_data.get("end_datetime", event.end_datetime) end = update_data.get("end_datetime", event.end_datetime)
@ -98,9 +225,14 @@ async def update_event(
setattr(event, key, value) setattr(event, key, value)
await db.commit() 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) @router.delete("/{event_id}", status_code=204)
@ -109,7 +241,6 @@ async def delete_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Delete a calendar event."""
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
@ -118,5 +249,4 @@ async def delete_event(
await db.delete(event) await db.delete(event)
await db.commit() await db.commit()
return None return None

View File

@ -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)

View File

@ -13,6 +13,7 @@ class CalendarEventCreate(BaseModel):
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = None
is_starred: bool = False is_starred: bool = False
calendar_id: Optional[int] = None # If None, server assigns default calendar
class CalendarEventUpdate(BaseModel): class CalendarEventUpdate(BaseModel):
@ -25,6 +26,7 @@ class CalendarEventUpdate(BaseModel):
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None
calendar_id: Optional[int] = None
class CalendarEventResponse(BaseModel): class CalendarEventResponse(BaseModel):
@ -38,6 +40,10 @@ class CalendarEventResponse(BaseModel):
location_id: Optional[int] location_id: Optional[int]
recurrence_rule: Optional[str] recurrence_rule: Optional[str]
is_starred: bool is_starred: bool
calendar_id: int
calendar_name: str
calendar_color: str
is_virtual: bool = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime