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:
parent
693ac4cfd6
commit
3d7fb1aa48
201
SERVICE_CONTRACT_WIZARD_README.md
Normal file
201
SERVICE_CONTRACT_WIZARD_README.md
Normal 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)
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(' ');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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] || [];
|
||||||
|
|||||||
@ -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
|
||||||
"""
|
"""
|
||||||
|
|||||||
0
app/modules/wiki/__init__.py
Normal file
0
app/modules/wiki/__init__.py
Normal file
0
app/modules/wiki/backend/__init__.py
Normal file
0
app/modules/wiki/backend/__init__.py
Normal file
54
app/modules/wiki/backend/router.py
Normal file
54
app/modules/wiki/backend/router.py
Normal 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)
|
||||||
183
app/modules/wiki/backend/service.py
Normal file
183
app/modules/wiki/backend/service.py
Normal 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,
|
||||||
|
}
|
||||||
0
app/modules/wiki/models/__init__.py
Normal file
0
app/modules/wiki/models/__init__.py
Normal file
24
app/modules/wiki/models/schemas.py
Normal file
24
app/modules/wiki/models/schemas.py
Normal 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
379
app/routers/anydesk.py
Normal 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
424
app/services/anydesk.py
Normal 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": []}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
418
app/timetracking/backend/service_contract_wizard.py
Normal file
418
app/timetracking/backend/service_contract_wizard.py
Normal 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(),
|
||||||
|
}
|
||||||
1435
app/timetracking/frontend/service_contract_wizard.html
Normal file
1435
app/timetracking/frontend/service_contract_wizard.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
42
apply_migration_115.py
Normal 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)
|
||||||
@ -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:
|
||||||
|
|||||||
4
main.py
4
main.py
@ -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
|
||||||
|
|||||||
12
migrations/114_add_customer_wiki_slug.sql
Normal file
12
migrations/114_add_customer_wiki_slug.sql
Normal 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;
|
||||||
67
migrations/115_anydesk_sessions.sql
Normal file
67
migrations/115_anydesk_sessions.sql
Normal 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;
|
||||||
8
migrations/116_add_anydesk_hardware_assets.sql
Normal file
8
migrations/116_add_anydesk_hardware_assets.sql
Normal 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);
|
||||||
@ -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
63
run_migration.py
Normal 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)
|
||||||
272
scripts/set_customer_wiki_slugs.py
Normal file
272
scripts/set_customer_wiki_slugs.py
Normal 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())
|
||||||
153
test_service_contract_wizard.py
Normal file
153
test_service_contract_wizard.py
Normal 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")
|
||||||
Loading…
Reference in New Issue
Block a user