Files
the-other-dude/backend/app/routers/maintenance_windows.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

310 lines
9.7 KiB
Python

"""Maintenance windows API endpoints.
Tenant-scoped routes under /api/tenants/{tenant_id}/ for:
- Maintenance window CRUD (list, create, update, delete)
- Filterable by status: upcoming, active, past
RLS enforced via get_db() (app_user engine with tenant context).
RBAC: operator and above for all operations.
"""
import json
import logging
import uuid
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, set_tenant_context
from app.middleware.rate_limit import limiter
from app.middleware.tenant_context import CurrentUser, get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["maintenance-windows"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _check_tenant_access(
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
) -> None:
"""Verify the current user is allowed to access the given tenant."""
if current_user.is_super_admin:
await set_tenant_context(db, str(tenant_id))
elif current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
def _require_operator(current_user: CurrentUser) -> None:
"""Raise 403 if user does not have at least operator role."""
if current_user.role == "viewer":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires at least operator role.",
)
# ---------------------------------------------------------------------------
# Request/response schemas
# ---------------------------------------------------------------------------
class MaintenanceWindowCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
device_ids: list[str] = []
start_at: datetime
end_at: datetime
suppress_alerts: bool = True
notes: Optional[str] = None
class MaintenanceWindowUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Optional[str] = None
device_ids: Optional[list[str]] = None
start_at: Optional[datetime] = None
end_at: Optional[datetime] = None
suppress_alerts: Optional[bool] = None
notes: Optional[str] = None
class MaintenanceWindowResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
tenant_id: str
name: str
device_ids: list[str]
start_at: str
end_at: str
suppress_alerts: bool
notes: Optional[str] = None
created_by: Optional[str] = None
created_at: str
# ---------------------------------------------------------------------------
# CRUD endpoints
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/maintenance-windows",
summary="List maintenance windows for tenant",
)
async def list_maintenance_windows(
tenant_id: uuid.UUID,
window_status: Optional[str] = Query(None, alias="status"),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, Any]]:
await _check_tenant_access(current_user, tenant_id, db)
_require_operator(current_user)
filters = ["1=1"]
params: dict[str, Any] = {}
if window_status == "active":
filters.append("mw.start_at <= NOW() AND mw.end_at >= NOW()")
elif window_status == "upcoming":
filters.append("mw.start_at > NOW()")
elif window_status == "past":
filters.append("mw.end_at < NOW()")
where = " AND ".join(filters)
result = await db.execute(
text(f"""
SELECT mw.id, mw.tenant_id, mw.name, mw.device_ids,
mw.start_at, mw.end_at, mw.suppress_alerts,
mw.notes, mw.created_by, mw.created_at
FROM maintenance_windows mw
WHERE {where}
ORDER BY mw.start_at DESC
"""),
params,
)
return [
{
"id": str(row[0]),
"tenant_id": str(row[1]),
"name": row[2],
"device_ids": row[3] if isinstance(row[3], list) else [],
"start_at": row[4].isoformat() if row[4] else None,
"end_at": row[5].isoformat() if row[5] else None,
"suppress_alerts": row[6],
"notes": row[7],
"created_by": str(row[8]) if row[8] else None,
"created_at": row[9].isoformat() if row[9] else None,
}
for row in result.fetchall()
]
@router.post(
"/tenants/{tenant_id}/maintenance-windows",
summary="Create maintenance window",
status_code=status.HTTP_201_CREATED,
)
@limiter.limit("20/minute")
async def create_maintenance_window(
request: Request,
tenant_id: uuid.UUID,
body: MaintenanceWindowCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_operator(current_user)
if body.end_at <= body.start_at:
raise HTTPException(422, "end_at must be after start_at")
window_id = str(uuid.uuid4())
await db.execute(
text("""
INSERT INTO maintenance_windows
(id, tenant_id, name, device_ids, start_at, end_at,
suppress_alerts, notes, created_by)
VALUES
(CAST(:id AS uuid), CAST(:tenant_id AS uuid),
:name, CAST(:device_ids AS jsonb), :start_at, :end_at,
:suppress_alerts, :notes, CAST(:created_by AS uuid))
"""),
{
"id": window_id,
"tenant_id": str(tenant_id),
"name": body.name,
"device_ids": json.dumps(body.device_ids),
"start_at": body.start_at,
"end_at": body.end_at,
"suppress_alerts": body.suppress_alerts,
"notes": body.notes,
"created_by": str(current_user.user_id),
},
)
await db.commit()
return {
"id": window_id,
"tenant_id": str(tenant_id),
"name": body.name,
"device_ids": body.device_ids,
"start_at": body.start_at.isoformat(),
"end_at": body.end_at.isoformat(),
"suppress_alerts": body.suppress_alerts,
"notes": body.notes,
"created_by": str(current_user.user_id),
"created_at": datetime.utcnow().isoformat(),
}
@router.put(
"/tenants/{tenant_id}/maintenance-windows/{window_id}",
summary="Update maintenance window",
)
@limiter.limit("20/minute")
async def update_maintenance_window(
request: Request,
tenant_id: uuid.UUID,
window_id: uuid.UUID,
body: MaintenanceWindowUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_operator(current_user)
# Build dynamic SET clause for partial updates
set_parts: list[str] = ["updated_at = NOW()"]
params: dict[str, Any] = {"window_id": str(window_id)}
if body.name is not None:
set_parts.append("name = :name")
params["name"] = body.name
if body.device_ids is not None:
set_parts.append("device_ids = CAST(:device_ids AS jsonb)")
params["device_ids"] = json.dumps(body.device_ids)
if body.start_at is not None:
set_parts.append("start_at = :start_at")
params["start_at"] = body.start_at
if body.end_at is not None:
set_parts.append("end_at = :end_at")
params["end_at"] = body.end_at
if body.suppress_alerts is not None:
set_parts.append("suppress_alerts = :suppress_alerts")
params["suppress_alerts"] = body.suppress_alerts
if body.notes is not None:
set_parts.append("notes = :notes")
params["notes"] = body.notes
set_clause = ", ".join(set_parts)
result = await db.execute(
text(f"""
UPDATE maintenance_windows
SET {set_clause}
WHERE id = CAST(:window_id AS uuid)
RETURNING id, tenant_id, name, device_ids, start_at, end_at,
suppress_alerts, notes, created_by, created_at
"""),
params,
)
row = result.fetchone()
if not row:
raise HTTPException(404, "Maintenance window not found")
await db.commit()
return {
"id": str(row[0]),
"tenant_id": str(row[1]),
"name": row[2],
"device_ids": row[3] if isinstance(row[3], list) else [],
"start_at": row[4].isoformat() if row[4] else None,
"end_at": row[5].isoformat() if row[5] else None,
"suppress_alerts": row[6],
"notes": row[7],
"created_by": str(row[8]) if row[8] else None,
"created_at": row[9].isoformat() if row[9] else None,
}
@router.delete(
"/tenants/{tenant_id}/maintenance-windows/{window_id}",
summary="Delete maintenance window",
status_code=status.HTTP_204_NO_CONTENT,
)
@limiter.limit("5/minute")
async def delete_maintenance_window(
request: Request,
tenant_id: uuid.UUID,
window_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
await _check_tenant_access(current_user, tenant_id, db)
_require_operator(current_user)
result = await db.execute(
text(
"DELETE FROM maintenance_windows WHERE id = CAST(:id AS uuid) RETURNING id"
),
{"id": str(window_id)},
)
if not result.fetchone():
raise HTTPException(404, "Maintenance window not found")
await db.commit()