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

344 lines
14 KiB
Markdown

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