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
14 KiB
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 |