release: v2.7.0

This commit is contained in:
Christian 2026-08-25 16:04:28 +02:00
parent b755d61a5e
commit 6faaa5f462
5 changed files with 248 additions and 3 deletions

View File

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

View File

@ -1 +1 @@
2.6.2
2.7.0

View File

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

View File

@ -8755,6 +8755,16 @@
</div>
</div>
<div class="card mb-3" id="caseLinkedActivitiesCard">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-activity me-2"></i>Linkede opkald og AnyDesk-sessioner</h6>
<span class="badge bg-light text-dark" id="caseLinkedActivitiesCount">0</span>
</div>
<div class="card-body p-0" id="caseLinkedActivitiesList">
<div class="text-muted text-center py-3">Henter linkede aktiviteter...</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-9">
<div class="card">
@ -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 = '<div class="text-muted text-center py-3">Ingen linkede opkald eller AnyDesk-sessioner.</div>';
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 = `<div class="list-group list-group-flush">${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 `<div class="list-group-item py-2">
<div class="d-flex align-items-start gap-2">
<i class="bi ${icon} text-primary mt-1"></i>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold">${title}</div>
<div class="small">${contact}${number}</div>
<div class="small text-muted">${escapeHtml(when)} · ${escapeHtml(formatDuration(activity.duration_minutes))} · ${actor}</div>
</div>
</div>
</div>`;
}).join('')}</div>`;
}
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 || []);

View File

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