HarborForge.Backend: dev-2026-03-29 -> main #13
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user