feat(migrations): add AnyDesk session management and customer wiki slug updates

- Created migration scripts for AnyDesk sessions and hardware assets.
- Implemented apply_migration_115.py to execute migration for AnyDesk sessions.
- Added set_customer_wiki_slugs.py script to update customer wiki slugs based on a predefined folder list.
- Developed run_migration.py to apply AnyDesk migration schema.
- Added tests for Service Contract Wizard to ensure functionality and dry-run mode.
This commit is contained in:
Christian 2026-02-10 14:40:38 +01:00
parent 693ac4cfd6
commit 3d7fb1aa48
40 changed files with 5313 additions and 16 deletions

View File

@ -0,0 +1,201 @@
# Service Contract Migration Wizard - Implementation Summary
## ✅ What Was Built
A step-by-step wizard that migrates Vtiger service contracts to Hub systems:
- **Cases** → Archived to `tticket_archived_tickets`
- **Timelogs** → Transferred as klippekort top-ups (prepaid card hours)
Features:
- ✅ Dry-run toggle (preview mode without database writes)
- ✅ Step-by-step review of each case/timelog
- ✅ Manual klippekort selection per timelog
- ✅ Progress tracking and summary report
- ✅ Read-only from Vtiger (no writes back to Vtiger)
## 🎯 Files Created/Modified
### New Files:
1. **[app/timetracking/backend/service_contract_wizard.py](app/timetracking/backend/service_contract_wizard.py)** (275 lines)
- Core wizard service with all business logic
- Methods: `load_contract_detailed_data()`, `archive_case()`, `transfer_timelog_to_klippekort()`, `get_wizard_summary()`
- Dry-run support built into each method
2. **[app/timetracking/frontend/service_contract_wizard.html](app/timetracking/frontend/service_contract_wizard.html)** (650 lines)
- Complete wizard UI with Nordic design
- Contract dropdown selector
- Progress bar with live counters
- Current item display with conditional klippekort dropdown
- Summary report on completion
### Modified Files:
1. **[app/services/vtiger_service.py](app/services/vtiger_service.py)** (+65 lines)
- Added `get_service_contracts(account_id=None)` - Fetch active service contracts
- Added `get_service_contract_cases(contract_id)` - Fetch cases linked to contract
- Added `get_service_contract_timelogs(contract_id)` - Fetch timelogs linked to contract
2. **[app/timetracking/backend/models.py](app/timetracking/backend/models.py)** (+70 lines)
- `ServiceContractBase` - Base contract model
- `ServiceContractItem` - Single case/timelog item
- `ServiceContractWizardData` - Complete contract data for wizard
- `ServiceContractWizardAction` - Action result (archive/transfer)
- `ServiceContractWizardSummary` - Final summary
- `TimologTransferRequest` - Request model for timelog transfer
- `TimologTransferResult` - Transfer result
3. **[app/timetracking/backend/router.py](app/timetracking/backend/router.py)** (+180 lines)
- `GET /api/v1/timetracking/service-contracts` - List contracts dropdown
- `POST /api/v1/timetracking/service-contracts/wizard/load` - Load contract data
- `POST /api/v1/timetracking/service-contracts/wizard/archive-case` - Archive case
- `POST /api/v1/timetracking/service-contracts/wizard/transfer-timelog` - Transfer timelog
- `GET /api/v1/timetracking/service-contracts/wizard/customer-cards/{customer_id}` - Get klippekort
4. **[app/timetracking/frontend/views.py](app/timetracking/frontend/views.py)** (+5 lines)
- Added frontend route: `/timetracking/service-contract-wizard`
## 🚀 How to Test
### 1. Start the API
```bash
docker-compose up -d api
docker-compose logs -f api
```
### 2. Access the Wizard
```
http://localhost:8000/timetracking/service-contract-wizard
```
### 3. Dry-Run Mode (Recommended First)
1. Check the "Preview Mode" checkbox at top (enabled by default)
2. Select a service contract from dropdown
3. Review each case/timelog and click "Gem & Næste"
4. No data is written to database in dry-run mode
5. Review summary report to see what WOULD be changed
### 4. Live Mode
1. **Uncheck** "Preview Mode" checkbox
2. Select same or different contract
3. Process items - changes ARE committed to database
4. Cases are exported to `tticket_archived_tickets`
5. Timelogs are added to klippekort via top-up transaction
## 🔍 Database Changes
### Dryrun Mode:
- All operations are **logged** but **NOT committed**
- Queries are constructed but rolled back
- UI shows what WOULD happen
### Live Mode:
- Cases are inserted into `tticket_archived_tickets` with:
- `source_system = 'vtiger_service_contract'`
- `external_id = vtiger case ID`
- Full case data in `raw_data` JSONB field
- Timelogs create transactions in `tticket_prepaid_transactions` with:
- `transaction_type = 'top_up'`
- Hours added to klippekort `purchased_hours`
- Description references vTiger timelog
## 📊 Data Flow
```
Vtiger Service Contract
SelectContract (dropdown)
LoadContractData
├─ Cases → Archive to tticket_archived_tickets
└─ Timelogs → Transfer to klippekort (top-up)
WizardProgress (step-by-step review)
├─ [DRY RUN] Preview mode (no DB writes)
└─ [LIVE] Commit to database
Summary Report
├─ Cases archived: N
├─ Hours transferred: N
└─ Failed items: N
```
## 🔐 Safety Features
1. **Dry-run mode enabled by default** - Users see what WOULD happen first
2. **Customer linking** - Looks up Hub customer ID from vTiger account
3. **Klippekort validation** - Verifies card belongs to customer before transfer
4. **Read-only from Vtiger** - No writes back to Vtiger (only reads)
5. **Transaction handling** - Each operation is atomic
6. **Audit logging** - All actions logged with DRY RUN/COMMITTED markers
## 🛠️ Technical Details
### Wizard Service (`ServiceContractWizardService`)
- Stateless service class
- All methods are static
- Database operations via `execute_query()` helpers
- Klippekort transfers via `KlippekortService.top_up_card()`
### Frontend UI
- Vanilla JavaScript (no frameworks)
- Nordic Top design system (matches existing Hub UI)
- Responsive Bootstrap 5 grid
- Real-time progress updates
- Conditional klippekort dropdown (only for timelogs)
### API Endpoints
- RESTful architecture
- All endpoints support `dry_run` query parameter
- Request/response models use Pydantic validation
- Comprehensive error handling with HTTPException
## 📝 Logging Output
### Dry-Run Mode:
```
🔍 DRY RUN: Would archive case 1x123: 'Case Title'
🔍 DRY RUN: Would transfer 5h to card 42 from timelog 2x456
```
### Live Mode:
```
✅ Archived case 1x123 to tticket_archived_tickets (ID: 1234)
✅ Transferred 5h from timelog 2x456 to card 42
```
## 🐛 Troubleshooting
### Contracts dropdown is empty:
- Verify Vtiger integration is configured (VTIGER_URL, VTIGER_USERNAME, VTIGER_API_KEY in .env)
- Check vTiger has active ServiceContracts
- Check API user has access to ServiceContracts module
### Klippekort dropdown empty for customer:
- Customer may not have any active prepaid cards
- Or customer is not linked between Vtiger account and Hub customer
- Create a prepaid card for the customer first
### Dry-run mode not working:
- Ensure checkbox is checked
- Check browser console for JavaScript errors
- Verify `dry_run` parameter is passed to API endpoints
## 📋 Next Steps
1. **Test with sample data** - Create test service contract in Vtiger
2. **Verify database changes** - Query `tticket_archived_tickets` post-migration
3. **Monitor klippekort** - Check `tticket_prepaid_transactions` for top-up entries
4. **Adjust as needed** - Tweak timelog filtering or case mapping based on results
## 🔗 Related Components
- **Klippekort System**: [app/ticket/backend/klippekort_service.py](app/ticket/backend/klippekort_service.py)
- **Archive System**: Database table `tticket_archived_tickets`
- **Timetracking Module**: [app/timetracking/](app/timetracking/)
- **Vtiger Integration**: [app/services/vtiger_service.py](app/services/vtiger_service.py)
---
**Status**: ✅ Ready for testing and deployment
**Estimated Time to Test**: 15-20 minutes
**Database Dependency**: PostgreSQL (no migrations needed - uses existing tables)

View File

@ -99,6 +99,18 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.session-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1rem;
}
.session-meta {
font-size: 0.9rem;
color: var(--text-secondary);
}
.remove-company-btn { .remove-company-btn {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
@ -147,6 +159,11 @@
<i class="bi bi-building"></i>Firmaer <i class="bi bi-building"></i>Firmaer
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#remote-sessions">
<i class="bi bi-display"></i>Remote Sessions
</a>
</li>
</ul> </ul>
</div> </div>
@ -236,6 +253,29 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Remote Sessions Tab -->
<div class="tab-pane fade" id="remote-sessions">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<h5 class="fw-bold mb-0">Remote Sessions</h5>
<div class="d-flex flex-wrap gap-2">
<select class="form-select form-select-sm" id="sessionCompanySelect" style="min-width: 200px;"></select>
<input type="number" class="form-control form-control-sm" id="sessionSagIdInput" placeholder="Sag ID (valgfri)" style="max-width: 140px;">
<button class="btn btn-primary btn-sm" onclick="startRemoteSession()">
<i class="bi bi-play-circle me-2"></i>Start Session
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadRemoteSessions()">
<i class="bi bi-arrow-repeat me-2"></i>Opdater
</button>
</div>
</div>
<div id="remoteSessionAlert" class="alert alert-info d-none"></div>
<div class="row g-3" id="remoteSessionsContainer">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -354,6 +394,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Worklog Suggestion Modal -->
<div class="modal fade" id="worklogSuggestionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Forslag til tidsregistrering</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="worklogSuggestionBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -369,6 +425,13 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => { document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
loadCompanies(); loadCompanies();
}); });
const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]');
if (remoteSessionsTab) {
remoteSessionsTab.addEventListener('shown.bs.tab', () => {
loadRemoteSessions();
});
}
}); });
async function loadContact() { async function loadContact() {
@ -416,6 +479,8 @@ function displayContact(contact) {
: '<span class="badge bg-secondary">Inaktiv</span>'; : '<span class="badge bg-secondary">Inaktiv</span>';
document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0; document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0;
populateSessionCompanySelect(contact);
// System Info // System Info
document.getElementById('vtigerId').textContent = contact.vtiger_id || '-'; document.getElementById('vtigerId').textContent = contact.vtiger_id || '-';
document.getElementById('createdAt').textContent = new Date(contact.created_at).toLocaleString('da-DK'); document.getElementById('createdAt').textContent = new Date(contact.created_at).toLocaleString('da-DK');
@ -426,6 +491,27 @@ function displayContact(contact) {
} }
} }
function populateSessionCompanySelect(contact) {
const select = document.getElementById('sessionCompanySelect');
if (!select) return;
const companies = contact.companies || [];
if (companies.length === 0) {
select.innerHTML = '<option value="">Ingen firmaer tilknyttet</option>';
select.disabled = true;
return;
}
const primary = companies.find(c => c.is_primary) || companies[0];
select.innerHTML = companies.map(c => {
const label = c.is_primary ? `${c.name} (Primær)` : c.name;
return `<option value="${c.id}">${escapeHtml(label)}</option>`;
}).join('');
select.value = String(primary.id);
select.disabled = false;
}
async function loadCompanies() { async function loadCompanies() {
if (!contactData) return; if (!contactData) return;
displayCompanies(contactData.companies); displayCompanies(contactData.companies);
@ -470,6 +556,164 @@ function displayCompanies(companies) {
`).join(''); `).join('');
} }
async function loadRemoteSessions() {
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
container.innerHTML = `
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
`;
try {
const response = await fetch(`/api/v1/anydesk/sessions?contact_id=${contactId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente sessioner');
}
const data = await response.json();
displayRemoteSessions(data.sessions || []);
} catch (error) {
console.error('Failed to load sessions:', error);
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Kunne ikke hente sessioner</div>';
}
}
function displayRemoteSessions(sessions) {
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
if (!sessions || sessions.length === 0) {
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen remote sessions endnu</div>';
return;
}
container.innerHTML = sessions.map(session => {
const badgeClass = getSessionBadgeClass(session.status);
const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-';
const linkButton = session.session_link
? `<a class="btn btn-sm btn-outline-primary" href="${session.session_link}" target="_blank">Åbn Link</a>`
: '';
const worklogButton = session.status === 'completed'
? `<button class="btn btn-sm btn-outline-secondary" onclick="showWorklogSuggestion(${session.id})">Vis forslag</button>`
: '';
return `
<div class="col-12">
<div class="session-card">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge ${badgeClass}">${escapeHtml(session.status || 'ukendt')}</span>
<strong>Session #${session.id}</strong>
</div>
<div class="session-meta">Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}</div>
${session.sag_id ? `<div class="session-meta">Sag ID: ${session.sag_id}</div>` : ''}
</div>
<div class="d-flex gap-2">
${linkButton}
${worklogButton}
</div>
</div>
</div>
</div>
`;
}).join('');
}
async function startRemoteSession() {
const alertBox = document.getElementById('remoteSessionAlert');
const select = document.getElementById('sessionCompanySelect');
if (!contactData || !select || select.disabled) {
alert('Kontakt skal have mindst et firma for at starte en session');
return;
}
const customerId = parseInt(select.value);
const sagIdValue = document.getElementById('sessionSagIdInput').value;
const sagId = sagIdValue ? parseInt(sagIdValue) : null;
try {
const response = await fetch('/api/v1/anydesk/start-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
contact_id: contactId,
sag_id: sagId,
description: `Remote support for ${contactData.first_name} ${contactData.last_name}`
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke starte session');
}
const data = await response.json();
if (alertBox) {
alertBox.classList.remove('d-none');
alertBox.innerHTML = `Session startet. ${data.session_link ? `Link: <a href="${data.session_link}" target="_blank">${data.session_link}</a>` : ''}`;
}
if (data.session_link) {
window.open(data.session_link, '_blank');
}
await loadRemoteSessions();
} catch (error) {
console.error('Failed to start session:', error);
alert('Fejl: ' + error.message);
}
}
async function showWorklogSuggestion(sessionId) {
try {
const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/worklog-suggestion`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke hente forslag');
}
const suggestion = await response.json();
const body = document.getElementById('worklogSuggestionBody');
if (body) {
body.innerHTML = `
<div class="mb-2"><strong>Varighed:</strong> ${suggestion.duration_hours} timer</div>
<div class="mb-2"><strong>Start:</strong> ${new Date(suggestion.start_time).toLocaleString('da-DK')}</div>
<div class="mb-2"><strong>Slut:</strong> ${new Date(suggestion.end_time).toLocaleString('da-DK')}</div>
<div class="mb-2"><strong>Beskrivelse:</strong> ${escapeHtml(suggestion.description || '')}</div>
<div class="small text-muted">Forslaget kan bruges til manuel tidsregistrering</div>
`;
}
const modal = new bootstrap.Modal(document.getElementById('worklogSuggestionModal'));
modal.show();
} catch (error) {
console.error('Failed to load suggestion:', error);
alert('Fejl: ' + error.message);
}
}
function getSessionBadgeClass(status) {
switch ((status || '').toLowerCase()) {
case 'active':
return 'bg-success';
case 'completed':
return 'bg-primary';
case 'failed':
return 'bg-danger';
case 'cancelled':
return 'bg-secondary';
default:
return 'bg-light text-dark';
}
}
async function loadCompaniesForSelect() { async function loadCompaniesForSelect() {
try { try {
const response = await fetch('/api/v1/customers?limit=1000'); const response = await fetch('/api/v1/customers?limit=1000');

View File

@ -69,6 +69,13 @@ class Settings(BaseSettings):
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300 NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
NEXTCLOUD_ENCRYPTION_KEY: str = "" NEXTCLOUD_ENCRYPTION_KEY: str = ""
# Wiki.js Integration
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
WIKI_API_TOKEN: str = ""
WIKI_API_KEY: str = ""
WIKI_TIMEOUT_SECONDS: int = 12
WIKI_READ_ONLY: bool = True
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://localhost:11434" OLLAMA_ENDPOINT: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3.2:3b" OLLAMA_MODEL: str = "llama3.2:3b"
@ -197,6 +204,21 @@ class Settings(BaseSettings):
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5 REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
REMINDERS_QUEUE_BATCH_SIZE: int = 10 REMINDERS_QUEUE_BATCH_SIZE: int = 10
# AnyDesk Remote Support Integration
ANYDESK_LICENSE_ID: str = ""
ANYDESK_API_TOKEN: str = ""
ANYDESK_PASSWORD: str = ""
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
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
# SMS Integration (CPSMS)
SMS_API_KEY: str = ""
SMS_USERNAME: str = ""
SMS_SENDER: str = "BMC Networks"
SMS_WEBHOOK_SECRET: str = ""
# Dev-only shortcuts # Dev-only shortcuts
DEV_ALLOW_ARCHIVED_IMPORT: bool = False DEV_ALLOW_ARCHIVED_IMPORT: bool = False

View File

@ -34,6 +34,7 @@ class CustomerBase(BaseModel):
postal_code: Optional[str] = None postal_code: Optional[str] = None
country: Optional[str] = "DK" country: Optional[str] = "DK"
website: Optional[str] = None website: Optional[str] = None
wiki_slug: Optional[str] = None
is_active: Optional[bool] = True is_active: Optional[bool] = True
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None
@ -53,6 +54,7 @@ class CustomerUpdate(BaseModel):
postal_code: Optional[str] = None postal_code: Optional[str] = None
country: Optional[str] = None country: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
wiki_slug: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None

View File

@ -98,6 +98,18 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.session-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1rem;
}
.session-meta {
font-size: 0.9rem;
color: var(--text-secondary);
}
.activity-item { .activity-item {
padding: 1.5rem; padding: 1.5rem;
border-left: 3px solid var(--accent-light); border-left: 3px solid var(--accent-light);
@ -326,6 +338,11 @@
<i class="bi bi-cloud"></i>Nextcloud <i class="bi bi-cloud"></i>Nextcloud
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#remote-sessions">
<i class="bi bi-display"></i>Remote Sessions
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#activity"> <a class="nav-link" data-bs-toggle="tab" href="#activity">
<i class="bi bi-clock-history"></i>Aktivitet <i class="bi bi-clock-history"></i>Aktivitet
@ -372,6 +389,10 @@
<span class="info-label">Hjemmeside</span> <span class="info-label">Hjemmeside</span>
<span class="info-value" id="website">-</span> <span class="info-value" id="website">-</span>
</div> </div>
<div class="info-row">
<span class="info-label">Kunde Wiki</span>
<span class="info-value" id="wikiLink">-</span>
</div>
</div> </div>
</div> </div>
@ -704,6 +725,28 @@
{% include "modules/nextcloud/templates/tab.html" %} {% include "modules/nextcloud/templates/tab.html" %}
</div> </div>
<!-- Remote Sessions Tab -->
<div class="tab-pane fade" id="remote-sessions">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<h5 class="fw-bold mb-0">Remote Sessions</h5>
<div class="d-flex flex-wrap gap-2">
<input type="text" class="form-control form-control-sm" id="customerSessionDescription" placeholder="Beskrivelse (valgfri)" style="min-width: 240px;">
<button class="btn btn-primary btn-sm" onclick="startCustomerSession()">
<i class="bi bi-play-circle me-2"></i>Start Session
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadCustomerSessions()">
<i class="bi bi-arrow-repeat me-2"></i>Opdater
</button>
</div>
</div>
<div id="customerSessionAlert" class="alert alert-info d-none"></div>
<div class="row g-3" id="customerSessionsContainer">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
</div>
</div>
<!-- Activity Tab --> <!-- Activity Tab -->
<div class="tab-pane fade" id="activity"> <div class="tab-pane fade" id="activity">
<h5 class="fw-bold mb-4">Aktivitet</h5> <h5 class="fw-bold mb-4">Aktivitet</h5>
@ -796,6 +839,12 @@
<input type="url" class="form-control" id="editWebsite" placeholder="https://"> <input type="url" class="form-control" id="editWebsite" placeholder="https://">
</div> </div>
<div class="col-md-6">
<label for="editWikiSlug" class="form-label">Wiki slug</label>
<input type="text" class="form-control" id="editWikiSlug" placeholder="norva24">
<div class="form-text">Bygger https://wiki.bmcnetworks.dk/en/Kunder/{slug}</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label for="editCountry" class="form-label">Land</label> <label for="editCountry" class="form-label">Land</label>
<select class="form-select" id="editCountry"> <select class="form-select" id="editCountry">
@ -1098,6 +1147,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Worklog Suggestion Modal -->
<div class="modal fade" id="customerWorklogSuggestionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Forslag til tidsregistrering</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="customerWorklogSuggestionBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -1182,6 +1247,14 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
// Load remote sessions when tab is shown
const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]');
if (remoteSessionsTab) {
remoteSessionsTab.addEventListener('shown.bs.tab', () => {
loadCustomerSessions();
}, { once: false });
}
eventListenersAdded = true; eventListenersAdded = true;
}); });
@ -1266,6 +1339,18 @@ function displayCustomer(customer) {
document.getElementById('phone').textContent = customer.phone || '-'; document.getElementById('phone').textContent = customer.phone || '-';
document.getElementById('website').textContent = customer.website || '-'; document.getElementById('website').textContent = customer.website || '-';
const wikiEl = document.getElementById('wikiLink');
if (wikiEl) {
const slug = (customer.wiki_slug || '').trim();
if (slug) {
const encoded = encodeURIComponent(slug);
const url = `https://wiki.bmcnetworks.dk/en/Kunder/${encoded}/`;
wikiEl.innerHTML = `<a href="${url}" target="_blank" rel="noopener">/en/Kunder/${escapeHtml(slug)}</a>`;
} else {
wikiEl.textContent = '-';
}
}
// Economic Information // Economic Information
document.getElementById('economicNumber').textContent = customer.economic_customer_number || '-'; document.getElementById('economicNumber').textContent = customer.economic_customer_number || '-';
document.getElementById('paymentTerms').textContent = customer.payment_terms_days document.getElementById('paymentTerms').textContent = customer.payment_terms_days
@ -3131,6 +3216,7 @@ function editCustomer() {
document.getElementById('editPhone').value = customerData.phone || ''; document.getElementById('editPhone').value = customerData.phone || '';
document.getElementById('editMobilePhone').value = customerData.mobile_phone || ''; document.getElementById('editMobilePhone').value = customerData.mobile_phone || '';
document.getElementById('editWebsite').value = customerData.website || ''; document.getElementById('editWebsite').value = customerData.website || '';
document.getElementById('editWikiSlug').value = customerData.wiki_slug || '';
document.getElementById('editCountry').value = customerData.country || 'DK'; document.getElementById('editCountry').value = customerData.country || 'DK';
document.getElementById('editAddress').value = customerData.address || ''; document.getElementById('editAddress').value = customerData.address || '';
document.getElementById('editPostalCode').value = customerData.postal_code || ''; document.getElementById('editPostalCode').value = customerData.postal_code || '';
@ -3151,6 +3237,7 @@ async function saveCustomerEdit() {
phone: document.getElementById('editPhone').value || null, phone: document.getElementById('editPhone').value || null,
mobile_phone: document.getElementById('editMobilePhone').value || null, mobile_phone: document.getElementById('editMobilePhone').value || null,
website: document.getElementById('editWebsite').value || null, website: document.getElementById('editWebsite').value || null,
wiki_slug: document.getElementById('editWikiSlug').value || null,
country: document.getElementById('editCountry').value || 'DK', country: document.getElementById('editCountry').value || 'DK',
address: document.getElementById('editAddress').value || null, address: document.getElementById('editAddress').value || null,
postal_code: document.getElementById('editPostalCode').value || null, postal_code: document.getElementById('editPostalCode').value || null,
@ -3500,6 +3587,154 @@ async function saveSubscription() {
} }
} }
async function loadCustomerSessions() {
const container = document.getElementById('customerSessionsContainer');
if (!container) return;
container.innerHTML = `
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
`;
try {
const response = await fetch(`/api/v1/anydesk/sessions?customer_id=${customerId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente sessioner');
}
const data = await response.json();
displayCustomerSessions(data.sessions || []);
} catch (error) {
console.error('Failed to load sessions:', error);
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Kunne ikke hente sessioner</div>';
}
}
function displayCustomerSessions(sessions) {
const container = document.getElementById('customerSessionsContainer');
if (!container) return;
if (!sessions || sessions.length === 0) {
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen remote sessions endnu</div>';
return;
}
container.innerHTML = sessions.map(session => {
const badgeClass = getSessionBadgeClass(session.status);
const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-';
const linkButton = session.session_link
? `<a class="btn btn-sm btn-outline-primary" href="${session.session_link}" target="_blank">Åbn Link</a>`
: '';
const worklogButton = session.status === 'completed'
? `<button class="btn btn-sm btn-outline-secondary" onclick="showCustomerWorklogSuggestion(${session.id})">Vis forslag</button>`
: '';
return `
<div class="col-12">
<div class="session-card">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge ${badgeClass}">${escapeHtml(session.status || 'ukendt')}</span>
<strong>Session #${session.id}</strong>
</div>
<div class="session-meta">Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}</div>
${session.contact_name ? `<div class="session-meta">Kontakt: ${escapeHtml(session.contact_name)}</div>` : ''}
${session.sag_id ? `<div class="session-meta">Sag ID: ${session.sag_id}</div>` : ''}
</div>
<div class="d-flex gap-2">
${linkButton}
${worklogButton}
</div>
</div>
</div>
</div>
`;
}).join('');
}
async function startCustomerSession() {
const alertBox = document.getElementById('customerSessionAlert');
const description = document.getElementById('customerSessionDescription')?.value?.trim() || null;
try {
const response = await fetch('/api/v1/anydesk/start-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
description: description || `Remote support for ${customerData?.name || 'customer'}`
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke starte session');
}
const data = await response.json();
if (alertBox) {
alertBox.classList.remove('d-none');
alertBox.innerHTML = `Session startet. ${data.session_link ? `Link: <a href="${data.session_link}" target="_blank">${data.session_link}</a>` : ''}`;
}
if (data.session_link) {
window.open(data.session_link, '_blank');
}
await loadCustomerSessions();
} catch (error) {
console.error('Failed to start session:', error);
alert('Fejl: ' + error.message);
}
}
async function showCustomerWorklogSuggestion(sessionId) {
try {
const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/worklog-suggestion`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke hente forslag');
}
const suggestion = await response.json();
const body = document.getElementById('customerWorklogSuggestionBody');
if (body) {
body.innerHTML = `
<div class="mb-2"><strong>Varighed:</strong> ${suggestion.duration_hours} timer</div>
<div class="mb-2"><strong>Start:</strong> ${new Date(suggestion.start_time).toLocaleString('da-DK')}</div>
<div class="mb-2"><strong>Slut:</strong> ${new Date(suggestion.end_time).toLocaleString('da-DK')}</div>
<div class="mb-2"><strong>Beskrivelse:</strong> ${escapeHtml(suggestion.description || '')}</div>
<div class="small text-muted">Forslaget kan bruges til manuel tidsregistrering</div>
`;
}
const modal = new bootstrap.Modal(document.getElementById('customerWorklogSuggestionModal'));
modal.show();
} catch (error) {
console.error('Failed to load suggestion:', error);
alert('Fejl: ' + error.message);
}
}
function getSessionBadgeClass(status) {
switch ((status || '').toLowerCase()) {
case 'active':
return 'bg-success';
case 'completed':
return 'bg-primary';
case 'failed':
return 'bg-danger';
case 'cancelled':
return 'bg-secondary';
default:
return 'bg-light text-dark';
}
}
function getInitials(name) { function getInitials(name) {
if (!name) return '?'; if (!name) return '?';
const words = name.trim().split(' '); const words = name.trim().split(' ');

View File

@ -192,3 +192,78 @@ class GroupCreate(BaseModel):
class GroupPermissionsUpdate(BaseModel): class GroupPermissionsUpdate(BaseModel):
permission_ids: List[int] permission_ids: List[int]
# =====================================================
# AnyDesk Remote Support Integration Schemas
# =====================================================
class AnyDeskSessionCreate(BaseModel):
"""Schema for creating a new AnyDesk session"""
customer_id: int
contact_id: Optional[int] = None
sag_id: Optional[int] = None # Case/ticket ID
description: Optional[str] = None
created_by_user_id: Optional[int] = None
class AnyDeskSession(BaseModel):
"""Full AnyDesk session schema"""
id: int
anydesk_session_id: str
customer_id: int
contact_id: Optional[int] = None
sag_id: Optional[int] = None
session_link: Optional[str] = None
status: str # active, completed, failed, cancelled
started_at: str
ended_at: Optional[str] = None
duration_minutes: Optional[int] = None
created_by_user_id: Optional[int] = None
created_at: str
updated_at: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class AnyDeskSessionDetail(AnyDeskSession):
"""AnyDesk session with additional details"""
contact_name: Optional[str] = None
customer_name: Optional[str] = None
sag_title: Optional[str] = None
created_by_user_name: Optional[str] = None
device_info: Optional[dict] = None
metadata: Optional[dict] = None
class AnyDeskWorklogSuggestion(BaseModel):
"""Suggested worklog entry from a completed session"""
session_id: int
duration_hours: float
duration_minutes: int
description: str
start_time: str
end_time: str
billable: bool = True
work_type: str = "remote_support"
class AnyDeskSessionWithWorklog(BaseModel):
"""AnyDesk session with suggested worklog entry"""
session: AnyDeskSession
suggested_worklog: AnyDeskWorklogSuggestion
class AnyDeskSessionHistory(BaseModel):
"""Session history response"""
sessions: List[AnyDeskSessionDetail]
total: int
limit: int
offset: int
class AnyDeskSessionUpdate(BaseModel):
"""Schema for updating a session (mainly status updates)"""
status: str
ended_at: Optional[str] = None
duration_minutes: Optional[int] = None

View File

@ -45,6 +45,32 @@ async def list_hardware(
return result or [] return result or []
@router.get("/hardware/by-customer/{customer_id}", response_model=List[dict])
async def list_hardware_by_customer(customer_id: int):
"""List hardware assets owned by a customer."""
query = """
SELECT * FROM hardware_assets
WHERE deleted_at IS NULL AND current_owner_customer_id = %s
ORDER BY created_at DESC
"""
result = execute_query(query, (customer_id,))
return result or []
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int):
"""List hardware assets linked to a contact via company relations."""
query = """
SELECT h.*
FROM hardware_assets h
JOIN contact_companies cc ON cc.customer_id = h.current_owner_customer_id
WHERE cc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result = execute_query(query, (contact_id,))
return result or []
@router.post("/hardware", response_model=dict) @router.post("/hardware", response_model=dict)
async def create_hardware(data: dict): async def create_hardware(data: dict):
"""Create a new hardware asset.""" """Create a new hardware asset."""
@ -53,9 +79,10 @@ async def create_hardware(data: dict):
INSERT INTO hardware_assets ( INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id, asset_type, brand, model, serial_number, customer_asset_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( params = (
@ -72,6 +99,8 @@ async def create_hardware(data: dict):
data.get("status_reason"), data.get("status_reason"),
data.get("warranty_until"), data.get("warranty_until"),
data.get("end_of_life"), data.get("end_of_life"),
data.get("anydesk_id"),
data.get("anydesk_link"),
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: if not result:
@ -103,6 +132,50 @@ async def create_hardware(data: dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/hardware/quick", response_model=dict)
async def quick_create_hardware(data: dict):
"""Quick create hardware with minimal fields (name + AnyDesk info)."""
try:
name = (data.get("name") or "").strip()
customer_id = data.get("customer_id")
anydesk_id = (data.get("anydesk_id") or "").strip() or None
anydesk_link = (data.get("anydesk_link") or "").strip() or None
if not name:
raise HTTPException(status_code=400, detail="Name is required")
if not customer_id:
raise HTTPException(status_code=400, detail="Customer ID is required")
query = """
INSERT INTO hardware_assets (
asset_type, model, current_owner_type, current_owner_customer_id,
status, anydesk_id, anydesk_link, notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
"andet",
name,
"customer",
customer_id,
"active",
anydesk_id,
anydesk_link,
"Quick created from case/ticket flow",
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create hardware")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to quick-create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/hardware/{hardware_id}", response_model=dict) @router.get("/hardware/{hardware_id}", response_model=dict)
async def get_hardware(hardware_id: int): async def get_hardware(hardware_id: int):
"""Get hardware details by ID.""" """Get hardware details by ID."""
@ -127,7 +200,7 @@ async def update_hardware(hardware_id: int, data: dict):
"asset_type", "brand", "model", "serial_number", "customer_asset_id", "asset_type", "brand", "model", "serial_number", "customer_asset_id",
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life", "status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id" "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link"
] ]
for field in allowed_fields: for field in allowed_fields:

View File

@ -203,6 +203,21 @@
</div> </div>
</div> </div>
<!-- AnyDesk -->
<div class="form-section">
<h3 class="form-section-title">🧷 AnyDesk</h3>
<div class="form-grid">
<div class="form-group">
<label for="anydesk_id">AnyDesk ID</label>
<input type="text" id="anydesk_id" name="anydesk_id" placeholder="123-456-789">
</div>
<div class="form-group">
<label for="anydesk_link">AnyDesk Link</label>
<input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://...">
</div>
</div>
</div>
<!-- Ownership --> <!-- Ownership -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">👥 Ejerskab</h3> <h3 class="form-section-title">👥 Ejerskab</h3>

View File

@ -266,6 +266,29 @@
</div> </div>
</div> </div>
<!-- AnyDesk Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-display"></i> AnyDesk</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">AnyDesk ID</span>
<span class="info-value">{{ hardware.anydesk_id or '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">AnyDesk Link</span>
<span class="info-value">
{% if hardware.anydesk_link %}
<a href="{{ hardware.anydesk_link }}" target="_blank" class="btn btn-sm btn-outline-primary">Connect</a>
{% else %}
-
{% endif %}
</span>
</div>
</div>
</div>
<!-- Tags Card --> <!-- Tags Card -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">

View File

@ -203,6 +203,21 @@
</div> </div>
</div> </div>
<!-- AnyDesk -->
<div class="form-section">
<h3 class="form-section-title">🧷 AnyDesk</h3>
<div class="form-grid">
<div class="form-group">
<label for="anydesk_id">AnyDesk ID</label>
<input type="text" id="anydesk_id" name="anydesk_id" value="{{ hardware.anydesk_id or '' }}" placeholder="123-456-789">
</div>
<div class="form-group">
<label for="anydesk_link">AnyDesk Link</label>
<input type="text" id="anydesk_link" name="anydesk_link" value="{{ hardware.anydesk_link or '' }}" placeholder="anydesk://...">
</div>
</div>
</div>
<!-- Ownership --> <!-- Ownership -->
<div class="form-section"> <div class="form-section">
<h3 class="form-section-title">👥 Ejerskab</h3> <h3 class="form-section-title">👥 Ejerskab</h3>

View File

@ -200,6 +200,44 @@
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">
<div class="col-12">
<div id="hardwareList" class="border rounded-3 p-3 bg-light">
<div class="text-muted small">Vælg en kontakt for at se relateret hardware.</div>
</div>
</div>
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="fw-bold mb-3">Quick opret hardware</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="hardwareNameInput" placeholder="PC, NAS, Server...">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="hardwareAnyDeskIdInput" placeholder="123-456-789">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk Link</label>
<input type="text" class="form-control" id="hardwareAnyDeskLinkInput" placeholder="anydesk://...">
</div>
<div class="col-12 d-flex justify-content-end">
<button type="button" class="btn btn-outline-primary" onclick="quickCreateHardware()">
<i class="bi bi-plus-circle me-2"></i>Opret hardware
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Metadata --> <!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
@ -258,6 +296,7 @@
<script> <script>
let selectedCustomer = null; let selectedCustomer = null;
let selectedContacts = {}; let selectedContacts = {};
let selectedContactsCompanies = {};
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
@ -392,6 +431,8 @@
const response = await fetch(`/api/v1/contacts/${id}`); const response = await fetch(`/api/v1/contacts/${id}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
selectedContactsCompanies[id] = data.companies || [];
if (data.companies && data.companies.length === 1) { if (data.companies && data.companies.length === 1) {
const company = data.companies[0]; const company = data.companies[0];
if (!selectedCustomer) { if (!selectedCustomer) {
@ -408,11 +449,15 @@
} catch (e) { } catch (e) {
console.error("Auto-select company failed", e); console.error("Auto-select company failed", e);
} }
loadHardwareForContacts();
} }
function removeContact(id) { function removeContact(id) {
delete selectedContacts[id]; delete selectedContacts[id];
delete selectedContactsCompanies[id];
renderSelections(); renderSelections();
loadHardwareForContacts();
} }
function renderSelections() { function renderSelections() {
@ -438,6 +483,138 @@
`).join(''); `).join('');
} }
async function loadHardwareForContacts() {
const hardwareList = document.getElementById('hardwareList');
if (!hardwareList) return;
const contactIds = Object.keys(selectedContacts).map(id => parseInt(id));
if (contactIds.length === 0) {
hardwareList.innerHTML = '<div class="text-muted small">Vælg en kontakt for at se relateret hardware.</div>';
return;
}
hardwareList.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter hardware...</div>';
try {
const responses = await Promise.all(
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`))
);
const datasets = await Promise.all(responses.map(r => r.ok ? r.json() : []));
const merged = new Map();
datasets.flat().forEach(item => {
if (!merged.has(item.id)) {
merged.set(item.id, item);
}
});
renderHardwareList(Array.from(merged.values()));
} catch (err) {
console.error('Failed to load hardware:', err);
hardwareList.innerHTML = '<div class="text-danger small">Kunne ikke hente hardware.</div>';
}
}
function renderHardwareList(items) {
const hardwareList = document.getElementById('hardwareList');
if (!hardwareList) return;
if (!items || items.length === 0) {
hardwareList.innerHTML = '<div class="text-muted small">Ingen hardware fundet for valgte kontakt(er).</div>';
return;
}
hardwareList.innerHTML = items.map(item => {
const name = item.model || item.brand || `Hardware #${item.id}`;
const anydeskId = item.anydesk_id || '-';
const anydeskLink = item.anydesk_link || '';
const linkBtn = anydeskLink
? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="openAnyDeskLink('${anydeskLink.replace(/'/g, "\\'")}')">Connect</button>`
: '';
const copyBtn = item.anydesk_id
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyAnyDeskId('${item.anydesk_id.replace(/'/g, "\\'")}')">Kopiér ID</button>`
: '';
return `
<div class="d-flex flex-wrap align-items-center justify-content-between border-bottom py-2">
<div>
<div class="fw-bold">${name}</div>
<div class="text-muted small">AnyDesk ID: ${anydeskId}</div>
</div>
<div class="d-flex gap-2">
${linkBtn}
${copyBtn}
</div>
</div>
`;
}).join('');
}
function openAnyDeskLink(link) {
if (!link) return;
window.open(link, '_blank');
}
async function copyAnyDeskId(anydeskId) {
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
alert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
}
async function quickCreateHardware() {
const name = document.getElementById('hardwareNameInput').value.trim();
const anydeskId = document.getElementById('hardwareAnyDeskIdInput').value.trim();
const anydeskLink = document.getElementById('hardwareAnyDeskLinkInput').value.trim();
if (!name) {
alert('Navn er påkrævet');
return;
}
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) {
alert('Vælg et firma før du opretter hardware');
return;
}
try {
const response = await fetch('/api/v1/hardware/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
customer_id: customerId,
anydesk_id: anydeskId || null,
anydesk_link: anydeskLink || null
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Kunne ikke oprette hardware');
}
document.getElementById('hardwareNameInput').value = '';
document.getElementById('hardwareAnyDeskIdInput').value = '';
document.getElementById('hardwareAnyDeskLinkInput').value = '';
await loadHardwareForContacts();
} catch (err) {
alert('Fejl: ' + err.message);
}
}
function getSingleContactCompanyId() {
const contactIds = Object.keys(selectedContactsCompanies);
if (contactIds.length !== 1) return null;
const companies = selectedContactsCompanies[contactIds[0]] || [];
if (companies.length !== 1) return null;
return companies[0].id;
}
async function quickCreateCustomer(name) { async function quickCreateCustomer(name) {
if (!name || name.trim().length < 2) return; if (!name || name.trim().length < 2) return;
const resultsDiv = document.getElementById('customerResults'); const resultsDiv = document.getElementById('customerResults');

View File

@ -602,7 +602,15 @@
<div class="case-summary-grid"> <div class="case-summary-grid">
<div class="summary-item"> <div class="summary-item">
<div class="summary-label">Kunde</div> <div class="summary-label">Kunde</div>
<div class="summary-value">{{ customer.name if customer else 'Ingen kunde' }}</div> <div class="summary-value">
{% if customer %}
<a class="summary-link" href="/customers/{{ customer.id }}">
{{ customer.name }}
</a>
{% else %}
Ingen kunde
{% endif %}
</div>
</div> </div>
<div class="summary-item"> <div class="summary-item">
<div class="summary-label">Hovedkontakt</div> <div class="summary-label">Hovedkontakt</div>
@ -1050,9 +1058,12 @@
<script> <script>
const caseId = {{ case.id }}; const caseId = {{ case.id }};
const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide";
let contactSearchTimeout; let contactSearchTimeout;
let customerSearchTimeout; let customerSearchTimeout;
let relationSearchTimeout; let relationSearchTimeout;
let wikiSearchTimeout;
let selectedRelationCaseId = null; let selectedRelationCaseId = null;
// Modal instances // Modal instances
@ -1088,6 +1099,17 @@
// Load Hardware & Locations // Load Hardware & Locations
loadCaseHardware(); loadCaseHardware();
loadCaseLocations(); loadCaseLocations();
loadCaseWiki();
const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) {
wikiSearchInput.addEventListener('input', () => {
clearTimeout(wikiSearchTimeout);
wikiSearchTimeout = setTimeout(() => {
loadCaseWiki(wikiSearchInput.value || '');
}, 300);
});
}
// Focus on title when create modal opens // Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal'); const createModalEl = document.getElementById('createRelatedCaseModal');
@ -1699,6 +1721,61 @@
} }
} }
// ============ Wiki Handling ============
async function loadCaseWiki(searchValue = '') {
const container = document.getElementById('wiki-list');
if (!container) return;
if (!wikiCustomerId) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>';
return;
}
container.innerHTML = '<div class="p-3 text-center text-muted small">Henter Wiki...</div>';
const params = new URLSearchParams();
const trimmed = (searchValue || '').trim();
if (trimmed) {
params.set('query', trimmed);
} else {
params.set('tag', wikiDefaultTag);
}
try {
const res = await fetch(`/api/v1/wiki/customers/${wikiCustomerId}/pages?${params.toString()}`);
if (!res.ok) {
throw new Error('Kunne ikke hente Wiki');
}
const payload = await res.json();
if (payload.errors && payload.errors.length) {
container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>';
return;
}
const pages = Array.isArray(payload.pages) ? payload.pages : [];
if (!pages.length) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>';
return;
}
container.innerHTML = pages.map(page => {
const title = page.title || page.path || 'Wiki side';
const url = page.url || page.path || '#';
const safeUrl = url ? encodeURI(url) : '#';
return `
<a class="list-group-item list-group-item-action" href="${safeUrl}" target="_blank" rel="noopener">
<div class="fw-semibold">${escapeHtml(title)}</div>
<small class="text-muted">${escapeHtml(page.path || '')}</small>
</a>
`;
}).join('');
} catch (e) {
console.error('Error loading Wiki:', e);
container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>';
}
}
async function promptLinkLocation() { async function promptLinkLocation() {
const id = prompt("Indtast Lokations ID:"); const id = prompt("Indtast Lokations ID:");
if (!id) return; if (!id) return;
@ -1880,6 +1957,20 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde WIKI</h6>
</div>
<div class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;">
<div class="p-2 border-bottom">
<input type="text" class="form-control form-control-sm" id="wikiSearchInput" placeholder="Soeg i Wiki (tom = guide)" style="font-size: 0.8rem;">
</div>
<div class="list-group list-group-flush" id="wiki-list">
<div class="p-3 text-center text-muted">Henter Wiki...</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6> <h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
@ -4089,8 +4180,8 @@
const viewDefaults = { const viewDefaults = {
'Pipeline': ['relations', 'sales', 'time'], 'Pipeline': ['relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations'], 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'],
'Sag-detalje': ['hardware', 'locations', 'contacts', 'customers', 'relations', 'files', 'emails', 'solution', 'time', 'sales', 'reminders'] 'Sag-detalje': ['hardware', 'locations', 'contacts', 'customers', 'wiki', 'relations', 'files', 'emails', 'solution', 'time', 'sales', 'reminders']
}; };
const standardModules = viewDefaults[viewName] || []; const standardModules = viewDefaults[viewName] || [];

View File

@ -12,7 +12,9 @@ async def search_customers(q: str = Query(..., min_length=2)):
sql = """ sql = """
SELECT id, name, cvr_number as cvr_nummer, email SELECT id, name, cvr_number as cvr_nummer, email
FROM customers FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s) AND deleted_at IS NULL WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL
AND is_active = true
ORDER BY name ASC ORDER BY name ASC
LIMIT 20 LIMIT 20
""" """

View File

View File

View File

@ -0,0 +1,54 @@
"""
Wiki.js Integration API Router (read-only)
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.core.config import settings
from app.core.database import execute_query_single
from app.modules.wiki.backend.service import WikiService
from app.modules.wiki.models.schemas import WikiPageResponse
logger = logging.getLogger(__name__)
router = APIRouter()
service = WikiService()
@router.get("/customers/{customer_id}/pages", response_model=WikiPageResponse)
async def list_customer_wiki_pages(
customer_id: int,
tag: Optional[str] = Query(None),
query: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
):
customer = execute_query_single(
"SELECT id, name, wiki_slug FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
wiki_slug = (customer.get("wiki_slug") or "").strip()
effective_tag = tag
if not effective_tag and not (query or "").strip():
effective_tag = "guide"
if not wiki_slug:
return WikiPageResponse(
pages=[],
path="/en/Kunder",
tag=effective_tag,
query=query,
base_url=settings.WIKI_BASE_URL,
)
payload = await service.list_customer_pages(
slug=wiki_slug,
tag=effective_tag,
query=query,
limit=limit,
)
return WikiPageResponse(**payload)

View File

@ -0,0 +1,183 @@
"""
Wiki.js Integration Service (read-only)
"""
import logging
from typing import Any, Dict, List, Optional
import aiohttp
from app.core.config import settings
logger = logging.getLogger(__name__)
class WikiService:
def __init__(self) -> None:
self.base_url = settings.WIKI_BASE_URL.rstrip("/")
self.api_token = settings.WIKI_API_KEY or settings.WIKI_API_TOKEN
self.timeout = settings.WIKI_TIMEOUT_SECONDS
self.read_only = settings.WIKI_READ_ONLY
if self.read_only:
logger.warning("\ud83d\udd12 Wiki.js READ_ONLY MODE ENABLED")
def _normalize_slug(self, slug: str) -> str:
normalized = (slug or "").strip().strip("/")
prefix = "en/Kunder/"
if normalized.lower().startswith(prefix.lower()):
normalized = normalized[len(prefix):]
return normalized
def build_customer_path(self, slug: str) -> str:
normalized = self._normalize_slug(slug)
base_path = "en/Kunder"
return f"{base_path}/{normalized}" if normalized else base_path
def _headers(self) -> Dict[str, str]:
if not self.api_token:
return {"Content-Type": "application/json"}
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
async def _graphql_request(self, query: str, variables: Optional[dict] = None) -> Dict[str, Any]:
if not self.api_token:
return {"errors": ["missing_token"]}
url = f"{self.base_url}/graphql"
payload = {"query": query, "variables": variables or {}}
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
async with session.post(url, json=payload, headers=self._headers()) as resp:
data = await resp.json()
if resp.status >= 400:
return {"errors": [data], "status": resp.status}
return data
except Exception as exc:
logger.error("\u274c Wiki.js request failed: %s", exc)
return {"errors": [str(exc)]}
async def list_customer_pages(
self,
slug: str,
tag: Optional[str] = "guide",
query: Optional[str] = None,
limit: int = 20,
) -> Dict[str, Any]:
path = self.build_customer_path(slug)
path_prefix = path.lstrip("/")
tag_list = [tag] if tag else None
query_value = query.strip() if query else None
gql_list = """
query WikiCustomerList($tags: [String!], $limit: Int) {
pages {
list(
limit: $limit,
orderBy: TITLE,
orderByDirection: ASC,
tags: $tags
) {
id
title
path
description
updatedAt
}
}
}
"""
gql_search = """
query WikiCustomerSearch($query: String!, $path: String) {
pages {
search(query: $query, path: $path) {
results {
id
title
path
description
}
}
}
}
"""
if query_value:
response = await self._graphql_request(
gql_search,
{
"query": query_value,
},
)
pages_raw = (((response.get("data") or {}).get("pages") or {}).get("search") or {}).get("results") or []
else:
response = await self._graphql_request(
gql_list,
{
"tags": tag_list,
"limit": max(1, min(limit, 100)),
},
)
pages_raw = (((response.get("data") or {}).get("pages") or {}).get("list")) or []
errors = response.get("errors")
if errors:
logger.error("\u274c Wiki.js query failed: %s", errors)
return {
"pages": [],
"path": path,
"tag": tag,
"query": query_value,
"base_url": self.base_url,
"errors": errors,
}
if not pages_raw and query_value:
response = await self._graphql_request(
gql_search,
{
"query": query_value,
"path": f"/{path}" if not path.startswith("/") else path,
},
)
if not response.get("errors"):
pages_raw = (((response.get("data") or {}).get("pages") or {}).get("search") or {}).get("results") or []
if path_prefix:
prefixes = [path_prefix]
if path_prefix.lower().startswith("en/"):
prefixes.append(path_prefix[3:])
prefix_lowers = [value.lower() for value in prefixes]
pages_raw = [
item for item in pages_raw
if any(
(item.get("path") or "").lstrip("/").lower().startswith(prefix)
for prefix in prefix_lowers
)
]
pages: List[Dict[str, Any]] = []
for item in pages_raw:
raw_path = item.get("path") if isinstance(item, dict) else None
if not raw_path:
continue
url = f"{self.base_url}{raw_path if raw_path.startswith('/') else '/' + raw_path}"
pages.append({
"id": item.get("id"),
"title": item.get("title") or raw_path,
"path": raw_path,
"description": item.get("description"),
"updated_at": item.get("updatedAt"),
"url": url,
})
return {
"pages": pages,
"path": f"/{path}",
"tag": tag,
"query": query_value,
"base_url": self.base_url,
}

View File

View File

@ -0,0 +1,24 @@
"""
Wiki.js schemas
"""
from typing import List, Optional
from pydantic import BaseModel
class WikiPage(BaseModel):
id: Optional[int] = None
title: str
path: str
description: Optional[str] = None
updated_at: Optional[str] = None
url: str
class WikiPageResponse(BaseModel):
pages: List[WikiPage]
path: str
tag: Optional[str] = None
query: Optional[str] = None
base_url: str
errors: Optional[list] = None

379
app/routers/anydesk.py Normal file
View File

@ -0,0 +1,379 @@
"""
AnyDesk Remote Support Router
REST API endpoints for managing remote support sessions
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from app.models.schemas import (
AnyDeskSessionCreate,
AnyDeskSession,
AnyDeskSessionDetail,
AnyDeskSessionHistory,
AnyDeskSessionWithWorklog
)
from app.services.anydesk import AnyDeskService
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
anydesk_service = AnyDeskService()
# =====================================================
# Session Management Endpoints
# =====================================================
@router.post("/anydesk/start-session", response_model=AnyDeskSession, tags=["Remote Support"])
async def start_remote_session(session_data: AnyDeskSessionCreate):
"""
Start a new AnyDesk remote support session
- **customer_id**: Required - Customer to provide support to
- **contact_id**: Optional - Specific contact person
- **sag_id**: Optional - Link to case/ticket for time tracking
- **description**: Optional - Purpose of session
- **created_by_user_id**: Optional - User initiating session
Returns session details with access link for sharing with customer
"""
try:
logger.info(f"🔗 Starting AnyDesk session for customer {session_data.customer_id}")
# Verify customer exists
cust_query = "SELECT id FROM customers WHERE id = %s"
customer = execute_query(cust_query, (session_data.customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Verify contact exists if provided
if session_data.contact_id:
contact_query = "SELECT id FROM contacts WHERE id = %s"
contact = execute_query(contact_query, (session_data.contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Verify sag exists if provided
if session_data.sag_id:
sag_query = "SELECT id FROM sag_sager WHERE id = %s"
sag = execute_query(sag_query, (session_data.sag_id,))
if not sag:
raise HTTPException(status_code=404, detail="Case not found")
# Create session via AnyDesk service
result = await anydesk_service.create_session(
customer_id=session_data.customer_id,
contact_id=session_data.contact_id,
sag_id=session_data.sag_id,
description=session_data.description,
created_by_user_id=session_data.created_by_user_id
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error starting session: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/sessions/{session_id}", response_model=AnyDeskSessionDetail, tags=["Remote Support"])
async def get_session_details(session_id: int):
"""
Get details of a specific AnyDesk session
Includes current status, duration, and linked entities (contact, customer, case)
"""
try:
query = """
SELECT
s.id, s.anydesk_session_id, s.customer_id, s.contact_id, s.sag_id,
s.session_link, s.status, s.started_at, s.ended_at, s.duration_minutes,
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
sag.title as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
LEFT JOIN users u ON s.created_by_user_id = u.id
WHERE s.id = %s
"""
result = execute_query(query, (session_id,))
if not result:
raise HTTPException(status_code=404, detail="Session not found")
session = result[0]
return AnyDeskSessionDetail(**session)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching session: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/anydesk/sessions/{session_id}/end", tags=["Remote Support"])
async def end_remote_session(session_id: int):
"""
End a remote support session and calculate duration
Returns completed session with duration in minutes and hours
"""
try:
logger.info(f"🛑 Ending AnyDesk session {session_id}")
result = await anydesk_service.end_session(session_id)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return JSONResponse(content=result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error ending session: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
async def get_session_history(
contact_id: Optional[int] = None,
customer_id: Optional[int] = None,
sag_id: Optional[int] = None,
limit: int = 50,
offset: int = 0
):
"""
Get session history filtered by contact, customer, or case
At least one filter parameter should be provided.
Results are paginated and sorted by date (newest first).
- **contact_id**: Get all sessions for a specific contact
- **customer_id**: Get all sessions for a company
- **sag_id**: Get sessions linked to a specific case
- **limit**: Number of sessions per page (default 50, max 100)
- **offset**: Pagination offset
"""
try:
if not any([contact_id, customer_id, sag_id]):
raise HTTPException(
status_code=400,
detail="At least one filter (contact_id, customer_id, or sag_id) is required"
)
# Validate limit
if limit > 100:
limit = 100
result = await anydesk_service.get_session_history(
contact_id=contact_id,
customer_id=customer_id,
sag_id=sag_id,
limit=limit,
offset=offset
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
# Enrich session data with contact/customer/user names
enriched_sessions = []
for session in result.get("sessions", []):
enriched_sessions.append(AnyDeskSessionDetail(**session))
return AnyDeskSessionHistory(
sessions=enriched_sessions,
total=result["total"],
limit=limit,
offset=offset
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching session history: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# =====================================================
# Worklog Integration Endpoints
# =====================================================
@router.get("/anydesk/sessions/{session_id}/worklog-suggestion", tags=["Remote Support", "Time Tracking"])
async def suggest_worklog_from_session(session_id: int):
"""
Get suggested worklog entry from a completed session
Calculates billable hours from session duration and provides
a pre-filled worklog suggestion for review/approval.
The worklog still needs to be created separately via the
timetracking/worklog endpoints after user approval.
"""
try:
# Get session
query = """
SELECT id, duration_minutes, customer_id, sag_id, contact_id,
started_at, ended_at, status
FROM anydesk_sessions
WHERE id = %s
"""
result = execute_query(query, (session_id,))
if not result:
raise HTTPException(status_code=404, detail="Session not found")
session = result[0]
if session["status"] != "completed":
raise HTTPException(
status_code=400,
detail=f"Cannot suggest worklog for non-completed session (status: {session['status']})"
)
if not session["duration_minutes"]:
raise HTTPException(
status_code=400,
detail="Session duration not calculated yet"
)
# Build worklog suggestion
duration_hours = round(session["duration_minutes"] / 60, 2)
suggestion = {
"session_id": session_id,
"duration_minutes": session["duration_minutes"],
"duration_hours": duration_hours,
"start_time": str(session["started_at"]),
"end_time": str(session["ended_at"]),
"description": f"Remote support session via AnyDesk",
"work_type": "remote_support",
"billable": True,
"linked_to": {
"customer_id": session["customer_id"],
"contact_id": session["contact_id"],
"sag_id": session["sag_id"]
}
}
logger.info(f"Generated worklog suggestion for session {session_id}: {duration_hours}h")
return JSONResponse(content=suggestion)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error generating worklog suggestion: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# =====================================================
# Status & Analytics Endpoints
# =====================================================
@router.get("/anydesk/stats", tags=["Remote Support", "Analytics"])
async def get_anydesk_stats():
"""
Get AnyDesk integration statistics
- Total sessions today/this week/this month
- Active sessions count
- Average session duration
- Most supported customers
"""
try:
stats = {
"sessions_today": 0,
"sessions_this_week": 0,
"sessions_this_month": 0,
"active_sessions": 0,
"average_duration_minutes": 0,
"total_support_hours": 0
}
# Get today's sessions
query = """
SELECT COUNT(*) as count FROM anydesk_sessions
WHERE DATE(started_at) = CURRENT_DATE
"""
result = execute_query(query)
stats["sessions_today"] = result[0]["count"] if result else 0
# Get this week's sessions
query = """
SELECT COUNT(*) as count FROM anydesk_sessions
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
"""
result = execute_query(query)
stats["sessions_this_week"] = result[0]["count"] if result else 0
# Get this month's sessions
query = """
SELECT COUNT(*) as count FROM anydesk_sessions
WHERE started_at >= DATE_TRUNC('month', CURRENT_DATE)
"""
result = execute_query(query)
stats["sessions_this_month"] = result[0]["count"] if result else 0
# Get active sessions
query = """
SELECT COUNT(*) as count FROM anydesk_sessions
WHERE status = 'active'
"""
result = execute_query(query)
stats["active_sessions"] = result[0]["count"] if result else 0
# Get average duration
query = """
SELECT AVG(duration_minutes) as avg_duration FROM anydesk_sessions
WHERE status = 'completed' AND duration_minutes IS NOT NULL
"""
result = execute_query(query)
stats["average_duration_minutes"] = round(result[0]["avg_duration"], 1) if result and result[0]["avg_duration"] else 0
# Get total support hours this month
query = """
SELECT SUM(duration_minutes) as total_minutes FROM anydesk_sessions
WHERE status = 'completed'
AND started_at >= DATE_TRUNC('month', CURRENT_DATE)
"""
result = execute_query(query)
total_minutes = result[0]["total_minutes"] if result and result[0]["total_minutes"] else 0
stats["total_support_hours"] = round(total_minutes / 60, 2)
return JSONResponse(content=stats)
except Exception as e:
logger.error(f"Error calculating stats: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/health", tags=["Remote Support", "Health"])
async def anydesk_health_check():
"""
Health check for AnyDesk integration
Returns configuration status, API connectivity, and last sync time
"""
return JSONResponse(content={
"service": "AnyDesk Remote Support",
"status": "operational",
"configured": bool(anydesk_service.api_token and anydesk_service.license_id),
"dry_run_mode": anydesk_service.dry_run,
"read_only_mode": anydesk_service.read_only,
"auto_start_enabled": anydesk_service.auto_start
})

424
app/services/anydesk.py Normal file
View File

@ -0,0 +1,424 @@
"""
AnyDesk Remote Support Service
Handles integration with AnyDesk API for remote session management
"""
import logging
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import httpx
from app.core.config import settings
from app.core.database import execute_query
logger = logging.getLogger(__name__)
class AnyDeskService:
"""
AnyDesk API Integration Service
Handles remote session creation, monitoring, and closure.
Respects safety switches: READ_ONLY and DRY_RUN
"""
BASE_URL = "https://api.anydesk.com"
def __init__(self):
self.api_token = settings.ANYDESK_API_TOKEN
self.license_id = settings.ANYDESK_LICENSE_ID
self.read_only = settings.ANYDESK_READ_ONLY
self.dry_run = settings.ANYDESK_DRY_RUN
self.timeout = settings.ANYDESK_TIMEOUT_SECONDS
self.auto_start = settings.ANYDESK_AUTO_START_SESSION
if not self.api_token or not self.license_id:
logger.warning("⚠️ AnyDesk credentials not configured - service disabled")
def _check_enabled(self) -> bool:
"""Check if AnyDesk is properly configured"""
if not self.api_token or not self.license_id:
logger.warning("AnyDesk service not configured (missing credentials)")
return False
return True
async def _api_call(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
"""
Make HTTP call to AnyDesk API
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (e.g., "/v1/sessions")
data: Request body data
Returns:
Response JSON dictionary
"""
if not self._check_enabled():
return {"error": "AnyDesk not configured"}
# Log the intent
log_msg = f"🔗 AnyDesk API: {method} {endpoint}"
if data:
log_msg += f" | Data: {json.dumps(data, indent=2)}"
logger.info(log_msg)
# DRY RUN: Don't actually call API
if self.dry_run:
logger.warning("⚠️ DRY_RUN=true: Simulating API response (no actual call)")
return self._simulate_response(method, endpoint, data)
# READ ONLY: Allow gets but not mutations
if self.read_only and method != "GET":
logger.warning(f"🔒 READ_ONLY=true: Blocking {method} request")
return {"error": "Read-only mode: mutations disabled"}
try:
headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
}
url = f"{self.BASE_URL}{endpoint}"
async with httpx.AsyncClient(timeout=self.timeout) as client:
if method == "GET":
response = await client.get(url, headers=headers)
elif method == "POST":
response = await client.post(url, headers=headers, json=data)
elif method == "PUT":
response = await client.put(url, headers=headers, json=data)
elif method == "DELETE":
response = await client.delete(url, headers=headers)
else:
return {"error": f"Unsupported method: {method}"}
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"❌ AnyDesk API error: {str(e)}")
return {"error": str(e)}
except Exception as e:
logger.error(f"❌ Unexpected error calling AnyDesk API: {str(e)}")
return {"error": str(e)}
def _simulate_response(self, method: str, endpoint: str, data: Optional[Dict]) -> Dict[str, Any]:
"""Generate simulated AnyDesk API response for DRY_RUN mode"""
import uuid
if "/sessions" in endpoint and method == "POST":
# Simulate session creation
session_id = f"session_{uuid.uuid4().hex[:12]}"
return {
"id": session_id,
"status": "active",
"access_code": f"AD-{uuid.uuid4().hex[:8].upper()}",
"link": f"https://anydesk.com/?phone={uuid.uuid4().hex[:8]}",
"created_at": datetime.utcnow().isoformat(),
"expires_at": (datetime.utcnow() + timedelta(hours=24)).isoformat(),
"simulator": True
}
elif "/sessions" in endpoint and method == "GET":
# Simulate session retrieval
return {
"id": "session_abc123",
"status": "active",
"device_name": "Customer PC",
"duration_seconds": 300,
"simulator": True
}
elif "/sessions" in endpoint and method == "DELETE":
# Simulate session termination
return {"status": "terminated", "simulator": True}
return {"status": "ok", "simulator": True}
async def create_session(
self,
customer_id: int,
contact_id: Optional[int] = None,
sag_id: Optional[int] = None,
description: Optional[str] = None,
created_by_user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Create a new unattended remote session
Args:
customer_id: BMC Hub customer ID
contact_id: Optional contact ID
sag_id: Optional case ID
description: Session description/purpose
created_by_user_id: User creating the session
Returns:
Session data with session_id, link, access_code, etc.
"""
# Prepare session data
session_data = {
"name": f"BMC Support - Customer {customer_id}",
"description": description or f"Support session for customer {customer_id}",
"license_id": self.license_id,
"auto_accept": True # Auto-accept connection requests
}
# Call AnyDesk API
result = await self._api_call("POST", "/v1/sessions", session_data)
if "error" in result:
logger.error(f"Failed to create AnyDesk session: {result['error']}")
return result
# Store session in database
session_id = result.get("id")
session_link = result.get("link") or result.get("access_code")
try:
query = """
INSERT INTO anydesk_sessions
(anydesk_session_id, customer_id, contact_id, sag_id, session_link,
status, created_by_user_id, device_info, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id,
session_link, status, started_at, ended_at, duration_minutes,
created_by_user_id, created_at, updated_at
"""
device_info = {
"created_via": "api",
"auto_start": self.auto_start,
"dry_run_mode": self.dry_run
}
metadata = {
"api_response": {
"expires_at": result.get("expires_at"),
"access_code": result.get("access_code")
}
}
db_result = execute_query(
query,
(session_id, customer_id, contact_id, sag_id, session_link,
"active", created_by_user_id, json.dumps(device_info), json.dumps(metadata))
)
if db_result:
logger.info(f"✅ Created AnyDesk session {session_id} in database")
return {
**db_result[0],
"api_response": result
}
else:
logger.error("Failed to store session in database")
return {"error": "Database storage failed"}
except Exception as e:
logger.error(f"Error storing session: {str(e)}")
return {"error": str(e)}
async def get_session(self, anydesk_session_id: str) -> Dict[str, Any]:
"""
Get session details from AnyDesk API
Args:
anydesk_session_id: AnyDesk session ID
Returns:
Session status and details
"""
result = await self._api_call("GET", f"/v1/sessions/{anydesk_session_id}")
return result
async def check_session_status(self, db_session_id: int) -> Dict[str, Any]:
"""
Check current status of a session in database
Args:
db_session_id: Database session ID
Returns:
Current session status, running time, etc.
"""
try:
query = """
SELECT id, anydesk_session_id, status, started_at, ended_at, duration_minutes
FROM anydesk_sessions
WHERE id = %s
"""
result = execute_query(query, (db_session_id,))
if result:
session = result[0]
# If session still active, try to get live status from AnyDesk
if session["status"] == "active":
api_result = await self.get_session(session["anydesk_session_id"])
if "error" not in api_result:
return {
"db_id": session["id"],
"status": "active",
"started_at": str(session["started_at"]),
"api_status": api_result
}
return {
"db_id": session["id"],
"status": session["status"],
"started_at": str(session["started_at"]),
"ended_at": str(session["ended_at"]) if session["ended_at"] else None,
"duration_minutes": session["duration_minutes"]
}
else:
return {"error": "Session not found"}
except Exception as e:
logger.error(f"Error checking session status: {str(e)}")
return {"error": str(e)}
async def end_session(self, db_session_id: int) -> Dict[str, Any]:
"""
End a remote session (stop AnyDesk connection and mark as completed)
Args:
db_session_id: Database session ID
Returns:
Confirmation with duration and suggested worklog
"""
try:
# Get session from DB
query = """
SELECT id, anydesk_session_id, started_at
FROM anydesk_sessions
WHERE id = %s AND status = 'active'
"""
result = execute_query(query, (db_session_id,))
if not result:
return {"error": "Session not found or already ended"}
session = result[0]
anydesk_session_id = session["anydesk_session_id"]
started_at = session["started_at"]
# Call AnyDesk API to terminate session
api_result = await self._api_call("DELETE", f"/v1/sessions/{anydesk_session_id}")
# Calculate duration
now = datetime.utcnow()
started = started_at.replace(tzinfo=None) if isinstance(started_at, datetime) else started_at
duration_seconds = int((now - started).total_seconds())
duration_minutes = round(duration_seconds / 60, 1)
# Update database
update_query = """
UPDATE anydesk_sessions
SET status = 'completed', ended_at = %s, duration_minutes = %s, updated_at = %s
WHERE id = %s
RETURNING id, anydesk_session_id, duration_minutes
"""
update_result = execute_query(
update_query,
(datetime.utcnow(), duration_minutes, datetime.utcnow(), db_session_id)
)
logger.info(f"✅ Ended AnyDesk session {anydesk_session_id} (Duration: {duration_minutes} min)")
return {
"id": db_session_id,
"status": "completed",
"duration_minutes": duration_minutes,
"duration_hours": round(duration_minutes / 60, 2),
"ended_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error ending session: {str(e)}")
return {"error": str(e)}
async def get_session_history(
self,
contact_id: Optional[int] = None,
customer_id: Optional[int] = None,
sag_id: Optional[int] = None,
limit: int = 50,
offset: int = 0
) -> Dict[str, Any]:
"""
Get session history for a contact/customer/case
Args:
contact_id: Filter by contact
customer_id: Filter by customer
sag_id: Filter by case
limit: Number of sessions to return
offset: Pagination offset
Returns:
List of sessions with details
"""
try:
# Build dynamic query based on filters
where_clauses = []
params = []
if contact_id:
where_clauses.append("contact_id = %s")
params.append(contact_id)
if customer_id:
where_clauses.append("customer_id = %s")
params.append(customer_id)
if sag_id:
where_clauses.append("sag_id = %s")
params.append(sag_id)
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT
s.id, s.anydesk_session_id, s.contact_id, s.customer_id, s.sag_id,
s.session_link, s.status, s.started_at, s.ended_at, s.duration_minutes,
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
sag.title as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
LEFT JOIN users u ON s.created_by_user_id = u.user_id
WHERE {where_clause}
ORDER BY s.started_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
result = execute_query(query, tuple(params))
# Count total
count_query = f"""
SELECT COUNT(*) as total
FROM anydesk_sessions
WHERE {where_clause}
"""
count_result = execute_query(count_query, tuple(params[:-2]))
total = count_result[0]["total"] if count_result else 0
return {
"sessions": result or [],
"total": total,
"limit": limit,
"offset": offset
}
except Exception as e:
logger.error(f"Error fetching session history: {str(e)}")
return {"error": str(e), "sessions": []}

View File

@ -488,6 +488,134 @@ class VTigerService:
logger.error(f"❌ Error updating subscription: {e}") logger.error(f"❌ Error updating subscription: {e}")
raise raise
async def get_service_contracts(self, account_id: Optional[str] = None) -> List[Dict]:
"""
Fetch service contracts from vTiger
Args:
account_id: Optional - filter by account ID (e.g., "3x760")
Returns:
List of service contract records with account_id included
"""
try:
if account_id:
query = (
"SELECT * FROM ServiceContracts "
f"WHERE sc_related_to='{account_id}' AND contract_status='Active';"
)
logger.info(f"🔍 Fetching active service contracts for account {account_id}")
else:
query = "SELECT * FROM ServiceContracts WHERE contract_status='Active';"
logger.info(f"🔍 Fetching all active service contracts")
contracts = await self.query(query)
logger.info(f"✅ Found {len(contracts)} service contracts")
# Normalize fields used by the wizard
for contract in contracts:
if 'account_id' not in contract:
contract['account_id'] = (
contract.get('accountid')
or contract.get('cf_service_contracts_account')
or contract.get('sc_related_to')
or ""
)
if 'contract_number' not in contract:
contract['contract_number'] = contract.get('contract_no', '')
if not contract['account_id']:
logger.warning(
f"⚠️ Contract {contract.get('id')} has no account_id, filling null"
)
return contracts
except Exception as e:
logger.error(f"❌ Error fetching service contracts: {e}")
return []
async def get_service_contract_cases(self, contract_id: str) -> List[Dict]:
"""
Fetch cases linked to a service contract
Args:
contract_id: vTiger service contract ID (e.g., "75x123")
Returns:
List of case records linked to contract
"""
try:
# Query cases linked via service contract reference
query = (
"SELECT * FROM Cases WHERE "
f"servicecontract_id='{contract_id}' OR "
f"parent_id='{contract_id}';"
)
logger.info(f"🔍 Fetching cases for service contract {contract_id}")
cases = await self.query(query)
logger.info(f"✅ Found {len(cases)} cases for contract")
return cases
except Exception as e:
logger.error(f"❌ Error fetching contract cases: {e}")
return []
async def get_service_contract_timelogs(self, contract_id: str) -> List[Dict]:
"""
Fetch time logs linked to a service contract
Args:
contract_id: vTiger service contract ID (e.g., "75x123")
Returns:
List of timelog records linked to contract
"""
try:
# Query timelogs linked via service contract reference
query = (
"SELECT * FROM Timelog WHERE "
f"relatedto='{contract_id}';"
)
logger.info(f"🔍 Fetching timelogs for service contract {contract_id}")
timelogs = await self.query(query)
# Fallback: timelogs may be linked to cases instead of the contract
if not timelogs:
cases = await self.get_service_contract_cases(contract_id)
case_ids = [case.get('id') for case in cases if case.get('id')]
if case_ids:
logger.info(
f" No direct timelogs found; querying by {len(case_ids)} case IDs"
)
timelog_map = {}
chunk_size = 20
for index in range(0, len(case_ids), chunk_size):
chunk = case_ids[index:index + chunk_size]
ids = "', '".join(chunk)
chunk_query = (
"SELECT * FROM Timelog WHERE "
f"relatedto IN ('{ids}');"
)
chunk_rows = await self.query(chunk_query)
for row in chunk_rows:
if row.get('id'):
timelog_map[row['id']] = row
timelogs = list(timelog_map.values())
logger.info(f"✅ Found {len(timelogs)} timelogs for contract")
return timelogs
except Exception as e:
logger.error(f"❌ Error fetching contract timelogs: {e}")
return []
# Singleton instance # Singleton instance
_vtiger_service = None _vtiger_service = None

View File

@ -237,6 +237,7 @@
</a> </a>
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li> <li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li> <li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li> <li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -281,6 +282,7 @@
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul> </ul>

View File

@ -481,6 +481,34 @@
<span id="selectedCustomerName"></span> <span id="selectedCustomerName"></span>
</div> </div>
<div class="card mt-3 border-0 shadow-sm">
<div class="card-body">
<h6 class="fw-bold mb-3"><i class="bi bi-display me-2"></i>Hardware (AnyDesk)</h6>
<div id="ticketHardwareList" class="border rounded-3 p-3 bg-light">
<div class="text-muted small">Vælg en kunde eller kontakt for at se relateret hardware.</div>
</div>
<div class="row g-3 mt-3">
<div class="col-md-4">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="ticketHardwareNameInput" placeholder="PC, NAS, Server...">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="ticketHardwareAnyDeskIdInput" placeholder="123-456-789">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk Link</label>
<input type="text" class="form-control" id="ticketHardwareAnyDeskLinkInput" placeholder="anydesk://...">
</div>
<div class="col-12 d-flex justify-content-end">
<button type="button" class="btn btn-outline-primary" onclick="quickCreateTicketHardware()">
<i class="bi bi-plus-circle me-2"></i>Opret hardware
</button>
</div>
</div>
</div>
</div>
<div class="alert alert-light mt-3"> <div class="alert alert-light mt-3">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
<small class="text-muted">Søg efter firmanavn, kontaktperson, CVR-nummer eller email</small> <small class="text-muted">Søg efter firmanavn, kontaktperson, CVR-nummer eller email</small>
@ -882,6 +910,126 @@ function selectCustomer(customer, contact) {
document.getElementById('selectedCustomerName').innerHTML = displayText; document.getElementById('selectedCustomerName').innerHTML = displayText;
document.getElementById('selectedCustomer').style.display = 'block'; document.getElementById('selectedCustomer').style.display = 'block';
document.getElementById('customerResults').classList.remove('show'); document.getElementById('customerResults').classList.remove('show');
loadHardwareForTicket();
}
async function loadHardwareForTicket() {
const list = document.getElementById('ticketHardwareList');
if (!list) return;
const customerId = document.getElementById('customerId').value;
const contactId = document.getElementById('contactId').value;
if (!customerId && !contactId) {
list.innerHTML = '<div class="text-muted small">Vælg en kunde eller kontakt for at se relateret hardware.</div>';
return;
}
list.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter hardware...</div>';
try {
const endpoint = contactId
? `/api/v1/hardware/by-contact/${contactId}`
: `/api/v1/hardware/by-customer/${customerId}`;
const response = await fetch(endpoint);
const items = response.ok ? await response.json() : [];
renderTicketHardware(items || []);
} catch (err) {
console.error('Failed to load hardware:', err);
list.innerHTML = '<div class="text-danger small">Kunne ikke hente hardware.</div>';
}
}
function renderTicketHardware(items) {
const list = document.getElementById('ticketHardwareList');
if (!list) return;
if (!items || items.length === 0) {
list.innerHTML = '<div class="text-muted small">Ingen hardware fundet for valgt kunde/kontakt.</div>';
return;
}
list.innerHTML = items.map(item => {
const name = item.model || item.brand || `Hardware #${item.id}`;
const anydeskId = item.anydesk_id || '-';
const linkBtn = item.anydesk_link
? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="openTicketAnyDeskLink('${item.anydesk_link.replace(/'/g, "\\'")}')">Connect</button>`
: '';
const copyBtn = item.anydesk_id
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyTicketAnyDeskId('${item.anydesk_id.replace(/'/g, "\\'")}')">Kopiér ID</button>`
: '';
return `
<div class="d-flex flex-wrap align-items-center justify-content-between border-bottom py-2">
<div>
<div class="fw-bold">${name}</div>
<div class="text-muted small">AnyDesk ID: ${anydeskId}</div>
</div>
<div class="d-flex gap-2">
${linkBtn}
${copyBtn}
</div>
</div>
`;
}).join('');
}
function openTicketAnyDeskLink(link) {
if (!link) return;
window.open(link, '_blank');
}
async function copyTicketAnyDeskId(anydeskId) {
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
alert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
}
async function quickCreateTicketHardware() {
const name = document.getElementById('ticketHardwareNameInput').value.trim();
const anydeskId = document.getElementById('ticketHardwareAnyDeskIdInput').value.trim();
const anydeskLink = document.getElementById('ticketHardwareAnyDeskLinkInput').value.trim();
const customerId = document.getElementById('customerId').value;
if (!name) {
alert('Navn er påkrævet');
return;
}
if (!customerId) {
alert('Vælg en kunde før du opretter hardware');
return;
}
try {
const response = await fetch('/api/v1/hardware/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
customer_id: parseInt(customerId),
anydesk_id: anydeskId || null,
anydesk_link: anydeskLink || null
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Kunne ikke oprette hardware');
}
document.getElementById('ticketHardwareNameInput').value = '';
document.getElementById('ticketHardwareAnyDeskIdInput').value = '';
document.getElementById('ticketHardwareAnyDeskLinkInput').value = '';
await loadHardwareForTicket();
} catch (err) {
alert('Fejl: ' + err.message);
}
} }
// Tag Input // Tag Input

View File

@ -23,11 +23,6 @@ async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302) return RedirectResponse(url="/sag", status_code=302)
@router.get("/{path:path}", include_in_schema=False)
async def ticket_catchall_redirect(path: str):
return RedirectResponse(url="/sag", status_code=302)
def _format_long_text(value: Optional[str]) -> str: def _format_long_text(value: Optional[str]) -> str:
if not value: if not value:
return "" return ""
@ -716,3 +711,9 @@ async def ticket_detail_page(request: Request, ticket_id: int):
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to load ticket detail: {e}") logger.error(f"❌ Failed to load ticket detail: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# Catch-all: other ticket routes redirect to /sag
@router.get("/{path:path}", include_in_schema=False)
async def ticket_catchall_redirect(path: str):
return RedirectResponse(url="/sag", status_code=302)

View File

@ -442,3 +442,86 @@ class TModuleUninstallResult(BaseModel):
views_dropped: List[str] = Field(default_factory=list) views_dropped: List[str] = Field(default_factory=list)
functions_dropped: List[str] = Field(default_factory=list) functions_dropped: List[str] = Field(default_factory=list)
rows_deleted: int = 0 rows_deleted: int = 0
# ============================================================================
# SERVICE CONTRACT MIGRATION WIZARD MODELS
# ============================================================================
class ServiceContractBase(BaseModel):
"""Base service contract model"""
id: str = Field(..., description="vTiger service contract ID")
contract_number: str
subject: str
account_id: str = Field(..., description="vTiger account ID")
class ServiceContractItem(BaseModel):
"""Single item (case or timelog) to process in wizard"""
type: str = Field(..., description="'case' or 'timelog'")
id: str = Field(..., description="vTiger ID")
title: str = Field(..., description="Display title")
description: Optional[str] = None
hours: Optional[Decimal] = None # For timelogs only
work_date: Optional[str] = None # For timelogs only
priority: Optional[str] = None # For cases only
status: Optional[str] = None
raw_data: dict = Field(default_factory=dict)
class ServiceContractWizardData(BaseModel):
"""Complete contract data for wizard"""
contract_id: str
contract_number: str
subject: str
account_id: str
account_name: Optional[str] = None
customer_id: Optional[int] = None
cases: List[dict] = Field(default_factory=list)
timelogs: List[dict] = Field(default_factory=list)
available_cards: List[dict] = Field(default_factory=list)
total_items: int
class ServiceContractWizardAction(BaseModel):
"""Action result from a wizard step"""
type: str = Field(..., description="'archive' or 'transfer'")
item_id: str = Field(..., description="vTiger item ID")
title: str
success: bool
message: str
dry_run: bool
result_id: Optional[int] = None # ID of archived/transferred item in Hub
timestamp: datetime = Field(default_factory=datetime.now)
class ServiceContractWizardSummary(BaseModel):
"""Summary of wizard execution"""
contract_id: str
contract_number: str
subject: str
dry_run: bool
total_items_processed: int
cases_archived: int
timelogs_transferred: int
failed_items: int
status: str = Field(..., description="'completed' or 'completed_with_errors'")
timestamp: datetime
class TimologTransferRequest(BaseModel):
"""Request to transfer single timelog to klippekort"""
timelog_id: str = Field(..., description="vTiger timelog ID")
card_id: int = Field(..., description="Hub klippekort card ID")
customer_id: int = Field(..., description="Hub customer ID")
contract_id: str = Field(..., description="Service contract ID for reference")
dry_run: bool = Field(default=False)
class TimologTransferResult(BaseModel):
"""Result of timelog transfer"""
success: bool
message: str
transaction_id: Optional[int] = None
new_card_balance: Optional[Decimal] = None
dry_run: bool

View File

@ -11,7 +11,7 @@ from typing import Optional, List, Dict, Any
from datetime import datetime, date from datetime import datetime, date
from calendar import monthrange from calendar import monthrange
from fastapi import APIRouter, HTTPException, Depends, Body from fastapi import APIRouter, HTTPException, Depends, Body, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.core.database import execute_query, execute_update, execute_query_single from app.core.database import execute_query, execute_update, execute_query_single
@ -29,7 +29,12 @@ from app.timetracking.backend.models import (
TModuleMetadata, TModuleMetadata,
TModuleUninstallRequest, TModuleUninstallRequest,
TModuleUninstallResult, TModuleUninstallResult,
TModuleBulkRateUpdate TModuleBulkRateUpdate,
ServiceContractWizardData,
ServiceContractWizardAction,
ServiceContractWizardSummary,
TimologTransferRequest,
TimologTransferResult,
) )
from app.timetracking.backend.vtiger_sync import vtiger_service from app.timetracking.backend.vtiger_sync import vtiger_service
from app.timetracking.backend.wizard import wizard from app.timetracking.backend.wizard import wizard
@ -37,6 +42,9 @@ from app.timetracking.backend.order_service import order_service
from app.timetracking.backend.economic_export import economic_service from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
from app.services.customer_consistency import CustomerConsistencyService from app.services.customer_consistency import CustomerConsistencyService
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
from app.services.vtiger_service import get_vtiger_service
from app.ticket.backend.klippekort_service import KlippekortService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1992,3 +2000,209 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
except Exception as e: except Exception as e:
logger.error(f"❌ Error creating internal time entry: {e}") logger.error(f"❌ Error creating internal time entry: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# SERVICE CONTRACT MIGRATION WIZARD ENDPOINTS
# ============================================================================
@router.get("/service-contracts", response_model=List[Dict[str, Any]], tags=["Service Contracts"])
async def list_service_contracts():
"""
Fetch active service contracts from vTiger for wizard dropdown
Returns:
List of contracts: id, contract_number, subject, account_id, account_name
"""
try:
contracts = await ServiceContractWizardService.get_active_contracts()
logger.info(f"✅ Retrieved {len(contracts)} service contracts")
return contracts
except Exception as e:
logger.error(f"❌ Error fetching service contracts: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/service-contracts/wizard/load", response_model=ServiceContractWizardData, tags=["Service Contracts"])
async def load_contract_wizard_data(
request_data: Dict[str, Any] = Body(...),
dry_run: bool = Query(False)
):
"""
Load contract data for wizard: cases + timelogs + available klippekort
Args:
contract_id: vTiger service contract ID (from body) - required
account_id: vTiger account ID (from body) - optional, will be looked up if empty
dry_run: Preview mode (no changes) (from query param)
Returns:
Contract data with items to process
"""
try:
contract_id = request_data.get('contract_id')
account_id = request_data.get('account_id', '')
if not contract_id:
logger.error("❌ contract_id is required in request body")
raise HTTPException(status_code=400, detail="contract_id is required")
logger.info(f"📥 Loading contract data: {contract_id} (dry_run={dry_run})")
contract_data = await ServiceContractWizardService.load_contract_detailed_data(
contract_id, account_id
)
if not contract_data:
raise HTTPException(status_code=404, detail="Service contract not found or no cases/timelogs")
return ServiceContractWizardData(**contract_data)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading contract data: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/service-contracts/wizard/archive-case", response_model=ServiceContractWizardAction, tags=["Service Contracts"])
async def archive_case_in_wizard(
request_data: Dict[str, Any] = Body(...),
dry_run: bool = Query(False)
):
"""
Archive a single case to tticket_archived_tickets
Args:
case_id: vTiger case ID (from body)
case_data: Complete case data from vTiger (from body)
contract_id: Service contract ID (for reference) (from body)
dry_run: Preview mode (from query param)
Returns:
Action result with success/failure
"""
try:
case_id = request_data.get('case_id')
case_data = request_data.get('case_data', {})
contract_id = request_data.get('contract_id', '')
if not case_id or not case_data:
raise HTTPException(status_code=400, detail="case_id and case_data are required")
logger.info(f"🔄 Archiving case {case_id} (dry_run={dry_run})")
success, message, archived_id = ServiceContractWizardService.archive_case(
case_data, contract_id, dry_run=dry_run
)
action = ServiceContractWizardAction(
type='archive',
item_id=case_id,
title=case_data.get('title', case_data.get('subject', 'Untitled')),
success=success,
message=message,
dry_run=dry_run,
result_id=archived_id
)
return action
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error archiving case: {e}")
case_id = request_data.get('case_id', 'unknown')
action = ServiceContractWizardAction(
type='archive',
item_id=case_id,
title="Unknown",
success=False,
message=f"Error: {str(e)}",
dry_run=dry_run,
result_id=None
)
return action
@router.post("/service-contracts/wizard/transfer-timelog", response_model=ServiceContractWizardAction, tags=["Service Contracts"])
async def transfer_timelog_in_wizard(
request: TimologTransferRequest
):
"""
Transfer timelog hours to customer's klippekort
Args:
request: TimologTransferRequest with timelog, card, customer IDs + dry_run flag
Returns:
Action result with success/failure
"""
try:
logger.info(f"🔄 Transferring timelog {request.timelog_id} to card {request.card_id} (dry_run={request.dry_run})")
# Fetch complete timelog data from vTiger
vtiger_svc = get_vtiger_service()
timelog = await vtiger_svc.query(f"SELECT * FROM Timelog WHERE id='{request.timelog_id}';")
if not timelog:
raise HTTPException(status_code=404, detail=f"Timelog {request.timelog_id} not found")
timelog_data = timelog[0]
success, message, transaction = ServiceContractWizardService.transfer_timelog_to_klippekort(
timelog_data,
request.card_id,
request.customer_id,
request.contract_id,
dry_run=request.dry_run
)
action = ServiceContractWizardAction(
type='transfer',
item_id=request.timelog_id,
title=f"{timelog_data.get('hours', 0)}h - {timelog_data.get('description', '')}",
success=success,
message=message,
dry_run=request.dry_run,
result_id=transaction.get('id') if transaction else None
)
return action
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error transferring timelog: {e}")
action = ServiceContractWizardAction(
type='transfer',
item_id=request.timelog_id,
title="Unknown",
success=False,
message=f"Error: {str(e)}",
dry_run=request.dry_run,
result_id=None
)
return action
@router.get("/service-contracts/wizard/customer-cards/{customer_id}", response_model=List[Dict[str, Any]], tags=["Service Contracts"])
async def get_customer_klippekort_cards(customer_id: int):
"""
Get active klippekort cards for a customer (for wizard dropdown)
Args:
customer_id: Hub customer ID
Returns:
List of active prepaid cards
"""
try:
cards = KlippekortService.get_active_cards_for_customer(customer_id)
logger.info(f"✅ Retrieved {len(cards)} active cards for customer {customer_id}")
return cards
except Exception as e:
logger.error(f"❌ Error fetching customer cards: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,418 @@
"""
Service Contract Migration Wizard
==================================
Wizard for migrating vTiger service contracts to Hub archive system.
Workflow:
1. User selects service contract from vTiger
2. Load contract's cases + timelogs
3. For each case: archive to tticket_archived_tickets
4. For each timelog: transfer hours to customer's klippekort (top-up)
5. Wizard displays progress with dry-run support
Dry-Run Mode:
- All operations logged but not committed
- Database transactions rolled back
- UI shows what would happen
"""
import logging
import json
import re
from datetime import datetime
from decimal import Decimal
from typing import List, Dict, Optional, Any, Tuple
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.config import settings
from app.services.vtiger_service import get_vtiger_service
from app.ticket.backend.klippekort_service import KlippekortService
logger = logging.getLogger(__name__)
class ServiceContractWizardService:
"""Service for vTiger service contract migration wizard"""
@staticmethod
async def get_active_contracts() -> List[Dict[str, Any]]:
"""
Fetch list of active service contracts from vTiger
Returns:
List of contracts with id, contract_number, subject, account_id, etc.
"""
try:
vtiger_service = get_vtiger_service()
contracts = await vtiger_service.get_service_contracts()
logger.info(f"✅ Fetched {len(contracts)} active service contracts from vTiger")
return contracts
except Exception as e:
logger.error(f"❌ Error fetching service contracts: {e}")
return []
@staticmethod
async def load_contract_detailed_data(
contract_id: str,
account_id: str = ""
) -> Dict[str, Any]:
"""
Load all data for a service contract: cases + timelogs
Args:
contract_id: vTiger service contract ID (e.g., "75x123") - required
account_id: vTiger account ID (e.g., "3x760") - optional, will try to extract from contract
Returns:
Dict with contract info + cases + timelogs
"""
try:
vtiger_service = get_vtiger_service()
# Fetch contract details (without account filter if account_id not provided)
contracts = await vtiger_service.get_service_contracts(account_id if account_id else None)
contract = next((c for c in contracts if c.get('id') == contract_id), None)
if not contract:
logger.error(f"❌ Service contract {contract_id} not found")
return {}
# Extract account_id from contract if not provided
if not account_id:
account_id = (
contract.get('account_id')
or contract.get('accountid')
or contract.get('cf_service_contracts_account')
or contract.get('sc_related_to')
or ""
)
if not account_id:
logger.error(f"❌ Could not determine account_id for contract {contract_id}")
return {}
logger.info(f" Extracted account_id={account_id} from contract")
# Fetch cases linked to contract
cases = await vtiger_service.get_service_contract_cases(contract_id)
logger.info(f"✅ Found {len(cases)} cases for contract {contract_id}")
# Fetch timelogs linked to contract
timelogs = await vtiger_service.get_service_contract_timelogs(contract_id)
logger.info(f"✅ Found {len(timelogs)} timelogs for contract {contract_id}")
# Get account info for klippekort lookup
account = await vtiger_service.get_account(account_id)
# Map vTiger account to Hub customer
customer_id = ServiceContractWizardService._lookup_customer_id(account_id)
if not customer_id:
logger.warning(f"⚠️ No Hub customer found for vTiger account {account_id}")
# Get available klippekort for customer
available_cards = KlippekortService.get_active_cards_for_customer(customer_id) if customer_id else []
return {
'contract_id': contract_id,
'contract_number': contract.get('contract_number') or contract.get('contract_no', ''),
'subject': contract.get('subject', ''),
'account_id': account_id,
'account_name': account.get('accountname', '') if account else '',
'customer_id': customer_id,
'cases': cases,
'timelogs': timelogs,
'available_cards': available_cards,
'total_items': len(cases) + len(timelogs),
}
except Exception as e:
logger.error(f"❌ Error loading contract data: {e}")
return {}
@staticmethod
def _lookup_customer_id(vtiger_account_id: str) -> Optional[int]:
"""
Map vTiger account ID to Hub customer ID via tmodule_customers table
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
Hub customer ID or None
"""
try:
result = execute_query_single(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s LIMIT 1",
(vtiger_account_id,)
)
return result['id'] if result else None
except Exception as e:
logger.error(f"❌ Error looking up customer ID: {e}")
return None
@staticmethod
def archive_case(
case_data: Dict[str, Any],
contract_id: str,
dry_run: bool = False
) -> Tuple[bool, str, Optional[int]]:
"""
Archive a single case to tticket_archived_tickets
Args:
case_data: Case dict from vTiger (id, title, description, etc.)
contract_id: Service contract ID (for reference)
dry_run: If True, log only without commit
Returns:
(success: bool, message: str, archived_id: int or None)
"""
try:
case_id = case_data.get('id')
title = case_data.get('title', case_data.get('subject', 'Untitled'))
description = case_data.get('description', '')
status = case_data.get('ticketstatus', case_data.get('status', 'Open'))
priority = case_data.get('priority', 'Normal')
if dry_run:
logger.info(f"🔍 DRY RUN: Would archive case {case_id}: '{title}'")
return (True, f"[DRY RUN] Would archive: {title}", None)
# Archive to tticket_archived_tickets
archived_id = execute_insert(
"""
INSERT INTO tticket_archived_tickets (
source_system, external_id, ticket_number, title,
description, status, priority, source_created_at, raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
'vtiger_service_contract',
case_id,
f"SC:{contract_id}", # Reference to service contract
title,
description,
status,
priority,
datetime.now(),
json.dumps(case_data)
)
)
logger.info(f"✅ Archived case {case_id} to tticket_archived_tickets (ID: {archived_id})")
return (True, f"Archived: {title}", archived_id)
except Exception as e:
logger.error(f"❌ Error archiving case: {e}")
return (False, f"Error: {str(e)}", None)
@staticmethod
def _extract_timelog_hours(timelog_data: Dict[str, Any]) -> Tuple[Decimal, Decimal]:
"""
Extract and normalize timelog hours from vTiger payload.
Returns:
(normalized_hours, raw_hours)
"""
raw_value = None
field_used = None
for key in ("time_spent", "duration", "total_hours", "hours"):
value = timelog_data.get(key)
if value not in (None, ""):
raw_value = value
field_used = key
break
# vTiger's 'duration' field is in seconds, convert to minutes
if field_used == "duration" and raw_value:
try:
seconds = Decimal(str(raw_value))
raw_minutes = seconds / Decimal(60)
except Exception:
raw_minutes = ServiceContractWizardService._parse_timelog_minutes(raw_value)
else:
raw_minutes = ServiceContractWizardService._parse_timelog_minutes(raw_value)
normalized = raw_minutes / Decimal(60)
return normalized, raw_minutes
@staticmethod
def _parse_timelog_minutes(raw_value: Any) -> Decimal:
"""Parse vTiger timelog time spent into minutes."""
if raw_value in (None, ""):
return Decimal(0)
if isinstance(raw_value, Decimal):
return raw_value
if isinstance(raw_value, (int, float)):
return Decimal(str(raw_value))
raw_str = str(raw_value).strip().lower()
if not raw_str:
return Decimal(0)
time_match = re.match(r"^(\d+):(\d+)(?::(\d+))?$", raw_str)
if time_match:
hours = int(time_match.group(1))
minutes = int(time_match.group(2))
seconds = int(time_match.group(3) or 0)
return Decimal(hours * 60 + minutes) + (Decimal(seconds) / Decimal(60))
total_minutes = Decimal(0)
has_unit = False
unit_patterns = [
(r"(\d+(?:[\.,]\d+)?)\s*(?:h|hour|hours)\b", Decimal(60)),
(r"(\d+(?:[\.,]\d+)?)\s*(?:m|min|minute|minutes)\b", Decimal(1)),
(r"(\d+(?:[\.,]\d+)?)\s*(?:s|sec|second|seconds)\b", Decimal(1) / Decimal(60)),
]
for pattern, multiplier in unit_patterns:
match = re.search(pattern, raw_str)
if match:
has_unit = True
value = Decimal(match.group(1).replace(",", "."))
total_minutes += value * multiplier
if has_unit:
return total_minutes
try:
return Decimal(raw_str.replace(",", "."))
except Exception:
return Decimal(0)
@staticmethod
def _apply_rounding_for_card(card_id: int, hours: Decimal) -> Decimal:
"""
Apply rounding rules for a prepaid card or fallback to timetracking settings.
"""
from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_HALF_UP
card = execute_query_single(
"SELECT rounding_minutes FROM tticket_prepaid_cards WHERE id = %s",
(card_id,)
)
rounding_minutes = int(card.get('rounding_minutes') or 0) if card else 0
if rounding_minutes > 0:
interval = Decimal(rounding_minutes) / Decimal(60)
return (hours / interval).to_integral_value(rounding=ROUND_CEILING) * interval
if settings.TIMETRACKING_AUTO_ROUND:
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
method = settings.TIMETRACKING_ROUND_METHOD
if method == "down":
return (hours / increment).to_integral_value(rounding=ROUND_DOWN) * increment
if method == "nearest":
return (hours / increment).to_integral_value(rounding=ROUND_HALF_UP) * increment
return (hours / increment).to_integral_value(rounding=ROUND_CEILING) * increment
return hours
@staticmethod
def transfer_timelog_to_klippekort(
timelog_data: Dict[str, Any],
card_id: int,
customer_id: int,
contract_id: str,
dry_run: bool = False
) -> Tuple[bool, str, Optional[Dict]]:
"""
Transfer timelog hours to customer's klippekort via top-up
Args:
timelog_data: Timelog dict from vTiger (id, hours, description, etc.)
card_id: Klippekort card ID to transfer to
customer_id: Hub customer ID
contract_id: Service contract ID (for reference)
dry_run: If True, calculate but don't commit
Returns:
(success: bool, message: str, transaction_result: dict or None)
"""
try:
timelog_id = timelog_data.get('id')
hours, raw_minutes = ServiceContractWizardService._extract_timelog_hours(timelog_data)
description = timelog_data.get('description', '')
work_date = timelog_data.get('workdate', '')
if raw_minutes > 0:
logger.info(
f" Normalized timelog {timelog_id} minutes {raw_minutes} to hours {hours}"
)
if hours <= 0:
logger.warning(f"⚠️ Skipping timelog {timelog_id} with {hours} hours")
return (False, f"Skipped: 0 hours", None)
# Verify card exists and belongs to customer
card = KlippekortService.get_card(card_id)
if not card or card['customer_id'] != customer_id:
msg = f"Card {card_id} not found or doesn't belong to customer {customer_id}"
logger.error(f"{msg}")
return (False, msg, None)
rounded_hours = ServiceContractWizardService._apply_rounding_for_card(card_id, hours)
if dry_run:
logger.info(
f"🔍 DRY RUN: Would transfer {rounded_hours}h to card {card_id} from timelog {timelog_id}"
)
return (True, f"[DRY RUN] Would transfer {rounded_hours}h to card", None)
# Top-up klippekort with timelog hours
transaction = KlippekortService.top_up_card(
card_id,
rounded_hours,
user_id=None,
note=f"SC migration: {description} (vTiger {timelog_id}) from {work_date}"
)
logger.info(f"✅ Transferred {rounded_hours}h from timelog {timelog_id} to card {card_id}")
return (True, f"Transferred {rounded_hours}h to card {card.get('card_number', '')}", transaction)
except Exception as e:
logger.error(f"❌ Error transferring timelog: {e}")
return (False, f"Error: {str(e)}", None)
@staticmethod
def get_wizard_summary(
contract_data: Dict[str, Any],
actions: List[Dict[str, Any]],
dry_run: bool = False
) -> Dict[str, Any]:
"""
Generate summary report of wizard actions
Args:
contract_data: Contract info (contract_id, subject, etc.)
actions: List of action dicts from wizard steps
dry_run: Whether wizard ran in dry-run mode
Returns:
Summary dict with counts and status
"""
archived_count = sum(1 for a in actions if a.get('type') == 'archive' and a.get('success'))
transferred_count = sum(1 for a in actions if a.get('type') == 'transfer' and a.get('success'))
failed_count = sum(1 for a in actions if not a.get('success'))
return {
'contract_id': contract_data.get('contract_id', ''),
'contract_number': contract_data.get('contract_number', ''),
'subject': contract_data.get('subject', ''),
'dry_run': dry_run,
'total_items_processed': len(actions),
'cases_archived': archived_count,
'timelogs_transferred': transferred_count,
'failed_items': failed_count,
'status': 'completed_with_errors' if failed_count > 0 else 'completed',
'timestamp': datetime.now().isoformat(),
}

File diff suppressed because it is too large Load Diff

View File

@ -50,3 +50,9 @@ async def timetracking_customers(request: Request):
async def timetracking_orders(request: Request): async def timetracking_orders(request: Request):
"""Order oversigt""" """Order oversigt"""
return templates.TemplateResponse("timetracking/frontend/orders.html", {"request": request}) return templates.TemplateResponse("timetracking/frontend/orders.html", {"request": request})
@router.get("/timetracking/service-contract-wizard", response_class=HTMLResponse, name="service_contract_wizard")
async def service_contract_wizard(request: Request):
"""Service Contract Migration Wizard"""
return templates.TemplateResponse("timetracking/frontend/service_contract_wizard.html", {"request": request})

42
apply_migration_115.py Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Apply AnyDesk migration"""
import psycopg2
import os
import sys
try:
db_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@localhost:5433/bmc_hub')
conn = psycopg2.connect(db_url)
cursor = conn.cursor()
with open('migrations/115_anydesk_sessions.sql', 'r') as f:
sql = f.read()
# Split by semicolons to execute statements separately
statements = [s.strip() for s in sql.split(';') if s.strip()]
for statement in statements:
print(f"Executing: {statement[:80]}...")
cursor.execute(statement)
conn.commit()
print("✅ Migration 115_anydesk_sessions.sql applied successfully!")
# Verify tables created
cursor.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_name LIKE 'anydesk%'
""")
tables = cursor.fetchall()
print(f"✅ Created tables: {[t[0] for t in tables]}")
cursor.close()
conn.close()
except Exception as e:
print(f"❌ Error: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -42,6 +42,7 @@ services:
- ./migrations:/app/migrations:ro - ./migrations:/app/migrations:ro
# Mount for local development - live code reload # Mount for local development - live code reload
- ./app:/app/app:ro - ./app:/app/app:ro
- ./templates:/app/templates:ro
- ./main.py:/app/main.py:ro - ./main.py:/app/main.py:ro
- ./VERSION:/app/VERSION:ro - ./VERSION:/app/VERSION:ro
env_file: env_file:

View File

@ -70,6 +70,7 @@ from app.auth.backend import views as auth_views
from app.auth.backend import admin as auth_admin_api from app.auth.backend import admin as auth_admin_api
from app.devportal.backend import router as devportal_api from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views from app.devportal.backend import views as devportal_views
from app.routers import anydesk
# Modules # Modules
from app.modules.webshop.backend import router as webshop_api from app.modules.webshop.backend import router as webshop_api
@ -83,6 +84,7 @@ from app.modules.locations.backend import router as locations_api
from app.modules.locations.frontend import views as locations_views from app.modules.locations.frontend import views as locations_views
from app.modules.nextcloud.backend import router as nextcloud_api from app.modules.nextcloud.backend import router as nextcloud_api
from app.modules.search.backend import router as search_api from app.modules.search.backend import router as search_api
from app.modules.wiki.backend import router as wiki_api
from app.fixed_price.backend import router as fixed_price_api from app.fixed_price.backend import router as fixed_price_api
# Configure logging # Configure logging
@ -256,6 +258,7 @@ app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversati
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"]) app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"]) app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"]) app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
app.include_router(anydesk.router, prefix="/api/v1", tags=["Remote Support"])
# Module Routers # Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"]) app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
@ -265,6 +268,7 @@ app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"]) app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"]) app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"]) app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
# Frontend Routers # Frontend Routers

View File

@ -0,0 +1,12 @@
-- Migration 114: Add wiki_slug to customers
-- Created: 2026-02-09
BEGIN;
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS wiki_slug VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_customers_wiki_slug
ON customers(wiki_slug);
COMMIT;

View File

@ -0,0 +1,67 @@
-- AnyDesk Remote Session Management
-- Tracks remote support sessions started via AnyDesk integration
-- Sessions can be linked to contacts, companies, or cases for activity history and time tracking
CREATE TABLE IF NOT EXISTS anydesk_sessions (
id SERIAL PRIMARY KEY,
anydesk_session_id VARCHAR(255) UNIQUE NOT NULL, -- External AnyDesk session ID
-- Relationship fields (at least one should be set)
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
-- Session details
session_link TEXT, -- Generated AnyDesk link (unique access code)
device_info JSONB DEFAULT '{}', -- Device info: os, device_type, etc.
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
-- Session lifecycle
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP WITH TIME ZONE,
duration_minutes INTEGER, -- Calculated from started_at to ended_at
-- Session status: draft, pending, active, completed, failed, cancelled
status VARCHAR(50) NOT NULL DEFAULT 'draft',
error_message TEXT,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Audit trail
metadata JSONB DEFAULT '{}' -- Flexible storage for additional info
);
-- Create indexes for common queries
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_contact_id ON anydesk_sessions(contact_id);
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_customer_id ON anydesk_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_sag_id ON anydesk_sessions(sag_id);
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_created_by_user_id ON anydesk_sessions(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_status ON anydesk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_started_at ON anydesk_sessions(started_at DESC);
-- View: Session history with related data (for timeline/activity log)
CREATE OR REPLACE VIEW anydesk_session_timeline AS
SELECT
s.id,
s.anydesk_session_id,
s.contact_id,
c.first_name || ' ' || c.last_name as contact_name,
s.customer_id,
cust.name as customer_name,
s.sag_id,
sag.title as sag_title,
s.session_link,
s.started_at,
s.ended_at,
s.duration_minutes,
s.status,
u.full_name as started_by_user,
s.created_at
FROM anydesk_sessions s
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
LEFT JOIN users u ON s.created_by_user_id = u.user_id
ORDER BY s.started_at DESC;

View File

@ -0,0 +1,8 @@
-- Add AnyDesk fields to hardware_assets
-- Enables storing AnyDesk connection info per device
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS anydesk_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS anydesk_link TEXT;
CREATE INDEX IF NOT EXISTS idx_hardware_anydesk_id ON hardware_assets(anydesk_id);

View File

@ -8,6 +8,7 @@ python-multipart==0.0.17
python-dateutil==2.8.2 python-dateutil==2.8.2
jinja2==3.1.4 jinja2==3.1.4
aiohttp==3.10.10 aiohttp==3.10.10
httpx==0.27.2
aiosmtplib==3.0.2 aiosmtplib==3.0.2
PyJWT==2.10.1 PyJWT==2.10.1
pyotp==2.9.0 pyotp==2.9.0

63
run_migration.py Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Simple migration runner - applies anydesk_sessions schema
"""
import psycopg2
import sys
sql_statements = [
"""
CREATE TABLE IF NOT EXISTS anydesk_sessions (
id SERIAL PRIMARY KEY,
anydesk_session_id VARCHAR(255) UNIQUE NOT NULL,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
session_link TEXT,
device_info JSONB DEFAULT '{}',
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP WITH TIME ZONE,
duration_minutes INTEGER,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
metadata JSONB DEFAULT '{}'
)
""",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_contact_id ON anydesk_sessions(contact_id)",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_customer_id ON anydesk_sessions(customer_id)",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_sag_id ON anydesk_sessions(sag_id)",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_created_by_user_id ON anydesk_sessions(created_by_user_id)",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_status ON anydesk_sessions(status)",
"CREATE INDEX IF NOT EXISTS idx_anydesk_sessions_started_at ON anydesk_sessions(started_at DESC)",
]
try:
# Connect to database
conn = psycopg2.connect("postgresql://bmc_hub:bmc_hub@localhost:5433/bmc_hub")
cursor = conn.cursor()
print("Applying AnyDesk migration...")
for i, statement in enumerate(sql_statements, 1):
preview = statement.replace('\n', ' ')[:80]
print(f" [{i}/{len(sql_statements)}] {preview}...")
cursor.execute(statement)
conn.commit()
print("✅ All statements executed successfully")
# Verify
cursor.execute("SELECT COUNT(*) as count FROM information_schema.tables WHERE table_name = 'anydesk_sessions'")
result = cursor.fetchone()
if result[0] > 0:
print("✅ anydesk_sessions table verified")
cursor.close()
conn.close()
except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Set customers.wiki_slug based on a provided folder list.
Usage:
python scripts/set_customer_wiki_slugs.py # dry run
python scripts/set_customer_wiki_slugs.py --apply # write changes
"""
import os
import sys
import argparse
from typing import Dict, List, Tuple
import psycopg2
from psycopg2.extras import RealDictCursor
FOLDERS = [
"Alcare",
"Arbodania",
"Bellevue",
"DreamHack",
"Glarmester-Svensson",
"GODT_Media",
"Grønnegaards",
"HarbourHouse",
"hedegaardsvej88",
"highwire",
"ImpactTV",
"Kjæden",
"K-pro",
"Laudpeople",
"Maskinsikkerhed",
"Nordisk Film TV A/S",
"Norva24",
"PFA diverse info",
"PFA-The-Union",
"Portalen",
"SamNetworks",
"skuespillerforeningen",
"Snowman",
"Stena",
"Sydkysten",
"TMNconsult",
"TrinityHr",
"Zantay",
"NEMB",
]
SUFFIX_TOKENS = {
"a/s",
"as",
"aps",
"ab",
"ltd",
"gmbh",
"inc",
"llc",
}
def normalize_name(value: str) -> str:
if not value:
return ""
text = value.casefold()
text = (
text.replace("&", " and ")
.replace("æ", "ae")
.replace("ø", "oe")
.replace("å", "aa")
)
for ch in "/_-.,":
text = text.replace(ch, " ")
tokens = [token for token in text.split() if token]
while tokens and tokens[-1] in SUFFIX_TOKENS:
tokens.pop()
return "".join(ch for ch in "".join(tokens) if ch.isalnum())
def fetch_customers(conn) -> List[Dict[str, str]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"SELECT id, name, wiki_slug FROM customers WHERE deleted_at IS NULL"
)
return cur.fetchall()
def build_lookup(customers: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]:
lookup: Dict[str, List[Dict[str, str]]] = {}
for cust in customers:
key = normalize_name(cust.get("name", ""))
if not key:
continue
lookup.setdefault(key, []).append(cust)
return lookup
def plan_updates(
lookup: Dict[str, List[Dict[str, str]]],
customers: List[Dict[str, str]],
) -> Tuple[List[Tuple[int, str, str]], List[str], List[str], Dict[str, List[str]]]:
updates: List[Tuple[int, str, str]] = []
missing: List[str] = []
ambiguous: List[str] = []
suggestions: Dict[str, List[str]] = {}
for folder in FOLDERS:
key = normalize_name(folder)
matches = lookup.get(key, [])
if not matches:
missing.append(folder)
folder_key = normalize_name(folder)
if folder_key:
ranked = []
for cust in customers:
cand_key = normalize_name(cust.get("name", ""))
if not cand_key:
continue
score = 1.0 - (
abs(len(folder_key) - len(cand_key))
/ max(len(folder_key), len(cand_key), 1)
)
overlap = (
sum(1 for ch in folder_key if ch in cand_key)
/ max(len(folder_key), 1)
)
ranked.append((score + overlap, cust.get("name", "")))
ranked.sort(reverse=True)
suggestions[folder] = [name for _, name in ranked[:3] if name]
continue
if len(matches) > 1:
ambiguous.append(folder)
continue
cust = matches[0]
cust_id = cust.get("id")
current_slug = cust.get("wiki_slug") or ""
if current_slug.strip() != folder:
updates.append((cust_id, cust.get("name", ""), folder))
return updates, missing, ambiguous, suggestions
def rank_candidates(
folder: str,
customers: List[Dict[str, str]],
limit: int = 5,
) -> List[Dict[str, str]]:
folder_key = normalize_name(folder)
if not folder_key:
return []
ranked = []
for cust in customers:
cand_key = normalize_name(cust.get("name", ""))
if not cand_key:
continue
score = 1.0 - (
abs(len(folder_key) - len(cand_key)) / max(len(folder_key), len(cand_key), 1)
)
overlap = sum(1 for ch in folder_key if ch in cand_key) / max(len(folder_key), 1)
ranked.append((score + overlap, cust))
ranked.sort(key=lambda item: item[0], reverse=True)
return [cust for _, cust in ranked[:limit]]
def prompt_for_missing(
missing: List[str],
customers: List[Dict[str, str]],
) -> List[Tuple[int, str, str]]:
selections: List[Tuple[int, str, str]] = []
if not missing:
return selections
print("\nInteractive matching for missing folders:")
for folder in missing:
print(f"\nFolder: {folder}")
candidates = rank_candidates(folder, customers)
if not candidates:
print(" No candidates.")
continue
print(" Choose a customer (0 to skip):")
for idx, cust in enumerate(candidates, start=1):
print(f" {idx}) {cust.get('id')}: {cust.get('name')}")
while True:
choice = input(" Selection: ").strip()
if choice == "" or choice == "0":
break
if not choice.isdigit():
print(" Please enter a number from the list.")
continue
index = int(choice)
if not 1 <= index <= len(candidates):
print(" Please enter a number from the list.")
continue
cust = candidates[index - 1]
current_slug = cust.get("wiki_slug") or ""
if current_slug.strip() == folder:
print(" Already set. Skipping.")
break
selections.append((cust.get("id"), cust.get("name", ""), folder))
break
return selections
def apply_updates(conn, updates: List[Tuple[int, str, str]]) -> int:
if not updates:
return 0
with conn.cursor() as cur:
for cust_id, _, slug in updates:
cur.execute(
"UPDATE customers SET wiki_slug = %s WHERE id = %s",
(slug, cust_id),
)
conn.commit()
return len(updates)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="Write updates to database")
parser.add_argument(
"--interactive",
action="store_true",
help="Prompt to match missing folders",
)
args = parser.parse_args()
db_url = os.environ.get("DATABASE_URL")
if not db_url:
print("DATABASE_URL is not set", file=sys.stderr)
return 1
conn = psycopg2.connect(db_url)
try:
customers = fetch_customers(conn)
lookup = build_lookup(customers)
updates, missing, ambiguous, suggestions = plan_updates(lookup, customers)
if args.interactive and missing:
updates.extend(prompt_for_missing(missing, customers))
print("Planned updates:")
for cust_id, name, slug in updates:
print(f" - {cust_id}: {name} -> {slug}")
if missing:
print("\nNo match for:")
for folder in missing:
hint = suggestions.get(folder, [])
if hint:
print(f" - {folder} (suggestions: {', '.join(hint)})")
else:
print(f" - {folder}")
if ambiguous:
print("\nMultiple matches for:")
for folder in ambiguous:
print(f" - {folder}")
if args.apply:
count = apply_updates(conn, updates)
print(f"\nApplied updates: {count}")
else:
print("\nDry run only. Use --apply to write changes.")
finally:
conn.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
Test Service Contract Wizard - Basic Verification
===================================================
Tests:
1. Frontend route is accessible
2. API endpoints have correct signatures
3. Wizard service functions can be called
4. Dry-run mode works without database writes
"""
import asyncio
import sys
from decimal import Decimal
# Test imports
print("🔍 Testing imports...")
try:
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
from app.timetracking.backend.models import (
ServiceContractWizardData,
ServiceContractWizardAction,
TimologTransferRequest,
)
print("✅ Service imports OK")
except ImportError as e:
print(f"❌ Import error: {e}")
sys.exit(1)
# Test Vtiger service
print("🔍 Testing Vtiger service extensions...")
try:
from app.services.vtiger_service import get_vtiger_service
vtiger_svc = get_vtiger_service()
# Check methods exist
assert hasattr(vtiger_svc, 'get_service_contracts'), "Missing get_service_contracts"
assert hasattr(vtiger_svc, 'get_service_contract_cases'), "Missing get_service_contract_cases"
assert hasattr(vtiger_svc, 'get_service_contract_timelogs'), "Missing get_service_contract_timelogs"
print("✅ Vtiger service methods OK")
except Exception as e:
print(f"❌ Vtiger service error: {e}")
sys.exit(1)
# Test Klippekort service
print("🔍 Testing Klippekort service...")
try:
from app.ticket.backend.klippekort_service import KlippekortService
# Check method exists
assert hasattr(KlippekortService, 'get_active_cards_for_customer'), "Missing get_active_cards_for_customer"
print("✅ Klippekort service OK")
except Exception as e:
print(f"❌ Klippekort service error: {e}")
sys.exit(1)
# Test Pydantic models
print("🔍 Testing Pydantic models...")
try:
# Try creating a model instance
action = ServiceContractWizardAction(
type='archive',
item_id='test123',
title='Test Case',
success=True,
message='Test message',
dry_run=True
)
assert action.type == 'archive'
assert action.dry_run == True
print("✅ Pydantic models OK")
except Exception as e:
print(f"❌ Model validation error: {e}")
sys.exit(1)
# Test dry-run case archiving (no database writes)
print("🔍 Testing dry-run case archiving...")
try:
case_data = {
'id': 'test_case_123',
'title': 'Test Case Title',
'description': 'Test Description',
'status': 'Open',
'priority': 'High'
}
success, message, archived_id = ServiceContractWizardService.archive_case(
case_data,
'test_contract_123',
dry_run=True
)
assert success == True, "Dry-run archiving failed"
assert '[DRY RUN]' in message, "Message should indicate dry-run"
assert archived_id is None, "Dry-run should not return an ID"
print(f"✅ Dry-run archiving OK: {message}")
except Exception as e:
print(f"❌ Dry-run archiving error: {e}")
sys.exit(1)
# Test wizard summary generation
print("🔍 Testing wizard summary...")
try:
contract_data = {
'contract_id': 'test_contract',
'contract_number': 'SC-001',
'subject': 'Test Contract'
}
actions = [
{'type': 'archive', 'success': True},
{'type': 'transfer', 'success': True},
{'type': 'archive', 'success': False},
]
summary = ServiceContractWizardService.get_wizard_summary(
contract_data,
actions,
dry_run=True
)
assert summary['cases_archived'] == 1
assert summary['timelogs_transferred'] == 1
assert summary['failed_items'] == 1
assert summary['dry_run'] == True
print(f"✅ Summary generation OK: {summary['status']}")
except Exception as e:
print(f"❌ Summary generation error: {e}")
sys.exit(1)
# Test frontend route exists
print("🔍 Testing frontend route...")
try:
from app.timetracking.frontend.views import router as frontend_router
# Check route exists
routes = [r.path for r in frontend_router.routes]
assert any('service-contract-wizard' in r for r in routes), "Frontend route not found"
print("✅ Frontend route OK")
except Exception as e:
print(f"❌ Frontend route error: {e}")
sys.exit(1)
print("\n" + "="*60)
print("✅ ALL TESTS PASSED - Service Contract Wizard is ready!")
print("="*60)
print("\nNext steps:")
print("1. Start the Hub API server: docker-compose up -d api")
print("2. Access the wizard: http://localhost:8000/timetracking/service-contract-wizard")
print("3. Test with dry-run mode first (checkbox enabled)")
print("4. Then run with live mode to commit changes")