HarborForge.Backend: dev-2026-03-29 -> main #13

Merged
hzhang merged 43 commits from dev-2026-03-29 into main 2026-04-05 22:08:15 +00:00
2 changed files with 198 additions and 1 deletions
Showing only changes of commit 43cf22b654 - Show all commits

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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)