diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index eb05f91..34fc3fa 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -2,11 +2,13 @@ BE-CAL-004: MinimumWorkload CRUD endpoints. BE-CAL-API-001: Single-slot creation endpoint. +BE-CAL-API-002: Day-view calendar query endpoint. """ from datetime import date as date_type +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.api.deps import get_current_user @@ -14,6 +16,8 @@ from app.core.config import get_db from app.models.calendar import SlotStatus, TimeSlot from app.models.models import User from app.schemas.calendar import ( + CalendarDayResponse, + CalendarSlotItem, MinimumWorkloadConfig, MinimumWorkloadResponse, MinimumWorkloadUpdate, @@ -29,6 +33,7 @@ from app.services.minimum_workload import ( upsert_workload_config, ) from app.services.overlap import check_overlap_for_create +from app.services.plan_slot import get_virtual_slots_for_date router = APIRouter(prefix="/calendar", tags=["Calendar"]) @@ -121,6 +126,124 @@ def create_slot( ) +# --------------------------------------------------------------------------- +# Day-view query (BE-CAL-API-002) +# --------------------------------------------------------------------------- + +# Statuses that no longer occupy calendar time — hidden from default view. +_INACTIVE_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.value} + + +def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem: + """Convert a real TimeSlot ORM object to a CalendarSlotItem.""" + return CalendarSlotItem( + id=slot.id, + virtual_id=None, + user_id=slot.user_id, + date=slot.date, + slot_type=slot.slot_type.value if hasattr(slot.slot_type, "value") else str(slot.slot_type), + estimated_duration=slot.estimated_duration, + scheduled_at=slot.scheduled_at.isoformat() if slot.scheduled_at else "", + started_at=slot.started_at.isoformat() if slot.started_at else None, + attended=slot.attended, + actual_duration=slot.actual_duration, + event_type=slot.event_type.value if slot.event_type and hasattr(slot.event_type, "value") else (str(slot.event_type) if slot.event_type else None), + event_data=slot.event_data, + priority=slot.priority, + status=slot.status.value if hasattr(slot.status, "value") else str(slot.status), + plan_id=slot.plan_id, + created_at=slot.created_at, + updated_at=slot.updated_at, + ) + + +def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem: + """Convert a virtual-slot dict to a CalendarSlotItem.""" + slot_type = vs["slot_type"] + slot_type_str = slot_type.value if hasattr(slot_type, "value") else str(slot_type) + + event_type = vs.get("event_type") + event_type_str = None + if event_type is not None: + event_type_str = event_type.value if hasattr(event_type, "value") else str(event_type) + + status = vs["status"] + status_str = status.value if hasattr(status, "value") else str(status) + + scheduled_at = vs["scheduled_at"] + scheduled_at_str = scheduled_at.isoformat() if hasattr(scheduled_at, "isoformat") else str(scheduled_at) + + return CalendarSlotItem( + id=None, + virtual_id=vs["virtual_id"], + user_id=vs["user_id"], + date=vs["date"], + slot_type=slot_type_str, + estimated_duration=vs["estimated_duration"], + scheduled_at=scheduled_at_str, + started_at=None, + attended=vs.get("attended", False), + actual_duration=vs.get("actual_duration"), + event_type=event_type_str, + event_data=vs.get("event_data"), + priority=vs.get("priority", 0), + status=status_str, + plan_id=vs.get("plan_id"), + created_at=None, + updated_at=None, + ) + + +@router.get( + "/day", + response_model=CalendarDayResponse, + summary="Get all calendar slots for a given day", +) +def get_calendar_day( + date: Optional[date_type] = Query(None, description="Target date (defaults to today)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return all calendar slots for the authenticated user on the given date. + + The response merges: + 1. **Real (materialized) slots** — rows from the ``time_slots`` table. + 2. **Virtual (plan-generated) slots** — synthesized from active + ``SchedulePlan`` rules that match the date but have not yet been + materialized. + + All slots are sorted by ``scheduled_at`` ascending. Inactive slots + (skipped / aborted) are excluded by default. + """ + target_date = date or date_type.today() + + # 1. Fetch real slots for the day + real_slots = ( + db.query(TimeSlot) + .filter( + TimeSlot.user_id == current_user.id, + TimeSlot.date == target_date, + TimeSlot.status.notin_(list(_INACTIVE_STATUSES)), + ) + .all() + ) + + items: list[CalendarSlotItem] = [_real_slot_to_item(s) for s in real_slots] + + # 2. Synthesize virtual plan slots for the day + virtual_slots = get_virtual_slots_for_date(db, current_user.id, target_date) + items.extend(_virtual_slot_to_item(vs) for vs in virtual_slots) + + # 3. Sort by scheduled_at ascending + items.sort(key=lambda item: item.scheduled_at) + + return CalendarDayResponse( + date=target_date, + user_id=current_user.id, + slots=items, + ) + + # --------------------------------------------------------------------------- # MinimumWorkload # --------------------------------------------------------------------------- diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py index 8e59ec3..1874cc2 100644 --- a/app/schemas/calendar.py +++ b/app/schemas/calendar.py @@ -2,6 +2,7 @@ BE-CAL-004: MinimumWorkload read/write schemas. BE-CAL-API-001: TimeSlot create / response schemas. +BE-CAL-API-002: Calendar day-view query schemas. """ from __future__ import annotations @@ -152,3 +153,46 @@ class TimeSlotCreateResponse(BaseModel): """Response after creating a slot — includes the slot and any warnings.""" slot: TimeSlotResponse warnings: list[WorkloadWarningItem] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Calendar day-view query (BE-CAL-API-002) +# --------------------------------------------------------------------------- + +class CalendarSlotItem(BaseModel): + """Unified slot item for day-view — covers both real and virtual slots. + + * For **real** (materialized) slots: ``id`` is set, ``virtual_id`` is None. + * For **virtual** (plan-generated) slots: ``id`` is None, ``virtual_id`` + is the ``plan-{plan_id}-{date}`` identifier. + """ + id: Optional[int] = Field(None, description="Real slot DB id (None for virtual)") + virtual_id: Optional[str] = Field(None, description="Virtual slot id (None for real)") + user_id: int + date: date + slot_type: str + estimated_duration: int + scheduled_at: str # HH:MM:SS ISO format + started_at: Optional[str] = None + attended: bool + actual_duration: Optional[int] = None + event_type: Optional[str] = None + event_data: Optional[dict[str, Any]] = None + priority: int + status: str + plan_id: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class CalendarDayResponse(BaseModel): + """Response for a single-day calendar query.""" + date: date + user_id: int + slots: list[CalendarSlotItem] = Field( + default_factory=list, + description="All slots for the day, sorted by scheduled_at ascending", + )