# Data Model: Maintenance Banner for Dashboards **Feature Branch**: `031-maintenance-banner` **Created**: 2026-05-21 **Status**: Phase 1 — Design ## Entity-Relationship Overview ``` ┌──────────────────────┐ ┌─────────────────────────────┐ │ MaintenanceSettings │ │ MaintenanceEvent │ │ (single row) │ │ │ │ ────────────────── │ │ id: str (PK) │ │ target_environment │◄──────│ task_id: str (FK→Task) │ │ display_timezone │ │ tables: JSON (str[]) │ │ dashboard_scope │ │ start_time: datetime │ │ excluded_ids: JSON │ │ end_time: datetime │ │ forced_ids: JSON │ │ message: str? │ │ updated_at │ │ status: enum(pending|applying │ └──────────────────────┘ │ |active|partial|ending │ │ |completed|failed) │ │ created_at / updated_at │ └──────────┬────────────────────┘ │ 1:N ▼ ┌─────────────────────────────┐ │ MaintenanceDashboardState │ │ │ │ id: str (PK) │ │ event_id: str (FK→Event) │ │ dashboard_id: int │ │ banner_id: str (FK→Banner) │ │ status: enum(pending_apply │ │ |active|removing │ │ |removed|apply_failed │ │ |removal_failed) │ │ created_at / updated_at │ └──────────┬──────────────────┘ │ N:1 ▼ ┌─────────────────────────────┐ │ MaintenanceDashboardBanner │ │ (ONE chart per dashboard) │ │ │ │ id: str (PK) │ │ environment_id: str │ │ dashboard_id: int │ │ chart_id: int? (Superset) │ │ banner_text: text │ │ status: enum(active|removed)│ │ UNIQUE (env_id, dash_id, │ │ status=active) │ │ created_at / updated_at │ └─────────────────────────────┘ ``` ## SQLAlchemy Models ### MaintenanceEvent ```python # backend/src/models/maintenance.py from sqlalchemy import Column, String, DateTime, JSON, Enum, Text from sqlalchemy.orm import relationship from src.models.base import Base import enum class MaintenanceEventStatus(str, enum.Enum): PENDING = "pending" APPLYING = "applying" ACTIVE = "active" PARTIAL = "partial" # some dashboards succeeded, some failed ENDING = "ending" COMPLETED = "completed" FAILED = "failed" class MaintenanceEvent(Base): __tablename__ = "maintenance_events" id = Column(String, primary_key=True, index=True) task_id = Column(String, nullable=True, index=True) # Loose reference — no FK to avoid circular dependency environment_id = Column(String, nullable=False) # Target env at event creation (snapshot) tables = Column(JSON, nullable=False) start_time = Column(DateTime(timezone=True), nullable=False) end_time = Column(DateTime(timezone=True), nullable=True) # nullable — "Окончание: уточняется" when absent message = Column(Text, nullable=True) status = Column( Enum(MaintenanceEventStatus), nullable=False, default=MaintenanceEventStatus.PENDING, index=True ) created_at = Column(DateTime(timezone=True), server_default="now()") updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()") dashboard_states = relationship( "MaintenanceDashboardState", back_populates="maintenance_event", cascade="all, delete-orphan" ) ``` ### MaintenanceDashboardBanner ```python class MaintenanceDashboardBannerStatus(str, enum.Enum): ACTIVE = "active" REMOVED = "removed" class MaintenanceDashboardBanner(Base): __tablename__ = "maintenance_dashboard_banners" id = Column(String, primary_key=True, index=True) environment_id = Column(String, nullable=False) dashboard_id = Column(Integer, nullable=False, index=True) chart_id = Column(Integer, nullable=True) # Superset markdown chart ID banner_text = Column(Text, nullable=True) # Current aggregated markdown status = Column( Enum(MaintenanceDashboardBannerStatus), nullable=False, default=MaintenanceDashboardBannerStatus.ACTIVE ) created_at = Column(DateTime(timezone=True), server_default="now()") updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()") dashboard_states = relationship("MaintenanceDashboardState", back_populates="banner") __table_args__ = ( Index("ix_banner_unique_active", "environment_id", "dashboard_id", unique=True, postgresql_where=(status == "active")), ) ``` ### MaintenanceDashboardState ```python class MaintenanceDashboardStateStatus(str, enum.Enum): PENDING_APPLY = "pending_apply" ACTIVE = "active" REMOVING = "removing" REMOVED = "removed" APPLY_FAILED = "apply_failed" REMOVAL_FAILED = "removal_failed" class MaintenanceDashboardState(Base): __tablename__ = "maintenance_dashboard_states" id = Column(String, primary_key=True, index=True) event_id = Column(String, ForeignKey("maintenance_events.id"), nullable=False, index=True) dashboard_id = Column(Integer, nullable=False, index=True) banner_id = Column(String, ForeignKey("maintenance_dashboard_banners.id"), nullable=True) status = Column( Enum(MaintenanceDashboardStateStatus), nullable=False, default=MaintenanceDashboardStateStatus.PENDING_APPLY ) created_at = Column(DateTime(timezone=True), server_default="now()") updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()") maintenance_event = relationship("MaintenanceEvent", back_populates="dashboard_states") banner = relationship("MaintenanceDashboardBanner", back_populates="dashboard_states") __table_args__ = ( Index("ix_mds_dashboard_status", "dashboard_id", "status"), ) ``` ### MaintenanceSettings ```python class DashboardScope(str, enum.Enum): PUBLISHED_ONLY = "published_only" DRAFT_ONLY = "draft_only" ALL = "all" class MaintenanceSettings(Base): __tablename__ = "maintenance_settings" id = Column(String, primary_key=True, default="default") target_environment_id = Column(String, nullable=False) display_timezone = Column(String, nullable=False, default="UTC") banner_template = Column(Text, nullable=False, default=( '
\n\n' '## ⚠️ Технические работы\n\n' '{message}\n\n' '**Начало:** {start_time}\n' '**Конец:** {end_time}\n\n' '*Данные могут быть неполными или временно недоступны.*\n\n' '
' )) dashboard_scope = Column( Enum(DashboardScope), nullable=False, default=DashboardScope.PUBLISHED_ONLY ) excluded_dashboard_ids = Column(JSON, nullable=False, default=list) forced_dashboard_ids = Column(JSON, nullable=False, default=list) updated_at = Column(DateTime(timezone=True), server_default="now()", onupdate="now()") __table_args__ = ( CheckConstraint("id = 'default'", name="ck_settings_singleton"), ) ``` ## Pydantic Schemas ### Request Schemas ```python # backend/src/api/routes/maintenance/_schemas.py from pydantic import BaseModel, Field from datetime import datetime class MaintenanceStartRequest(BaseModel): tables: list[str] = Field(..., min_length=1) start_time: datetime # required end_time: datetime | None = None # optional — absent → «уточняется» message: str | None = Field(None, max_length=500) 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 ``` ### Response Schemas ```python class MaintenanceStartResponse(BaseModel): task_id: str maintenance_id: str status: str # "pending" class MaintenanceDashboardResult(BaseModel): id: int title: str chart_id: int | None = None class MaintenanceFailedDashboard(BaseModel): id: int title: str error: str 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] = [] class MaintenanceEndResponse(BaseModel): task_id: str status: str # "pending" class MaintenanceEndTaskResult(BaseModel): maintenance_id: str removed_from: int = 0 failed_removals: list[MaintenanceFailedDashboard] = [] class MaintenanceEndAllTaskResult(BaseModel): closed_events: int = 0 removed_from: int = 0 failed_removals: list[MaintenanceFailedDashboard] = [] 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 class MaintenanceEventItem(BaseModel): id: str tables: list[str] start_time: str end_time: str message: str | None = None status: str affected_count: int created_at: str class MaintenanceEventListResponse(BaseModel): active: list[MaintenanceEventItem] completed: list[MaintenanceEventItem] class MaintenanceDashboardBannerState(BaseModel): dashboard_id: int active: bool events: list[str] # maintenance event IDs start_time: str | None = None end_time: str | None = None ``` ## Index & Query Patterns | Query | Index | Frequency | |-------|-------|-----------| | Find active events | `maintenance_events(status)` | Per polling cycle (30s) | | Find active banners for dashboard D | `maintenance_dashboard_states(dashboard_id, status)` | Per dashboard hub load | | Find all states for event E | `maintenance_dashboard_states(event_id)` | Per event detail view | | Check for duplicate active event | Application-level (tables JSON comparison) | Per /start call | | Get settings | `maintenance_settings(id="default")` — single row | Per /start call | ## State Transitions ### MaintenanceEvent ``` [created] → PENDING → APPLYING → ACTIVE (all banners placed) └──→ PARTIAL (some banners failed) └──→ FAILED (all banners failed) ACTIVE ──→ ENDING → COMPLETED PARTIAL ──→ ENDING → COMPLETED ``` ### MaintenanceDashboardBanner ``` [created] → ACTIVE ──→ REMOVED ``` ### MaintenanceDashboardState ``` [created] → PENDING_APPLY → ACTIVE └──→ APPLY_FAILED ACTIVE ──→ REMOVING → REMOVED └──→ REMOVAL_FAILED ``` ### Event Conflict Matrix | Dashboard has | New /start | Idempotency Key | Behavior | |--------------|-----------|-----------------|----------| | No banner | tables match | — | Create `MaintenanceDashboardBanner` + `MaintenanceDashboardState(PENDING_APPLY→ACTIVE)` | | Banner ACTIVE from M1 | tables + (start, end) differ | New event | Create `MaintenanceDashboardState(PENDING_APPLY)` → link to existing Banner → rebuild text | | Banner ACTIVE from M1 | same (tables, start, end) | Duplicate | Return existing `maintenance_id`, `status:"already_active"` | | Banner ACTIVE from M1 | same tables+start, end was None, now set | New event | Different `end_time` value → new event. Old event remains active until explicitly ended. | | Banner REMOVED | any tables | — | M1 already completed, treat as no banner |