Files
HarborForge.Backend/app/api/routers/calendar.py
zhi 22a0097a5d BE-CAL-API-007: implement date-list API endpoint
- Add GET /calendar/dates endpoint that returns sorted future dates
  with at least one materialized (real) slot
- Excludes skipped/aborted slots and pure plan-generated virtual dates
- Add DateListResponse schema
2026-03-31 20:46:34 +00:00

1010 lines
33 KiB
Python

"""Calendar API router.
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).
BE-CAL-API-005: Plan schedule / plan list endpoints.
BE-CAL-API-006: Plan edit / plan cancel endpoints.
BE-CAL-API-007: Date-list endpoint.
"""
from datetime import date as date_type
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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 SchedulePlan, SlotStatus, TimeSlot
from app.models.models import User
from app.schemas.calendar import (
CalendarDayResponse,
CalendarSlotItem,
DateListResponse,
MinimumWorkloadConfig,
MinimumWorkloadResponse,
MinimumWorkloadUpdate,
SchedulePlanCancelResponse,
SchedulePlanCreate,
SchedulePlanEdit,
SchedulePlanListResponse,
SchedulePlanResponse,
SlotConflictItem,
TimeSlotCancelResponse,
TimeSlotCreate,
TimeSlotCreateResponse,
TimeSlotEdit,
TimeSlotEditResponse,
TimeSlotResponse,
)
from app.services.minimum_workload import (
get_workload_config,
get_workload_warnings_for_date,
replace_workload_config,
upsert_workload_config,
)
from app.services.overlap import check_overlap_for_create, check_overlap_for_edit
from app.services.plan_slot import (
detach_slot_from_plan,
get_virtual_slots_for_date,
materialize_from_virtual_id,
parse_virtual_slot_id,
)
from app.services.slot_immutability import (
ImmutableSlotError,
guard_cancel_real_slot,
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"])
# ---------------------------------------------------------------------------
# TimeSlot creation (BE-CAL-API-001)
# ---------------------------------------------------------------------------
def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
"""Convert a TimeSlot ORM object to a response schema."""
return TimeSlotResponse(
id=slot.id,
user_id=slot.user_id,
date=slot.date,
slot_type=slot.slot_type.value if hasattr(slot.slot_type, "value") else str(slot.slot_type),
estimated_duration=slot.estimated_duration,
scheduled_at=slot.scheduled_at.isoformat() if slot.scheduled_at else "",
started_at=slot.started_at.isoformat() if slot.started_at else None,
attended=slot.attended,
actual_duration=slot.actual_duration,
event_type=slot.event_type.value if slot.event_type and hasattr(slot.event_type, "value") else (str(slot.event_type) if slot.event_type else None),
event_data=slot.event_data,
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
created_at=slot.created_at,
updated_at=slot.updated_at,
)
@router.post(
"/slots",
response_model=TimeSlotCreateResponse,
status_code=201,
summary="Create a single calendar slot",
)
def create_slot(
payload: TimeSlotCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a one-off calendar slot.
- **Overlap detection**: rejects the request if the proposed slot
overlaps with existing real or virtual slots on the same day.
- **Workload warnings**: after successful creation, returns any
minimum-workload warnings (advisory only, does not block creation).
"""
target_date = payload.date or date_type.today()
# --- Overlap check (hard reject) ---
conflicts = check_overlap_for_create(
db,
user_id=current_user.id,
target_date=target_date,
scheduled_at=payload.scheduled_at,
estimated_duration=payload.estimated_duration,
)
if conflicts:
raise HTTPException(
status_code=409,
detail={
"message": "Slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Create the slot ---
slot = TimeSlot(
user_id=current_user.id,
date=target_date,
slot_type=payload.slot_type.value,
estimated_duration=payload.estimated_duration,
scheduled_at=payload.scheduled_at,
event_type=payload.event_type.value if payload.event_type else None,
event_data=payload.event_data,
priority=payload.priority,
status=SlotStatus.NOT_STARTED,
)
db.add(slot)
db.commit()
db.refresh(slot)
# --- Workload warnings (advisory) ---
warnings = get_workload_warnings_for_date(db, current_user.id, target_date)
return TimeSlotCreateResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
# ---------------------------------------------------------------------------
# Day-view query (BE-CAL-API-002)
# ---------------------------------------------------------------------------
# Statuses that no longer occupy calendar time — hidden from default view.
_INACTIVE_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.value}
def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
"""Convert a real TimeSlot ORM object to a CalendarSlotItem."""
return CalendarSlotItem(
id=slot.id,
virtual_id=None,
user_id=slot.user_id,
date=slot.date,
slot_type=slot.slot_type.value if hasattr(slot.slot_type, "value") else str(slot.slot_type),
estimated_duration=slot.estimated_duration,
scheduled_at=slot.scheduled_at.isoformat() if slot.scheduled_at else "",
started_at=slot.started_at.isoformat() if slot.started_at else None,
attended=slot.attended,
actual_duration=slot.actual_duration,
event_type=slot.event_type.value if slot.event_type and hasattr(slot.event_type, "value") else (str(slot.event_type) if slot.event_type else None),
event_data=slot.event_data,
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
created_at=slot.created_at,
updated_at=slot.updated_at,
)
def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
"""Convert a virtual-slot dict to a CalendarSlotItem."""
slot_type = vs["slot_type"]
slot_type_str = slot_type.value if hasattr(slot_type, "value") else str(slot_type)
event_type = vs.get("event_type")
event_type_str = None
if event_type is not None:
event_type_str = event_type.value if hasattr(event_type, "value") else str(event_type)
status = vs["status"]
status_str = status.value if hasattr(status, "value") else str(status)
scheduled_at = vs["scheduled_at"]
scheduled_at_str = scheduled_at.isoformat() if hasattr(scheduled_at, "isoformat") else str(scheduled_at)
return CalendarSlotItem(
id=None,
virtual_id=vs["virtual_id"],
user_id=vs["user_id"],
date=vs["date"],
slot_type=slot_type_str,
estimated_duration=vs["estimated_duration"],
scheduled_at=scheduled_at_str,
started_at=None,
attended=vs.get("attended", False),
actual_duration=vs.get("actual_duration"),
event_type=event_type_str,
event_data=vs.get("event_data"),
priority=vs.get("priority", 0),
status=status_str,
plan_id=vs.get("plan_id"),
created_at=None,
updated_at=None,
)
@router.get(
"/day",
response_model=CalendarDayResponse,
summary="Get all calendar slots for a given day",
)
def get_calendar_day(
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return all calendar slots for the authenticated user on the given date.
The response merges:
1. **Real (materialized) slots** — rows from the ``time_slots`` table.
2. **Virtual (plan-generated) slots** — synthesized from active
``SchedulePlan`` rules that match the date but have not yet been
materialized.
All slots are sorted by ``scheduled_at`` ascending. Inactive slots
(skipped / aborted) are excluded by default.
"""
target_date = date or date_type.today()
# 1. Fetch real slots for the day
real_slots = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == current_user.id,
TimeSlot.date == target_date,
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
)
.all()
)
items: list[CalendarSlotItem] = [_real_slot_to_item(s) for s in real_slots]
# 2. Synthesize virtual plan slots for the day
virtual_slots = get_virtual_slots_for_date(db, current_user.id, target_date)
items.extend(_virtual_slot_to_item(vs) for vs in virtual_slots)
# 3. Sort by scheduled_at ascending
items.sort(key=lambda item: item.scheduled_at)
return CalendarDayResponse(
date=target_date,
user_id=current_user.id,
slots=items,
)
# ---------------------------------------------------------------------------
# Slot edit (BE-CAL-API-003)
# ---------------------------------------------------------------------------
def _apply_edit_fields(slot: TimeSlot, payload: TimeSlotEdit) -> None:
"""Apply non-None fields from *payload* to a TimeSlot ORM object."""
if payload.slot_type is not None:
slot.slot_type = payload.slot_type.value
if payload.scheduled_at is not None:
slot.scheduled_at = payload.scheduled_at
if payload.estimated_duration is not None:
slot.estimated_duration = payload.estimated_duration
if payload.event_type is not None:
slot.event_type = payload.event_type.value
if payload.event_data is not None:
slot.event_data = payload.event_data
if payload.priority is not None:
slot.priority = payload.priority
@router.patch(
"/slots/{slot_id}",
response_model=TimeSlotEditResponse,
summary="Edit a real (materialized) calendar slot",
)
def edit_real_slot(
slot_id: int,
payload: TimeSlotEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Edit an existing real (materialized) slot.
- **Immutability**: rejects edits to past slots.
- **Overlap detection**: if time/duration changed, rejects on overlap
(excluding the slot being edited).
- **Plan detach**: if the slot was materialized from a plan, editing
detaches it (sets ``plan_id`` to NULL).
- **Workload warnings**: returned after successful edit (advisory only).
"""
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_edit_real_slot(db, slot)
except ImmutableSlotError as e:
raise HTTPException(status_code=422, detail=str(e))
# --- Determine effective time/duration for overlap check ---
effective_scheduled_at = payload.scheduled_at if payload.scheduled_at is not None else slot.scheduled_at
effective_duration = payload.estimated_duration if payload.estimated_duration is not None else slot.estimated_duration
# --- Overlap check (if time or duration changed) ---
time_changed = (
(payload.scheduled_at is not None and payload.scheduled_at != slot.scheduled_at)
or (payload.estimated_duration is not None and payload.estimated_duration != slot.estimated_duration)
)
if time_changed:
conflicts = check_overlap_for_edit(
db,
user_id=current_user.id,
slot_id=slot.id,
target_date=slot.date,
scheduled_at=effective_scheduled_at,
estimated_duration=effective_duration,
)
if conflicts:
raise HTTPException(
status_code=409,
detail={
"message": "Edited slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Detach from plan if applicable ---
if slot.plan_id is not None:
detach_slot_from_plan(slot)
# --- Apply edits ---
_apply_edit_fields(slot, payload)
db.commit()
db.refresh(slot)
# --- Workload warnings ---
warnings = get_workload_warnings_for_date(db, current_user.id, slot.date)
return TimeSlotEditResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
@router.patch(
"/slots/virtual/{virtual_id}",
response_model=TimeSlotEditResponse,
summary="Edit a virtual (plan-generated) calendar slot",
)
def edit_virtual_slot(
virtual_id: str,
payload: TimeSlotEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Edit a virtual (plan-generated) slot.
This triggers **materialization**: the virtual slot is first converted
into a real TimeSlot row, then the edits are applied, and the slot is
detached from its plan (``plan_id`` set to NULL).
- **Immutability**: rejects edits to past virtual slots.
- **Overlap detection**: checks overlap with the edited time/duration
against existing slots on the same day.
- **Workload warnings**: returned after successful edit (advisory only).
"""
# --- 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_edit_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")
# --- Determine effective time/duration for overlap check ---
effective_scheduled_at = payload.scheduled_at if payload.scheduled_at is not None else slot.scheduled_at
effective_duration = payload.estimated_duration if payload.estimated_duration is not None else slot.estimated_duration
# --- Overlap check (exclude newly materialized slot) ---
conflicts = check_overlap_for_edit(
db,
user_id=current_user.id,
slot_id=slot.id,
target_date=slot.date,
scheduled_at=effective_scheduled_at,
estimated_duration=effective_duration,
)
if conflicts:
db.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Edited slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Detach from plan ---
detach_slot_from_plan(slot)
# --- Apply edits ---
_apply_edit_fields(slot, payload)
db.commit()
db.refresh(slot)
# --- Workload warnings ---
warnings = get_workload_warnings_for_date(db, current_user.id, slot.date)
return TimeSlotEditResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
# ---------------------------------------------------------------------------
# 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",
)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# 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,
)
# ---------------------------------------------------------------------------
# Date list (BE-CAL-API-007)
# ---------------------------------------------------------------------------
# Statuses considered inactive — slots with these statuses are excluded
# from the date-list result because they no longer occupy calendar time.
_DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.value}
@router.get(
"/dates",
response_model=DateListResponse,
summary="List future dates that have materialized slots",
)
def list_dates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return a sorted list of future dates that have at least one
materialized (real) slot.
- Only dates **today or later** are included.
- Only **active** slots are counted (skipped / aborted are excluded).
- Pure plan-generated (virtual) dates that have not been materialized
are **not** included.
"""
today = date_type.today()
rows = (
db.query(TimeSlot.date)
.filter(
TimeSlot.user_id == current_user.id,
TimeSlot.date >= today,
TimeSlot.status.notin_(list(_DATE_LIST_EXCLUDED_STATUSES)),
)
.group_by(TimeSlot.date)
.order_by(TimeSlot.date.asc())
.all()
)
return DateListResponse(dates=[r[0] for r in rows])
# ---------------------------------------------------------------------------
# MinimumWorkload
# ---------------------------------------------------------------------------
@router.get(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Get current user's minimum workload configuration",
)
def get_my_workload_config(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the workload thresholds for the authenticated user.
If no configuration has been saved yet, returns default (all-zero)
thresholds.
"""
cfg = get_workload_config(db, current_user.id)
return MinimumWorkloadResponse(user_id=current_user.id, config=cfg)
@router.put(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Replace the current user's minimum workload configuration",
)
def put_my_workload_config(
payload: MinimumWorkloadConfig,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Full replacement of the workload configuration."""
row = replace_workload_config(db, current_user.id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=current_user.id, config=row.config)
@router.patch(
"/workload-config",
response_model=MinimumWorkloadResponse,
summary="Partially update the current user's minimum workload configuration",
)
def patch_my_workload_config(
payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Partial update — only the provided periods are overwritten."""
row = upsert_workload_config(db, current_user.id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=current_user.id, config=row.config)
# ---------------------------------------------------------------------------
# Admin: manage another user's workload config
# ---------------------------------------------------------------------------
def _require_admin(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin required")
return current_user
@router.get(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Get a specific user's minimum workload configuration",
)
def get_user_workload_config(
user_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
cfg = get_workload_config(db, user_id)
return MinimumWorkloadResponse(user_id=user_id, config=cfg)
@router.put(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Replace a specific user's minimum workload configuration",
)
def put_user_workload_config(
user_id: int,
payload: MinimumWorkloadConfig,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
row = replace_workload_config(db, user_id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=user_id, config=row.config)
@router.patch(
"/workload-config/{user_id}",
response_model=MinimumWorkloadResponse,
summary="[Admin] Partially update a specific user's minimum workload configuration",
)
def patch_user_workload_config(
user_id: int,
payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
row = upsert_workload_config(db, user_id, payload)
db.commit()
db.refresh(row)
return MinimumWorkloadResponse(user_id=user_id, config=row.config)