Files
HarborForge.Backend/app/api/routers/calendar.py
zhi 751b3bc574 BE-CAL-API-001: Implement single slot creation API
- Add TimeSlotCreate, TimeSlotResponse, TimeSlotCreateResponse schemas
- Add SlotConflictItem, SlotTypeEnum, EventTypeEnum, SlotStatusEnum to schemas
- Add POST /calendar/slots endpoint with overlap detection and workload warnings
- Add _slot_to_response helper for ORM -> schema conversion
2026-03-31 05:45:58 +00:00

245 lines
8.0 KiB
Python

"""Calendar API router.
BE-CAL-004: MinimumWorkload CRUD endpoints.
BE-CAL-API-001: Single-slot creation endpoint.
"""
from datetime import date as date_type
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
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 (
MinimumWorkloadConfig,
MinimumWorkloadResponse,
MinimumWorkloadUpdate,
SlotConflictItem,
TimeSlotCreate,
TimeSlotCreateResponse,
TimeSlotResponse,
)
from app.services.minimum_workload import (
get_workload_config,
get_workload_warnings_for_date,
replace_workload_config,
upsert_workload_config,
)
from app.services.overlap import check_overlap_for_create
router = APIRouter(prefix="/calendar", tags=["Calendar"])
# ---------------------------------------------------------------------------
# TimeSlot creation (BE-CAL-API-001)
# ---------------------------------------------------------------------------
def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
"""Convert a TimeSlot ORM object to a response schema."""
return TimeSlotResponse(
id=slot.id,
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,
)
@router.post(
"/slots",
response_model=TimeSlotCreateResponse,
status_code=201,
summary="Create a single calendar slot",
)
def create_slot(
payload: TimeSlotCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a one-off calendar slot.
- **Overlap detection**: rejects the request if the proposed slot
overlaps with existing real or virtual slots on the same day.
- **Workload warnings**: after successful creation, returns any
minimum-workload warnings (advisory only, does not block creation).
"""
target_date = payload.date or date_type.today()
# --- Overlap check (hard reject) ---
conflicts = check_overlap_for_create(
db,
user_id=current_user.id,
target_date=target_date,
scheduled_at=payload.scheduled_at,
estimated_duration=payload.estimated_duration,
)
if conflicts:
raise HTTPException(
status_code=409,
detail={
"message": "Slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Create the slot ---
slot = TimeSlot(
user_id=current_user.id,
date=target_date,
slot_type=payload.slot_type.value,
estimated_duration=payload.estimated_duration,
scheduled_at=payload.scheduled_at,
event_type=payload.event_type.value if payload.event_type else None,
event_data=payload.event_data,
priority=payload.priority,
status=SlotStatus.NOT_STARTED,
)
db.add(slot)
db.commit()
db.refresh(slot)
# --- Workload warnings (advisory) ---
warnings = get_workload_warnings_for_date(db, current_user.id, target_date)
return TimeSlotCreateResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
# ---------------------------------------------------------------------------
# MinimumWorkload
# ---------------------------------------------------------------------------
@router.get(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Get current user's minimum workload configuration",
)
def get_my_workload_config(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the workload thresholds for the authenticated user.
If no configuration has been saved yet, returns default (all-zero)
thresholds.
"""
cfg = get_workload_config(db, current_user.id)
return MinimumWorkloadResponse(user_id=current_user.id, config=cfg)
@router.put(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Replace the current user's minimum workload configuration",
)
def put_my_workload_config(
payload: MinimumWorkloadConfig,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Full replacement of the workload configuration."""
row = replace_workload_config(db, current_user.id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=current_user.id, config=row.config)
@router.patch(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Partially update the current user's minimum workload configuration",
)
def patch_my_workload_config(
payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Partial update — only the provided periods are overwritten."""
row = upsert_workload_config(db, current_user.id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=current_user.id, config=row.config)
# ---------------------------------------------------------------------------
# Admin: manage another user's workload config
# ---------------------------------------------------------------------------
def _require_admin(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin required")
return current_user
@router.get(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Get a specific user's minimum workload configuration",
)
def get_user_workload_config(
user_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
cfg = get_workload_config(db, user_id)
return MinimumWorkloadResponse(user_id=user_id, config=cfg)
@router.put(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Replace a specific user's minimum workload configuration",
)
def put_user_workload_config(
user_id: int,
payload: MinimumWorkloadConfig,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
row = replace_workload_config(db, user_id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=user_id, config=row.config)
@router.patch(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Partially update a specific user's minimum workload configuration",
)
def patch_user_workload_config(
user_id: int,
payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
row = upsert_workload_config(db, user_id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=user_id, config=row.config)