feat: Enhance hardware detail view with ESET data synchronization and specifications

- Added a button to sync ESET data in the hardware detail view.
- Introduced a new tab for ESET specifications, displaying relevant device information.
- Included ESET UUID and group details in the hardware information section.
- Implemented a JavaScript function to handle ESET data synchronization via API.
- Updated the ESET import template to improve device listing and inline contact selection.
- Enhanced the Nextcloud and locations routers to support customer ID resolution from contacts.
- Added utility functions for retrieving customer IDs linked to contacts.
- Removed debug information from the service contract wizard for cleaner output.
This commit is contained in:
Christian 2026-02-11 23:51:21 +01:00
parent 297a8ef2d6
commit 489f81a1e3
15 changed files with 2141 additions and 74 deletions

View File

@ -6,6 +6,15 @@ Handles contact CRUD operations with multi-company support
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List from typing import Optional, List
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
save_subscription_comment,
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
)
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -358,3 +367,88 @@ async def unlink_contact_from_company(contact_id: int, customer_id: int):
except Exception as e: except Exception as e:
logger.error(f"Failed to unlink contact from company: {e}") logger.error(f"Failed to unlink contact from company: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts", response_model=dict)
async def get_related_contacts(contact_id: int):
"""
Get contacts from the same companies as the contact (excluding itself).
"""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return {"contacts": []}
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
c.created_at, c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id IN ({placeholders}) AND c.id <> %s
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
"""
params = tuple(customer_ids + [contact_id])
results = execute_query(query, params) or []
return {"contacts": results}
except Exception as e:
logger.error(f"Failed to get related contacts for {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/subscriptions")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
return {
"status": "no_linked_customer",
"message": "Kontakt er ikke tilknyttet et firma",
"recurring_orders": [],
"sales_orders": [],
"subscriptions": [],
"expired_subscriptions": [],
"bmc_office_subscriptions": [],
}
return await get_customer_subscriptions(customer_id)
@router.post("/contacts/{contact_id}/subscriptions/lock")
async def lock_contact_subscriptions(contact_id: int, lock_request: dict):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await lock_customer_subscriptions(customer_id, lock_request)
@router.post("/contacts/{contact_id}/subscription-comment")
async def save_contact_subscription_comment(contact_id: int, data: SubscriptionComment):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await save_subscription_comment(customer_id, data)
@router.get("/contacts/{contact_id}/subscription-comment")
async def get_contact_subscription_comment(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_comment(customer_id)
@router.get("/contacts/{contact_id}/subscriptions/billing-matrix")
async def get_contact_subscription_billing_matrix(
contact_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show"),
):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_billing_matrix(customer_id, months)

View File

@ -7,6 +7,15 @@ from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert from app.core.database import execute_query, execute_insert
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
save_subscription_comment,
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
)
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -314,3 +323,86 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
except Exception as e: except Exception as e:
logger.error(f"Failed to link contact to company: {e}") logger.error(f"Failed to link contact to company: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts")
async def get_related_contacts(contact_id: int):
"""Get contacts from the same companies as the contact (excluding itself)."""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return {"contacts": []}
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
c.created_at, c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id IN ({placeholders}) AND c.id <> %s
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
"""
params = tuple(customer_ids + [contact_id])
results = execute_query(query, params) or []
return {"contacts": results}
except Exception as e:
logger.error(f"Failed to get related contacts for {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/subscriptions")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
return {
"status": "no_linked_customer",
"message": "Kontakt er ikke tilknyttet et firma",
"recurring_orders": [],
"sales_orders": [],
"subscriptions": [],
"expired_subscriptions": [],
"bmc_office_subscriptions": [],
}
return await get_customer_subscriptions(customer_id)
@router.post("/contacts/{contact_id}/subscriptions/lock")
async def lock_contact_subscriptions(contact_id: int, lock_request: dict):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await lock_customer_subscriptions(customer_id, lock_request)
@router.post("/contacts/{contact_id}/subscription-comment")
async def save_contact_subscription_comment(contact_id: int, data: SubscriptionComment):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await save_subscription_comment(customer_id, data)
@router.get("/contacts/{contact_id}/subscription-comment")
async def get_contact_subscription_comment(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_comment(customer_id)
@router.get("/contacts/{contact_id}/subscriptions/billing-matrix")
async def get_contact_subscription_billing_matrix(
contact_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show"),
):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_billing_matrix(customer_id, months)

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ from pathlib import Path
from app.core.database import execute_query, execute_update from app.core.database import execute_query, execute_update
from app.models.schemas import Conversation, ConversationUpdate from app.models.schemas import Conversation, ConversationUpdate
from app.core.config import settings from app.core.config import settings
from app.core.contact_utils import get_contact_customer_ids
router = APIRouter() router = APIRouter()
@ -20,6 +21,7 @@ router = APIRouter()
async def get_conversations( async def get_conversations(
request: Request, request: Request,
customer_id: Optional[int] = None, customer_id: Optional[int] = None,
contact_id: Optional[int] = None,
ticket_id: Optional[int] = None, ticket_id: Optional[int] = None,
only_mine: bool = False, only_mine: bool = False,
include_deleted: bool = False include_deleted: bool = False
@ -34,7 +36,20 @@ async def get_conversations(
if not include_deleted: if not include_deleted:
where_clauses.append("deleted_at IS NULL") where_clauses.append("deleted_at IS NULL")
if customer_id: if contact_id:
contact_customer_ids = get_contact_customer_ids(contact_id)
if customer_id is not None:
if customer_id not in contact_customer_ids:
return []
contact_customer_ids = [customer_id]
if not contact_customer_ids:
return []
placeholders = ",".join(["%s"] * len(contact_customer_ids))
where_clauses.append(f"customer_id IN ({placeholders})")
params.extend(contact_customer_ids)
elif customer_id:
where_clauses.append("customer_id = %s") where_clauses.append("customer_id = %s")
params.append(customer_id) params.append(customer_id)

32
app/core/contact_utils.py Normal file
View File

@ -0,0 +1,32 @@
"""
Contact helpers for resolving linked customers.
"""
from typing import List, Optional
from app.core.database import execute_query
def get_contact_customer_ids(contact_id: int) -> List[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, customer_id
"""
rows = execute_query(query, (contact_id,)) or []
return [row["customer_id"] for row in rows]
def get_primary_customer_id(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, customer_id
LIMIT 1
"""
rows = execute_query(query, (contact_id,)) or []
if not rows:
return None
return rows[0]["customer_id"]

View File

@ -147,12 +147,12 @@ async def list_hardware_by_customer(customer_id: int):
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict]) @router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int): async def list_hardware_by_contact(contact_id: int):
"""List hardware assets linked to a contact via company relations.""" """List hardware assets linked directly to a contact."""
query = """ query = """
SELECT h.* SELECT DISTINCT h.*
FROM hardware_assets h FROM hardware_assets h
JOIN contact_companies cc ON cc.customer_id = h.current_owner_customer_id JOIN hardware_contacts hc ON hc.hardware_id = h.id
WHERE cc.contact_id = %s AND h.deleted_at IS NULL WHERE hc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC ORDER BY h.created_at DESC
""" """
result = execute_query(query, (contact_id,)) result = execute_query(query, (contact_id,))
@ -730,9 +730,12 @@ async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
@router.get("/hardware/eset/devices", response_model=dict) @router.get("/hardware/eset/devices", response_model=dict)
async def list_eset_devices(): async def list_eset_devices(
page_size: Optional[int] = Query(None, ge=1, le=1000),
page_token: Optional[str] = Query(None)
):
"""List devices directly from ESET Device Management.""" """List devices directly from ESET Device Management."""
payload = await eset_service.list_devices() payload = await eset_service.list_devices(page_size=page_size, page_token=page_token)
if not payload: if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET") raise HTTPException(status_code=404, detail="No devices returned from ESET")
return payload return payload

View File

@ -1,10 +1,12 @@
import logging import logging
from fastapi import APIRouter, HTTPException, Query, Request, Form from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
from app.core.database import execute_query from app.core.database import execute_query
from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -50,6 +52,89 @@ def build_location_tree(items: list) -> list:
return roots return roots
def extract_eset_specs_summary(hardware: dict) -> dict:
specs = hardware.get("hardware_specs") or {}
if not isinstance(specs, dict):
return {}
if "device" in specs and isinstance(specs.get("device"), dict):
specs = specs["device"]
def format_bytes(raw_value: Optional[str]) -> Optional[str]:
if not raw_value:
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
return None
if value <= 0:
return None
gb = value / (1024 ** 3)
return f"{gb:.1f} GB"
os_info = specs.get("operatingSystem") or {}
os_name = os_info.get("displayName")
os_version = (os_info.get("version") or {}).get("name")
hardware_profiles = specs.get("hardwareProfiles") or []
profile = hardware_profiles[0] if hardware_profiles else {}
bios = profile.get("bios") or {}
processors = profile.get("processors") or []
hard_drives = profile.get("hardDrives") or []
network_adapters = profile.get("networkAdapters") or []
cpu_models = [p.get("caption") for p in processors if isinstance(p, dict) and p.get("caption")]
disk_models = [d.get("displayName") for d in hard_drives if isinstance(d, dict) and d.get("displayName")]
disk_sizes = [format_bytes(d.get("capacityBytes")) for d in hard_drives if isinstance(d, dict)]
disk_summaries = []
for drive in hard_drives:
if not isinstance(drive, dict):
continue
name = drive.get("displayName")
size = format_bytes(drive.get("capacityBytes"))
if name and size:
disk_summaries.append(f"{name} ({size})")
elif name:
disk_summaries.append(name)
adapter_names = [n.get("caption") for n in network_adapters if isinstance(n, dict) and n.get("caption")]
macs = [n.get("macAddress") for n in network_adapters if isinstance(n, dict) and n.get("macAddress")]
deployed_components = []
for comp in specs.get("deployedComponents") or []:
if not isinstance(comp, dict):
continue
name = comp.get("displayName") or comp.get("name")
version = (comp.get("version") or {}).get("name")
if name and version:
deployed_components.append(f"{name} {version}")
elif name:
deployed_components.append(name)
return {
"os_name": os_name,
"os_version": os_version,
"primary_local_ip": specs.get("primaryLocalIpAddress"),
"public_ip": specs.get("publicIpAddress"),
"device_name": specs.get("displayName"),
"manufacturer": profile.get("manufacturer"),
"model": profile.get("model"),
"bios_serial": bios.get("serialNumber"),
"bios_vendor": bios.get("manufacturer"),
"cpu_models": cpu_models,
"disk_models": disk_models,
"disk_summaries": disk_summaries,
"disk_sizes": disk_sizes,
"adapter_names": adapter_names,
"macs": macs,
"functionality_status": specs.get("functionalityStatus"),
"last_sync_time": specs.get("lastSyncTime"),
"device_type": specs.get("deviceType"),
"deployed_components": deployed_components,
}
@router.get("/hardware", response_class=HTMLResponse) @router.get("/hardware", response_class=HTMLResponse)
async def hardware_list( async def hardware_list(
request: Request, request: Request,
@ -114,7 +199,10 @@ async def create_hardware_form(request: Request):
@router.get("/hardware/eset", response_class=HTMLResponse) @router.get("/hardware/eset", response_class=HTMLResponse)
async def hardware_eset_overview(request: Request): async def hardware_eset_overview(
request: Request,
current_user: dict = Depends(get_optional_user),
):
"""Display ESET sync overview (matches + incidents).""" """Display ESET sync overview (matches + incidents)."""
matches_query = """ matches_query = """
SELECT SELECT
@ -155,7 +243,8 @@ async def hardware_eset_overview(request: Request):
return templates.TemplateResponse("modules/hardware/templates/eset_overview.html", { return templates.TemplateResponse("modules/hardware/templates/eset_overview.html", {
"request": request, "request": request,
"matches": matches or [], "matches": matches or [],
"incidents": incidents or [] "incidents": incidents or [],
"current_user": current_user,
}) })
@ -265,7 +354,8 @@ async def hardware_detail(request: Request, hardware_id: int):
"attachments": attachments or [], "attachments": attachments or [],
"cases": cases or [], "cases": cases or [],
"tags": tags or [], "tags": tags or [],
"location_tree": location_tree or [] "location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware)
}) })

View File

@ -183,6 +183,9 @@
<!-- Link to Edit --> <!-- Link to Edit -->
<div class="ms-auto d-flex gap-2"> <div class="ms-auto d-flex gap-2">
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
<i class="bi bi-arrow-repeat"></i>
</button>
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger"> <a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
@ -201,6 +204,11 @@
<i class="bi bi-card-text me-2"></i>Hardware Detaljer <i class="bi bi-card-text me-2"></i>Hardware Detaljer
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab">
<i class="bi bi-cpu me-2"></i>Specifikationer
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab"> <button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Historik <i class="bi bi-clock-history me-2"></i>Historik
@ -249,6 +257,18 @@
<span class="info-label">Mærke/Model</span> <span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span> <span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
</div> </div>
{% if hardware.eset_uuid %}
<div class="info-row">
<span class="info-label">ESET UUID</span>
<span class="info-value" style="word-break: break-all;">{{ hardware.eset_uuid }}</span>
</div>
{% endif %}
{% if hardware.eset_group %}
<div class="info-row">
<span class="info-label">ESET Gruppe</span>
<span class="info-value" style="word-break: break-all;">{{ hardware.eset_group }}</span>
</div>
{% endif %}
{% if hardware.internal_asset_id %} {% if hardware.internal_asset_id %}
<div class="info-row"> <div class="info-row">
<span class="info-label">Internt Asset ID</span> <span class="info-label">Internt Asset ID</span>
@ -276,6 +296,7 @@
</div> </div>
</div> </div>
<!-- AnyDesk Card --> <!-- AnyDesk Card -->
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %} {% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
<div class="card mb-4 shadow-sm border-0"> <div class="card mb-4 shadow-sm border-0">
@ -426,6 +447,97 @@
</div> </div>
</div> </div>
<!-- Tab: Specs -->
<div class="tab-pane fade" id="eset-specs" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6>
</div>
<div class="card-body">
{% if eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models) %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<tbody>
<tr>
<th style="width: 200px;">Enhedsnavn</th>
<td>{{ eset_specs.device_name or '-' }}</td>
</tr>
<tr>
<th>OS</th>
<td>{{ eset_specs.os_name or '-' }}{% if eset_specs.os_version %} ({{ eset_specs.os_version }}){% endif %}</td>
</tr>
<tr>
<th>Sidst sync</th>
<td>{{ eset_specs.last_sync_time or '-' }}</td>
</tr>
<tr>
<th>Status</th>
<td>{{ eset_specs.functionality_status or '-' }}</td>
</tr>
<tr>
<th>Device type</th>
<td>{{ eset_specs.device_type or '-' }}</td>
</tr>
<tr>
<th>Local IP</th>
<td>{{ eset_specs.primary_local_ip or '-' }}</td>
</tr>
<tr>
<th>Public IP</th>
<td>{{ eset_specs.public_ip or '-' }}</td>
</tr>
<tr>
<th>Chassis</th>
<td>{{ eset_specs.manufacturer or '-' }} / {{ eset_specs.model or '-' }}</td>
</tr>
<tr>
<th>BIOS Serial</th>
<td>{{ eset_specs.bios_serial or '-' }}</td>
</tr>
<tr>
<th>CPU</th>
<td>{{ eset_specs.cpu_models | join(', ') if eset_specs.cpu_models else '-' }}</td>
</tr>
<tr>
<th>Disk</th>
<td>{{ eset_specs.disk_summaries | join(', ') if eset_specs.disk_summaries else (eset_specs.disk_models | join(', ') if eset_specs.disk_models else '-') }}</td>
</tr>
<tr>
<th>Netkort</th>
<td>{{ eset_specs.adapter_names | join(', ') if eset_specs.adapter_names else '-' }}</td>
</tr>
<tr>
<th>MAC</th>
<td>{{ eset_specs.macs | join(', ') if eset_specs.macs else '-' }}</td>
</tr>
<tr>
<th>Installeret</th>
<td>{{ eset_specs.deployed_components | join(', ') if eset_specs.deployed_components else '-' }}</td>
</tr>
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">Ingen ESET specifikationer fundet endnu.</div>
{% endif %}
</div>
</div>
{% if hardware.hardware_specs %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-shield-check me-2"></i>ESET Data</h6>
</div>
<div class="card-body">
<div class="mb-2 text-muted" style="font-size: 0.85rem;">
Rå data fra ESET (til match/diagnose)
</div>
<pre class="p-3 rounded" style="background: var(--bg-body); border: 1px solid rgba(0,0,0,0.1); max-height: 420px; overflow: auto; font-size: 0.85rem;">{{ hardware.hardware_specs | tojson(indent=2) }}</pre>
</div>
</div>
{% endif %}
</div>
<!-- Tab: History --> <!-- Tab: History -->
<div class="tab-pane fade" id="history" role="tabpanel"> <div class="tab-pane fade" id="history" role="tabpanel">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
@ -605,6 +717,19 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
async function syncEsetData() {
try {
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/sync-eset`, { method: 'POST' });
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
location.reload();
} catch (err) {
alert(`ESET opdatering fejlede: ${err.message}`);
}
}
// Tree Toggle Function // Tree Toggle Function
function toggleLocationChildren(event, nodeId) { function toggleLocationChildren(event, nodeId) {
event.preventDefault(); event.preventDefault();

View File

@ -68,6 +68,82 @@
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
} }
.contact-result.active,
.contact-result:focus {
outline: none;
background: var(--accent-light);
}
.contact-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.devices-table {
display: block;
}
.devices-cards {
display: none;
}
.device-card {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px;
padding: 1rem;
background: var(--bg-body);
margin-bottom: 1rem;
}
.device-card-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.device-card-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.inline-results {
max-height: 180px;
overflow: auto;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
margin-top: 0.35rem;
}
.inline-result {
padding: 0.5rem 0.7rem;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.inline-result:hover,
.inline-result.active {
background: var(--accent-light);
}
@media (max-width: 991px) {
.devices-table {
display: none;
}
.devices-cards {
display: block;
}
}
</style> </style>
{% endblock %} {% endblock %}
@ -86,7 +162,7 @@
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div> <div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button> <button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
</div> </div>
<div class="table-responsive"> <div class="table-responsive devices-table">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
<tr> <tr>
@ -104,6 +180,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="devicesCards" class="devices-cards"></div>
</div> </div>
</div> </div>
@ -124,15 +201,17 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Find kontakt</label> <label class="form-label">Find kontakt</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="contactSearch" placeholder="Navn eller email"> <input type="text" class="form-control" id="contactSearch" placeholder="Navn eller email" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button> <button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div> </div>
<div id="contactResults" class="contact-results"></div> <div id="contactResults" class="contact-results"></div>
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Valgt kontakt</label> <label class="form-label">Valgt kontakt</label>
<input type="text" class="form-control" id="selectedContact" placeholder="Ingen valgt" readonly> <input type="text" class="form-control" id="selectedContact" placeholder="Ingen valgt" readonly>
<div id="selectedContactChip" class="contact-chip d-none"></div>
</div> </div>
<div id="importStatus" class="contact-muted"></div> <div id="importStatus" class="contact-muted"></div>
@ -150,8 +229,13 @@
<script> <script>
const devicesTable = document.getElementById('devicesTable'); const devicesTable = document.getElementById('devicesTable');
const deviceStatus = document.getElementById('deviceStatus'); const deviceStatus = document.getElementById('deviceStatus');
const devicesCards = document.getElementById('devicesCards');
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal')); const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
let selectedContactId = null; let selectedContactId = null;
let contactResults = [];
let contactSearchTimer = null;
const inlineSelections = {};
const inlineTimers = {};
function parseDevices(payload) { function parseDevices(payload) {
if (Array.isArray(payload)) return payload; if (Array.isArray(payload)) return payload;
@ -159,6 +243,11 @@
return payload.devices || payload.items || payload.results || payload.data || []; return payload.devices || payload.items || payload.results || payload.data || [];
} }
function getNextPageToken(payload) {
if (!payload || typeof payload !== 'object') return null;
return payload.nextPageToken || payload.next_page_token || payload.nextPage || null;
}
function getField(device, keys) { function getField(device, keys) {
for (const key of keys) { for (const key of keys) {
if (device[key]) return device[key]; if (device[key]) return device[key];
@ -169,6 +258,9 @@
function renderDevices(devices) { function renderDevices(devices) {
if (!devices.length) { if (!devices.length) {
devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>'; devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
if (devicesCards) {
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
}
return; return;
} }
@ -190,20 +282,179 @@
</tr> </tr>
`; `;
}).join(''); }).join('');
if (devicesCards) {
devicesCards.innerHTML = devices.map((device, index) => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
const safeName = name || '-';
const safeSerial = serial || '-';
const safeGroup = group || '-';
const safeUuid = uuid || '';
return `
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
<div class="device-card-title">${safeName}</div>
<div class="device-card-meta">Serial: ${safeSerial}</div>
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
<div class="mt-3">
<label class="form-label small">Vaelg kontakt (ejer)</label>
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
<div id="inlineResults-${index}" class="inline-results"></div>
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
<div id="inlineStatus-${index}" class="contact-muted"></div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-sm btn-primary" onclick="importDeviceInline(${index})">Importer</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openImportModal('${safeUuid || ''}')">Detaljer</button>
</div>
</div>
</div>
`;
}).join('');
bindInlineSearch();
}
} }
async function loadDevices() { function bindInlineSearch() {
deviceStatus.textContent = 'Henter...'; document.querySelectorAll('.inline-contact-search').forEach(input => {
input.addEventListener('input', (event) => {
const index = event.target.dataset.index;
const query = event.target.value.trim();
if (inlineTimers[index]) {
clearTimeout(inlineTimers[index]);
}
if (query.length < 2) {
clearInlineResults(index);
return;
}
inlineTimers[index] = setTimeout(() => {
searchInlineContacts(query, index);
}, 250);
});
});
}
function clearInlineResults(index) {
const results = document.getElementById(`inlineResults-${index}`);
if (results) results.innerHTML = '';
}
async function searchInlineContacts(query, index) {
const results = document.getElementById(`inlineResults-${index}`);
if (!results) return;
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
try { try {
const response = await fetch('/api/v1/hardware/eset/devices'); const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
if (!response.ok) { if (!response.ok) {
const err = await response.text(); const err = await response.text();
throw new Error(err || 'Request failed'); throw new Error(err || 'Request failed');
} }
const data = await response.json(); const data = await response.json();
const devices = parseDevices(data); const contacts = data.contacts || [];
deviceStatus.textContent = `${devices.length} devices hentet`; if (!contacts.length) {
renderDevices(devices); results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map((c, idx) => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
return `
<div class="inline-result" onclick="selectInlineContact(${index}, ${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
<div>
<div>${name || 'Ukendt'}</div>
<div class="contact-muted">${c.email || ''}</div>
</div>
<div class="contact-muted">${company || '-'}</div>
</div>
`;
}).join('');
} catch (err) {
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
}
}
function selectInlineContact(index, id, name, company) {
inlineSelections[index] = { id, label: company ? `${name} (${company})` : name };
const selectedEl = document.getElementById(`inlineSelected-${index}`);
if (selectedEl) selectedEl.textContent = inlineSelections[index].label || 'Ingen valgt';
clearInlineResults(index);
}
async function importDeviceInline(index) {
const card = document.querySelector(`.device-card[data-index="${index}"]`);
const statusEl = document.getElementById(`inlineStatus-${index}`);
if (!card || !statusEl) return;
const uuid = card.dataset.uuid || '';
if (!uuid) {
statusEl.textContent = 'Manglende UUID';
return;
}
statusEl.textContent = 'Importer...';
try {
const payload = { device_uuid: uuid };
if (inlineSelections[index]?.id) {
payload.contact_id = inlineSelections[index].id;
}
const response = await fetch('/api/v1/hardware/eset/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
statusEl.textContent = `Importeret hardware #${data.id}`;
} catch (err) {
statusEl.textContent = `Fejl: ${err.message}`;
}
}
async function loadDevices() {
deviceStatus.textContent = 'Henter...';
const allDevices = [];
let pageToken = null;
let pageIndex = 0;
const pageSize = 200;
const maxPages = 20;
try {
while (pageIndex < maxPages) {
const params = new URLSearchParams();
params.set('page_size', String(pageSize));
if (pageToken) {
params.set('page_token', pageToken);
}
deviceStatus.textContent = `Henter side ${pageIndex + 1}...`;
const response = await fetch(`/api/v1/hardware/eset/devices?${params.toString()}`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const devices = parseDevices(data);
allDevices.push(...devices);
pageToken = getNextPageToken(data);
if (!pageToken || !devices.length) {
break;
}
pageIndex += 1;
}
if (pageIndex >= maxPages) {
deviceStatus.textContent = `${allDevices.length} devices hentet (afkortet)`;
} else {
deviceStatus.textContent = `${allDevices.length} devices hentet`;
}
renderDevices(allDevices);
} catch (err) { } catch (err) {
deviceStatus.textContent = 'Fejl ved hentning'; deviceStatus.textContent = 'Fejl ved hentning';
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`; devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
@ -215,9 +466,17 @@
document.getElementById('contactSearch').value = ''; document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').innerHTML = ''; document.getElementById('contactResults').innerHTML = '';
document.getElementById('selectedContact').value = ''; document.getElementById('selectedContact').value = '';
document.getElementById('selectedContactChip').classList.add('d-none');
document.getElementById('selectedContactChip').textContent = '';
document.getElementById('importStatus').textContent = ''; document.getElementById('importStatus').textContent = '';
selectedContactId = null; selectedContactId = null;
contactResults = [];
importModal.show(); importModal.show();
setTimeout(() => {
const input = document.getElementById('contactSearch');
if (input) input.focus();
}, 150);
} }
async function searchContacts() { async function searchContacts() {
@ -236,6 +495,7 @@
} }
const data = await response.json(); const data = await response.json();
const contacts = data.contacts || []; const contacts = data.contacts || [];
contactResults = contacts;
if (!contacts.length) { if (!contacts.length) {
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>'; results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
return; return;
@ -244,7 +504,7 @@
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim(); const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', '); const company = (c.company_names || []).join(', ');
return ` return `
<div class="contact-result" onclick="selectContact(${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')"> <div class="contact-result" tabindex="0" onclick="selectContact(${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
<div> <div>
<div>${name || 'Ukendt'}</div> <div>${name || 'Ukendt'}</div>
<div class="contact-muted">${c.email || ''}</div> <div class="contact-muted">${c.email || ''}</div>
@ -253,6 +513,7 @@
</div> </div>
`; `;
}).join(''); }).join('');
highlightFirstContact();
} catch (err) { } catch (err) {
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`; results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
} }
@ -262,9 +523,67 @@
selectedContactId = id; selectedContactId = id;
const label = company ? `${name} (${company})` : name; const label = company ? `${name} (${company})` : name;
document.getElementById('selectedContact').value = label; document.getElementById('selectedContact').value = label;
const chip = document.getElementById('selectedContactChip');
chip.textContent = `Ejer: ${label}`;
chip.classList.remove('d-none');
document.getElementById('contactResults').innerHTML = ''; document.getElementById('contactResults').innerHTML = '';
} }
function highlightFirstContact() {
const first = document.querySelector('#contactResults .contact-result');
if (!first) return;
document.querySelectorAll('#contactResults .contact-result').forEach(el => el.classList.remove('active'));
first.classList.add('active');
}
function selectHighlightedContact() {
const active = document.querySelector('#contactResults .contact-result.active');
if (active) {
active.click();
} else if (contactResults.length > 0) {
const c = contactResults[0];
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
selectContact(c.id, name, company);
}
}
function moveHighlight(step) {
const items = Array.from(document.querySelectorAll('#contactResults .contact-result'));
if (!items.length) return;
let index = items.findIndex(el => el.classList.contains('active'));
if (index === -1) index = 0;
index = (index + step + items.length) % items.length;
items.forEach(el => el.classList.remove('active'));
items[index].classList.add('active');
items[index].scrollIntoView({ block: 'nearest' });
}
document.getElementById('contactSearch').addEventListener('input', () => {
if (contactSearchTimer) {
clearTimeout(contactSearchTimer);
}
contactSearchTimer = setTimeout(() => {
const query = document.getElementById('contactSearch').value.trim();
if (query.length >= 2) {
searchContacts();
}
}, 250);
});
document.getElementById('contactSearch').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
selectHighlightedContact();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveHighlight(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(-1);
}
});
async function importDevice() { async function importDevice() {
const uuid = document.getElementById('importDeviceUuid').value.trim(); const uuid = document.getElementById('importDeviceUuid').value.trim();
const statusEl = document.getElementById('importStatus'); const statusEl = document.getElementById('importStatus');

View File

@ -74,6 +74,11 @@
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a> <a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div> </div>
</div> </div>
{% if current_user %}
<div class="mb-3 text-muted small">
Logget ind som {{ current_user.full_name or current_user.username or current_user.email }}
</div>
{% endif %}
<div class="section-card"> <div class="section-card">
<div class="section-title">🔗 Matchede enheder</div> <div class="section-title">🔗 Matchede enheder</div>

View File

@ -31,6 +31,7 @@ import json
from pydantic import ValidationError from pydantic import ValidationError
from app.core.database import execute_query from app.core.database import execute_query
from app.core.contact_utils import get_contact_customer_ids
from app.modules.locations.models.schemas import ( from app.modules.locations.models.schemas import (
Location, LocationCreate, LocationUpdate, LocationDetail, Location, LocationCreate, LocationUpdate, LocationDetail,
AuditLogEntry, LocationSearchResponse, AuditLogEntry, LocationSearchResponse,
@ -288,6 +289,39 @@ async def get_locations_by_customer(customer_id: int):
) )
@router.get("/locations/by-contact/{contact_id}", response_model=List[Location])
async def get_locations_by_contact(contact_id: int):
"""
Get all locations linked to a contact via the contact's companies.
Path parameter: contact_id
Returns: List of Location objects ordered by name
"""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return []
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.customer_id IN ({placeholders}) AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, tuple(customer_ids))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting contact locations: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by contact"
)
# ============================================================================ # ============================================================================
# 11c. GET /api/v1/locations/by-ids - Fetch by IDs # 11c. GET /api/v1/locations/by-ids - Fetch by IDs
# ============================================================================ # ============================================================================

View File

@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Query
from app.core.crypto import encrypt_secret from app.core.crypto import encrypt_secret
from app.core.database import execute_query from app.core.database import execute_query
from app.core.contact_utils import get_primary_customer_id
from app.modules.nextcloud.backend.service import NextcloudService from app.modules.nextcloud.backend.service import NextcloudService
from app.modules.nextcloud.models.schemas import ( from app.modules.nextcloud.models.schemas import (
NextcloudInstanceCreate, NextcloudInstanceCreate,
@ -24,6 +25,21 @@ router = APIRouter()
service = NextcloudService() service = NextcloudService()
def _resolve_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]:
if customer_id is not None:
return customer_id
if contact_id is not None:
return get_primary_customer_id(contact_id)
return None
def _require_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]:
resolved = _resolve_customer_id(customer_id, contact_id)
if contact_id is not None and resolved is None:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return resolved
def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict): def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict):
query = """ query = """
INSERT INTO nextcloud_audit_log INSERT INTO nextcloud_audit_log
@ -43,12 +59,16 @@ def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: di
@router.get("/instances") @router.get("/instances")
async def list_instances(customer_id: Optional[int] = Query(None)): async def list_instances(
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL" query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL"
params: List[int] = [] params: List[int] = []
if customer_id is not None: if resolved_customer_id is not None:
query += " AND customer_id = %s" query += " AND customer_id = %s"
params.append(customer_id) params.append(resolved_customer_id)
return execute_query(query, tuple(params)) or [] return execute_query(query, tuple(params)) or []
@ -61,6 +81,18 @@ async def get_instance_for_customer(customer_id: int):
return result[0] return result[0]
@router.get("/contacts/{contact_id}/instance")
async def get_instance_for_contact(contact_id: int):
customer_id = _resolve_customer_id(None, contact_id)
if not customer_id:
return None
query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
result = execute_query(query, (customer_id,))
if not result:
return None
return result[0]
@router.post("/instances") @router.post("/instances")
async def create_instance(payload: NextcloudInstanceCreate): async def create_instance(payload: NextcloudInstanceCreate):
try: try:
@ -166,18 +198,28 @@ async def rotate_credentials(instance_id: int, payload: NextcloudInstanceUpdate)
@router.get("/instances/{instance_id}/status") @router.get("/instances/{instance_id}/status")
async def get_status(instance_id: int, customer_id: Optional[int] = Query(None)): async def get_status(
response = await service.get_status(instance_id, customer_id) instance_id: int,
if customer_id is not None: customer_id: Optional[int] = Query(None),
_audit(customer_id, instance_id, "status", {"instance_id": instance_id}, response) contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.get_status(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "status", {"instance_id": instance_id}, response)
return response return response
@router.get("/instances/{instance_id}/groups") @router.get("/instances/{instance_id}/groups")
async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)): async def list_groups(
response = await service.list_groups(instance_id, customer_id) instance_id: int,
if customer_id is not None: customer_id: Optional[int] = Query(None),
_audit(customer_id, instance_id, "groups", {"instance_id": instance_id}, response) contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.list_groups(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "groups", {"instance_id": instance_id}, response)
return response return response
@ -185,17 +227,19 @@ async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)
async def list_users( async def list_users(
instance_id: int, instance_id: int,
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
include_details: bool = Query(False), include_details: bool = Query(False),
limit: int = Query(200, ge=1, le=500), limit: int = Query(200, ge=1, le=500),
): ):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
if include_details: if include_details:
response = await service.list_users_details(instance_id, customer_id, search, limit) response = await service.list_users_details(instance_id, resolved_customer_id, search, limit)
else: else:
response = await service.list_users(instance_id, customer_id, search) response = await service.list_users(instance_id, resolved_customer_id, search)
if customer_id is not None: if resolved_customer_id is not None:
_audit( _audit(
customer_id, resolved_customer_id,
instance_id, instance_id,
"users", "users",
{ {
@ -214,11 +258,13 @@ async def get_user_details(
instance_id: int, instance_id: int,
uid: str, uid: str,
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
): ):
response = await service.get_user_details(instance_id, uid, customer_id) resolved_customer_id = _require_customer_id(customer_id, contact_id)
if customer_id is not None: response = await service.get_user_details(instance_id, uid, resolved_customer_id)
if resolved_customer_id is not None:
_audit( _audit(
customer_id, resolved_customer_id,
instance_id, instance_id,
"user_details", "user_details",
{"instance_id": instance_id, "uid": uid}, {"instance_id": instance_id, "uid": uid},
@ -228,15 +274,26 @@ async def get_user_details(
@router.get("/instances/{instance_id}/shares") @router.get("/instances/{instance_id}/shares")
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)): async def list_shares(
response = await service.list_public_shares(instance_id, customer_id) instance_id: int,
if customer_id is not None: customer_id: Optional[int] = Query(None),
_audit(customer_id, instance_id, "shares", {"instance_id": instance_id}, response) contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.list_public_shares(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "shares", {"instance_id": instance_id}, response)
return response return response
@router.post("/instances/{instance_id}/users") @router.post("/instances/{instance_id}/users")
async def create_user(instance_id: int, payload: NextcloudUserCreate, customer_id: Optional[int] = Query(None)): async def create_user(
instance_id: int,
payload: NextcloudUserCreate,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
password = secrets.token_urlsafe(12) password = secrets.token_urlsafe(12)
request_payload = { request_payload = {
"userid": payload.uid, "userid": payload.uid,
@ -246,9 +303,9 @@ async def create_user(instance_id: int, payload: NextcloudUserCreate, customer_i
"groups[]": payload.groups, "groups[]": payload.groups,
} }
response = await service.create_user(instance_id, customer_id, request_payload) response = await service.create_user(instance_id, resolved_customer_id, request_payload)
if customer_id is not None: if resolved_customer_id is not None:
_audit(customer_id, instance_id, "create_user", {"uid": payload.uid}, response) _audit(resolved_customer_id, instance_id, "create_user", {"uid": payload.uid}, response)
return {"result": response, "generated_password": password if payload.send_welcome else None} return {"result": response, "generated_password": password if payload.send_welcome else None}
@ -258,27 +315,41 @@ async def reset_password(
uid: str, uid: str,
payload: NextcloudPasswordReset, payload: NextcloudPasswordReset,
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
): ):
password = secrets.token_urlsafe(12) password = secrets.token_urlsafe(12)
response = await service.reset_password(instance_id, customer_id, uid, password) resolved_customer_id = _require_customer_id(customer_id, contact_id)
if customer_id is not None: response = await service.reset_password(instance_id, resolved_customer_id, uid, password)
_audit(customer_id, instance_id, "reset_password", {"uid": uid}, response) if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "reset_password", {"uid": uid}, response)
return {"result": response, "generated_password": password if payload.send_email else None} return {"result": response, "generated_password": password if payload.send_email else None}
@router.post("/instances/{instance_id}/users/{uid}/disable") @router.post("/instances/{instance_id}/users/{uid}/disable")
async def disable_user(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)): async def disable_user(
response = await service.disable_user(instance_id, customer_id, uid) instance_id: int,
if customer_id is not None: uid: str,
_audit(customer_id, instance_id, "disable_user", {"uid": uid}, response) customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.disable_user(instance_id, resolved_customer_id, uid)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "disable_user", {"uid": uid}, response)
return response return response
@router.post("/instances/{instance_id}/users/{uid}/resend-guide") @router.post("/instances/{instance_id}/users/{uid}/resend-guide")
async def resend_guide(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)): async def resend_guide(
instance_id: int,
uid: str,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = {"status": "queued", "uid": uid} response = {"status": "queued", "uid": uid}
if customer_id is not None: if resolved_customer_id is not None:
_audit(customer_id, instance_id, "resend_guide", {"uid": uid}, response) _audit(resolved_customer_id, instance_id, "resend_guide", {"uid": uid}, response)
return response return response

View File

@ -859,7 +859,11 @@ async def deactivate_stage(stage_id: int):
# ============================ # ============================
@router.get("/opportunities", tags=["Opportunities"]) @router.get("/opportunities", tags=["Opportunities"])
async def list_opportunities(customer_id: Optional[int] = None, stage_id: Optional[int] = None): async def list_opportunities(
customer_id: Optional[int] = None,
stage_id: Optional[int] = None,
contact_id: Optional[int] = None,
):
query = """ query = """
SELECT o.*, c.name AS customer_name, SELECT o.*, c.name AS customer_name,
s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost
@ -875,6 +879,9 @@ async def list_opportunities(customer_id: Optional[int] = None, stage_id: Option
if stage_id is not None: if stage_id is not None:
query += " AND o.stage_id = %s" query += " AND o.stage_id = %s"
params.append(stage_id) params.append(stage_id)
if contact_id is not None:
query += " AND EXISTS (SELECT 1 FROM pipeline_opportunity_contacts poc WHERE poc.opportunity_id = o.id AND poc.contact_id = %s)"
params.append(contact_id)
query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC" query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC"
if params: if params:

View File

@ -96,15 +96,20 @@ class EsetService:
) )
return None return None
async def list_devices(self) -> Optional[Dict[str, Any]]: async def list_devices(self, page_size: Optional[int] = None, page_token: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""List devices from ESET Device Management.""" """List devices from ESET Device Management."""
if not self.enabled: if not self.enabled:
logger.warning("ESET not enabled") logger.warning("ESET not enabled")
return None return None
url = f"{self.base_url}/v1/devices" url = f"{self.base_url}/v1/devices"
params: Dict[str, Any] = {}
if page_size:
params["pageSize"] = page_size
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client: async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
payload = await self._get_json(client, url) payload = await self._get_json(client, url, params=params or None)
if not payload: if not payload:
logger.warning("ESET devices payload empty") logger.warning("ESET devices payload empty")
return payload return payload

View File

@ -602,7 +602,6 @@
<tr> <tr>
<th></th> <th></th>
<th>Timer</th> <th>Timer</th>
<th>Rå værdi (debug)</th>
<th>Dato</th> <th>Dato</th>
<th>Case Nr.</th> <th>Case Nr.</th>
<th>Rel. Case ID</th> <th>Rel. Case ID</th>
@ -1002,17 +1001,6 @@
const row = document.createElement('tr'); const row = document.createElement('tr');
const hoursData = getTimelogHours(item); const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field); const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
// DEBUG: Log første entry for at verificere parsing
if (timelogs.indexOf(item) === 0) {
console.log('🔍 TIMELOG DEBUG:', {
id: item.id,
hoursData: hoursData,
parsedHours: hours,
rawItem: item
});
}
const relatedId = getTimelogRelatedId(item) || '-'; const relatedId = getTimelogRelatedId(item) || '-';
const relatedCase = caseMap.get(relatedId); const relatedCase = caseMap.get(relatedId);
const caseNumber = relatedCase const caseNumber = relatedCase
@ -1020,14 +1008,11 @@
: '-'; : '-';
const description = item.description || item.subject || item.note || '-'; const description = item.description || item.subject || item.note || '-';
const rawDebug = `${hoursData.field}=${hoursData.value}`;
row.innerHTML = ` row.innerHTML = `
<td> <td>
<input type="checkbox" class="timelog-checkbox" data-timelog-id="${item.id}"> <input type="checkbox" class="timelog-checkbox" data-timelog-id="${item.id}">
</td> </td>
<td>${hours ? `${hours}h` : '-'}</td> <td>${hours ? `${hours}h` : '-'}</td>
<td style="font-size: 0.85em; color: #999;">${rawDebug}</td>
<td>${getTimelogDate(item)}</td> <td>${getTimelogDate(item)}</td>
<td>${caseNumber}</td> <td>${caseNumber}</td>
<td>${relatedId}</td> <td>${relatedId}</td>