# 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=(
        '<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

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