From 4daca03a554d44a331e9df105c07adfe9d1d1ce4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 25 Aug 2026 16:04:28 +0200 Subject: [PATCH] release: v2.7.0 --- MDfile/RELEASE_NOTES_v2.7.0.md | 25 +++++ VERSION | 2 +- app/modules/sag/backend/router.py | 114 ++++++++++++++++++++++- app/modules/sag/templates/detail_v3.html | 58 +++++++++++- tests/test_sag_module.py | 52 +++++++++++ 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 MDfile/RELEASE_NOTES_v2.7.0.md diff --git a/MDfile/RELEASE_NOTES_v2.7.0.md b/MDfile/RELEASE_NOTES_v2.7.0.md new file mode 100644 index 0000000..4a005d1 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.7.0.md @@ -0,0 +1,25 @@ +# Release Notes: v2.7.0 + +**Dato:** 25. august 2026 + +## Overblik + +Version 2.7.0 gør linkede telefonopkald og AnyDesk-sessioner synlige i både sagens tidsregistrering og samlede historik. + +## Tidsregistrering + +- Ny oversigt over linkede opkald og AnyDesk-sessioner på sagen. +- Aktiviteter viser type, tidspunkt, varighed, kontakt/nummer og medarbejder. +- Aktiviteterne vises som kildedata og opretter ikke automatisk fakturerbar tid. + +## Historik + +- Linkede indgående og udgående opkald indgår som selvstændige historikhændelser. +- Linkede AnyDesk-sessioner indgår med status, varighed og ansvarlig medarbejder. +- Historik med undersager inkluderer også aktiviteter linket til undersagerne. + +## Verifikation + +- Sag-modul: **33 bestået**. +- Telefoni: **5 bestået**. +- Python-kompilering og diff-kontrol gennemført. diff --git a/VERSION b/VERSION index 097a15a..24ba9a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.2 +2.7.0 diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 1c2f8c8..b692c2a 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -8,7 +8,7 @@ import base64 import html from pathlib import Path from datetime import datetime, timedelta, timezone -from typing import List, Optional, Dict +from typing import Any, List, Optional, Dict from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends @@ -3469,6 +3469,97 @@ async def get_kommentarer(sag_id: int): raise HTTPException(status_code=500, detail="Failed to get comments") +def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]: + """Return non-billable source activities linked directly to cases.""" + if not case_ids: + return [] + + placeholders = ",".join(["%s"] * len(case_ids)) + activities: List[Dict[str, Any]] = [] + + if _table_exists("telefoni_opkald"): + call_query = f""" + SELECT t.id, t.sag_id, t.direction, t.ekstern_nummer, t.intern_extension, + t.started_at, t.ended_at, t.duration_sec, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, + NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name + 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 + WHERE t.sag_id IN ({placeholders}) + ORDER BY t.started_at DESC, t.id DESC + LIMIT 600 + """ + for row in execute_query(call_query, tuple(case_ids)) or []: + started_at = row.get("started_at") + if not started_at: + continue + direction = str(row.get("direction") or "inbound").lower() + duration_seconds = row.get("duration_sec") + 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())) + activities.append({ + "id": f"call:{row.get('id')}", + "source_id": row.get("id"), + "activity_type": "call", + "sag_id": row.get("sag_id"), + "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), + "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), + "duration_minutes": round(float(duration_seconds or 0) / 60, 1), + "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), + "contact_name": row.get("contact_name"), + "number": row.get("ekstern_nummer"), + "direction": direction, + "title": "Udgående opkald" if direction == "outbound" else "Indgående opkald", + "status": "completed" if row.get("ended_at") else "started", + }) + + if _table_exists("anydesk_sessions"): + anydesk_query = f""" + SELECT a.id, a.anydesk_session_id, a.sag_id, a.started_at, a.ended_at, + a.duration_minutes, a.status, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, + NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name + FROM anydesk_sessions a + LEFT JOIN users u ON u.user_id = a.created_by_user_id + LEFT JOIN contacts c ON c.id = a.contact_id + WHERE a.sag_id IN ({placeholders}) + ORDER BY a.started_at DESC, a.id DESC + LIMIT 600 + """ + for row in execute_query(anydesk_query, tuple(case_ids)) or []: + started_at = row.get("started_at") + if not started_at: + continue + duration_minutes = row.get("duration_minutes") + if duration_minutes is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): + duration_minutes = max(0, round((row["ended_at"] - started_at).total_seconds() / 60, 1)) + activities.append({ + "id": f"anydesk:{row.get('id')}", + "source_id": row.get("id"), + "external_id": row.get("anydesk_session_id"), + "activity_type": "anydesk", + "sag_id": row.get("sag_id"), + "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), + "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), + "duration_minutes": float(duration_minutes or 0), + "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), + "contact_name": row.get("contact_name"), + "title": "AnyDesk-session", + "status": str(row.get("status") or "unknown"), + }) + + activities.sort(key=lambda item: str(item.get("timestamp") or ""), reverse=True) + return activities + + +@router.get("/sag/{sag_id}/linked-activities") +async def get_case_linked_activities(sag_id: int): + _assert_sag_exists(sag_id) + activities = _get_case_linked_activities([sag_id]) + return {"activities": activities, "total": len(activities)} + + @router.get("/sag/{sag_id}/timeline") async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)): """Return a unified timeline for the case with optional child cases.""" @@ -3576,6 +3667,27 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)): events = [] + for activity in _get_case_linked_activities(case_ids): + duration = float(activity.get("duration_minutes") or 0) + duration_text = f"{duration:g} min" if duration > 0 else "Varighed ikke registreret" + contact = str(activity.get("contact_name") or activity.get("number") or "Ukendt kontakt") + activity_type = str(activity.get("activity_type") or "activity") + description = f"{contact}\n{duration_text}" + if activity_type == "call" and activity.get("number") and activity.get("contact_name"): + description = f"{contact} · {activity.get('number')}\n{duration_text}" + events.append({ + "id": activity.get("id"), + "event_type": activity_type, + "event_subtype": activity.get("direction") or activity.get("status"), + "source": "call" if activity_type == "call" else "anydesk", + "timestamp": activity.get("timestamp"), + "sag_id": activity.get("sag_id"), + "sag_titel": next((row.get("titel") for row in case_rows if row.get("id") == activity.get("sag_id")), None), + "forfatter": activity.get("actor_name") or "System", + "title": activity.get("title"), + "description": description, + }) + case_details_query = f""" SELECT s.id, s.titel, s.created_at, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 75c2ace..d52c4ec 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -8755,6 +8755,16 @@ +
+
+
Linkede opkald og AnyDesk-sessioner
+ 0 +
+
+
Henter linkede aktiviteter...
+
+
+
@@ -12269,11 +12279,53 @@ } } + function renderCaseLinkedActivities(payload) { + const container = document.getElementById('caseLinkedActivitiesList'); + const count = document.getElementById('caseLinkedActivitiesCount'); + if (!container) return; + const activities = Array.isArray(payload?.activities) ? payload.activities : []; + if (count) count.textContent = String(activities.length); + if (!activities.length) { + container.innerHTML = '
Ingen linkede opkald eller AnyDesk-sessioner.
'; + return; + } + + const formatDuration = (minutes) => { + const value = Number(minutes || 0); + if (!value) return 'Varighed ikke registreret'; + if (value < 60) return `${Math.round(value)} min`; + const hours = Math.floor(value / 60); + const rest = Math.round(value % 60); + return rest ? `${hours} t ${rest} min` : `${hours} t`; + }; + + container.innerHTML = `
${activities.map((activity) => { + const isCall = activity.activity_type === 'call'; + const icon = isCall ? 'bi-telephone' : 'bi-display'; + const title = escapeHtml(activity.title || (isCall ? 'Opkald' : 'AnyDesk-session')); + const contact = escapeHtml(activity.contact_name || activity.number || 'Ukendt kontakt'); + const actor = escapeHtml(activity.actor_name || 'Ukendt medarbejder'); + const when = activity.timestamp ? new Date(activity.timestamp).toLocaleString('da-DK') : '-'; + const number = isCall && activity.number && activity.contact_name ? ` · ${escapeHtml(activity.number)}` : ''; + return `
+
+ +
+
${title}
+
${contact}${number}
+
${escapeHtml(when)} · ${escapeHtml(formatDuration(activity.duration_minutes))} · ${actor}
+
+
+
`; + }).join('')}
`; + } + async function loadTimeTrackingTab() { try { - const [res, ownTimerRes] = await Promise.all([ + const [res, ownTimerRes, linkedActivitiesRes] = await Promise.all([ fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`, { credentials: 'include' }), fetch('/api/v1/timetracking/time/my-switchable', { credentials: 'include' }).catch(() => null), + fetch(`/api/v1/sag/${timeCaseId}/linked-activities`, { credentials: 'include' }).catch(() => null), ]); if (!res.ok) throw new Error('Kunne ikke hente tidsforbrug'); const entries = await res.json(); @@ -12281,8 +12333,12 @@ if (ownTimerRes && ownTimerRes.ok) { ownTimers = await ownTimerRes.json().catch(() => null); } + const linkedActivities = linkedActivitiesRes && linkedActivitiesRes.ok + ? await linkedActivitiesRes.json().catch(() => ({ activities: [] })) + : { activities: [] }; updateCaseTimerControls(entries || [], ownTimers); renderCaseTimerWorkQueue(entries || []); + renderCaseLinkedActivities(linkedActivities); timeV1EntriesById = Object.fromEntries((entries || []).map((entry) => [Number(entry.id), entry])); window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { timetracking: (entries || []).length }); renderTimeV1Timeline(entries || []); diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index 88c1d72..85dd043 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -298,3 +298,55 @@ def test_case_sms_listener_does_not_break_click_to_call_script(): assert "console.warn('Kunne ikke logge sendt SMS i kommentarer', err);\n }\n });" in template assert "async function ringOutFromCase(number, opts = {})" in template assert "alert(error?.message" not in template + + +def test_linked_calls_and_anydesk_sessions_are_normalized_for_case_activity(monkeypatch): + monkeypatch.setattr(sag_router, "_table_exists", lambda name: name in {"telefoni_opkald", "anydesk_sessions"}) + + def fake_execute_query(query, params=None): + if "FROM telefoni_opkald" in query: + return [{ + "id": 11, + "sag_id": 42, + "direction": "outbound", + "ekstern_nummer": "+4530480748", + "intern_extension": "204", + "started_at": datetime(2026, 8, 25, 10, 0), + "ended_at": datetime(2026, 8, 25, 10, 5), + "duration_sec": 300, + "actor_name": "Christian", + "contact_name": "Thomas", + }] + if "FROM anydesk_sessions" in query: + return [{ + "id": 12, + "anydesk_session_id": "ad-12", + "sag_id": 42, + "started_at": datetime(2026, 8, 25, 11, 0), + "ended_at": datetime(2026, 8, 25, 11, 30), + "duration_minutes": 30, + "status": "completed", + "actor_name": "Christian", + "contact_name": "Thomas", + }] + return [] + + monkeypatch.setattr(sag_router, "execute_query", fake_execute_query) + + activities = sag_router._get_case_linked_activities([42]) + + assert [item["activity_type"] for item in activities] == ["anydesk", "call"] + assert activities[0]["duration_minutes"] == 30 + assert activities[1]["duration_minutes"] == 5 + assert activities[1]["title"] == "Udgående opkald" + + +def test_time_tab_and_history_include_linked_case_activities(): + template = Path("app/modules/sag/templates/detail_v3.html").read_text() + source = Path("app/modules/sag/backend/router.py").read_text() + + assert 'id="caseLinkedActivitiesList"' in template + assert "/linked-activities" in template + assert "renderCaseLinkedActivities(linkedActivities)" in template + assert 'event_type": activity_type' in source + assert 'source": "call" if activity_type == "call" else "anydesk"' in source