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
|
||||
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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user