UMBRA/backend/app/routers/calendars.py
Kyle Pope 0a449f166c Polish pass: action all remaining QA suggestions before merge
P-01: Clamp delta poll since param to max 24h in the past (projects +
calendars) to prevent expensive full-table scans from malicious timestamps.

P-02: Validate individual user_id elements in ProjectMemberInvite and
TaskAssignmentCreate with Annotated[int, Field(ge=1, le=2147483647)].

P-04: Only enable delta polling for shared projects (member_count > 0).
Solo projects skip the 5s poll entirely.

P-05: Remove fragile 200ms onBlur timeout in ProjectShareSheet search.
The onMouseDown preventDefault on dropdown items already prevents blur
from firing before click registers.

P-06/S-04: Replace manual dict construction in model_validators with
__table__.columns iteration so new fields are auto-included.

S-01: Replace bare except in ProjectResponse.compute_member_count with
logger.debug to surface errors in development.

S-03: Consolidate cascade_projects_on_disconnect from 2 project ID
queries into 1 using IN clause with both user IDs.

S-05: Send version in toggleTaskMutation, updateTaskStatusMutation,
and toggleSubtaskMutation for full optimistic locking coverage. Handle
409 with refresh toast.

S-07: Replace window.location.href with React Router navigateRef in
task_assigned toast for client-side navigation.

S-08: Already fixed in previous commit (subtask comment selectinload).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 05:28:34 +08:00

202 lines
6.7 KiB
Python

from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, 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.models.calendar_member import CalendarMember
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
from app.services.calendar_sharing import require_permission
from app.routers.auth import get_current_user
from app.models.user import User
router = APIRouter()
@router.get("/", response_model=List[CalendarResponse])
async def get_calendars(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(
select(Calendar)
.where(Calendar.user_id == current_user.id)
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
)
calendars = result.scalars().all()
# Populate member_count for shared calendars
cal_ids = [c.id for c in calendars if c.is_shared]
count_map: dict[int, int] = {}
if cal_ids:
counts = await db.execute(
select(CalendarMember.calendar_id, func.count())
.where(
CalendarMember.calendar_id.in_(cal_ids),
CalendarMember.status == "accepted",
)
.group_by(CalendarMember.calendar_id)
)
count_map = dict(counts.all())
return [
CalendarResponse.model_validate(c, from_attributes=True).model_copy(
update={"member_count": count_map.get(c.id, 0)}
)
for c in calendars
]
@router.post("/", response_model=CalendarResponse, status_code=201)
async def create_calendar(
calendar: CalendarCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
new_calendar = Calendar(
name=calendar.name,
color=calendar.color,
is_default=False,
is_system=False,
is_visible=True,
user_id=current_user.id,
)
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 = Path(ge=1, le=2147483647),
calendar_update: CalendarUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.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 = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.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 user's default calendar
default_result = await db.execute(
select(Calendar).where(
Calendar.user_id == current_user.id,
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
# ──────────────────────────────────────────────
# DELTA POLLING
# ──────────────────────────────────────────────
class CalendarPollResponse(BaseModel):
has_changes: bool
calendar_updated_at: str | None = None
changed_event_ids: list[int] = []
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
async def poll_calendar(
calendar_id: int = Path(ge=1, le=2147483647),
since: str = Query(..., description="ISO timestamp to check for changes since"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
await require_permission(db, calendar_id, current_user.id, "read_only")
try:
since_dt = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
# Clamp to max 24h in the past to prevent expensive full-table scans
from datetime import timedelta
min_since = datetime.now() - timedelta(hours=24)
if since_dt < min_since:
since_dt = min_since
# Check calendar-level update
cal_result = await db.execute(
select(Calendar.updated_at).where(Calendar.id == calendar_id)
)
calendar_updated = cal_result.scalar_one_or_none()
if not calendar_updated:
raise HTTPException(status_code=404, detail="Calendar not found")
calendar_changed = calendar_updated > since_dt
# Check event-level changes using the ix_events_calendar_updated index
event_result = await db.execute(
select(CalendarEvent.id).where(
CalendarEvent.calendar_id == calendar_id,
CalendarEvent.updated_at > since_dt,
)
)
changed_event_ids = [r[0] for r in event_result.all()]
has_changes = calendar_changed or len(changed_event_ids) > 0
return CalendarPollResponse(
has_changes=has_changes,
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
changed_event_ids=changed_event_ids,
)