Release v2.2.46: mission schema resilience and repair migration

This commit is contained in:
Christian 2026-03-04 07:12:29 +01:00
parent 4760b8b3c4
commit 2bd5a3e057
5 changed files with 159 additions and 1 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ htmlcov/
.coverage .coverage
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
RELEASE_NOTES_v2.2.38.md

19
RELEASE_NOTES_v2.2.46.md Normal file
View File

@ -0,0 +1,19 @@
# Release Notes v2.2.46
Dato: 4. marts 2026
## Fixes og driftssikring
- Mission Control backend tåler nu manglende mission-tabeller uden at crashe requests, og logger tydelige advarsler.
- Tilføjet idempotent reparationsmigration for Mission Control schema (`143_mission_control_repair.sql`) til miljøer med delvist oprettede tabeller.
- Opdateret `.gitignore` med release-note undtagelse fra tidligere drift.
## Ændrede filer
- `app/dashboard/backend/mission_service.py`
- `migrations/143_mission_control_repair.sql`
- `.gitignore`
- `VERSION`
- `RELEASE_NOTES_v2.2.46.md`
## Drift
- Deploy: `./updateto.sh v2.2.46`
- Migration (hvis nødvendig): `docker compose exec db psql -U bmc_hub -d bmc_hub -f migrations/143_mission_control_repair.sql`

View File

@ -1 +1 @@
2.2.45 2.2.46

View File

@ -1,10 +1,19 @@
import json import json
import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
class MissionService: class MissionService:
@staticmethod
def _table_exists(table_name: str) -> bool:
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
return bool(row and row.get("table_name"))
@staticmethod @staticmethod
def get_ring_timeout_seconds() -> int: def get_ring_timeout_seconds() -> int:
raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180" raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
@ -16,6 +25,9 @@ class MissionService:
@staticmethod @staticmethod
def expire_stale_ringing_calls() -> None: def expire_stale_ringing_calls() -> None:
if not MissionService._table_exists("mission_call_state"):
return
timeout_seconds = MissionService.get_ring_timeout_seconds() timeout_seconds = MissionService.get_ring_timeout_seconds()
execute_query( execute_query(
""" """
@ -108,6 +120,10 @@ class MissionService:
customer_name: Optional[str] = None, customer_name: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if not MissionService._table_exists("mission_events"):
logger.warning("Mission table missing: mission_events (event skipped)")
return {}
rows = execute_query( rows = execute_query(
""" """
INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload) INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload)
@ -168,6 +184,10 @@ class MissionService:
@staticmethod @staticmethod
def get_active_calls() -> list[Dict[str, Any]]: def get_active_calls() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_call_state"):
logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return []
MissionService.expire_stale_ringing_calls() MissionService.expire_stale_ringing_calls()
rows = execute_query( rows = execute_query(
""" """
@ -181,6 +201,10 @@ class MissionService:
@staticmethod @staticmethod
def get_active_alerts() -> list[Dict[str, Any]]: def get_active_alerts() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_uptime_alerts"):
logger.warning("Mission table missing: mission_uptime_alerts (active alerts unavailable)")
return []
rows = execute_query( rows = execute_query(
""" """
SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at
@ -193,6 +217,10 @@ class MissionService:
@staticmethod @staticmethod
def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]: def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_events"):
logger.warning("Mission table missing: mission_events (live feed unavailable)")
return []
rows = execute_query( rows = execute_query(
""" """
SELECT id, event_type, severity, title, source, customer_name, payload, created_at SELECT id, event_type, severity, title, source, customer_name, payload, created_at

View File

@ -0,0 +1,110 @@
-- Migration 143: Repair Mission Control schema on partially-initialized databases
-- Safe to run multiple times.
CREATE TABLE IF NOT EXISTS mission_events (
id BIGSERIAL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
title VARCHAR(255) NOT NULL,
source VARCHAR(64),
customer_name VARCHAR(255),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS event_type VARCHAR(64);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS severity VARCHAR(16) NOT NULL DEFAULT 'info';
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS title VARCHAR(255);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS source VARCHAR(64);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS customer_name VARCHAR(255);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_mission_events_created_at ON mission_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mission_events_event_type ON mission_events(event_type);
CREATE TABLE IF NOT EXISTS mission_call_state (
call_id VARCHAR(128) PRIMARY KEY,
queue_name VARCHAR(128),
caller_number VARCHAR(64),
contact_name VARCHAR(255),
company_name VARCHAR(255),
customer_tag VARCHAR(64),
state VARCHAR(32) NOT NULL,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
answered_at TIMESTAMP,
ended_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS call_id VARCHAR(128);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS queue_name VARCHAR(128);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS caller_number VARCHAR(64);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS customer_tag VARCHAR(64);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS state VARCHAR(32) NOT NULL DEFAULT 'ringing';
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS answered_at TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS ended_at TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS last_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'mission_call_state_pkey'
AND conrelid = 'mission_call_state'::regclass
) THEN
ALTER TABLE mission_call_state ADD PRIMARY KEY (call_id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_mission_call_state_state ON mission_call_state(state);
CREATE INDEX IF NOT EXISTS idx_mission_call_state_updated_at ON mission_call_state(updated_at DESC);
CREATE TABLE IF NOT EXISTS mission_uptime_alerts (
alert_key VARCHAR(255) PRIMARY KEY,
service_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255),
status VARCHAR(32) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
started_at TIMESTAMP,
resolved_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb
);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS alert_key VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS service_name VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS customer_name VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS status VARCHAR(32);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS started_at TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'mission_uptime_alerts_pkey'
AND conrelid = 'mission_uptime_alerts'::regclass
) THEN
ALTER TABLE mission_uptime_alerts ADD PRIMARY KEY (alert_key);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_mission_uptime_active ON mission_uptime_alerts(is_active, updated_at DESC);
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('mission_call_ring_timeout_seconds', '180', 'mission', 'Seconds before stale ringing calls auto-expire', 'integer', true)
ON CONFLICT (key) DO NOTHING;