- Add POST /calendar/slots/{slot_id}/cancel for real slot cancellation
- Add POST /calendar/slots/virtual/{virtual_id}/cancel for virtual slot cancellation
- Virtual cancel materializes the slot first, then marks as Skipped
- Both endpoints enforce past-slot immutability guard
- Both endpoints detach from plan (set plan_id=NULL)
- Status set to SlotStatus.SKIPPED on cancel
- Add TimeSlotCancelResponse schema
677 lines
22 KiB
Python
677 lines
22 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).
|
|
"""
|
|
|
|
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 SlotStatus, TimeSlot
|
|
from app.models.models import User
|
|
from app.schemas.calendar import (
|
|
CalendarDayResponse,
|
|
CalendarSlotItem,
|
|
MinimumWorkloadConfig,
|
|
MinimumWorkloadResponse,
|
|
MinimumWorkloadUpdate,
|
|
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,
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|