diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index da3fc82..f624ca7 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -5,6 +5,7 @@ BE-CAL-API-001: Single-slot creation endpoint. BE-CAL-API-002: Day-view calendar query endpoint. BE-CAL-API-003: Calendar slot edit endpoints (real + virtual). BE-CAL-API-004: Calendar slot cancel endpoints (real + virtual). +BE-CAL-API-005: Plan schedule / plan list endpoints. """ from datetime import date as date_type @@ -15,7 +16,7 @@ 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.calendar import SchedulePlan, SlotStatus, TimeSlot from app.models.models import User from app.schemas.calendar import ( CalendarDayResponse, @@ -23,6 +24,9 @@ from app.schemas.calendar import ( MinimumWorkloadConfig, MinimumWorkloadResponse, MinimumWorkloadUpdate, + SchedulePlanCreate, + SchedulePlanListResponse, + SchedulePlanResponse, SlotConflictItem, TimeSlotCancelResponse, TimeSlotCreate, @@ -553,6 +557,113 @@ def cancel_virtual_slot( ) +# --------------------------------------------------------------------------- +# SchedulePlan (BE-CAL-API-005) +# --------------------------------------------------------------------------- + +def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse: + """Convert a SchedulePlan ORM object to a response schema.""" + return SchedulePlanResponse( + id=plan.id, + user_id=plan.user_id, + slot_type=plan.slot_type.value if hasattr(plan.slot_type, "value") else str(plan.slot_type), + estimated_duration=plan.estimated_duration, + at_time=plan.at_time.isoformat() if plan.at_time else "", + on_day=plan.on_day.value if plan.on_day and hasattr(plan.on_day, "value") else (str(plan.on_day) if plan.on_day else None), + on_week=plan.on_week, + on_month=plan.on_month.value if plan.on_month and hasattr(plan.on_month, "value") else (str(plan.on_month) if plan.on_month else None), + event_type=plan.event_type.value if plan.event_type and hasattr(plan.event_type, "value") else (str(plan.event_type) if plan.event_type else None), + event_data=plan.event_data, + is_active=plan.is_active, + created_at=plan.created_at, + updated_at=plan.updated_at, + ) + + +@router.post( + "/plans", + response_model=SchedulePlanResponse, + status_code=201, + summary="Create a recurring schedule plan", +) +def create_plan( + payload: SchedulePlanCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new recurring schedule plan. + + The plan defines a template for virtual slots that are generated + on matching dates. Period-parameter hierarchy is enforced: + ``on_month`` requires ``on_week``, which requires ``on_day``. + ``at_time`` is always required. + """ + plan = SchedulePlan( + user_id=current_user.id, + slot_type=payload.slot_type.value, + estimated_duration=payload.estimated_duration, + at_time=payload.at_time, + on_day=payload.on_day.value if payload.on_day else None, + on_week=payload.on_week, + on_month=payload.on_month.value if payload.on_month else None, + event_type=payload.event_type.value if payload.event_type else None, + event_data=payload.event_data, + is_active=True, + ) + db.add(plan) + db.commit() + db.refresh(plan) + + return _plan_to_response(plan) + + +@router.get( + "/plans", + response_model=SchedulePlanListResponse, + summary="List all schedule plans for the current user", +) +def list_plans( + include_inactive: bool = Query(False, description="Include cancelled/inactive plans"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return all schedule plans for the authenticated user. + + By default only active plans are returned. Pass + ``include_inactive=true`` to also include cancelled plans. + """ + q = db.query(SchedulePlan).filter(SchedulePlan.user_id == current_user.id) + if not include_inactive: + q = q.filter(SchedulePlan.is_active.is_(True)) + q = q.order_by(SchedulePlan.created_at.desc()) + plans = q.all() + + return SchedulePlanListResponse( + plans=[_plan_to_response(p) for p in plans], + ) + + +@router.get( + "/plans/{plan_id}", + response_model=SchedulePlanResponse, + summary="Get a single schedule plan by ID", +) +def get_plan( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return a single schedule plan owned by the authenticated user.""" + plan = ( + db.query(SchedulePlan) + .filter(SchedulePlan.id == plan_id, SchedulePlan.user_id == current_user.id) + .first() + ) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found") + return _plan_to_response(plan) + + # --------------------------------------------------------------------------- # MinimumWorkload # --------------------------------------------------------------------------- diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py index 4ed4582..f12456e 100644 --- a/app/schemas/calendar.py +++ b/app/schemas/calendar.py @@ -251,3 +251,89 @@ class TimeSlotCancelResponse(BaseModel): """Response after cancelling a slot — includes the cancelled slot.""" slot: TimeSlotResponse message: str = Field("Slot cancelled successfully", description="Human-readable result") + + +# --------------------------------------------------------------------------- +# SchedulePlan enums (mirror DB enums) +# --------------------------------------------------------------------------- + +class DayOfWeekEnum(str, Enum): + SUN = "sun" + MON = "mon" + TUE = "tue" + WED = "wed" + THU = "thu" + FRI = "fri" + SAT = "sat" + + +class MonthOfYearEnum(str, Enum): + JAN = "jan" + FEB = "feb" + MAR = "mar" + APR = "apr" + MAY = "may" + JUN = "jun" + JUL = "jul" + AUG = "aug" + SEP = "sep" + OCT = "oct" + NOV = "nov" + DEC = "dec" + + +# --------------------------------------------------------------------------- +# SchedulePlan create / response (BE-CAL-API-005) +# --------------------------------------------------------------------------- + +class SchedulePlanCreate(BaseModel): + """Request body for creating a recurring schedule plan.""" + slot_type: SlotTypeEnum = Field(..., description="work | on_call | entertainment | system") + estimated_duration: int = Field(..., ge=1, le=50, description="Duration in minutes (1-50)") + at_time: time = Field(..., description="Daily scheduled time (HH:MM)") + on_day: Optional[DayOfWeekEnum] = Field(None, description="Day of week (sun-sat)") + on_week: Optional[int] = Field(None, ge=1, le=4, description="Week of month (1-4)") + on_month: Optional[MonthOfYearEnum] = Field(None, description="Month (jan-dec)") + event_type: Optional[EventTypeEnum] = Field(None, description="job | entertainment | system_event") + event_data: Optional[dict[str, Any]] = Field(None, description="Event details JSON") + + @field_validator("at_time") + @classmethod + def _validate_at_time(cls, v: time) -> time: + if v.hour > 23: + raise ValueError("at_time hour must be between 00 and 23") + return v + + @model_validator(mode="after") + def _validate_hierarchy(self) -> "SchedulePlanCreate": + """Enforce period-parameter hierarchy: on_month → on_week → on_day.""" + if self.on_month is not None and self.on_week is None: + raise ValueError("on_month requires on_week to be set") + if self.on_week is not None and self.on_day is None: + raise ValueError("on_week requires on_day to be set") + return self + + +class SchedulePlanResponse(BaseModel): + """Response for a single SchedulePlan.""" + id: int + user_id: int + slot_type: str + estimated_duration: int + at_time: str # HH:MM:SS ISO format + on_day: Optional[str] = None + on_week: Optional[int] = None + on_month: Optional[str] = None + event_type: Optional[str] = None + event_data: Optional[dict[str, Any]] = None + is_active: bool + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SchedulePlanListResponse(BaseModel): + """Response for listing schedule plans.""" + plans: list[SchedulePlanResponse] = Field(default_factory=list)