Files
ss-tools/specs/031-maintenance-banner/data-model.md
busya d9669698b8 feat(031): Maintenance Banner for Dashboards — full spec→plan→tasks package
Spec: 5 user stories (P1-P3), 17 FRs, 7 SCs, 12 edge cases, RBAC matrix
Plan: 7 research decisions, 15 semantic contracts (C1-C4)
Data: 4 SQLAlchemy models (Banner entity enforces one-chart-per-dashboard)
UX: async-only API, banner template with optional variables, admin settings UI
Tasks: 64 tasks across 8 phases, 12 reused frontend components
Workflow: component reuse scan mandated in speckit.plan/tasks + semantics-svelte
Constitution: filled with 7 architecture principles from ADRs
2026-05-22 16:20:39 +03:00

14 KiB

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

# 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

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

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

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=(
        '<div style="background:#FFF3E0;padding:16px;border-left:4px solid #FF9800;border-radius:4px">\n\n'
        '## ⚠️ Технические работы\n\n'
        '{message}\n\n'
        '**Начало:** {start_time}\n'
        '**Конец:** {end_time}\n\n'
        '*Данные могут быть неполными или временно недоступны.*\n\n'
        '</div>'
    ))
    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

# 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

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