Introduce a new API key authentication mechanism to support service-to-service communication (e.g., Airflow, CI/CD) without browser-based JWT login. - Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC. - Implement `require_api_key_or_jwt` dependency with environment scoping. - Add admin CRUD endpoints for API key lifecycle management. - Add API key management UI in System Settings. - Update maintenance API to support explicit `environment_id` and API key auth. - Add i18n support for API keys and connection settings. - Include Python and Bash usage examples in the `examples/` directory. - Update technical specifications and documentation.
593 lines
23 KiB
Python
593 lines
23 KiB
Python
# #region MaintenanceRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, routes, maintenance, api]
|
|
# @BRIEF Route handler stubs for all 7 Maintenance Banner API endpoints with RBAC guards per FR-015.
|
|
# @LAYER API
|
|
# @RELATION DEPENDS_ON -> [MaintenanceSchemasModule]
|
|
# @RELATION DEPENDS_ON -> [MaintenanceRouter]
|
|
# @RELATION DEPENDS_ON -> [AppDependencies]
|
|
# @RELATION DEPENDS_ON -> [MaintenanceServiceModule]
|
|
# @INVARIANT All mutation endpoints return 202 {task_id} per spec.
|
|
# @INVARIANT RBAC enforced per FR-015 matrix via has_permission() dependency.
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, cast
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ....core.logger import belief_scope, logger as app_logger
|
|
from ....core.task_manager import TaskManager
|
|
|
|
app_logger = cast(Any, app_logger)
|
|
from ....dependencies import (
|
|
check_api_key_environment_scope,
|
|
get_db,
|
|
get_task_manager,
|
|
has_permission,
|
|
require_api_key_or_jwt,
|
|
)
|
|
from ....models.api_key import APIKey
|
|
from ....models.maintenance import (
|
|
MaintenanceDashboardBanner,
|
|
MaintenanceDashboardBannerStatus,
|
|
MaintenanceDashboardState,
|
|
MaintenanceDashboardStateStatus,
|
|
MaintenanceEvent,
|
|
MaintenanceEventStatus,
|
|
MaintenanceSettings,
|
|
)
|
|
from ._router import router
|
|
from ._schemas import (
|
|
MaintenanceAlreadyActiveResponse,
|
|
MaintenanceDashboardBannerState,
|
|
MaintenanceEndResponse,
|
|
MaintenanceEventItem,
|
|
MaintenanceEventListResponse,
|
|
MaintenanceSettingsResponse,
|
|
MaintenanceSettingsUpdate,
|
|
MaintenanceStartRequest,
|
|
MaintenanceStartResponse,
|
|
)
|
|
|
|
|
|
# ── GET /api/maintenance/dashboard-banners ───────────────────
|
|
# FR-015: viewer, operator, admin all have READ access
|
|
|
|
# #region list_dashboard_banners [C:2] [TYPE Function]
|
|
# @BRIEF Get per-dashboard banner state for the Dashboard Hub indicator.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
|
|
@router.get("/dashboard-banners", response_model=list[MaintenanceDashboardBannerState])
|
|
async def list_dashboard_banners(
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("maintenance", "READ")),
|
|
):
|
|
with belief_scope("list_dashboard_banners"):
|
|
# Query active banners with their linked active/pending states
|
|
active_banners = (
|
|
db.query(MaintenanceDashboardBanner)
|
|
.filter(MaintenanceDashboardBanner.status == MaintenanceDashboardBannerStatus.ACTIVE)
|
|
.all()
|
|
)
|
|
|
|
result: list[MaintenanceDashboardBannerState] = []
|
|
for banner in active_banners:
|
|
linked_states = (
|
|
db.query(MaintenanceDashboardState)
|
|
.filter(
|
|
MaintenanceDashboardState.banner_id == banner.id,
|
|
MaintenanceDashboardState.status.in_([
|
|
MaintenanceDashboardStateStatus.ACTIVE,
|
|
MaintenanceDashboardStateStatus.PENDING_APPLY,
|
|
]),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
event_ids: list[str] = []
|
|
start_times: list[str] = []
|
|
end_times: list[str] = []
|
|
|
|
for state in linked_states:
|
|
event = (
|
|
db.query(MaintenanceEvent)
|
|
.filter(MaintenanceEvent.id == state.event_id)
|
|
.first()
|
|
)
|
|
if event:
|
|
event_ids.append(event.id)
|
|
if event.start_time:
|
|
start_times.append(event.start_time.isoformat())
|
|
if event.end_time:
|
|
end_times.append(event.end_time.isoformat())
|
|
|
|
result.append(
|
|
MaintenanceDashboardBannerState(
|
|
dashboard_id=banner.dashboard_id,
|
|
active=True,
|
|
events=event_ids,
|
|
start_time=min(start_times) if start_times else None,
|
|
end_time=max(end_times) if end_times else None,
|
|
)
|
|
)
|
|
|
|
return result
|
|
# #endregion list_dashboard_banners
|
|
|
|
|
|
# ── GET /api/maintenance/events ──────────────────────────────
|
|
# FR-015: operator, admin (NOT viewer)
|
|
|
|
# #region list_events [C:2] [TYPE Function]
|
|
# @BRIEF Get active and completed maintenance event lists with affected dashboard counts.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
|
|
@router.get("/events", response_model=MaintenanceEventListResponse)
|
|
async def list_events(
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("maintenance", "READ")),
|
|
task_manager: TaskManager = Depends(get_task_manager),
|
|
):
|
|
with belief_scope("list_events"):
|
|
# ── Auto-expiry: end events past their end_time ──
|
|
now = datetime.now(timezone.utc)
|
|
expired = (
|
|
db.query(MaintenanceEvent)
|
|
.filter(
|
|
MaintenanceEvent.status.in_([
|
|
MaintenanceEventStatus.ACTIVE,
|
|
MaintenanceEventStatus.PARTIAL,
|
|
MaintenanceEventStatus.ENDING,
|
|
]),
|
|
MaintenanceEvent.end_time.isnot(None),
|
|
MaintenanceEvent.end_time < now,
|
|
)
|
|
.all()
|
|
)
|
|
for ev in expired:
|
|
app_logger.reason(
|
|
"Auto-ending expired maintenance event",
|
|
extra={"event_id": ev.id, "end_time": ev.end_time.isoformat()},
|
|
)
|
|
# Schedule proper cleanup via task manager (removes banner from dashboard)
|
|
task = await task_manager.create_task(
|
|
plugin_id="maintenance_banner_apply",
|
|
params={
|
|
"operation": "end",
|
|
"event_id": ev.id,
|
|
"environment_id": ev.environment_id,
|
|
"user": "system",
|
|
},
|
|
)
|
|
app_logger.reflect(
|
|
"Scheduled cleanup for expired event",
|
|
extra={"event_id": ev.id, "task_id": task.id},
|
|
)
|
|
|
|
# Active events: ACTIVE, PARTIAL (per spec T040)
|
|
active_statuses = [
|
|
MaintenanceEventStatus.ACTIVE,
|
|
MaintenanceEventStatus.PARTIAL,
|
|
]
|
|
active_events = (
|
|
db.query(MaintenanceEvent)
|
|
.filter(MaintenanceEvent.status.in_(active_statuses))
|
|
.order_by(MaintenanceEvent.created_at.desc())
|
|
.all()
|
|
)
|
|
|
|
# Completed events: COMPLETED, FAILED
|
|
completed_events = (
|
|
db.query(MaintenanceEvent)
|
|
.filter(
|
|
MaintenanceEvent.status.in_([
|
|
MaintenanceEventStatus.COMPLETED,
|
|
MaintenanceEventStatus.FAILED,
|
|
])
|
|
)
|
|
.order_by(MaintenanceEvent.created_at.desc())
|
|
.limit(50)
|
|
.all()
|
|
)
|
|
|
|
def _to_item(event: MaintenanceEvent) -> MaintenanceEventItem:
|
|
affected_count = (
|
|
db.query(MaintenanceDashboardState)
|
|
.filter(
|
|
MaintenanceDashboardState.event_id == event.id,
|
|
MaintenanceDashboardState.status.in_([
|
|
MaintenanceDashboardStateStatus.ACTIVE,
|
|
MaintenanceDashboardStateStatus.PENDING_APPLY,
|
|
]),
|
|
)
|
|
.count()
|
|
)
|
|
return MaintenanceEventItem(
|
|
id=event.id,
|
|
tables=list(event.tables) if event.tables else [],
|
|
start_time=event.start_time.isoformat() if event.start_time else "",
|
|
end_time=event.end_time.isoformat() if event.end_time else None,
|
|
message=event.message or None,
|
|
status=event.status.value,
|
|
affected_count=affected_count,
|
|
created_at=event.created_at.isoformat() if event.created_at else "",
|
|
)
|
|
|
|
return MaintenanceEventListResponse(
|
|
active=[_to_item(e) for e in active_events],
|
|
completed=[_to_item(e) for e in completed_events],
|
|
)
|
|
# #endregion list_events
|
|
|
|
|
|
# ── POST /api/maintenance/start ──────────────────────────────
|
|
# FR-001: Always returns 202 {task_id} after validation
|
|
# FR-015: operator, admin (NOT viewer)
|
|
|
|
# #region start_maintenance [C:3] [TYPE Function]
|
|
# @BRIEF Start a maintenance event. Validates input, creates event, dispatches TaskManager task.
|
|
# Always returns 202 with task_id and maintenance_id.
|
|
# Idempotency: same (tables, start_time, end_time) returns already_active (409-like but 200).
|
|
# Tables sorted & lowered for idempotency key. Message NOT in key.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
|
|
# @RELATION DEPENDS_ON -> [TaskManager]
|
|
@router.post(
|
|
"/start",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
responses={
|
|
202: {"model": MaintenanceStartResponse},
|
|
409: {"model": MaintenanceAlreadyActiveResponse},
|
|
},
|
|
)
|
|
async def start_maintenance(
|
|
request: MaintenanceStartRequest,
|
|
db: Session = Depends(get_db),
|
|
_auth=Depends(require_api_key_or_jwt("maintenance:start", "maintenance", "WRITE")),
|
|
task_manager: TaskManager = Depends(get_task_manager),
|
|
):
|
|
with belief_scope("start_maintenance"):
|
|
# ── Validation ──
|
|
|
|
# Validate end_time > start_time
|
|
if request.end_time and request.start_time and request.end_time <= request.start_time:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="end_time must be after start_time",
|
|
)
|
|
|
|
# Validate times are within reasonable range (future ±1h grace period)
|
|
now = datetime.now(timezone.utc)
|
|
grace_threshold = 3600 # 1 hour in seconds
|
|
if request.start_time:
|
|
diff = (request.start_time - now).total_seconds() if request.start_time.tzinfo else \
|
|
(request.start_time.replace(tzinfo=timezone.utc) - now).total_seconds()
|
|
if diff < -grace_threshold:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="start_time is too far in the past (max 1h grace period)",
|
|
)
|
|
|
|
# Validate tables list length
|
|
if len(request.tables) > 100:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Maximum 100 tables allowed",
|
|
)
|
|
|
|
# Sanitize message (HTML escape already done in rendering, but check for raw HTML)
|
|
message = request.message
|
|
if message:
|
|
import html as _html
|
|
sanitized = _html.escape(message)
|
|
if sanitized != message:
|
|
# Message contained HTML — accept it, it will be escaped at render time
|
|
pass
|
|
|
|
# ── Idempotency check ──
|
|
from ....services.maintenance import build_idempotency_key
|
|
|
|
sorted_tables = sorted(t.strip().lower() for t in request.tables if t.strip())
|
|
idem_tables = frozenset(sorted_tables)
|
|
idem_start = request.start_time.isoformat() if request.start_time else None
|
|
idem_end = request.end_time.isoformat() if request.end_time else None
|
|
|
|
# Find existing active event with same idempotency key
|
|
active_statuses = [
|
|
MaintenanceEventStatus.PENDING,
|
|
MaintenanceEventStatus.APPLYING,
|
|
MaintenanceEventStatus.ACTIVE,
|
|
MaintenanceEventStatus.PARTIAL,
|
|
]
|
|
existing = (
|
|
db.query(MaintenanceEvent)
|
|
.filter(
|
|
MaintenanceEvent.status.in_(active_statuses),
|
|
)
|
|
.all()
|
|
)
|
|
for ev in existing:
|
|
ev_tables = frozenset(
|
|
sorted(t.lower() for t in (ev.tables or []) if t)
|
|
)
|
|
ev_start = ev.start_time.isoformat() if ev.start_time else None
|
|
ev_end = ev.end_time.isoformat() if ev.end_time else None
|
|
if ev_tables == idem_tables and ev_start == idem_start and ev_end == idem_end:
|
|
# Same key → return already_active
|
|
app_logger.reflect(
|
|
"Idempotency hit — event already active",
|
|
extra={"existing_id": ev.id},
|
|
)
|
|
return MaintenanceAlreadyActiveResponse(
|
|
maintenance_id=ev.id,
|
|
status="already_active",
|
|
)
|
|
|
|
# ── Environment scoping ──
|
|
env_id = request.environment_id
|
|
|
|
# ── Create event ──
|
|
event = MaintenanceEvent(
|
|
tables=sorted_tables,
|
|
start_time=request.start_time,
|
|
end_time=request.end_time,
|
|
message=message,
|
|
status=MaintenanceEventStatus.PENDING,
|
|
environment_id=env_id,
|
|
)
|
|
db.add(event)
|
|
db.flush()
|
|
|
|
# ── Dispatch TaskManager task ──
|
|
task = await task_manager.create_task(
|
|
plugin_id="maintenance_banner_apply",
|
|
params={
|
|
"operation": "start",
|
|
"event_id": event.id,
|
|
"environment_id": env_id,
|
|
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
|
|
},
|
|
)
|
|
event.task_id = task.id
|
|
db.commit()
|
|
|
|
app_logger.reflect(
|
|
"Maintenance start dispatched",
|
|
extra={"event_id": event.id, "task_id": task.id},
|
|
)
|
|
|
|
return MaintenanceStartResponse(
|
|
task_id=task.id,
|
|
maintenance_id=event.id,
|
|
status="pending",
|
|
)
|
|
# #endregion start_maintenance
|
|
|
|
|
|
# ── POST /api/maintenance/{maintenance_id}/end ───────────────
|
|
# FR-006: Remove banners for a specific maintenance event
|
|
# FR-015: operator, admin (NOT viewer)
|
|
|
|
# #region end_maintenance [C:3] [TYPE Function]
|
|
# @BRIEF End a specific maintenance event by ID. Removes banners from affected dashboards.
|
|
# Always returns 202 with task_id. Idempotent: already_completed returns success.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
|
|
@router.post(
|
|
"/{maintenance_id}/end",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
response_model=MaintenanceEndResponse,
|
|
)
|
|
async def end_maintenance(
|
|
maintenance_id: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
_auth=Depends(require_api_key_or_jwt("maintenance:end", "maintenance", "WRITE")),
|
|
task_manager: TaskManager = Depends(get_task_manager),
|
|
):
|
|
with belief_scope("end_maintenance", f"maintenance_id={maintenance_id}"):
|
|
# Check event exists
|
|
event = db.query(MaintenanceEvent).filter(
|
|
MaintenanceEvent.id == maintenance_id
|
|
).first()
|
|
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Maintenance event {maintenance_id} not found",
|
|
)
|
|
|
|
# Validate API key environment scope against event's environment
|
|
if _auth.startswith("api_key:"):
|
|
await check_api_key_environment_scope(request, db, event.environment_id or "")
|
|
|
|
# If already completed, return idempotent success
|
|
if event.status == MaintenanceEventStatus.COMPLETED:
|
|
app_logger.reflect(
|
|
"Event already completed, returning idempotent",
|
|
extra={"event_id": maintenance_id},
|
|
)
|
|
return MaintenanceEndResponse(
|
|
task_id=event.task_id or "",
|
|
status="already_completed",
|
|
)
|
|
|
|
# ── Dispatch TaskManager task ──
|
|
task = await task_manager.create_task(
|
|
plugin_id="maintenance_banner_apply",
|
|
params={
|
|
"operation": "end",
|
|
"event_id": maintenance_id,
|
|
"environment_id": event.environment_id,
|
|
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
|
|
},
|
|
)
|
|
|
|
app_logger.reflect(
|
|
"Maintenance end dispatched",
|
|
extra={"event_id": maintenance_id, "task_id": task.id},
|
|
)
|
|
|
|
return MaintenanceEndResponse(
|
|
task_id=task.id,
|
|
status="pending",
|
|
)
|
|
# #endregion end_maintenance
|
|
|
|
|
|
# ── POST /api/maintenance/end-all ────────────────────────────
|
|
# FR-007: Bulk removal of all active banners
|
|
# FR-015: operator, admin (NOT viewer)
|
|
|
|
# #region end_all_maintenance [C:3] [TYPE Function]
|
|
# @BRIEF End all active maintenance events. Removes all banners from all dashboards.
|
|
# Always returns 202 with task_id. Supports environment scoping for API keys.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
|
|
@router.post(
|
|
"/end-all",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
response_model=MaintenanceEndResponse,
|
|
)
|
|
async def end_all_maintenance(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
_auth=Depends(require_api_key_or_jwt("maintenance:end_all", "maintenance", "WRITE")),
|
|
task_manager: TaskManager = Depends(get_task_manager),
|
|
):
|
|
with belief_scope("end_all_maintenance"):
|
|
# ── Resolve environment scope ──
|
|
effective_env_id: str | None = None
|
|
try:
|
|
body = await request.json()
|
|
effective_env_id = body.get("environment_id")
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Enforce API key environment scoping ──
|
|
if _auth.startswith("api_key:"):
|
|
# Auto-scope to the key's environment if not specified in body
|
|
if not effective_env_id:
|
|
raw_key = request.headers.get("X-API-Key")
|
|
if raw_key:
|
|
from ....core.auth.api_key import hash_api_key
|
|
key_hash = hash_api_key(raw_key)
|
|
key_row = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
|
|
if key_row and key_row.environment_id:
|
|
effective_env_id = key_row.environment_id
|
|
|
|
await check_api_key_environment_scope(request, db, effective_env_id or "")
|
|
|
|
# ── Dispatch TaskManager task ──
|
|
task = await task_manager.create_task(
|
|
plugin_id="maintenance_banner_apply",
|
|
params={
|
|
"operation": "end_all",
|
|
"environment_id": effective_env_id or "",
|
|
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
|
|
},
|
|
)
|
|
|
|
app_logger.reflect(
|
|
"End-all maintenance dispatched",
|
|
extra={"task_id": task.id, "environment_id": effective_env_id},
|
|
)
|
|
|
|
return MaintenanceEndResponse(
|
|
task_id=task.id,
|
|
status="pending",
|
|
)
|
|
# #endregion end_all_maintenance
|
|
|
|
|
|
# ── GET /api/maintenance/settings ────────────────────────────
|
|
# FR-009: Get maintenance settings configuration
|
|
# FR-015: operator, admin (NOT viewer)
|
|
|
|
# #region get_maintenance_settings [C:2] [TYPE Function]
|
|
# @BRIEF Get current maintenance settings configuration.
|
|
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
|
|
@router.get("/settings", response_model=MaintenanceSettingsResponse)
|
|
async def get_maintenance_settings(
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("maintenance", "READ")),
|
|
):
|
|
with belief_scope("get_maintenance_settings"):
|
|
settings = db.query(MaintenanceSettings).filter(
|
|
MaintenanceSettings.id == "default"
|
|
).first()
|
|
|
|
if not settings:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Maintenance settings not configured",
|
|
)
|
|
|
|
return MaintenanceSettingsResponse(
|
|
target_environment_id=settings.target_environment_id,
|
|
display_timezone=settings.display_timezone,
|
|
banner_template=settings.banner_template,
|
|
dashboard_scope=settings.dashboard_scope.value,
|
|
excluded_dashboard_ids=list(settings.excluded_dashboard_ids or []),
|
|
forced_dashboard_ids=list(settings.forced_dashboard_ids or []),
|
|
updated_at=settings.updated_at.isoformat() if settings.updated_at else None,
|
|
)
|
|
# #endregion get_maintenance_settings
|
|
|
|
|
|
# ── PUT /api/maintenance/settings ────────────────────────────
|
|
# FR-009: Update maintenance settings configuration
|
|
# FR-015: admin ONLY (NOT operator, NOT viewer)
|
|
|
|
# #region update_maintenance_settings [C:2] [TYPE Function]
|
|
# @BRIEF Update maintenance settings. All fields optional for partial update.
|
|
# admin role required per FR-015.
|
|
# @RELATION DEPENDS_ON -> [has_permission("admin:settings", "WRITE")]
|
|
@router.put("/settings", response_model=MaintenanceSettingsResponse)
|
|
async def update_maintenance_settings(
|
|
settings_data: MaintenanceSettingsUpdate,
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_maintenance_settings"):
|
|
settings = db.query(MaintenanceSettings).filter(
|
|
MaintenanceSettings.id == "default"
|
|
).first()
|
|
|
|
if not settings:
|
|
# Auto-create with defaults on first save (upsert behavior).
|
|
# Column-level defaults from the model apply for omitted fields.
|
|
settings = MaintenanceSettings(
|
|
id="default",
|
|
target_environment_id=settings_data.target_environment_id or "",
|
|
)
|
|
db.add(settings)
|
|
db.flush()
|
|
|
|
# Apply partial updates
|
|
update_data = settings_data.model_dump(exclude_unset=True)
|
|
|
|
if "target_environment_id" in update_data:
|
|
settings.target_environment_id = update_data["target_environment_id"]
|
|
if "display_timezone" in update_data:
|
|
settings.display_timezone = update_data["display_timezone"]
|
|
if "banner_template" in update_data:
|
|
settings.banner_template = update_data["banner_template"]
|
|
if "dashboard_scope" in update_data:
|
|
from ....models.maintenance import DashboardScope
|
|
settings.dashboard_scope = DashboardScope(update_data["dashboard_scope"])
|
|
if "excluded_dashboard_ids" in update_data:
|
|
settings.excluded_dashboard_ids = update_data["excluded_dashboard_ids"]
|
|
if "forced_dashboard_ids" in update_data:
|
|
settings.forced_dashboard_ids = update_data["forced_dashboard_ids"]
|
|
|
|
db.commit()
|
|
db.refresh(settings)
|
|
|
|
return MaintenanceSettingsResponse(
|
|
target_environment_id=settings.target_environment_id,
|
|
display_timezone=settings.display_timezone,
|
|
banner_template=settings.banner_template,
|
|
dashboard_scope=settings.dashboard_scope.value,
|
|
excluded_dashboard_ids=list(settings.excluded_dashboard_ids or []),
|
|
forced_dashboard_ids=list(settings.forced_dashboard_ids or []),
|
|
updated_at=settings.updated_at.isoformat() if settings.updated_at else None,
|
|
)
|
|
# #endregion update_maintenance_settings
|
|
|
|
# #endregion MaintenanceRoutesModule
|