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:
parent
297a8ef2d6
commit
489f81a1e3
@ -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)
|
||||||
|
|||||||
@ -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
@ -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
32
app/core/contact_utils.py
Normal 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"]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user