diff --git a/MDfile/RELEASE_NOTES_v2.7.1.md b/MDfile/RELEASE_NOTES_v2.7.1.md new file mode 100644 index 0000000..82c06eb --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.7.1.md @@ -0,0 +1,20 @@ +# Release Notes: v2.7.1 + +**Dato:** 25. august 2026 + +## Overblik + +Version 2.7.1 retter linkning og varighed for ældre telefonopkald fra Mission Control. + +## Telefoni + +- Ældre opkald fra `mission_call_state` flyttes til den almindelige telefonilog, når en kontakt eller sag linkes. +- Alle linkede opkald bliver dermed synlige på sagen samt i Tidregistrering og Historik. +- Den stabile eksterne call-id og kildetype sendes med ved linkning. +- Forsinkede afslutningshændelser kan ikke længere skabe varigheder på flere måneder. +- Eksisterende urimelige varigheder over 12 timer skjules som ukendt varighed. + +## Verifikation + +- Telefoni- og sag-tests: **41 bestået**. +- Python-kompilering og diff-kontrol gennemført. diff --git a/VERSION b/VERSION index 24ba9a3..860487c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.0 +2.7.1 diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index b692c2a..6ad5ed7 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -3496,8 +3496,11 @@ def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]: continue direction = str(row.get("direction") or "inbound").lower() duration_seconds = row.get("duration_sec") + if duration_seconds is not None and not 0 <= float(duration_seconds) <= 43200: + duration_seconds = None if duration_seconds is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): - duration_seconds = max(0, int((row["ended_at"] - started_at).total_seconds())) + calculated_seconds = int((row["ended_at"] - started_at).total_seconds()) + duration_seconds = calculated_seconds if 0 <= calculated_seconds <= 43200 else None activities.append({ "id": f"call:{row.get('id')}", "source_id": row.get("id"), diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index fe26f51..bbc3c44 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -838,7 +838,13 @@ async def sag_detaljer(request: Request, sag_id: int): t.ekstern_nummer, t.started_at, t.ended_at, - t.duration_sec, + CASE + WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec + WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL + AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int + ELSE NULL + END AS duration_sec, u.username, u.full_name, CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name @@ -1193,7 +1199,13 @@ async def sag_detaljer_v3(request: Request, sag_id: int): t.ekstern_nummer, t.started_at, t.ended_at, - t.duration_sec, + CASE + WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec + WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL + AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int + ELSE NULL + END AS duration_sec, u.username, u.full_name, CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py index 7f84797..6906f0f 100644 --- a/app/modules/telefoni/backend/router.py +++ b/app/modules/telefoni/backend/router.py @@ -775,13 +775,13 @@ async def list_calls( t.sag_id, t.started_at, t.ended_at, - COALESCE( - t.duration_sec, - CASE - WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0) - ELSE NULL - END - ) AS duration_sec, + CASE + WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec + WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL + AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int + ELSE NULL + END AS duration_sec, t.created_at, u.username, u.full_name, @@ -794,7 +794,8 @@ async def list_calls( ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC LIMIT 1 ) AS contact_company, - s.titel AS sag_titel + s.titel AS sag_titel, + 'telefoni'::VARCHAR AS source FROM telefoni_opkald t LEFT JOIN users u ON u.user_id = t.bruger_id LEFT JOIN contacts c ON c.id = t.kontakt_id @@ -903,6 +904,38 @@ async def list_calls( @router.patch("/telefoni/calls/{call_id}") async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate): existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,)) + if not existing and data.source == "legacy_mission" and data.callid: + promoted = execute_query( + """ + INSERT INTO telefoni_opkald + (callid, direction, ekstern_nummer, started_at, ended_at, duration_sec, raw_payload) + SELECT + m.call_id, + CASE WHEN LOWER(COALESCE(m.state, '')) IN ('outbound', 'udgaaende') THEN 'outbound' ELSE 'inbound' END, + m.caller_number, + m.started_at, + m.ended_at, + CASE + WHEN m.started_at IS NOT NULL AND m.ended_at IS NOT NULL + AND m.ended_at - m.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (m.ended_at - m.started_at))::int + ELSE NULL + END, + COALESCE(m.last_payload, '{}'::jsonb) + FROM mission_call_state m + WHERE m.call_id = %s + ON CONFLICT (callid) DO NOTHING + RETURNING id + """, + (data.callid.strip(),), + ) or [] + if promoted: + call_id = int(promoted[0]["id"]) + existing = {"id": call_id} + else: + existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE callid = %s", (data.callid.strip(),)) + if existing: + call_id = int(existing["id"]) if not existing: raise HTTPException(status_code=404, detail="Call not found") diff --git a/app/modules/telefoni/backend/schemas.py b/app/modules/telefoni/backend/schemas.py index e93ff47..8ee4e56 100644 --- a/app/modules/telefoni/backend/schemas.py +++ b/app/modules/telefoni/backend/schemas.py @@ -5,6 +5,8 @@ from typing import Optional class TelefoniCallLinkUpdate(BaseModel): sag_id: Optional[int] = None kontakt_id: Optional[int] = None + callid: Optional[str] = None + source: Optional[str] = None class TelefoniUserMappingUpdate(BaseModel): diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py index f93a130..71bff26 100644 --- a/app/modules/telefoni/backend/service.py +++ b/app/modules/telefoni/backend/service.py @@ -170,7 +170,9 @@ class TelefoniService: duration_sec = COALESCE( EXCLUDED.duration_sec, CASE - WHEN telefoni_opkald.started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int, 0) + WHEN telefoni_opkald.started_at IS NOT NULL + AND NOW() - telefoni_opkald.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int ELSE NULL END ) diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html index 6cc68d8..186c33c 100644 --- a/app/modules/telefoni/templates/log.html +++ b/app/modules/telefoni/templates/log.html @@ -63,6 +63,8 @@ {% for call in initial_calls %} { if (!linkSagState.callId || !linkSagState.selectedSagId) return; try { + const call = telefoniCallMap.get(Number(linkSagState.callId)); const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ sag_id: linkSagState.selectedSagId }) + body: JSON.stringify({ sag_id: linkSagState.selectedSagId, callid: call?.callid || null, source: call?.source || null }) }); if (!res.ok) { const t = await res.text(); @@ -954,6 +958,8 @@ function hydrateCallMapFromSsrRows() { telefoniCallMap.set(callId, { id: callId, + callid: String(row.dataset.callid || '').trim() || null, + source: String(row.dataset.source || '').trim() || 'telefoni', direction: String(row.dataset.direction || '').trim() || null, display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null, ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null, diff --git a/tests/test_telefoni_call_logging.py b/tests/test_telefoni_call_logging.py index 78ee4b6..e658113 100644 --- a/tests/test_telefoni_call_logging.py +++ b/tests/test_telefoni_call_logging.py @@ -1,3 +1,4 @@ +import asyncio import sys from pathlib import Path @@ -5,7 +6,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from starlette.requests import Request +from app.modules.telefoni.backend import router as telefoni_router from app.modules.telefoni.backend.router import _is_internal_request_target +from app.modules.telefoni.backend.schemas import TelefoniCallLinkUpdate from app.modules.telefoni.backend.service import TelefoniService @@ -67,3 +70,51 @@ def test_click_to_call_recognizes_local_and_internal_hub_targets(): def test_click_to_call_rejects_external_hub_target_fallback(): assert _is_internal_request_target(_request_for_host("example.com")) is False assert _is_internal_request_target(_request_for_host("172.16.30.183")) is False + + +def test_legacy_mission_call_is_promoted_when_linked_to_case(monkeypatch): + executed = [] + + def fake_single(query, params=None): + if "WHERE id = %s" in query: + return None + return None + + def fake_query(query, params=None): + executed.append((query, params)) + if "FROM mission_call_state" in query: + return [{"id": 77}] + if "UPDATE telefoni_opkald" in query: + return [{"id": 77, "sag_id": 42}] + return [] + + monkeypatch.setattr(telefoni_router, "execute_query_single", fake_single) + monkeypatch.setattr(telefoni_router, "execute_query", fake_query) + + result = asyncio.run(telefoni_router.update_call_links( + -1, + TelefoniCallLinkUpdate(sag_id=42, callid="legacy-123", source="legacy_mission"), + )) + + assert result["sag_id"] == 42 + assert any("INSERT INTO telefoni_opkald" in query for query, _ in executed) + assert any(params == (42, 77) for query, params in executed if "UPDATE telefoni_opkald" in query) + + +def test_telefoni_ui_sends_legacy_source_identity_when_linking(): + template = Path("app/modules/telefoni/templates/log.html").read_text(encoding="utf-8") + + assert 'data-callid="{{ call.callid or \'\' }}"' in template + assert "callid: call?.callid || null" in template + assert "source: call?.source || null" in template + + +def test_stale_termination_duration_is_not_derived_from_current_time(monkeypatch): + queries = [] + monkeypatch.setattr( + "app.modules.telefoni.backend.service.execute_query", + lambda query, params=(): queries.append(query) or [{"id": 1}], + ) + + assert TelefoniService.terminate_call("stale-call", None) is True + assert "INTERVAL '12 hours'" in queries[0]