release: v2.7.1

This commit is contained in:
Christian 2026-08-25 18:42:43 +02:00
parent 4daca03a55
commit 2c2db54d16
9 changed files with 144 additions and 15 deletions

View File

@ -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.

View File

@ -1 +1 @@
2.7.0
2.7.1

View File

@ -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"),

View File

@ -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

View File

@ -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)
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,
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")

View File

@ -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):

View File

@ -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
)

View File

@ -63,6 +63,8 @@
{% for call in initial_calls %}
<tr
data-call-id="{{ call.id }}"
data-callid="{{ call.callid or '' }}"
data-source="{{ call.source or 'telefoni' }}"
data-direction="{{ call.direction or '' }}"
data-display-number="{{ call.display_number or '' }}"
data-ekstern-nummer="{{ call.display_number or '' }}"
@ -446,11 +448,12 @@ async function searchContacts(query) {
}
async function patchCallContact(callId, contactId) {
const call = telefoniCallMap.get(Number(callId));
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ kontakt_id: contactId })
body: JSON.stringify({ kontakt_id: contactId, callid: call?.callid || null, source: call?.source || null })
});
if (!res.ok) {
const t = await res.text();
@ -850,11 +853,12 @@ function initLinkSagModalEvents() {
confirmBtn.addEventListener('click', async () => {
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,

View File

@ -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]