bmc_hub/app/dashboard/backend/mission_service.py

291 lines
12 KiB
Python
Raw Normal View History

import json
import logging
from typing import Any, Dict, Optional
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
class MissionService:
@staticmethod
def _safe(label: str, func, default):
try:
return func()
except Exception as exc:
logger.error("❌ Mission state component failed: %s (%s)", label, exc)
return default
@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
def get_ring_timeout_seconds() -> int:
raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
try:
value = int(raw)
except (TypeError, ValueError):
value = 180
return max(30, min(value, 3600))
@staticmethod
def expire_stale_ringing_calls() -> None:
if not MissionService._table_exists("mission_call_state"):
return
timeout_seconds = MissionService.get_ring_timeout_seconds()
execute_query(
"""
UPDATE mission_call_state
SET state = 'hangup',
ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW()
WHERE state = 'ringing'
AND started_at < (NOW() - make_interval(secs => %s))
""",
(timeout_seconds,),
)
@staticmethod
def get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
if not row:
return default
value = row.get("value")
if value is None or value == "":
return default
return str(value)
@staticmethod
def parse_json_setting(key: str, default: Any) -> Any:
raw = MissionService.get_setting_value(key, None)
if raw is None:
return default
try:
return json.loads(raw)
except Exception:
return default
@staticmethod
def build_alert_key(service_name: str, customer_name: Optional[str]) -> str:
customer_part = (customer_name or "").strip().lower() or "global"
return f"{service_name.strip().lower()}::{customer_part}"
@staticmethod
def resolve_contact_context(caller_number: Optional[str]) -> Dict[str, Optional[str]]:
if not caller_number:
return {"contact_name": None, "company_name": None, "customer_tag": None}
query = """
SELECT
c.id,
c.first_name,
c.last_name,
(
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS company_name,
(
SELECT t.name
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'contact'
AND et.entity_id = c.id
AND LOWER(t.name) IN ('vip', 'serviceaftale', 'service agreement')
ORDER BY t.name
LIMIT 1
) AS customer_tag
FROM contacts c
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
LIMIT 1
"""
row = execute_query_single(query, (caller_number, caller_number))
if not row:
return {"contact_name": None, "company_name": None, "customer_tag": None}
contact_name = f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip() or None
return {
"contact_name": contact_name,
"company_name": row.get("company_name"),
"customer_tag": row.get("customer_tag"),
}
@staticmethod
def insert_event(
*,
event_type: str,
title: str,
severity: str = "info",
source: Optional[str] = None,
customer_name: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not MissionService._table_exists("mission_events"):
logger.warning("Mission table missing: mission_events (event skipped)")
return {}
rows = execute_query(
"""
INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, event_type, severity, title, source, customer_name, payload, created_at
""",
(event_type, severity, title, source, customer_name, json.dumps(payload or {})),
)
return rows[0] if rows else {}
@staticmethod
def get_kpis() -> Dict[str, int]:
query = """
SELECT
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet') AS open_cases,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) = 'åben' AND ansvarlig_bruger_id IS NULL) AS new_cases,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND ansvarlig_bruger_id IS NULL) AS unassigned_cases,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS deadlines_today,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_deadlines
FROM sag_sager
"""
row = execute_query_single(query) or {}
return {
"open_cases": int(row.get("open_cases") or 0),
"new_cases": int(row.get("new_cases") or 0),
"unassigned_cases": int(row.get("unassigned_cases") or 0),
"deadlines_today": int(row.get("deadlines_today") or 0),
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
}
@staticmethod
def get_employee_deadlines() -> list[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
COALESCE(u.full_name, u.username, 'Ukendt') AS employee_name,
COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) AS deadlines_today,
COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) AS overdue_deadlines
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(s.status) <> 'afsluttet'
AND s.deadline IS NOT NULL
GROUP BY COALESCE(u.full_name, u.username, 'Ukendt')
HAVING COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) > 0
OR COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) > 0
ORDER BY overdue_deadlines DESC, deadlines_today DESC, employee_name ASC
"""
) or []
return [
{
"employee_name": row.get("employee_name"),
"deadlines_today": int(row.get("deadlines_today") or 0),
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
}
for row in rows
]
@staticmethod
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()
rows = execute_query(
"""
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at
FROM mission_call_state
WHERE state = 'ringing'
ORDER BY started_at DESC
"""
)
return rows or []
@staticmethod
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(
"""
SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at
FROM mission_uptime_alerts
WHERE is_active = TRUE
ORDER BY started_at ASC NULLS LAST
"""
)
return rows or []
@staticmethod
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(
"""
SELECT id, event_type, severity, title, source, customer_name, payload, created_at
FROM mission_events
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return rows or []
@staticmethod
def get_state() -> Dict[str, Any]:
kpis_default = {
"open_cases": 0,
"new_cases": 0,
"unassigned_cases": 0,
"deadlines_today": 0,
"overdue_deadlines": 0,
}
return {
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
"config": {
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
"sound_enabled": MissionService._safe(
"config.sound_enabled",
lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
True,
),
"sound_volume": MissionService._safe(
"config.sound_volume",
lambda: int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
70,
),
"sound_events": MissionService._safe(
"config.sound_events",
lambda: MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
["incoming_call", "uptime_down", "critical_event"],
),
"kpi_visible": MissionService._safe(
"config.kpi_visible",
lambda: MissionService.parse_json_setting(
"mission_kpi_visible",
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
),
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
),
"customer_filter": MissionService._safe(
"config.customer_filter",
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
"",
),
},
}