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:
parent
7d4089762e
commit
093bceed06
89
backend/alembic/versions/006_add_calendars.py
Normal file
89
backend/alembic/versions/006_add_calendars.py
Normal 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')
|
||||||
@ -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"])
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
20
backend/app/models/calendar.py
Normal file
20
backend/app/models/calendar.py
Normal 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")
|
||||||
@ -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 ""
|
||||||
|
|||||||
102
backend/app/routers/calendars.py
Normal file
102
backend/app/routers/calendars.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
27
backend/app/schemas/calendar.py
Normal file
27
backend/app/schemas/calendar.py
Normal 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)
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user