# Tasks: Maintenance Banner for Dashboards **Input**: Design documents from `specs/031-maintenance-banner/` **Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/modules.md ✅ **Tests**: Included for all C3+ contracts, new API endpoints, database models, and ADR-rejected regression paths. **Organization**: Tasks grouped by user story for independent implementation and verification. ## Format: `- [ ] T### [P?] [USx] Description with exact file path` - **[P]**: Can run in parallel (different files, no dependencies) - **[USx]**: Which user story (US1-US5); `--` for setup/foundational/polish phases - Paths relative to repository root --- ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Project scaffolding — directories, package inits, Alembic migration skeleton. - [ ] T001 Create `backend/src/api/routes/maintenance/` package directory structure (`__init__.py`) - [ ] T002 [P] Create `backend/tests/` test file stubs for maintenance feature: `test_maintenance_service.py`, `test_sql_table_extractor.py`, `test_maintenance_api.py` - [ ] T003 [P] Create frontend directory structure: `frontend/src/routes/maintenance/`, `frontend/src/lib/stores/maintenance.svelte.js`, `frontend/src/lib/api/maintenance.js` (empty placeholder files). Note: UI components reuse existing primitives — no new component scaffolds needed. - [ ] T004 Generate Alembic migration for new tables (`maintenance_events`, `maintenance_dashboard_states`, `maintenance_settings`) via `alembic revision --autogenerate -m "add maintenance banner tables"` after models are defined in Phase 2 --- ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented. All models, schemas, SupersetClient extension, SQL extractor, router scaffold. **⚠️ CRITICAL**: No user story work can begin until this phase is complete. ### Database Models - [ ] T005 [P] [--] Implement `MaintenanceEvent` SQLAlchemy model in `backend/src/models/maintenance.py` with columns (id, task_id — loose reference, environment_id — snapshot of target env, tables JSON, start_time, end_time nullable, message, status enum: pending/applying/active/partial/ending/completed/failed, timestamps) and relationship to MaintenanceDashboardState. Contract: `[DEF:MaintenanceEvent:Model]` C1. - [ ] T005a [P] [--] Implement `MaintenanceDashboardBanner` SQLAlchemy model in `backend/src/models/maintenance.py` — canonical "one chart per dashboard" entity with columns (id, environment_id, dashboard_id, chart_id, banner_text, status enum: active/removed, timestamps) and unique partial index on (environment_id, dashboard_id) WHERE status='active'. Contract: `[DEF:MaintenanceDashboardBanner:Model]` C1. - [ ] T006 [P] [--] Implement `MaintenanceDashboardState` SQLAlchemy model in `backend/src/models/maintenance.py` with columns (id, event_id FK, dashboard_id, banner_id FK→maintenance_dashboard_banners, status enum: pending_apply/active/removing/removed/apply_failed/removal_failed, timestamps) and composite index on (dashboard_id, status). Contract: `[DEF:MaintenanceDashboardState:Model]` C1. - [ ] T007 [P] [--] Implement `MaintenanceSettings` SQLAlchemy model in `backend/src/models/maintenance.py` — single-row table (id="default") with CheckConstraint(id='default'), columns (target_environment_id, display_timezone default "UTC", banner_template text, dashboard_scope enum, excluded_dashboard_ids JSON, forced_dashboard_ids JSON, updated_at). Contract: `[DEF:MaintenanceSettings:Model]` C1. ### Pydantic Schemas - [ ] T008 [--] Implement all Pydantic request/response schemas in `backend/src/api/routes/maintenance/_schemas.py`: MaintenanceStartRequest, MaintenanceSettingsUpdate, MaintenanceStartResponse, MaintenanceTaskResult, MaintenanceDashboardResult, MaintenanceFailedDashboard, MaintenanceEndResponse, MaintenanceEndTaskResult, MaintenanceEndAllTaskResult, MaintenanceSettingsResponse, MaintenanceEventItem, MaintenanceEventListResponse, MaintenanceDashboardBannerState. Contract: `[DEF:MaintenanceSchemas:Module]` C1. ### SupersetClient Extension (C4 — REQUIRES belief runtime) - [ ] T009 [--] Implement `SupersetDashboardsWriteMixin` in `backend/src/core/superset_client/_dashboards_write.py` with methods: `create_markdown_chart(dashboard_id, markdown_text) → chart_id` (POST /api/v1/chart/ with viz_type="markdown"), `update_markdown_chart(chart_id, markdown_text)` (PUT /api/v1/chart/{id} to update markdown params), `update_dashboard_layout(dashboard_id, chart_id)` (insert at position (0,0), full width 12 cols, shift existing components down), `remove_chart_from_layout(dashboard_id, chart_id)` (remove from position_json), `delete_chart(chart_id)` (DELETE /api/v1/chart/{id}), `get_dashboard_layout(dashboard_id)` (GET /api/v1/dashboard/{id} for position_json). Contract: `[DEF:SupersetDashboardsWriteMixin:Class]` C4. (RATIONALE: chart_id tracked in MaintenanceDashboardBanner DB table — no need to scan position_json) - [ ] T010 [--] Instrument `SupersetDashboardsWriteMixin` with belief runtime markers: `belief_scope` context manager wrapping each Superset API call, `log(REASON)` before POST/PUT/DELETE, `log(REFLECT)` on success, `log(EXPLORE)` on HTTP errors. ### SQL Table Extractor (C2) - [ ] T011 [--] Implement `SqlTableExtractor` module in `backend/src/services/sql_table_extractor.py` with two-phase extraction: (1) determine Jinja block spans vs SQL spans, (2) in Jinja spans — extract `"schema.table"` from string values, (3) in SQL spans — regex `[a-zA-Z][\w]*\.[a-zA-Z][\w]*` + sqlparse filtering to reject SQL string literal/comment positions. Orchestrator: `extract_tables_from_sql(raw_sql) → set[str]`. Contract: `[DEF:SqlTableExtractor:Module]` C2. (RATIONALE: Jinja string values in {% set %} blocks must NOT be filtered as SQL strings; REJECTED: sqlparse-only approach loses Jinja-embedded tables) ### Router & App Registration - [ ] T012 [--] Implement `MaintenanceRouter` in `backend/src/api/routes/maintenance/_router.py` — creates `APIRouter(prefix="/api/maintenance", tags=["maintenance"])`. Contract: `[DEF:MaintenanceRouter:Module]` C2. - [ ] T013 [--] Implement route handler stubs in `backend/src/api/routes/maintenance/_routes.py` — all 7 endpoint signatures with placeholder returns and proper RBAC `Depends(require_role(...))` guards. Contract: `[DEF:MaintenanceRoutes:Module]` C3 (stub phase). - [ ] T014 [--] Register `maintenance_router` in main FastAPI application (`backend/src/app.py`) and register `SupersetDashboardsWriteMixin` in SupersetClient assembly. ### Seed Data - [ ] T015 [--] Create Alembic data migration (or startup check) to insert default `MaintenanceSettings` row (id="default", target_environment_id=, dashboard_scope="published_only", excluded=[], forced=[]) if not exists. **Checkpoint**: Foundation ready — models, schemas, SupersetClient extension, SQL extractor, router, and seed data in place. All user stories can now begin. --- ## Phase 3: User Story 1 — Banner Placement (Priority: P1) 🎯 MVP **Goal**: External tool calls `POST /api/maintenance/start` → superset-tools finds affected dashboards → places markdown banners. **Independent Test**: Call API with a known table name, verify banner appears on affected dashboards in Superset. ### Tests for US1 (Write FIRST, ensure they FAIL) - [ ] T016 [P] [US1] Write contract test for `POST /api/maintenance/start` in `backend/tests/test_maintenance_api.py` — test valid request returns `202`, test missing `start_time` returns 422, test `end_time < start_time` returns 422, test past `start_time` >1h returns 422, test `tables` >100 entries returns 422, test optional `end_time` (omitted → task result shows «уточняется»), test duplicate request returns `200 {status:"already_active"}`, test no-match in task result, test RBAC returns 403, test HTML injection in message returns 400. - [ ] T017 [P] [US1] Write unit test for `SqlTableExtractor.extract_tables_from_sql()` in `backend/tests/test_sql_table_extractor.py` — test table-based matching, virtual-SQL extraction with Jinja `{% set %}`, string literal false-positive rejection, case-insensitive matching. - [ ] T018 [P] [US1] Write integration test for `MaintenanceService.start_maintenance()` in `backend/tests/test_maintenance_service.py` — test dashboard discovery, markdown chart creation, MaintenanceEvent persistence, MaintenanceDashboardState creation, idempotency check. ### Implementation for US1 - [ ] T019 [US1] Implement `find_affected_dashboards()` method in `backend/src/services/maintenance_service.py` — fetches all datasets from Superset, matches table-based datasets via (schema, table_name), matches virtual datasets via SqlTableExtractor, applies scope/excluded/forced filtering from MaintenanceSettings, returns deduplicated dashboard list. Contract C3 within `[DEF:MaintenanceService:Class]`. - [ ] T019a [US1] Implement `ensure_banner_chart()` method in `backend/src/services/maintenance_service.py` — idempotently gets or creates the shared `MaintenanceDashboardBanner` for a dashboard. If banner exists (ACTIVE in DB) → return it. Otherwise → `create_markdown_chart()` + `update_dashboard_layout()`, store chart_id in banner row. Contract C3. (RATIONALE: DB is source of truth for chart existence — no position_json scanning needed; REJECTED: per-event chart creation) - [ ] T020 [US1] Implement `build_banner_text()` method in `backend/src/services/maintenance_service.py` — reads `banner_template` and `display_timezone` from MaintenanceSettings, substitutes `{start_time}`, `{end_time}`, `{message}` variables. Missing variables in template are simply omitted. Single-event: direct substitution. Multi-event: aggregates per-event start/end/message, then substitutes into template. Handles absent `end_time` → «Окончание: уточняется». Contract C2. - [ ] T021 [US1] Implement `start_maintenance()` method in `backend/src/services/maintenance_service.py` — orchestrates full flow: (1) idempotency check by `(tables, start_time, end_time)` key using row-level lock (`SELECT ... FOR UPDATE` on maintenance_events to prevent race condition from concurrent identical /start calls), (2) create MaintenanceEvent (status=PENDING→APPLYING), (3) `find_affected_dashboards()`, (4) for each dashboard: `ensure_banner_chart()` → create MaintenanceDashboardState (PENDING_APPLY→ACTIVE or APPLY_FAILED), rebuild aggregated banner text via `build_banner_text()` + `update_markdown_chart()`, (5) transition event to ACTIVE (all success) or PARTIAL (some failed). Contract C4. - [ ] T022 [US1] Instrument `start_maintenance()` with belief runtime: `belief_scope("start_maintenance")`, `log(REASON)` at entry/checkpoint, `log(REFLECT)` on completion, `log(EXPLORE)` on Superset errors. - [ ] T023 [US1] Implement `POST /api/maintenance/start` route handler in `backend/src/api/routes/maintenance/_routes.py` — validates MaintenanceStartRequest: `start_time` required, `end_time` optional (absent → banner text shows «Окончание: уточняется»), message field sanitized (reject raw HTML, escape special chars). Idempotency check by `(tables, start_time, end_time)` key. Otherwise: create MaintenanceEvent, dispatch task via TaskManager, return `202 {task_id, maintenance_id, status:"pending"}`. RBAC: `Depends(require_role("operator"))`. - [ ] T024 [US1] Run backend verification: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_api.py backend/tests/test_maintenance_service.py backend/tests/test_sql_table_extractor.py -v` **Checkpoint**: `POST /api/maintenance/start` fully functional. Banners placed on affected dashboards. Idempotency working. --- ## Phase 4: User Story 2 — Banner Removal (Priority: P1) **Goal**: External tool calls `POST /api/maintenance/{id}/end` or `/end-all` → banners removed from dashboards. **Independent Test**: After US1, call /end and verify banners disappear; call again and verify idempotent. ### Tests for US2 (Write FIRST, ensure they FAIL) - [ ] T025 [P] [US2] Write contract test for `POST /api/maintenance/{id}/end` in `backend/tests/test_maintenance_api.py` — test successful removal, test already-completed idempotent response, test multi-event scenario (banner stays when other active events exist, text rebuilt). - [ ] T026 [P] [US2] Write integration test for `MaintenanceService.end_maintenance()` in `backend/tests/test_maintenance_service.py` — test chart deletion via Superset API, test MaintenanceDashboardState transition to REMOVED, test MaintenanceEvent transition to COMPLETED, test banner rebuild when other active events remain. - [ ] T026a [P] [US2] Write integration test for `MaintenanceService.end_all_maintenance()` in `backend/tests/test_maintenance_service.py` — test bulk closure of all active events, verify each transitions to COMPLETED, test mixed state (some dashboards have other active events → banner stays, others → banner removed). ### Implementation for US2 - [ ] T027 [US2] Implement `rebuild_banner()` method in `backend/src/services/maintenance_service.py` — for a given `MaintenanceDashboardBanner`, queries all active `MaintenanceDashboardState` records linked to it, calls `build_banner_text()` for aggregated text, updates the shared markdown chart in Superset via `SupersetDashboardsWriteMixin.update_markdown_chart()`. Contract C3 within `[DEF:MaintenanceService:Class]`. - [ ] T028 [US2] Implement `end_maintenance()` method in `backend/src/services/maintenance_service.py` — set event status to ENDING, then for each active `MaintenanceDashboardState` in the event: (1) check if OTHER active event-states still reference the same banner; (2) if yes → call `rebuild_banner()` (remove this event's info from aggregated text), transition state to REMOVED; (3) if no → transition banner to REMOVED, call `remove_chart_from_layout()` then `delete_chart()`, transition state to REMOVING→REMOVED (or REMOVAL_FAILED on error); (4) transition event to COMPLETED. Contract C4. (RATIONALE: conditional removal prevents banner flicker; REJECTED: unconditional removal) - [ ] T029 [US2] Instrument `end_maintenance()` with belief runtime markers. - [ ] T030 [US2] Implement `end_all_maintenance()` method in `backend/src/services/maintenance_service.py` — iterates all active events, calls `end_maintenance()` for each. Contract C4. - [ ] T031 [US2] Implement `POST /api/maintenance/{maintenance_id}/end` and `POST /api/maintenance/end-all` route handlers in `backend/src/api/routes/maintenance/_routes.py` — dispatch tasks via TaskManager, return task_id immediately. RBAC: `Depends(require_role("operator"))`. - [ ] T032 [US2] Run backend verification: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_maintenance_api.py backend/tests/test_maintenance_service.py -v -k "end"` **Checkpoint**: Banner removal fully functional. Multi-event conflict handling verified. Idempotent /end confirmed. --- ## Phase 5: User Story 4 — Settings Configuration (Priority: P3) **Goal**: Administrator can configure maintenance scope (published/draft/all, excluded dashboards, forced dashboards) via API and UI. **Note**: Settings model is already in Phase 2 (T007). This phase adds API endpoints and UI panel. ### Tests for US4 - [ ] T033 [P] [US4] Write API test for `GET /api/maintenance/settings` and `PUT /api/maintenance/settings` in `backend/tests/test_maintenance_api.py` — test read returns defaults, test update with valid payload, test RBAC (admin only for PUT), test invalid scope rejection. ### Implementation for US4 - [ ] T034 [US4] Implement `GET /api/maintenance/settings` and `PUT /api/maintenance/settings` route handlers in `backend/src/api/routes/maintenance/_routes.py`. GET: read MaintenanceSettings row. PUT: validate and update fields (target_environment_id, display_timezone, banner_template, dashboard_scope, excluded, forced). Return updated settings. RBAC: GET = `operator+`, PUT = `admin`. - [ ] T035 [P] [US4] Implement `MaintenanceApiClient` module in `frontend/src/lib/api/maintenance.js` — typed fetch wrappers: `getSettings()`, `updateSettings(payload)`, `getEvents()`, `startMaintenance(payload)`, `endMaintenance(id)`, `endAllMaintenance()`, `getDashboardBanners()`. Uses `requestApi` from existing API layer. Contract: `[DEF:MaintenanceApiClient:Module]` C2. - [ ] T036 [US4] Implement `MaintenanceSettingsPanel` component in `frontend/src/lib/components/MaintenanceSettingsPanel.svelte` — collapsible via `
`. Form uses: `` from `$lib/ui/Input.svelte` for timezone, native `` for scope (matches ScheduleConfig pattern), `` from `$lib/components/ui/SearchableMultiSelect.svelte` for excluded/forced dashboards (options: `{id, name}` from dashboard list), `