"""Calendar API router. 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, Query 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 ( CalendarDayResponse, CalendarSlotItem, 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 from app.services.plan_slot import get_virtual_slots_for_date 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, ) # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- @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)