Compare commits
1 Commits
43cf22b654
...
78d836c71e
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d836c71e |
@@ -6,6 +6,7 @@ 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.
|
||||
BE-CAL-API-006: Plan edit / plan cancel endpoints.
|
||||
"""
|
||||
|
||||
from datetime import date as date_type
|
||||
@@ -24,7 +25,9 @@ from app.schemas.calendar import (
|
||||
MinimumWorkloadConfig,
|
||||
MinimumWorkloadResponse,
|
||||
MinimumWorkloadUpdate,
|
||||
SchedulePlanCancelResponse,
|
||||
SchedulePlanCreate,
|
||||
SchedulePlanEdit,
|
||||
SchedulePlanListResponse,
|
||||
SchedulePlanResponse,
|
||||
SlotConflictItem,
|
||||
@@ -54,6 +57,8 @@ from app.services.slot_immutability import (
|
||||
guard_cancel_virtual_slot,
|
||||
guard_edit_real_slot,
|
||||
guard_edit_virtual_slot,
|
||||
guard_plan_cancel_no_past_retroaction,
|
||||
guard_plan_edit_no_past_retroaction,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["Calendar"])
|
||||
@@ -664,6 +669,178 @@ def get_plan(
|
||||
return _plan_to_response(plan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan edit / cancel (BE-CAL-API-006)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_plan_hierarchy(
|
||||
on_day: str | None,
|
||||
on_week: int | None,
|
||||
on_month: str | None,
|
||||
) -> None:
|
||||
"""Enforce period-parameter hierarchy after merging edited values.
|
||||
|
||||
Raises HTTPException(422) if the hierarchy is violated.
|
||||
"""
|
||||
if on_month is not None and on_week is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="on_month requires on_week to be set",
|
||||
)
|
||||
if on_week is not None and on_day is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="on_week requires on_day to be set",
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/plans/{plan_id}",
|
||||
response_model=SchedulePlanResponse,
|
||||
summary="Edit a recurring schedule plan",
|
||||
)
|
||||
def edit_plan(
|
||||
plan_id: int,
|
||||
payload: SchedulePlanEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Edit an existing schedule plan.
|
||||
|
||||
- Only **future** virtual/materialized slots are affected; past
|
||||
materialized slots remain untouched.
|
||||
- Period-parameter hierarchy (``on_month`` → ``on_week`` → ``on_day``)
|
||||
is validated after merging edited values with existing plan values.
|
||||
- Inactive (cancelled) plans cannot be edited.
|
||||
"""
|
||||
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")
|
||||
|
||||
if not plan.is_active:
|
||||
raise HTTPException(status_code=422, detail="Cannot edit an inactive (cancelled) plan")
|
||||
|
||||
# --- Identify past slots that must NOT be touched ---
|
||||
_past_ids = guard_plan_edit_no_past_retroaction(db, plan_id)
|
||||
|
||||
# --- Apply clear flags first (set to NULL) ---
|
||||
if payload.clear_on_month:
|
||||
plan.on_month = None
|
||||
if payload.clear_on_week:
|
||||
plan.on_week = None
|
||||
if payload.clear_on_day:
|
||||
plan.on_day = None
|
||||
|
||||
# --- Apply provided values ---
|
||||
if payload.slot_type is not None:
|
||||
plan.slot_type = payload.slot_type.value
|
||||
if payload.estimated_duration is not None:
|
||||
plan.estimated_duration = payload.estimated_duration
|
||||
if payload.at_time is not None:
|
||||
plan.at_time = payload.at_time
|
||||
if payload.on_day is not None:
|
||||
plan.on_day = payload.on_day.value
|
||||
if payload.on_week is not None:
|
||||
plan.on_week = payload.on_week
|
||||
if payload.on_month is not None:
|
||||
plan.on_month = payload.on_month.value
|
||||
if payload.event_type is not None:
|
||||
plan.event_type = payload.event_type.value
|
||||
if payload.event_data is not None:
|
||||
plan.event_data = payload.event_data
|
||||
|
||||
# --- Validate hierarchy with merged values ---
|
||||
effective_on_day = plan.on_day
|
||||
effective_on_week = plan.on_week
|
||||
effective_on_month = plan.on_month
|
||||
_validate_plan_hierarchy(effective_on_day, effective_on_week, effective_on_month)
|
||||
|
||||
# --- Detach future materialized slots so they keep old data ---
|
||||
# Future materialized slots with plan_id set are detached because
|
||||
# they were generated from the old plan template. New virtual slots
|
||||
# will reflect the updated plan going forward.
|
||||
from datetime import date as date_type
|
||||
today = date_type.today()
|
||||
future_materialized = (
|
||||
db.query(TimeSlot)
|
||||
.filter(
|
||||
TimeSlot.plan_id == plan_id,
|
||||
TimeSlot.date >= today,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for slot in future_materialized:
|
||||
slot.plan_id = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
|
||||
return _plan_to_response(plan)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans/{plan_id}/cancel",
|
||||
response_model=SchedulePlanCancelResponse,
|
||||
summary="Cancel a recurring schedule plan",
|
||||
)
|
||||
def cancel_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Cancel (soft-delete) a schedule plan.
|
||||
|
||||
- Sets the plan's ``is_active`` flag to ``False``.
|
||||
- **Past** materialized slots are preserved untouched.
|
||||
- **Future** materialized slots that still reference this plan are
|
||||
detached (``plan_id`` set to NULL) so they remain on the calendar
|
||||
as standalone slots. If you want to also cancel those future slots,
|
||||
cancel them individually via the slot-cancel endpoints.
|
||||
"""
|
||||
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")
|
||||
|
||||
if not plan.is_active:
|
||||
raise HTTPException(status_code=422, detail="Plan is already cancelled")
|
||||
|
||||
# --- Identify past slots that must NOT be touched ---
|
||||
past_ids = guard_plan_cancel_no_past_retroaction(db, plan_id)
|
||||
|
||||
# --- Detach future materialized slots ---
|
||||
from datetime import date as date_type
|
||||
today = date_type.today()
|
||||
future_materialized = (
|
||||
db.query(TimeSlot)
|
||||
.filter(
|
||||
TimeSlot.plan_id == plan_id,
|
||||
TimeSlot.date >= today,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for slot in future_materialized:
|
||||
slot.plan_id = None
|
||||
|
||||
# --- Deactivate the plan ---
|
||||
plan.is_active = False
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
|
||||
return SchedulePlanCancelResponse(
|
||||
plan=_plan_to_response(plan),
|
||||
message="Plan cancelled successfully",
|
||||
preserved_past_slot_ids=past_ids,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MinimumWorkload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -337,3 +337,57 @@ class SchedulePlanResponse(BaseModel):
|
||||
class SchedulePlanListResponse(BaseModel):
|
||||
"""Response for listing schedule plans."""
|
||||
plans: list[SchedulePlanResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SchedulePlan edit / cancel (BE-CAL-API-006)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SchedulePlanEdit(BaseModel):
|
||||
"""Request body for editing a recurring schedule plan.
|
||||
|
||||
All fields are optional — only provided fields are updated.
|
||||
Period-parameter hierarchy (on_month → on_week → on_day) is
|
||||
validated after merging with existing plan values.
|
||||
"""
|
||||
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
|
||||
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
|
||||
at_time: Optional[time] = Field(None, description="New daily time (HH:MM)")
|
||||
on_day: Optional[DayOfWeekEnum] = Field(None, description="New day of week (sun-sat), use 'clear' param to remove")
|
||||
on_week: Optional[int] = Field(None, ge=1, le=4, description="New week of month (1-4), use 'clear' param to remove")
|
||||
on_month: Optional[MonthOfYearEnum] = Field(None, description="New month (jan-dec), use 'clear' param to remove")
|
||||
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
|
||||
event_data: Optional[dict[str, Any]] = Field(None, description="New event details JSON")
|
||||
clear_on_day: bool = Field(False, description="Clear on_day (set to NULL)")
|
||||
clear_on_week: bool = Field(False, description="Clear on_week (set to NULL)")
|
||||
clear_on_month: bool = Field(False, description="Clear on_month (set to NULL)")
|
||||
|
||||
@field_validator("at_time")
|
||||
@classmethod
|
||||
def _validate_at_time(cls, v: Optional[time]) -> Optional[time]:
|
||||
if v is not None and v.hour > 23:
|
||||
raise ValueError("at_time hour must be between 00 and 23")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _at_least_one_field(self) -> "SchedulePlanEdit":
|
||||
"""Ensure at least one editable field or clear flag is provided."""
|
||||
has_value = any(
|
||||
getattr(self, f) is not None
|
||||
for f in ("slot_type", "estimated_duration", "at_time", "on_day",
|
||||
"on_week", "on_month", "event_type", "event_data")
|
||||
)
|
||||
has_clear = self.clear_on_day or self.clear_on_week or self.clear_on_month
|
||||
if not has_value and not has_clear:
|
||||
raise ValueError("At least one field must be provided for edit")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulePlanCancelResponse(BaseModel):
|
||||
"""Response after cancelling a plan."""
|
||||
plan: SchedulePlanResponse
|
||||
message: str = Field("Plan cancelled successfully", description="Human-readable result")
|
||||
preserved_past_slot_ids: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of past materialized slots that were NOT affected",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user