release: v2.7.0
This commit is contained in:
parent
b755d61a5e
commit
4daca03a55
25
MDfile/RELEASE_NOTES_v2.7.0.md
Normal file
25
MDfile/RELEASE_NOTES_v2.7.0.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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 || []);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user