feat(anydesk): Implement multi-ID support for AnyDesk cases
- Added endpoints to list, upsert, and delete AnyDesk IDs associated with cases. - Introduced normalization for AnyDesk IDs and ensured case existence checks. - Enhanced session management with quick-connect functionality and local session synchronization. - Created a new job for syncing AnyDesk sessions from a local endpoint. - Added database migration for the new `sag_anydesk_ids` table to store AnyDesk IDs per case.
This commit is contained in:
parent
ee8c517acc
commit
270af0e277
@ -253,6 +253,11 @@ class Settings(BaseSettings):
|
||||
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
||||
ANYDESK_TIMEOUT_SECONDS: int = 30
|
||||
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
|
||||
ANYDESK_LOCAL_SESSIONS_URL: str = "http://localhost:8001/anydesk/sessions"
|
||||
ANYDESK_LOCAL_SYNC_ENABLED: bool = True
|
||||
ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES: int = 15
|
||||
ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS: int = 20
|
||||
ANYDESK_LOCAL_SYNC_DRY_RUN: bool = False
|
||||
|
||||
# Telefoni (Yealink) Integration
|
||||
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
||||
|
||||
41
app/jobs/anydesk_local_sync.py
Normal file
41
app/jobs/anydesk_local_sync.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""
|
||||
AnyDesk local sessions sync job.
|
||||
Polls local AnyDesk bridge endpoint and enriches local session rows.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.anydesk import AnyDeskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
anydesk_service = AnyDeskService()
|
||||
|
||||
|
||||
async def sync_anydesk_local_sessions():
|
||||
"""Sync AnyDesk sessions from local endpoint every N minutes."""
|
||||
if not settings.ANYDESK_LOCAL_SYNC_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("🔄 AnyDesk local sync started")
|
||||
result = await anydesk_service.fetch_sessions_from_local_endpoint(
|
||||
endpoint_url=settings.ANYDESK_LOCAL_SESSIONS_URL,
|
||||
timeout_seconds=settings.ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS,
|
||||
dry_run=settings.ANYDESK_LOCAL_SYNC_DRY_RUN,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
logger.error("❌ AnyDesk local sync failed: %s", result["error"])
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"✅ AnyDesk local sync completed: total=%s imported=%s updated=%s matched=%s errors=%s",
|
||||
result.get("total", 0),
|
||||
result.get("imported", 0),
|
||||
result.get("updated", 0),
|
||||
result.get("matched", 0),
|
||||
len(result.get("errors") or []),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("❌ Unexpected AnyDesk local sync error: %s", exc)
|
||||
@ -1842,7 +1842,16 @@
|
||||
}
|
||||
|
||||
.case-tabs-topbar.topbar-secondary {
|
||||
grid-template-columns: repeat(8, minmax(150px, 1fr));
|
||||
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
|
||||
grid-template-columns:
|
||||
minmax(110px, 0.75fr) /* Type */
|
||||
minmax(110px, 0.8fr) /* Prioritet */
|
||||
minmax(105px, 0.75fr) /* Oprettet */
|
||||
minmax(195px, 1.3fr) /* Arbejdsstart (2 knapper) */
|
||||
minmax(195px, 1.3fr) /* Start senest (2 knapper) */
|
||||
minmax(150px, 1.1fr) /* Deadline (1 knap) */
|
||||
minmax(120px, 0.85fr) /* AnyDesk */
|
||||
minmax(140px, 1fr) /* Dokumenter */;
|
||||
}
|
||||
|
||||
.case-tabs-topbar-item {
|
||||
@ -1966,12 +1975,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topbar-deferred-shortcuts {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-deferred-current {
|
||||
margin-top: 0.4rem;
|
||||
@ -1994,24 +1997,7 @@
|
||||
background: rgba(15, 76, 117, 0.09);
|
||||
}
|
||||
|
||||
.topbar-mini-trigger {
|
||||
border: 1px solid rgba(0,0,0,0.14);
|
||||
background: rgba(255,255,255,0.75);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0.34rem 0.58rem;
|
||||
min-height: 30px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.topbar-mini-trigger:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
.topbar-secondary-action:hover {
|
||||
border-color: var(--accent);
|
||||
@ -2019,31 +2005,6 @@
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-add {
|
||||
background: rgba(32, 120, 72, 0.24);
|
||||
border-color: rgba(92, 194, 132, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-type {
|
||||
background: rgba(80, 120, 200, 0.18);
|
||||
border-color: rgba(140, 180, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-created {
|
||||
background: rgba(40, 120, 80, 0.22);
|
||||
border-color: rgba(90, 200, 140, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-priority {
|
||||
background: rgba(110, 80, 170, 0.24);
|
||||
border-color: rgba(180, 145, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start {
|
||||
background: rgba(150, 120, 40, 0.24);
|
||||
border-color: rgba(230, 190, 90, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-deferred-current {
|
||||
color: rgba(236, 242, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@ -2056,16 +2017,6 @@
|
||||
background: rgba(19, 100, 154, 0.24);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
|
||||
background: rgba(150, 90, 30, 0.24);
|
||||
border-color: rgba(230, 160, 90, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-deadline {
|
||||
background: rgba(150, 40, 40, 0.24);
|
||||
border-color: rgba(240, 120, 120, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-secondary-action {
|
||||
background: rgba(20, 27, 38, 0.72);
|
||||
border-color: rgba(170, 190, 216, 0.35);
|
||||
@ -2081,16 +2032,7 @@
|
||||
background: rgba(20, 27, 38, 0.78);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-mini-trigger {
|
||||
background: rgba(20, 27, 38, 0.78);
|
||||
border-color: rgba(170, 190, 216, 0.4);
|
||||
color: #dce8f4;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .topbar-mini-trigger:hover {
|
||||
border-color: #9fc4e8;
|
||||
color: #9fc4e8;
|
||||
}
|
||||
|
||||
.case-add-side-backdrop {
|
||||
position: fixed;
|
||||
@ -2399,43 +2341,56 @@
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item field-start">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div>
|
||||
<div class="topbar-secondary-inline">
|
||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
||||
<input
|
||||
id="topbarStartDateInput"
|
||||
type="date"
|
||||
class="case-inline-select"
|
||||
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
|
||||
onchange="saveCaseStartDateFromTopbar()"
|
||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
||||
>
|
||||
<button type="button" class="topbar-secondary-action is-icon" onclick="clearCaseStartDateFromTopbar()" title="Fjern startdato">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-deferred-shortcuts">
|
||||
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(0)">I dag</button>
|
||||
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(1)">+1 dag</button>
|
||||
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(7)">+1 uge</button>
|
||||
<button type="button" class="topbar-mini-trigger" onclick="openStartDateModal()">Flere valg</button>
|
||||
</div>
|
||||
<div class="topbar-deferred-shortcuts">
|
||||
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModal()">Trigger</button>
|
||||
<div class="dropdown">
|
||||
<button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Udskyd til...</h6></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(0);"><i class="bi bi-calendar-event me-2 text-muted"></i>I dag</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(1);"><i class="bi bi-calendar-plus me-2 text-muted"></i>I morgen (+1 dag)</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(7);"><i class="bi bi-calendar-week me-2 text-muted"></i>Næste uge (+1 uge)</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openStartDateModal();"><i class="bi bi-gear me-2 text-muted"></i>Flere valg...</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModal();"><i class="bi bi-lightning-charge me-2 text-muted"></i>Sæt afhængighed (Trigger)...</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item field-start-before">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
|
||||
<div class="topbar-secondary-inline">
|
||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
||||
<input
|
||||
id="topbarDeferredInput"
|
||||
type="date"
|
||||
class="case-inline-select"
|
||||
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
|
||||
onchange="updateDeferredUntil(this.value || null)"
|
||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
||||
>
|
||||
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value=''" title="Fjern dato"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="topbar-deferred-shortcuts">
|
||||
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
|
||||
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
|
||||
<div class="dropdown">
|
||||
<button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Venter på sag...</h6></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('lukket');"><i class="bi bi-check2-circle me-2 text-success"></i>Trigger: Sag Lukket</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('løst');"><i class="bi bi-check-lg me-2 text-primary"></i>Trigger: Sag Løst</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% set deferred_case_ns = namespace(title='') %}
|
||||
{% if case.deferred_until_case_id %}
|
||||
@ -2459,21 +2414,22 @@
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item field-deadline">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
|
||||
<div class="topbar-secondary-inline">
|
||||
<div class="topbar-secondary-inline" style="gap: 0.25rem;">
|
||||
<input
|
||||
id="topbarDeadlineInput"
|
||||
type="date"
|
||||
class="case-inline-select"
|
||||
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
|
||||
onchange="updateDeadline(this.value || null)"
|
||||
style="padding-left: 0.35rem; padding-right: 0.1rem;"
|
||||
>
|
||||
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button>
|
||||
<button type="button" class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item field-anydesk">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div>
|
||||
<button type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Registrer AnyDesk session for denne sag">
|
||||
<i class="bi bi-plus-circle"></i> Registrer session
|
||||
<button id="caseAnyDeskOpenBtn" type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Start AnyDesk quick connect for denne sag">
|
||||
<i class="bi bi-plug"></i> Quick connect
|
||||
</button>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item field-documents">
|
||||
@ -3487,36 +3443,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AnyDesk manual registration modal -->
|
||||
<!-- AnyDesk quick-connect modal -->
|
||||
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Registrer AnyDesk session</h5>
|
||||
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">AnyDesk ID</label>
|
||||
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" />
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
||||
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Hvilken enhed hjælper du med?</label>
|
||||
<input type="text" class="form-control" id="caseAnydeskDeviceNameInput" placeholder="fx Reception-PC, Lager printer, Router i teknikrum" />
|
||||
<label class="form-label">Gemte IDs på sagen</label>
|
||||
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
|
||||
<span class="text-muted small">Indlæser...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enhedstype</label>
|
||||
<select class="form-select" id="caseAnydeskDeviceTypeInput">
|
||||
<option value="placebo" selected>Placebo / ukendt (kan linkes senere)</option>
|
||||
<option value="desktop">Desktop / PC</option>
|
||||
<option value="laptop">Laptop</option>
|
||||
<option value="server">Server</option>
|
||||
<option value="network">Netværksudstyr</option>
|
||||
<option value="printer">Printer</option>
|
||||
<option value="phone">Telefon / mobil</option>
|
||||
<option value="other">Andet</option>
|
||||
<label class="form-label">Relatér til hardware (valgfri)</label>
|
||||
<select class="form-select" id="caseAnydeskHardwareSelect">
|
||||
<option value="">Ingen hardware valgt</option>
|
||||
</select>
|
||||
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Kontakt (valgfri)</label>
|
||||
<select class="form-select" id="caseAnydeskContactSelect">
|
||||
@ -3531,12 +3492,12 @@
|
||||
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
||||
</div>
|
||||
<div class="small text-muted mt-2">
|
||||
Sessionen gemmes direkte på sagen. Hvis du vælger type "Placebo", kan den senere linkes til rigtig hardware.
|
||||
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()">Gem registrering</button>
|
||||
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -9463,6 +9424,7 @@
|
||||
<script>
|
||||
let currentSearchType = null;
|
||||
let searchDebounceIds = null;
|
||||
let caseAnyDeskConnectInFlight = false;
|
||||
const caseIds = {{ case.id }};
|
||||
const currentCaseTitle = {{ (case.titel or '') | tojson }};
|
||||
let caseAddPanelInitialized = false;
|
||||
@ -9482,60 +9444,192 @@
|
||||
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
|
||||
];
|
||||
|
||||
function openCaseAnyDeskModal() {
|
||||
function normalizeAnyDeskIdClient(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
return digits || raw;
|
||||
}
|
||||
|
||||
function onCaseAnyDeskIdInputChange() {
|
||||
const input = document.getElementById('caseAnydeskIdInput');
|
||||
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
|
||||
if (!input || !linkBtn) return;
|
||||
|
||||
const id = normalizeAnyDeskIdClient(input.value);
|
||||
if (!id) {
|
||||
linkBtn.style.display = 'none';
|
||||
linkBtn.setAttribute('href', '#');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = id;
|
||||
linkBtn.style.display = '';
|
||||
linkBtn.setAttribute('href', `anydesk:${id}`);
|
||||
}
|
||||
|
||||
function setCaseAnyDeskInputFromSaved(anydeskId) {
|
||||
const input = document.getElementById('caseAnydeskIdInput');
|
||||
if (!input) return;
|
||||
input.value = normalizeAnyDeskIdClient(anydeskId);
|
||||
onCaseAnyDeskIdInputChange();
|
||||
}
|
||||
|
||||
function renderCaseAnyDeskSavedIds(entries) {
|
||||
const container = document.getElementById('caseAnyDeskSavedIds');
|
||||
if (!container) return;
|
||||
|
||||
if (!entries?.length) {
|
||||
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map((entry) => {
|
||||
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
|
||||
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
|
||||
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
|
||||
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
|
||||
const select = document.getElementById('caseAnydeskHardwareSelect');
|
||||
if (!select) return;
|
||||
|
||||
let html = '<option value="">Ingen hardware valgt</option>';
|
||||
|
||||
if (Array.isArray(caseHardware) && caseHardware.length) {
|
||||
html += '<optgroup label="Hardware på sagen">';
|
||||
html += caseHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
|
||||
html += '</optgroup>';
|
||||
}
|
||||
|
||||
if (Array.isArray(customerHardware) && customerHardware.length) {
|
||||
html += '<optgroup label="Andet hardware hos kunden">';
|
||||
html += customerHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
|
||||
html += '</optgroup>';
|
||||
}
|
||||
|
||||
select.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadCaseAnyDeskContext() {
|
||||
const [savedIdsRes, hardwareRes] = await Promise.all([
|
||||
fetch(`/api/v1/anydesk/cases/${caseIds}/ids`, { credentials: 'include' }),
|
||||
fetch(`/api/v1/anydesk/cases/${caseIds}/hardware-options`, { credentials: 'include' })
|
||||
]);
|
||||
|
||||
if (!savedIdsRes.ok) {
|
||||
throw new Error('Kunne ikke hente gemte AnyDesk IDs');
|
||||
}
|
||||
if (!hardwareRes.ok) {
|
||||
throw new Error('Kunne ikke hente hardwarevalg');
|
||||
}
|
||||
|
||||
const savedIdsPayload = await savedIdsRes.json();
|
||||
const hardwarePayload = await hardwareRes.json();
|
||||
|
||||
renderCaseAnyDeskSavedIds(savedIdsPayload?.ids || []);
|
||||
renderCaseAnyDeskHardwareOptions(hardwarePayload?.case_hardware || [], hardwarePayload?.customer_hardware || []);
|
||||
|
||||
const primary = (savedIdsPayload?.ids || []).find((item) => item?.is_primary);
|
||||
if (primary?.anydesk_id) {
|
||||
setCaseAnyDeskInputFromSaved(primary.anydesk_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function openCaseAnyDeskModal() {
|
||||
if (caseAnyDeskConnectInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!caseAnyDeskModal) {
|
||||
const modalEl = document.getElementById('caseAnyDeskModal');
|
||||
if (!modalEl) return;
|
||||
caseAnyDeskModal = new bootstrap.Modal(modalEl);
|
||||
}
|
||||
const anydeskInput = document.getElementById('caseAnydeskIdInput');
|
||||
const deviceInput = document.getElementById('caseAnydeskDeviceNameInput');
|
||||
const typeInput = document.getElementById('caseAnydeskDeviceTypeInput');
|
||||
|
||||
const noteInput = document.getElementById('caseAnydeskNoteInput');
|
||||
if (anydeskInput) anydeskInput.value = '';
|
||||
if (deviceInput) deviceInput.value = '';
|
||||
if (typeInput) typeInput.value = 'placebo';
|
||||
if (noteInput) noteInput.value = '';
|
||||
|
||||
const saved = document.getElementById('caseAnyDeskSavedIds');
|
||||
if (saved) {
|
||||
saved.innerHTML = '<span class="text-muted small">Indlæser...</span>';
|
||||
}
|
||||
|
||||
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
|
||||
if (hardwareSelect) {
|
||||
hardwareSelect.innerHTML = '<option value="">Indlæser hardware...</option>';
|
||||
}
|
||||
|
||||
const input = document.getElementById('caseAnydeskIdInput');
|
||||
if (input && !input.value) {
|
||||
input.value = '';
|
||||
}
|
||||
onCaseAnyDeskIdInputChange();
|
||||
|
||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
||||
}
|
||||
|
||||
try {
|
||||
await loadCaseAnyDeskContext();
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Kunne ikke hente AnyDesk data';
|
||||
if (saved) {
|
||||
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
caseAnyDeskModal.show();
|
||||
}
|
||||
|
||||
async function registerCaseAnyDeskSession() {
|
||||
const anydeskId = (document.getElementById('caseAnydeskIdInput')?.value || '').trim();
|
||||
const assistedDevice = (document.getElementById('caseAnydeskDeviceNameInput')?.value || '').trim();
|
||||
const deviceType = (document.getElementById('caseAnydeskDeviceTypeInput')?.value || 'placebo').trim();
|
||||
if (caseAnyDeskConnectInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anydeskId = normalizeAnyDeskIdClient(document.getElementById('caseAnydeskIdInput')?.value || '');
|
||||
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
|
||||
const hardwareIdRaw = document.getElementById('caseAnydeskHardwareSelect')?.value || '';
|
||||
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
||||
|
||||
if (!anydeskId) {
|
||||
alert('Udfyld AnyDesk ID');
|
||||
return;
|
||||
}
|
||||
if (!assistedDevice) {
|
||||
alert('Udfyld hvilken enhed du hjælper med');
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = {{ customer.id if customer else 'null' }};
|
||||
if (!customerId) {
|
||||
alert('Sagen har ingen kunde - kan ikke registrere AnyDesk session');
|
||||
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
|
||||
return;
|
||||
}
|
||||
|
||||
const createdByUserId = await ensureCaseCurrentUserId();
|
||||
|
||||
const payload = {
|
||||
sag_id: caseIds,
|
||||
customer_id: customerId,
|
||||
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
|
||||
anydesk_id: anydeskId,
|
||||
assisted_device: assistedDevice,
|
||||
device_type: deviceType,
|
||||
notes,
|
||||
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
|
||||
hardware_asset_id: hardwareIdRaw ? Number(hardwareIdRaw) : null,
|
||||
note: notes,
|
||||
make_primary: true,
|
||||
created_by_user_id: createdByUserId || null
|
||||
};
|
||||
|
||||
caseAnyDeskConnectInFlight = true;
|
||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
|
||||
}
|
||||
if (openBtn) {
|
||||
openBtn.disabled = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/anydesk/register-manual-session', {
|
||||
const res = await fetch(`/api/v1/anydesk/cases/${caseIds}/connect`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
@ -9544,17 +9638,31 @@
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || 'Kunne ikke registrere session');
|
||||
throw new Error(txt || 'Kunne ikke starte AnyDesk quick connect');
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
if (caseAnyDeskModal) caseAnyDeskModal.hide();
|
||||
alert(`AnyDesk session registreret (ID: ${result?.session?.id || '-'})`);
|
||||
|
||||
if (result?.deep_link) {
|
||||
window.location.href = result.deep_link;
|
||||
}
|
||||
|
||||
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
|
||||
if (typeof loadComments === 'function') {
|
||||
loadComments();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Fejl ved registrering: ' + (e.message || 'Ukendt fejl'));
|
||||
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
|
||||
} finally {
|
||||
caseAnyDeskConnectInFlight = false;
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
||||
}
|
||||
if (openBtn) {
|
||||
openBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9578,6 +9686,10 @@
|
||||
window._showRelModal = renderCaseAddWorkspaceModal;
|
||||
}
|
||||
|
||||
// Remove legacy modal instance to avoid duplicate relQaModalEl IDs
|
||||
// when side-panel rendering is active.
|
||||
document.querySelectorAll('body > #relQaModalEl').forEach((el) => el.remove());
|
||||
|
||||
renderCaseAddActionList(caseAddActiveAction);
|
||||
caseAddPanelInitialized = true;
|
||||
}
|
||||
@ -9594,15 +9706,28 @@
|
||||
if (typeof caseAddOriginalShowRelModal === 'function') {
|
||||
window._showRelModal = caseAddOriginalShowRelModal;
|
||||
}
|
||||
|
||||
const workspace = document.getElementById('caseAddSideWorkspace');
|
||||
if (workspace) {
|
||||
workspace.innerHTML = '<div class="text-muted small">Vaelg en handling i venstre side.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
|
||||
const workspace = document.getElementById('caseAddSideWorkspace');
|
||||
if (!workspace) return;
|
||||
|
||||
// Ensure only one relQaModalEl exists. Duplicate IDs can break button
|
||||
// handlers and value lookups across add forms.
|
||||
document.querySelectorAll('#relQaModalEl').forEach((el) => {
|
||||
if (!workspace.contains(el)) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
workspace.innerHTML = `
|
||||
<div id="relQaModalEl" class="d-flex flex-column gap-2">
|
||||
<div class="section-title">${title}</div>
|
||||
<div id="relQaModalTitle" class="section-title">${title}</div>
|
||||
<div id="relQaModalBody">${bodyHtml}</div>
|
||||
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
|
||||
@ -9681,14 +9806,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRelQaEl = document.getElementById('relQaModalEl');
|
||||
if (existingRelQaEl && !workspace.contains(existingRelQaEl)) {
|
||||
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
||||
if (existingModalInstance) {
|
||||
existingModalInstance.hide();
|
||||
document.querySelectorAll('#relQaModalEl').forEach((existingRelQaEl) => {
|
||||
if (!workspace.contains(existingRelQaEl)) {
|
||||
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
||||
if (existingModalInstance) {
|
||||
existingModalInstance.hide();
|
||||
}
|
||||
existingRelQaEl.remove();
|
||||
}
|
||||
existingRelQaEl.remove();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.resolve(relFn(caseIds, currentCaseTitle));
|
||||
@ -13020,9 +13146,9 @@
|
||||
function getRelQaPrimaryButton() {
|
||||
const sidePanel = document.getElementById('caseAddSidePanel');
|
||||
if (sidePanel && sidePanel.classList.contains('open')) {
|
||||
return sidePanel.querySelector('#relQaModalFooter .btn-primary');
|
||||
return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
|
||||
}
|
||||
return document.querySelector('#relQaModalEl .btn-primary');
|
||||
return document.querySelector('body > #relQaModalEl .btn-primary');
|
||||
}
|
||||
|
||||
function closeRelQaSurfaceAfterSave() {
|
||||
@ -13145,21 +13271,47 @@
|
||||
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
|
||||
const saveBtn = getRelQaPrimaryButton();
|
||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
|
||||
let success = 0; let failed = 0;
|
||||
for (const file of fileInput.files) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const desc = document.getElementById('rqf_desc').value;
|
||||
if (desc) fd.append('description', desc);
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
|
||||
if (r.ok) success++; else failed++;
|
||||
} catch { failed++; }
|
||||
}
|
||||
closeRelQaSurfaceAfterSave();
|
||||
if (typeof showNotification === 'function') {
|
||||
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
|
||||
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
for (const file of fileInput.files) {
|
||||
fd.append('files', file);
|
||||
}
|
||||
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (typeof showNotification === 'function') showNotification(d.detail || 'Upload fejlede', 'error');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await r.json().catch(() => []);
|
||||
const uploadedCount = Array.isArray(saved) ? saved.length : 0;
|
||||
if (uploadedCount === 0) {
|
||||
if (typeof showNotification === 'function') showNotification('Filer blev ikke gemt. Prøv igen.', 'warning');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof loadSagFiles === 'function') {
|
||||
await loadSagFiles();
|
||||
}
|
||||
closeRelQaSurfaceAfterSave();
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${uploadedCount} fil(er) uploadet ✓`, 'success');
|
||||
}
|
||||
} catch {
|
||||
if (typeof showNotification === 'function') showNotification('Upload fejlede', 'error');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -13669,7 +13821,7 @@
|
||||
window._submitRelNote = async function(caseId) {
|
||||
const text = document.getElementById('rqn_text').value.trim();
|
||||
if (!text) return;
|
||||
const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
|
||||
const saveBtn = getRelQaPrimaryButton();
|
||||
if (saveBtn) { saveBtn.disabled = true; }
|
||||
try {
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
|
||||
@ -13782,11 +13934,52 @@
|
||||
};
|
||||
|
||||
window._submitRelReminder = async function(caseId) {
|
||||
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value };
|
||||
const whenInput = document.getElementById('rqr_at')?.value || '';
|
||||
const message = (document.getElementById('rqr_msg')?.value || '').trim();
|
||||
if (!whenInput) {
|
||||
if (typeof showNotification === 'function') showNotification('Vælg tidspunkt', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
let reminderUserId = null;
|
||||
try {
|
||||
if (typeof ensureReminderUserId === 'function') {
|
||||
reminderUserId = await ensureReminderUserId();
|
||||
} else if (typeof getReminderUserId === 'function') {
|
||||
reminderUserId = getReminderUserId();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!reminderUserId) {
|
||||
if (typeof showNotification === 'function') showNotification('Mangler bruger-id til reminder', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledAtIso = new Date(whenInput).toISOString();
|
||||
const baseTitle = (message || 'Husk opfoelgning').trim();
|
||||
const safeTitle = (baseTitle.length >= 3 ? baseTitle : 'Reminder').slice(0, 255);
|
||||
const payload = {
|
||||
title: safeTitle,
|
||||
message: message || null,
|
||||
priority: 'normal',
|
||||
event_type: 'reminder',
|
||||
trigger_type: 'time_based',
|
||||
trigger_config: {},
|
||||
recipient_user_ids: [Number(reminderUserId)],
|
||||
recipient_emails: [],
|
||||
notify_mattermost: false,
|
||||
notify_email: false,
|
||||
notify_frontend: true,
|
||||
override_user_preferences: false,
|
||||
recurrence_type: 'once',
|
||||
recurrence_day_of_week: null,
|
||||
recurrence_day_of_month: null,
|
||||
scheduled_at: scheduledAtIso,
|
||||
};
|
||||
const saveBtn = getRelQaPrimaryButton();
|
||||
if (saveBtn) { saveBtn.disabled = true; }
|
||||
try {
|
||||
const r = await fetch('/api/v1/reminders', {
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/reminders?user_id=${encodeURIComponent(reminderUserId)}`, {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
@ -13800,8 +13993,12 @@
|
||||
|
||||
// ── shared modal helper ───────────────────────────────────────────
|
||||
window._showRelModal = function(title, bodyHtml, footerBtns) {
|
||||
let el = document.getElementById('relQaModalEl');
|
||||
let el = document.querySelector('body > #relQaModalEl.modal');
|
||||
if (!el) {
|
||||
const nonModal = document.getElementById('relQaModalEl');
|
||||
if (nonModal && !nonModal.classList.contains('modal')) {
|
||||
nonModal.remove();
|
||||
}
|
||||
el = document.createElement('div');
|
||||
el.id = 'relQaModalEl';
|
||||
el.className = 'modal fade';
|
||||
|
||||
@ -5,8 +5,10 @@ REST API endpoints for managing remote support sessions
|
||||
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@ -26,6 +28,23 @@ router = APIRouter()
|
||||
anydesk_service = AnyDeskService()
|
||||
|
||||
|
||||
def _normalize_anydesk_id(raw_value: Optional[str]) -> str:
|
||||
"""Normalize AnyDesk ID by stripping non-digits when possible."""
|
||||
raw_text = str(raw_value or "").strip()
|
||||
digits_only = re.sub(r"\D", "", raw_text)
|
||||
return digits_only or raw_text
|
||||
|
||||
|
||||
def _ensure_case_exists(sag_id: int) -> dict:
|
||||
case_row = execute_query(
|
||||
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,),
|
||||
)
|
||||
if not case_row:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
return case_row[0]
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Session Management Endpoints
|
||||
# =====================================================
|
||||
@ -342,6 +361,424 @@ async def register_manual_session(data: dict):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
||||
async def list_case_anydesk_ids(sag_id: int):
|
||||
"""List saved AnyDesk IDs for a case (multi-ID support)."""
|
||||
try:
|
||||
_ensure_case_exists(sag_id)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
sai.id,
|
||||
sai.sag_id,
|
||||
sai.anydesk_id,
|
||||
sai.hardware_asset_id,
|
||||
sai.is_primary,
|
||||
sai.note,
|
||||
sai.created_by_user_id,
|
||||
sai.created_at,
|
||||
sai.updated_at,
|
||||
h.brand,
|
||||
h.model,
|
||||
h.serial_number,
|
||||
h.anydesk_id AS hardware_anydesk_id
|
||||
FROM sag_anydesk_ids sai
|
||||
LEFT JOIN hardware_assets h ON h.id = sai.hardware_asset_id
|
||||
WHERE sai.sag_id = %s
|
||||
AND sai.deleted_at IS NULL
|
||||
ORDER BY sai.is_primary DESC, sai.updated_at DESC, sai.id DESC
|
||||
""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
|
||||
for row in rows:
|
||||
brand = (row.get("brand") or "").strip()
|
||||
model = (row.get("model") or "").strip()
|
||||
serial = (row.get("serial_number") or "").strip()
|
||||
fragments = [f for f in [brand, model] if f]
|
||||
if serial:
|
||||
fragments.append(f"SN: {serial}")
|
||||
row["hardware_label"] = " - ".join(fragments) if fragments else None
|
||||
|
||||
return {"ids": rows}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error listing case AnyDesk IDs: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not load case AnyDesk IDs")
|
||||
|
||||
|
||||
@router.post("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
||||
async def upsert_case_anydesk_id(sag_id: int, data: dict):
|
||||
"""Create or update a saved AnyDesk ID on a case."""
|
||||
try:
|
||||
_ensure_case_exists(sag_id)
|
||||
|
||||
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
|
||||
if not anydesk_id:
|
||||
raise HTTPException(status_code=400, detail="anydesk_id is required")
|
||||
|
||||
hardware_asset_id = data.get("hardware_asset_id")
|
||||
created_by_user_id = data.get("created_by_user_id")
|
||||
note = (data.get("note") or "").strip() or None
|
||||
is_primary = bool(data.get("is_primary"))
|
||||
|
||||
if hardware_asset_id is not None:
|
||||
asset = execute_query(
|
||||
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
|
||||
(hardware_asset_id,),
|
||||
)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Hardware asset not found")
|
||||
|
||||
row = execute_query(
|
||||
"""
|
||||
INSERT INTO sag_anydesk_ids (
|
||||
sag_id,
|
||||
anydesk_id,
|
||||
hardware_asset_id,
|
||||
is_primary,
|
||||
note,
|
||||
created_by_user_id,
|
||||
deleted_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NULL)
|
||||
ON CONFLICT (sag_id, anydesk_id)
|
||||
DO UPDATE SET
|
||||
hardware_asset_id = EXCLUDED.hardware_asset_id,
|
||||
is_primary = EXCLUDED.is_primary,
|
||||
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
|
||||
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
|
||||
deleted_at = NULL,
|
||||
updated_at = NOW()
|
||||
RETURNING id, sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id, created_at, updated_at
|
||||
""",
|
||||
(sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id),
|
||||
)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="Could not save AnyDesk ID")
|
||||
|
||||
if is_primary:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE sag_anydesk_ids
|
||||
SET is_primary = FALSE, updated_at = NOW()
|
||||
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
|
||||
""",
|
||||
(sag_id, row[0]["id"]),
|
||||
)
|
||||
|
||||
return {"ok": True, "entry": row[0]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error upserting case AnyDesk ID: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not save case AnyDesk ID")
|
||||
|
||||
|
||||
@router.delete("/anydesk/cases/{sag_id}/ids/{entry_id}", tags=["Remote Support"])
|
||||
async def delete_case_anydesk_id(sag_id: int, entry_id: int):
|
||||
"""Soft-delete a saved AnyDesk ID from a case."""
|
||||
try:
|
||||
_ensure_case_exists(sag_id)
|
||||
result = execute_query(
|
||||
"""
|
||||
UPDATE sag_anydesk_ids
|
||||
SET deleted_at = NOW(), updated_at = NOW(), is_primary = FALSE
|
||||
WHERE id = %s AND sag_id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(entry_id, sag_id),
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="AnyDesk ID entry not found")
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting case AnyDesk ID: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not delete case AnyDesk ID")
|
||||
|
||||
|
||||
@router.get("/anydesk/cases/{sag_id}/hardware-options", tags=["Remote Support"])
|
||||
async def get_case_anydesk_hardware_options(sag_id: int):
|
||||
"""Get hardware options: case-linked assets first, then customer fallback assets."""
|
||||
try:
|
||||
case_row = _ensure_case_exists(sag_id)
|
||||
customer_id = case_row.get("customer_id")
|
||||
|
||||
case_hardware = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
h.id,
|
||||
h.brand,
|
||||
h.model,
|
||||
h.serial_number,
|
||||
h.anydesk_id,
|
||||
h.current_owner_customer_id
|
||||
FROM sag_hardware sh
|
||||
JOIN hardware_assets h ON h.id = sh.hardware_id
|
||||
WHERE sh.sag_id = %s
|
||||
AND sh.deleted_at IS NULL
|
||||
AND h.deleted_at IS NULL
|
||||
ORDER BY h.brand, h.model, h.id
|
||||
""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
|
||||
case_ids = {row["id"] for row in case_hardware}
|
||||
customer_hardware = []
|
||||
|
||||
if customer_id:
|
||||
customer_hardware = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
h.id,
|
||||
h.brand,
|
||||
h.model,
|
||||
h.serial_number,
|
||||
h.anydesk_id,
|
||||
h.current_owner_customer_id
|
||||
FROM hardware_assets h
|
||||
WHERE h.deleted_at IS NULL
|
||||
AND h.current_owner_customer_id = %s
|
||||
ORDER BY h.brand, h.model, h.id
|
||||
""",
|
||||
(customer_id,),
|
||||
) or []
|
||||
customer_hardware = [row for row in customer_hardware if row["id"] not in case_ids]
|
||||
|
||||
def _with_label(row: dict) -> dict:
|
||||
brand = (row.get("brand") or "").strip()
|
||||
model = (row.get("model") or "").strip()
|
||||
serial = (row.get("serial_number") or "").strip()
|
||||
aid = (row.get("anydesk_id") or "").strip()
|
||||
parts = [p for p in [brand, model] if p]
|
||||
if serial:
|
||||
parts.append(f"SN: {serial}")
|
||||
if aid:
|
||||
parts.append(f"AD: {aid}")
|
||||
row["label"] = " - ".join(parts) if parts else f"Hardware #{row.get('id')}"
|
||||
return row
|
||||
|
||||
return {
|
||||
"case_hardware": [_with_label(row) for row in case_hardware],
|
||||
"customer_hardware": [_with_label(row) for row in customer_hardware],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error loading AnyDesk hardware options: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not load hardware options")
|
||||
|
||||
|
||||
@router.post("/anydesk/cases/{sag_id}/connect", tags=["Remote Support"])
|
||||
async def connect_case_anydesk(sag_id: int, data: dict):
|
||||
"""
|
||||
Quick connect flow for a case:
|
||||
- Save AnyDesk ID on case
|
||||
- Create local session row for enrichment
|
||||
- Return AnyDesk deep link for app open
|
||||
"""
|
||||
try:
|
||||
case_row = _ensure_case_exists(sag_id)
|
||||
customer_id = case_row.get("customer_id")
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="Case has no customer")
|
||||
|
||||
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
|
||||
if not anydesk_id:
|
||||
raise HTTPException(status_code=400, detail="anydesk_id is required")
|
||||
|
||||
created_by_user_id = data.get("created_by_user_id")
|
||||
contact_id = data.get("contact_id")
|
||||
hardware_asset_id = data.get("hardware_asset_id")
|
||||
note = (data.get("note") or "").strip() or None
|
||||
make_primary = bool(data.get("make_primary", True))
|
||||
|
||||
if contact_id is not None:
|
||||
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
if hardware_asset_id is not None:
|
||||
asset = execute_query(
|
||||
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
|
||||
(hardware_asset_id,),
|
||||
)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Hardware asset not found")
|
||||
|
||||
case_anydesk_row = execute_query(
|
||||
"""
|
||||
INSERT INTO sag_anydesk_ids (
|
||||
sag_id,
|
||||
anydesk_id,
|
||||
hardware_asset_id,
|
||||
is_primary,
|
||||
note,
|
||||
created_by_user_id,
|
||||
deleted_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NULL)
|
||||
ON CONFLICT (sag_id, anydesk_id)
|
||||
DO UPDATE SET
|
||||
hardware_asset_id = EXCLUDED.hardware_asset_id,
|
||||
is_primary = EXCLUDED.is_primary,
|
||||
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
|
||||
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
|
||||
deleted_at = NULL,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(sag_id, anydesk_id, hardware_asset_id, make_primary, note, created_by_user_id),
|
||||
)
|
||||
case_anydesk_id = case_anydesk_row[0]["id"] if case_anydesk_row else None
|
||||
|
||||
if make_primary and case_anydesk_id:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE sag_anydesk_ids
|
||||
SET is_primary = FALSE, updated_at = NOW()
|
||||
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
|
||||
""",
|
||||
(sag_id, case_anydesk_id),
|
||||
)
|
||||
|
||||
manual_external_id = f"case-{uuid4().hex[:12]}"
|
||||
deep_link = f"anydesk:{anydesk_id}"
|
||||
|
||||
# Idempotency guard: avoid creating duplicate rows when users double-click connect.
|
||||
recent_existing = execute_query(
|
||||
"""
|
||||
SELECT id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at, session_link
|
||||
FROM anydesk_sessions
|
||||
WHERE sag_id = %s
|
||||
AND status IN ('active', 'pending')
|
||||
AND (
|
||||
COALESCE(device_info->>'to_id', '') = %s
|
||||
OR COALESCE(device_info->>'customer_machine_id', '') = %s
|
||||
)
|
||||
AND started_at >= NOW() - INTERVAL '10 minutes'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(sag_id, anydesk_id, anydesk_id),
|
||||
)
|
||||
if recent_existing:
|
||||
existing = recent_existing[0]
|
||||
logger.info("ℹ️ Reusing in-flight AnyDesk session for case %s (session %s)", sag_id, existing.get("id"))
|
||||
return {
|
||||
"ok": True,
|
||||
"already_registering": True,
|
||||
"deep_link": existing.get("session_link") or deep_link,
|
||||
"anydesk_id": anydesk_id,
|
||||
"session": {
|
||||
"id": existing.get("id"),
|
||||
"anydesk_session_id": existing.get("anydesk_session_id"),
|
||||
"customer_id": existing.get("customer_id"),
|
||||
"contact_id": existing.get("contact_id"),
|
||||
"sag_id": existing.get("sag_id"),
|
||||
"status": existing.get("status"),
|
||||
"started_at": existing.get("started_at"),
|
||||
},
|
||||
"case_anydesk_id": case_anydesk_id,
|
||||
}
|
||||
|
||||
device_info = {
|
||||
"to_id": anydesk_id,
|
||||
"customer_machine_id": anydesk_id,
|
||||
"hardware_asset_id": hardware_asset_id,
|
||||
"case_anydesk_id": case_anydesk_id,
|
||||
"source": "case_quick_connect",
|
||||
}
|
||||
metadata = {
|
||||
"note": note,
|
||||
"source": "case_quick_connect",
|
||||
"needs_local_sync_enrichment": True,
|
||||
}
|
||||
|
||||
created = execute_query(
|
||||
"""
|
||||
INSERT INTO anydesk_sessions (
|
||||
anydesk_session_id,
|
||||
contact_id,
|
||||
customer_id,
|
||||
sag_id,
|
||||
session_link,
|
||||
device_info,
|
||||
created_by_user_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_minutes,
|
||||
status,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s::jsonb,
|
||||
%s,
|
||||
NOW(),
|
||||
NULL,
|
||||
NULL,
|
||||
'active',
|
||||
%s::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
|
||||
""",
|
||||
(
|
||||
manual_external_id,
|
||||
contact_id,
|
||||
customer_id,
|
||||
sag_id,
|
||||
deep_link,
|
||||
json.dumps(device_info),
|
||||
created_by_user_id,
|
||||
json.dumps(metadata),
|
||||
),
|
||||
)
|
||||
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to create AnyDesk session row")
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sag_id,
|
||||
"System",
|
||||
f"🖥️ AnyDesk quick-connect startet (ID: {anydesk_id})",
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
logger.info("✅ AnyDesk quick-connect prepared for case %s", sag_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"deep_link": deep_link,
|
||||
"anydesk_id": anydesk_id,
|
||||
"session": created[0],
|
||||
"case_anydesk_id": case_anydesk_id,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error in AnyDesk quick-connect: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not start AnyDesk quick-connect")
|
||||
|
||||
|
||||
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
|
||||
async def get_session_history(
|
||||
contact_id: Optional[int] = None,
|
||||
@ -488,7 +925,7 @@ async def get_anydesk_stats():
|
||||
"sessions_this_month": 0,
|
||||
"active_sessions": 0,
|
||||
"average_duration_minutes": 0,
|
||||
"total_support_hours": 0
|
||||
"total_support_hours": 0.0
|
||||
}
|
||||
|
||||
# Get today's sessions
|
||||
@ -676,8 +1113,162 @@ async def sessions_overview(
|
||||
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
|
||||
total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
|
||||
|
||||
def _is_synthetic_session_id(session_id: Optional[str]) -> bool:
|
||||
sid = str(session_id or "")
|
||||
return sid.startswith("case-") or sid.startswith("manual-") or sid.startswith("local-")
|
||||
|
||||
def _dedupe_key(row: dict) -> str:
|
||||
# Prefer stable external AnyDesk session IDs when they are not synthetic.
|
||||
sid = str(row.get("anydesk_session_id") or "").strip()
|
||||
if sid and not _is_synthetic_session_id(sid):
|
||||
return f"sid:{sid}"
|
||||
|
||||
machine_id = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
|
||||
tech_id = str(row.get("technician_id") or "").strip()
|
||||
# Use 15-minute buckets to collapse short-lived duplicates from hub/import/local sync.
|
||||
started = row.get("started_at")
|
||||
if started:
|
||||
minutes = int(started.timestamp() // (15 * 60))
|
||||
bucket = str(minutes)
|
||||
else:
|
||||
bucket = "unknown"
|
||||
return f"heur:{machine_id}:{tech_id}:{bucket}"
|
||||
|
||||
def _row_score(row: dict) -> int:
|
||||
score = 0
|
||||
if row.get("sag_id"):
|
||||
score += 200
|
||||
if row.get("customer_id"):
|
||||
score += 120
|
||||
if row.get("contact_id"):
|
||||
score += 100
|
||||
if row.get("hardware_asset_id"):
|
||||
score += 80
|
||||
duration = row.get("duration_minutes")
|
||||
if duration is not None:
|
||||
try:
|
||||
if float(duration) > 0:
|
||||
score += 60
|
||||
else:
|
||||
score += 20
|
||||
except Exception:
|
||||
score += 10
|
||||
if row.get("ended_at"):
|
||||
score += 25
|
||||
if not _is_synthetic_session_id(row.get("anydesk_session_id")):
|
||||
score += 40
|
||||
return score
|
||||
|
||||
def _backfill_enrichment(winner: dict, loser: dict) -> None:
|
||||
"""Copy missing enriched fields from loser into winner."""
|
||||
for field in (
|
||||
"tech_name",
|
||||
"technician_id",
|
||||
"remote_alias",
|
||||
"remote_id",
|
||||
"customer_machine_id",
|
||||
"customer_alias",
|
||||
"contact_id",
|
||||
"contact_name",
|
||||
"contact_email",
|
||||
"customer_id",
|
||||
"customer_name",
|
||||
"sag_id",
|
||||
"sag_titel",
|
||||
"sag_status",
|
||||
"hardware_asset_id",
|
||||
"hw_brand",
|
||||
"hw_model",
|
||||
"hw_anydesk_id",
|
||||
"hw_customer_id",
|
||||
"notes",
|
||||
):
|
||||
if not winner.get(field) and loser.get(field):
|
||||
winner[field] = loser.get(field)
|
||||
|
||||
deduped_rows: dict[str, dict] = {}
|
||||
for row in (rows or []):
|
||||
key = _dedupe_key(row)
|
||||
if key not in deduped_rows:
|
||||
deduped_rows[key] = row
|
||||
continue
|
||||
|
||||
current = deduped_rows[key]
|
||||
current_score = _row_score(current)
|
||||
incoming_score = _row_score(row)
|
||||
|
||||
if incoming_score > current_score:
|
||||
winner = row
|
||||
other = current
|
||||
else:
|
||||
winner = current
|
||||
other = row
|
||||
|
||||
# Merge useful enrichment from the losing row into the winner.
|
||||
if not winner.get("duration_minutes") and other.get("duration_minutes") is not None:
|
||||
winner["duration_minutes"] = other.get("duration_minutes")
|
||||
if not winner.get("ended_at") and other.get("ended_at"):
|
||||
winner["ended_at"] = other.get("ended_at")
|
||||
if (winner.get("status") in (None, "", "active", "pending")) and other.get("status"):
|
||||
if other.get("status") in ("completed", "failed", "cancelled"):
|
||||
winner["status"] = other.get("status")
|
||||
if not winner.get("notes") and other.get("notes"):
|
||||
winner["notes"] = other.get("notes")
|
||||
_backfill_enrichment(winner, other)
|
||||
|
||||
deduped_rows[key] = winner
|
||||
|
||||
# Second pass: merge neighboring buckets for same machine ID when timestamps are close.
|
||||
# This catches duplicates where one row lands in an adjacent 15-minute bucket.
|
||||
second_pass_rows = list(deduped_rows.values())
|
||||
second_pass_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
|
||||
|
||||
clustered: list[dict] = []
|
||||
for row in second_pass_rows:
|
||||
row_machine = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
|
||||
row_started = row.get("started_at")
|
||||
attached = False
|
||||
|
||||
if row_machine and row_started:
|
||||
for target in clustered:
|
||||
target_machine = str(target.get("customer_machine_id") or target.get("remote_id") or "").strip()
|
||||
target_started = target.get("started_at")
|
||||
if not target_machine or not target_started:
|
||||
continue
|
||||
if row_machine != target_machine:
|
||||
continue
|
||||
|
||||
if abs(target_started - row_started) <= timedelta(minutes=20):
|
||||
target_score = _row_score(target)
|
||||
row_score = _row_score(row)
|
||||
winner = row if row_score > target_score else target
|
||||
loser = target if winner is row else row
|
||||
|
||||
if not winner.get("duration_minutes") and loser.get("duration_minutes") is not None:
|
||||
winner["duration_minutes"] = loser.get("duration_minutes")
|
||||
if not winner.get("ended_at") and loser.get("ended_at"):
|
||||
winner["ended_at"] = loser.get("ended_at")
|
||||
if (winner.get("status") in (None, "", "active", "pending")) and loser.get("status"):
|
||||
if loser.get("status") in ("completed", "failed", "cancelled", "registered"):
|
||||
winner["status"] = loser.get("status")
|
||||
if not winner.get("notes") and loser.get("notes"):
|
||||
winner["notes"] = loser.get("notes")
|
||||
_backfill_enrichment(winner, loser)
|
||||
|
||||
if winner is row:
|
||||
clustered.remove(target)
|
||||
clustered.append(winner)
|
||||
attached = True
|
||||
break
|
||||
|
||||
if not attached:
|
||||
clustered.append(row)
|
||||
|
||||
merged_rows = clustered
|
||||
merged_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
|
||||
|
||||
sessions = []
|
||||
for r in (rows or []):
|
||||
for r in merged_rows:
|
||||
sessions.append({
|
||||
"id": r["id"],
|
||||
"anydesk_session_id": r["anydesk_session_id"],
|
||||
@ -715,7 +1306,8 @@ async def sessions_overview(
|
||||
} if r["sag_id"] else None,
|
||||
})
|
||||
|
||||
return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
|
||||
# Return deduplicated total for UI consistency on overview endpoint.
|
||||
return JSONResponse(content={"sessions": sessions, "total": len(sessions), "limit": limit, "offset": offset, "raw_total": total})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sessions_overview: {e}")
|
||||
|
||||
@ -9,8 +9,9 @@ import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import time
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
import httpx
|
||||
import aiohttp
|
||||
|
||||
@ -577,3 +578,232 @@ class AnyDeskService:
|
||||
"total_from_api": len(entries),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_local_sessions(payload: Any) -> List[dict]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
for key in ("sessions", "list", "data", "items", "results"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(value: Any) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
if value > 10_000_000_000:
|
||||
value = value / 1000
|
||||
try:
|
||||
return datetime.utcfromtimestamp(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(text).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def fetch_sessions_from_local_endpoint(
|
||||
self,
|
||||
endpoint_url: str,
|
||||
timeout_seconds: int = 20,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll local AnyDesk bridge endpoint and upsert/enrich local sessions.
|
||||
|
||||
Endpoint expected: http://localhost:8001/anydesk/sessions
|
||||
"""
|
||||
imported = 0
|
||||
updated = 0
|
||||
matched = 0
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
logger.info("📡 Polling local AnyDesk sessions from %s", endpoint_url)
|
||||
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||
response = await client.get(endpoint_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
logger.error("❌ Local AnyDesk polling failed: %s", exc)
|
||||
return {"error": str(exc), "imported": 0, "updated": 0, "matched": 0, "total": 0, "errors": [str(exc)]}
|
||||
|
||||
entries = self._extract_local_sessions(payload)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
sid = str(entry.get("sid") or entry.get("session_id") or entry.get("anydesk_session_id") or entry.get("id") or "").strip()
|
||||
|
||||
to_raw = entry.get("to")
|
||||
to_obj = to_raw if isinstance(to_raw, dict) else {}
|
||||
to_id = str(
|
||||
entry.get("to_id")
|
||||
or entry.get("anydesk_id")
|
||||
or entry.get("customer_machine_id")
|
||||
or to_obj.get("cid")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
from_raw = entry.get("from")
|
||||
from_obj = from_raw if isinstance(from_raw, dict) else {}
|
||||
from_id = str(entry.get("from_id") or from_obj.get("cid") or "").strip()
|
||||
|
||||
started = self._parse_timestamp(
|
||||
entry.get("started_at")
|
||||
or entry.get("start")
|
||||
or entry.get("start-time")
|
||||
or entry.get("start_time")
|
||||
)
|
||||
ended = self._parse_timestamp(
|
||||
entry.get("ended_at")
|
||||
or entry.get("end")
|
||||
or entry.get("end-time")
|
||||
or entry.get("end_time")
|
||||
)
|
||||
|
||||
duration_minutes = entry.get("duration_minutes")
|
||||
if duration_minutes is None:
|
||||
duration_seconds = entry.get("duration_seconds")
|
||||
if duration_seconds is None:
|
||||
duration_seconds = entry.get("duration")
|
||||
if duration_seconds is not None:
|
||||
try:
|
||||
duration_minutes = round(float(duration_seconds) / 60, 1)
|
||||
except Exception:
|
||||
duration_minutes = None
|
||||
|
||||
status = str(entry.get("status") or "").strip().lower()
|
||||
if not status:
|
||||
if bool(entry.get("active")):
|
||||
status = "active"
|
||||
elif ended or duration_minutes is not None:
|
||||
status = "completed"
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
if not sid and not to_id:
|
||||
continue
|
||||
|
||||
existing = []
|
||||
if sid:
|
||||
existing = execute_query(
|
||||
"SELECT id FROM anydesk_sessions WHERE anydesk_session_id = %s",
|
||||
(sid,),
|
||||
) or []
|
||||
|
||||
if not existing and to_id:
|
||||
existing = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
FROM anydesk_sessions
|
||||
WHERE COALESCE(device_info->>'to_id', '') = %s
|
||||
AND started_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(to_id,),
|
||||
) or []
|
||||
|
||||
device_info = {
|
||||
"remote_alias": entry.get("remote_alias") or from_obj.get("alias"),
|
||||
"from_id": from_id,
|
||||
"to_id": to_id,
|
||||
"local_alias": entry.get("local_alias") or to_obj.get("alias"),
|
||||
"imported_from_local_endpoint": True,
|
||||
}
|
||||
metadata = {
|
||||
"source": "local_anydesk_sessions",
|
||||
"raw": entry,
|
||||
}
|
||||
|
||||
if existing:
|
||||
matched += 1
|
||||
if dry_run:
|
||||
continue
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE anydesk_sessions
|
||||
SET
|
||||
status = COALESCE(%s, status),
|
||||
ended_at = COALESCE(%s, ended_at),
|
||||
duration_minutes = COALESCE(%s, duration_minutes),
|
||||
device_info = COALESCE(device_info, '{}'::jsonb) || %s::jsonb,
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
status,
|
||||
ended,
|
||||
duration_minutes,
|
||||
json.dumps(device_info),
|
||||
json.dumps(metadata),
|
||||
existing[0]["id"],
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
insert_sid = sid or f"local-{uuid4().hex[:12]}"
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO anydesk_sessions (
|
||||
anydesk_session_id,
|
||||
session_link,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_minutes,
|
||||
status,
|
||||
device_info,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW(), NOW())
|
||||
""",
|
||||
(
|
||||
insert_sid,
|
||||
f"anydesk:{to_id}" if to_id else None,
|
||||
started or datetime.utcnow(),
|
||||
ended,
|
||||
duration_minutes,
|
||||
status,
|
||||
json.dumps(device_info),
|
||||
json.dumps(metadata),
|
||||
),
|
||||
)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
logger.warning("⚠️ Failed local AnyDesk entry sync: %s", exc)
|
||||
errors.append(str(exc))
|
||||
|
||||
logger.info(
|
||||
"✅ Local AnyDesk sync done: %d imported, %d updated (%d matched), %d errors",
|
||||
imported,
|
||||
updated,
|
||||
matched,
|
||||
len(errors),
|
||||
)
|
||||
return {
|
||||
"imported": imported,
|
||||
"updated": updated,
|
||||
"matched": matched,
|
||||
"total": len(entries),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
16
main.py
16
main.py
@ -213,6 +213,22 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
if settings.ANYDESK_LOCAL_SYNC_ENABLED:
|
||||
from app.jobs.anydesk_local_sync import sync_anydesk_local_sessions
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=sync_anydesk_local_sessions,
|
||||
trigger=IntervalTrigger(minutes=settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES),
|
||||
id='anydesk_local_sync',
|
||||
name='AnyDesk Local Sessions Sync',
|
||||
max_instances=1,
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
"✅ AnyDesk local sync job scheduled (every %d minutes)",
|
||||
settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES,
|
||||
)
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
|
||||
from app.modules.links.jobs.dead_link_check import check_links_health
|
||||
|
||||
|
||||
47
migrations/165_sag_anydesk_ids.sql
Normal file
47
migrations/165_sag_anydesk_ids.sql
Normal file
@ -0,0 +1,47 @@
|
||||
-- Migration 165: AnyDesk IDs per case (multi-ID support)
|
||||
-- Stores quick-connect IDs on cases and optional hardware relation
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sag_anydesk_ids (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||
anydesk_id VARCHAR(64) NOT NULL,
|
||||
hardware_asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (sag_id, anydesk_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_sag_id
|
||||
ON sag_anydesk_ids(sag_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_anydesk_id
|
||||
ON sag_anydesk_ids(anydesk_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_hardware_asset_id
|
||||
ON sag_anydesk_ids(hardware_asset_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_primary
|
||||
ON sag_anydesk_ids(sag_id, is_primary)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Keep updated_at fresh
|
||||
CREATE OR REPLACE FUNCTION update_sag_anydesk_ids_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_update_sag_anydesk_ids_updated_at ON sag_anydesk_ids;
|
||||
CREATE TRIGGER trg_update_sag_anydesk_ids_updated_at
|
||||
BEFORE UPDATE ON sag_anydesk_ids
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_sag_anydesk_ids_updated_at();
|
||||
Loading…
Reference in New Issue
Block a user