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 122 additions and 0 deletions
Showing only changes of commit b00c928148 - Show all commits

View File

@@ -4,6 +4,7 @@ BE-CAL-004: MinimumWorkload CRUD endpoints.
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).
"""
from datetime import date as date_type
@@ -23,6 +24,7 @@ from app.schemas.calendar import (
MinimumWorkloadResponse,
MinimumWorkloadUpdate,
SlotConflictItem,
TimeSlotCancelResponse,
TimeSlotCreate,
TimeSlotCreateResponse,
TimeSlotEdit,
@@ -44,6 +46,8 @@ from app.services.plan_slot import (
)
from app.services.slot_immutability import (
ImmutableSlotError,
guard_cancel_real_slot,
guard_cancel_virtual_slot,
guard_edit_real_slot,
guard_edit_virtual_slot,
)
@@ -442,6 +446,113 @@ def edit_virtual_slot(
)
# ---------------------------------------------------------------------------
# Slot cancel (BE-CAL-API-004)
# ---------------------------------------------------------------------------
@router.post(
"/slots/{slot_id}/cancel",
response_model=TimeSlotCancelResponse,
summary="Cancel a real (materialized) calendar slot",
)
def cancel_real_slot(
slot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel an existing real (materialized) slot.
- **Immutability**: rejects cancellation of past slots.
- **Plan detach**: if the slot was materialized from a plan, cancelling
detaches it (sets ``plan_id`` to NULL) so the plan no longer claims
that date.
- **Status**: sets the slot status to ``Skipped``.
"""
slot = (
db.query(TimeSlot)
.filter(TimeSlot.id == slot_id, TimeSlot.user_id == current_user.id)
.first()
)
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
# --- Past-slot guard ---
try:
guard_cancel_real_slot(db, slot)
except ImmutableSlotError as e:
raise HTTPException(status_code=422, detail=str(e))
# --- Detach from plan if applicable ---
if slot.plan_id is not None:
detach_slot_from_plan(slot)
# --- Update status ---
slot.status = SlotStatus.SKIPPED
db.commit()
db.refresh(slot)
return TimeSlotCancelResponse(
slot=_slot_to_response(slot),
message="Slot cancelled successfully",
)
@router.post(
"/slots/virtual/{virtual_id}/cancel",
response_model=TimeSlotCancelResponse,
summary="Cancel a virtual (plan-generated) calendar slot",
)
def cancel_virtual_slot(
virtual_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel a virtual (plan-generated) slot.
This triggers **materialization**: the virtual slot is first converted
into a real TimeSlot row, then immediately set to ``Skipped`` status
and detached from its plan (``plan_id`` set to NULL).
- **Immutability**: rejects cancellation of past virtual slots.
"""
# --- Validate virtual_id format ---
parsed = parse_virtual_slot_id(virtual_id)
if parsed is None:
raise HTTPException(status_code=400, detail=f"Invalid virtual slot id: {virtual_id}")
plan_id, slot_date = parsed
# --- Past-slot guard ---
try:
guard_cancel_virtual_slot(virtual_id)
except ImmutableSlotError as e:
raise HTTPException(status_code=422, detail=str(e))
# --- Materialize ---
try:
slot = materialize_from_virtual_id(db, virtual_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# --- Verify ownership ---
if slot.user_id != current_user.id:
db.rollback()
raise HTTPException(status_code=404, detail="Slot not found")
# --- Detach from plan ---
detach_slot_from_plan(slot)
# --- Update status ---
slot.status = SlotStatus.SKIPPED
db.commit()
db.refresh(slot)
return TimeSlotCancelResponse(
slot=_slot_to_response(slot),
message="Virtual slot materialized and cancelled successfully",
)
# ---------------------------------------------------------------------------
# MinimumWorkload
# ---------------------------------------------------------------------------

View File

@@ -4,6 +4,7 @@ BE-CAL-004: MinimumWorkload read/write schemas.
BE-CAL-API-001: TimeSlot create / response schemas.
BE-CAL-API-002: Calendar day-view query schemas.
BE-CAL-API-003: TimeSlot edit schemas.
BE-CAL-API-004: TimeSlot cancel schemas.
"""
from __future__ import annotations
@@ -240,3 +241,13 @@ class CalendarDayResponse(BaseModel):
default_factory=list,
description="All slots for the day, sorted by scheduled_at ascending",
)
# ---------------------------------------------------------------------------
# TimeSlot cancel (BE-CAL-API-004)
# ---------------------------------------------------------------------------
class TimeSlotCancelResponse(BaseModel):
"""Response after cancelling a slot — includes the cancelled slot."""
slot: TimeSlotResponse
message: str = Field("Slot cancelled successfully", description="Human-readable result")