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