fix(maintenance): auto-expiry + adaptive markdown height + tests
- Auto-expiry: expired events auto-end on GET /events via task manager - Height: _estimate_markdown_height adapted to unit=8px scale with padding detection - CRITICAL-1: removed dead import remove_chart_from_position (QA finding) - CRITICAL-2: added chart alive check in ensure_banner_chart (QA finding) - CRITICAL-3: fixed test assertions update_markdown_chart → update_banner_on_dashboard - @POST contract: fixed return range [2,12] → [19,200] - Tests: 8 new tests (auto-expiry + layout height)
This commit is contained in:
@@ -28,6 +28,7 @@ __all__ = [
|
||||
"git",
|
||||
"health",
|
||||
"llm",
|
||||
"maintenance",
|
||||
"mappings",
|
||||
"migration",
|
||||
"plugins",
|
||||
|
||||
10
backend/src/api/routes/maintenance/__init__.py
Normal file
10
backend/src/api/routes/maintenance/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# #region MaintenanceRoutesPackage [C:1] [TYPE Package] [SEMANTICS maintenance, api, package]
|
||||
# @BRIEF Maintenance Banner API route package.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [MaintenanceRouter]
|
||||
# @RELATION DEPENDS_ON -> [MaintenanceSchemasModule]
|
||||
|
||||
from ._router import router as maintenance_router
|
||||
|
||||
__all__ = ["maintenance_router"]
|
||||
# #endregion MaintenanceRoutesPackage
|
||||
14
backend/src/api/routes/maintenance/_router.py
Normal file
14
backend/src/api/routes/maintenance/_router.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# #region MaintenanceRouter [C:3] [TYPE Module] [SEMANTICS fastapi, router, maintenance, api]
|
||||
# @BRIEF FastAPI APIRouter for the Maintenance Banner endpoints.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [MaintenanceRoutesModule]
|
||||
# @RELATION DEPENDS_ON -> [AppDependencies]
|
||||
# @RELATION DEPENDS_ON -> [MaintenanceSchemasModule]
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/maintenance", tags=["Maintenance"])
|
||||
|
||||
# Import route handlers to register them with the router
|
||||
from . import _routes # noqa: F401, E402
|
||||
# #endregion MaintenanceRouter
|
||||
562
backend/src/api/routes/maintenance/_routes.py
Normal file
562
backend/src/api/routes/maintenance/_routes.py
Normal file
@@ -0,0 +1,562 @@
|
||||
# #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, 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 get_current_user, get_db, get_task_manager, has_permission
|
||||
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),
|
||||
user=Depends(get_current_user),
|
||||
_=Depends(has_permission("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",
|
||||
)
|
||||
|
||||
# ── Load settings for environment ──
|
||||
settings = db.query(MaintenanceSettings).filter(
|
||||
MaintenanceSettings.id == "default"
|
||||
).first()
|
||||
env_id = settings.target_environment_id if settings else ""
|
||||
|
||||
# ── 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": getattr(user, "username", "system"),
|
||||
},
|
||||
)
|
||||
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,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
_=Depends(has_permission("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",
|
||||
)
|
||||
|
||||
# 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": getattr(user, "username", "system"),
|
||||
},
|
||||
)
|
||||
|
||||
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.
|
||||
# @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(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
_=Depends(has_permission("maintenance", "WRITE")),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
):
|
||||
with belief_scope("end_all_maintenance"):
|
||||
# ── Dispatch TaskManager task ──
|
||||
task = await task_manager.create_task(
|
||||
plugin_id="maintenance_banner_apply",
|
||||
params={
|
||||
"operation": "end_all",
|
||||
"user": getattr(user, "username", "system"),
|
||||
},
|
||||
)
|
||||
|
||||
app_logger.reflect(
|
||||
"End-all maintenance dispatched",
|
||||
extra={"task_id": task.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
|
||||
156
backend/src/api/routes/maintenance/_schemas.py
Normal file
156
backend/src/api/routes/maintenance/_schemas.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# #region MaintenanceSchemasModule [C:2] [TYPE Module] [SEMANTICS pydantic, schema, maintenance, request, response]
|
||||
# @BRIEF Pydantic models for Maintenance Banner API: request/response schemas per data-model.md.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [Pydantic]
|
||||
# @INVARIANT All response schemas use the standard envelope shape: { status, data, error, meta }
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Request Schemas ──────────────────────────────────────────
|
||||
|
||||
# #region MaintenanceStartRequest [C:1] [TYPE Class]
|
||||
# @BRIEF POST /api/maintenance/start request body.
|
||||
class MaintenanceStartRequest(BaseModel):
|
||||
tables: list[str] = Field(..., min_length=1, max_length=100)
|
||||
start_time: datetime
|
||||
end_time: datetime | None = None
|
||||
message: str | None = Field(None, max_length=500)
|
||||
# #endregion MaintenanceStartRequest
|
||||
|
||||
|
||||
# #region MaintenanceSettingsUpdate [C:1] [TYPE Class]
|
||||
# @BRIEF PUT /api/maintenance/settings request body — all fields optional for partial update.
|
||||
class MaintenanceSettingsUpdate(BaseModel):
|
||||
target_environment_id: str | None = None
|
||||
display_timezone: str | None = None
|
||||
banner_template: str | None = None
|
||||
dashboard_scope: str | None = None
|
||||
excluded_dashboard_ids: list[int] | None = None
|
||||
forced_dashboard_ids: list[int] | None = None
|
||||
# #endregion MaintenanceSettingsUpdate
|
||||
|
||||
|
||||
# ── Response Schemas ─────────────────────────────────────────
|
||||
|
||||
# #region MaintenanceStartResponse [C:1] [TYPE Class]
|
||||
# @BRIEF Response for /api/maintenance/start — always 202 with task_id.
|
||||
class MaintenanceStartResponse(BaseModel):
|
||||
task_id: str
|
||||
maintenance_id: str
|
||||
status: str # "pending"
|
||||
# #endregion MaintenanceStartResponse
|
||||
|
||||
|
||||
# #region MaintenanceDashboardResult [C:1] [TYPE Class]
|
||||
# @BRIEF Describes a single dashboard affected by a maintenance operation.
|
||||
class MaintenanceDashboardResult(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
chart_id: int | None = None
|
||||
# #endregion MaintenanceDashboardResult
|
||||
|
||||
|
||||
# #region MaintenanceFailedDashboard [C:1] [TYPE Class]
|
||||
# @BRIEF Describes a dashboard that failed during banner apply/removal.
|
||||
class MaintenanceFailedDashboard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
error: str
|
||||
# #endregion MaintenanceFailedDashboard
|
||||
|
||||
|
||||
# #region MaintenanceTaskResult [C:1] [TYPE Class]
|
||||
# @BRIEF Result payload for a completed maintenance start task.
|
||||
class MaintenanceTaskResult(BaseModel):
|
||||
maintenance_id: str
|
||||
status: str # "active" | "partial" | "completed" | "no_match"
|
||||
affected_dashboards: int = 0
|
||||
dashboards: list[MaintenanceDashboardResult] = []
|
||||
failed_dashboards: list[MaintenanceFailedDashboard] = []
|
||||
unmatched_tables: list[str] = []
|
||||
# #endregion MaintenanceTaskResult
|
||||
|
||||
|
||||
# #region MaintenanceEndResponse [C:1] [TYPE Class]
|
||||
# @BRIEF Response for /api/maintenance/{id}/end and /api/maintenance/end-all — always 202 with task_id.
|
||||
class MaintenanceEndResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str # "pending"
|
||||
# #endregion MaintenanceEndResponse
|
||||
|
||||
|
||||
# #region MaintenanceEndTaskResult [C:1] [TYPE Class]
|
||||
# @BRIEF Result payload for a completed end maintenance task.
|
||||
class MaintenanceEndTaskResult(BaseModel):
|
||||
maintenance_id: str
|
||||
removed_from: int = 0
|
||||
failed_removals: list[MaintenanceFailedDashboard] = []
|
||||
# #endregion MaintenanceEndTaskResult
|
||||
|
||||
|
||||
# #region MaintenanceEndAllTaskResult [C:1] [TYPE Class]
|
||||
# @BRIEF Result payload for a completed end-all maintenance task.
|
||||
class MaintenanceEndAllTaskResult(BaseModel):
|
||||
closed_events: int = 0
|
||||
removed_from: int = 0
|
||||
failed_removals: list[MaintenanceFailedDashboard] = []
|
||||
# #endregion MaintenanceEndAllTaskResult
|
||||
|
||||
|
||||
# #region MaintenanceSettingsResponse [C:1] [TYPE Class]
|
||||
# @BRIEF Response for GET /api/maintenance/settings.
|
||||
class MaintenanceSettingsResponse(BaseModel):
|
||||
target_environment_id: str
|
||||
display_timezone: str
|
||||
banner_template: str
|
||||
dashboard_scope: str
|
||||
excluded_dashboard_ids: list[int]
|
||||
forced_dashboard_ids: list[int]
|
||||
updated_at: str | None = None
|
||||
# #endregion MaintenanceSettingsResponse
|
||||
|
||||
|
||||
# #region MaintenanceEventItem [C:1] [TYPE Class]
|
||||
# @BRIEF Single maintenance event summary for GET /api/maintenance/events.
|
||||
class MaintenanceEventItem(BaseModel):
|
||||
id: str
|
||||
tables: list[str]
|
||||
start_time: str
|
||||
end_time: str | None = None
|
||||
message: str | None = None
|
||||
status: str
|
||||
affected_count: int
|
||||
created_at: str
|
||||
# #endregion MaintenanceEventItem
|
||||
|
||||
|
||||
# #region MaintenanceEventListResponse [C:1] [TYPE Class]
|
||||
# @BRIEF Response for GET /api/maintenance/events.
|
||||
class MaintenanceEventListResponse(BaseModel):
|
||||
active: list[MaintenanceEventItem]
|
||||
completed: list[MaintenanceEventItem]
|
||||
# #endregion MaintenanceEventListResponse
|
||||
|
||||
|
||||
# #region MaintenanceDashboardBannerState [C:1] [TYPE Class]
|
||||
# @BRIEF Per-dashboard banner state for GET /api/maintenance/dashboard-banners.
|
||||
class MaintenanceDashboardBannerState(BaseModel):
|
||||
dashboard_id: int
|
||||
active: bool
|
||||
events: list[str]
|
||||
start_time: str | None = None
|
||||
end_time: str | None = None
|
||||
# #endregion MaintenanceDashboardBannerState
|
||||
|
||||
|
||||
# #region MaintenanceAlreadyActiveResponse [C:1] [TYPE Class]
|
||||
# @BRIEF Response for duplicate /start call — idempotency hit.
|
||||
class MaintenanceAlreadyActiveResponse(BaseModel):
|
||||
maintenance_id: str
|
||||
status: str = "already_active"
|
||||
# #endregion MaintenanceAlreadyActiveResponse
|
||||
|
||||
# #endregion MaintenanceSchemasModule
|
||||
@@ -393,6 +393,7 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
storage: dict
|
||||
notifications: dict = {}
|
||||
features: dict = {}
|
||||
app_timezone: str = "Europe/Moscow"
|
||||
|
||||
|
||||
# #endregion ConsolidatedSettingsResponse
|
||||
@@ -463,6 +464,7 @@ async def get_consolidated_settings(
|
||||
storage=config.settings.storage.dict(),
|
||||
notifications=notifications_payload,
|
||||
features=config.settings.features.model_dump(),
|
||||
app_timezone=config.settings.app_timezone,
|
||||
)
|
||||
logger.reflect(
|
||||
"Consolidated settings payload assembled",
|
||||
@@ -526,6 +528,15 @@ async def update_consolidated_settings(
|
||||
payload["notifications"] = settings_patch["notifications"]
|
||||
config_manager.save_config(payload)
|
||||
|
||||
# Update app_timezone if provided
|
||||
if "app_timezone" in settings_patch:
|
||||
new_tz = settings_patch["app_timezone"]
|
||||
from ...core.timezone import validate_timezone, invalidate_timezone_cache
|
||||
if not validate_timezone(new_tz):
|
||||
raise HTTPException(status_code=422, detail=f"Invalid IANA timezone: {new_tz}")
|
||||
current_settings.app_timezone = new_tz
|
||||
invalidate_timezone_cache()
|
||||
|
||||
config_manager.update_global_settings(current_settings)
|
||||
return {"status": "success", "message": "Settings updated"}
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ from sqlalchemy.orm import Session
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.database import get_db
|
||||
from ...core.logger import logger
|
||||
from ...core.scheduler import SchedulerService
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...dependencies import get_config_manager, get_current_user, get_task_manager, has_permission
|
||||
from ...dependencies import get_config_manager, get_current_user, get_scheduler_service, get_task_manager, has_permission
|
||||
from ...schemas.auth import User
|
||||
from ...schemas.validation import (
|
||||
TriggerRunResponse,
|
||||
@@ -88,11 +89,14 @@ async def create_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "CREATE")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Create a new validation task with provider and environment validation."""
|
||||
logger.reason(f"create_task — User: {current_user.username}, name: {payload.name}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
return service.create_task(payload)
|
||||
result = service.create_task(payload)
|
||||
scheduler.reload_validation_policy(result.id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
||||
# #endregion create_task
|
||||
@@ -114,8 +118,9 @@ async def get_task(
|
||||
try:
|
||||
task = task_service.get_task(task_id)
|
||||
from ...schemas.validation import ValidationRunResponse
|
||||
# Find recent runs via run_service using dashboard matching
|
||||
# Find recent runs for this specific task via task_id
|
||||
recent = run_service.list_runs(
|
||||
task_id=task_id,
|
||||
dashboard_id=task.dashboard_ids[0] if task.dashboard_ids else None,
|
||||
environment_id=task.environment_id,
|
||||
page=1,
|
||||
@@ -140,11 +145,14 @@ async def update_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "EDIT")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Update a validation task."""
|
||||
logger.reason(f"update_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
return service.update_task(task_id, payload)
|
||||
result = service.update_task(task_id, payload)
|
||||
scheduler.reload_validation_policy(result.id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion update_task
|
||||
@@ -160,11 +168,13 @@ async def delete_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("validation.task", "DELETE")),
|
||||
service: ValidationTaskService = Depends(_get_task_service),
|
||||
scheduler: SchedulerService = Depends(get_scheduler_service),
|
||||
):
|
||||
"""Delete a validation task."""
|
||||
logger.reason(f"delete_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
||||
try:
|
||||
service.delete_task(task_id, delete_runs=delete_runs)
|
||||
scheduler.remove_validation_job(task_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
# #endregion delete_task
|
||||
|
||||
Reference in New Issue
Block a user