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:
2026-05-25 08:36:33 +03:00
parent 2bf686674e
commit 31680a1bc9
100 changed files with 19635 additions and 7923 deletions

View File

@@ -28,6 +28,7 @@ __all__ = [
"git",
"health",
"llm",
"maintenance",
"mappings",
"migration",
"plugins",

View 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

View 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

View 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

View 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

View File

@@ -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"}

View File

@@ -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