release: v2.7.1
This commit is contained in:
parent
4daca03a55
commit
2c2db54d16
20
MDfile/RELEASE_NOTES_v2.7.1.md
Normal file
20
MDfile/RELEASE_NOTES_v2.7.1.md
Normal 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.
|
||||||
@ -3496,8 +3496,11 @@ def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
direction = str(row.get("direction") or "inbound").lower()
|
direction = str(row.get("direction") or "inbound").lower()
|
||||||
duration_seconds = row.get("duration_sec")
|
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):
|
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({
|
activities.append({
|
||||||
"id": f"call:{row.get('id')}",
|
"id": f"call:{row.get('id')}",
|
||||||
"source_id": row.get("id"),
|
"source_id": row.get("id"),
|
||||||
|
|||||||
@ -838,7 +838,13 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
t.ekstern_nummer,
|
t.ekstern_nummer,
|
||||||
t.started_at,
|
t.started_at,
|
||||||
t.ended_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.username,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_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.ekstern_nummer,
|
||||||
t.started_at,
|
t.started_at,
|
||||||
t.ended_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.username,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
|
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
|
||||||
|
|||||||
@ -775,13 +775,13 @@ async def list_calls(
|
|||||||
t.sag_id,
|
t.sag_id,
|
||||||
t.started_at,
|
t.started_at,
|
||||||
t.ended_at,
|
t.ended_at,
|
||||||
COALESCE(
|
|
||||||
t.duration_sec,
|
|
||||||
CASE
|
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
|
ELSE NULL
|
||||||
END
|
END AS duration_sec,
|
||||||
) AS duration_sec,
|
|
||||||
t.created_at,
|
t.created_at,
|
||||||
u.username,
|
u.username,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
@ -794,7 +794,8 @@ async def list_calls(
|
|||||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS contact_company,
|
) AS contact_company,
|
||||||
s.titel AS sag_titel
|
s.titel AS sag_titel,
|
||||||
|
'telefoni'::VARCHAR AS source
|
||||||
FROM telefoni_opkald t
|
FROM telefoni_opkald t
|
||||||
LEFT JOIN users u ON u.user_id = t.bruger_id
|
LEFT JOIN users u ON u.user_id = t.bruger_id
|
||||||
LEFT JOIN contacts c ON c.id = t.kontakt_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}")
|
@router.patch("/telefoni/calls/{call_id}")
|
||||||
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
|
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
|
||||||
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
|
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:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Call not found")
|
raise HTTPException(status_code=404, detail="Call not found")
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ from typing import Optional
|
|||||||
class TelefoniCallLinkUpdate(BaseModel):
|
class TelefoniCallLinkUpdate(BaseModel):
|
||||||
sag_id: Optional[int] = None
|
sag_id: Optional[int] = None
|
||||||
kontakt_id: Optional[int] = None
|
kontakt_id: Optional[int] = None
|
||||||
|
callid: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TelefoniUserMappingUpdate(BaseModel):
|
class TelefoniUserMappingUpdate(BaseModel):
|
||||||
|
|||||||
@ -170,7 +170,9 @@ class TelefoniService:
|
|||||||
duration_sec = COALESCE(
|
duration_sec = COALESCE(
|
||||||
EXCLUDED.duration_sec,
|
EXCLUDED.duration_sec,
|
||||||
CASE
|
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
|
ELSE NULL
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
|
|||||||
@ -63,6 +63,8 @@
|
|||||||
{% for call in initial_calls %}
|
{% for call in initial_calls %}
|
||||||
<tr
|
<tr
|
||||||
data-call-id="{{ call.id }}"
|
data-call-id="{{ call.id }}"
|
||||||
|
data-callid="{{ call.callid or '' }}"
|
||||||
|
data-source="{{ call.source or 'telefoni' }}"
|
||||||
data-direction="{{ call.direction or '' }}"
|
data-direction="{{ call.direction or '' }}"
|
||||||
data-display-number="{{ call.display_number or '' }}"
|
data-display-number="{{ call.display_number or '' }}"
|
||||||
data-ekstern-nummer="{{ call.display_number or '' }}"
|
data-ekstern-nummer="{{ call.display_number or '' }}"
|
||||||
@ -446,11 +448,12 @@ async function searchContacts(query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function patchCallContact(callId, contactId) {
|
async function patchCallContact(callId, contactId) {
|
||||||
|
const call = telefoniCallMap.get(Number(callId));
|
||||||
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
|
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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) {
|
if (!res.ok) {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
@ -850,11 +853,12 @@ function initLinkSagModalEvents() {
|
|||||||
confirmBtn.addEventListener('click', async () => {
|
confirmBtn.addEventListener('click', async () => {
|
||||||
if (!linkSagState.callId || !linkSagState.selectedSagId) return;
|
if (!linkSagState.callId || !linkSagState.selectedSagId) return;
|
||||||
try {
|
try {
|
||||||
|
const call = telefoniCallMap.get(Number(linkSagState.callId));
|
||||||
const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, {
|
const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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) {
|
if (!res.ok) {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
@ -954,6 +958,8 @@ function hydrateCallMapFromSsrRows() {
|
|||||||
|
|
||||||
telefoniCallMap.set(callId, {
|
telefoniCallMap.set(callId, {
|
||||||
id: callId,
|
id: callId,
|
||||||
|
callid: String(row.dataset.callid || '').trim() || null,
|
||||||
|
source: String(row.dataset.source || '').trim() || 'telefoni',
|
||||||
direction: String(row.dataset.direction || '').trim() || null,
|
direction: String(row.dataset.direction || '').trim() || null,
|
||||||
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
|
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
|
||||||
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,
|
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -5,7 +6,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|||||||
|
|
||||||
from starlette.requests import Request
|
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.router import _is_internal_request_target
|
||||||
|
from app.modules.telefoni.backend.schemas import TelefoniCallLinkUpdate
|
||||||
from app.modules.telefoni.backend.service import TelefoniService
|
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():
|
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("example.com")) is False
|
||||||
assert _is_internal_request_target(_request_for_host("172.16.30.183")) 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]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user