bmc_hub/app/modules/hardware/backend/router.py
Christian 9ca562745a feat(subscriptions): enhance subscription update logic to allow direct edits for drafts and add new endpoint for manual invoice processing
feat(ticket): update email integration to use new priority constants for ticket classification

feat(procurement): add procurement overview page with dynamic data loading and display

test(subscriptions): add tests for billing calendar to ensure correct invoice dates

feat(reminder): implement automated task lists with user-defined rules for reminders

feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history

test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
2026-09-07 19:05:58 +02:00

1527 lines
58 KiB
Python

import json
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from app.core.database import execute_query
from app.services.eset_service import eset_service
from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class MobileRecorderProvisionRequest(BaseModel):
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
name: str = Field(min_length=1, max_length=120)
asset_type: str = "mobile_recorder"
manufacturer: str = "Apple"
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
model: Optional[str] = Field(default=None, max_length=100)
device_type: Optional[str] = Field(default=None, max_length=100)
serial_number: str = Field(min_length=1, max_length=100)
udid: Optional[str] = Field(default=None, max_length=160)
ecid: Optional[str] = Field(default=None, max_length=160)
imei: Optional[str] = Field(default=None, max_length=40)
wifi_mac: Optional[str] = Field(default=None, max_length=40)
os: str = "iOS"
os_version: Optional[str] = Field(default=None, max_length=40)
supervised: bool = False
status: str = "ready"
def _provisioning_token_or_401(
authorization: Optional[str], x_provisioning_token: Optional[str],
) -> None:
"""Authenticate a headless provisioning client without accepting user JWTs."""
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
if not expected:
logger.error("Mobile Recorder provisioning was called but no service token is configured")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provisioning endpoint is disabled: service token is not configured",
)
bearer = (authorization or "").strip()
supplied = (x_provisioning_token or "").strip()
if not supplied and bearer.lower().startswith("bearer "):
supplied = bearer[7:].strip()
if not supplied or not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provisioning token",
headers={"WWW-Authenticate": "Bearer"},
)
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
return str(value).strip() if value is not None and str(value).strip() else None
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
async def provision_mobile_recorder(
payload: MobileRecorderProvisionRequest,
authorization: Optional[str] = Header(default=None),
x_provisioning_token: Optional[str] = Header(default=None),
):
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
_provisioning_token_or_401(authorization, x_provisioning_token)
if payload.asset_type != "mobile_recorder":
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
if payload.status != "ready":
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
serial_number = _clean_provisioning_value(payload.serial_number)
if not serial_number:
raise HTTPException(status_code=422, detail="serial_number is required")
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
recorder_name = _clean_provisioning_value(payload.name)
mobile_specs = {
"recorder_number": payload.recorder_number,
"name": recorder_name,
"udid": _clean_provisioning_value(payload.udid),
"ecid": _clean_provisioning_value(payload.ecid),
"imei": _clean_provisioning_value(payload.imei),
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
"os": _clean_provisioning_value(payload.os) or "iOS",
"os_version": _clean_provisioning_value(payload.os_version),
"supervised": bool(payload.supervised),
"provisioning_status": "ready",
"source": "apple_configurator",
}
existing = execute_query(
"""SELECT id, hardware_specs FROM hardware_assets
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
ORDER BY id LIMIT 2""",
(serial_number,),
) or []
if len(existing) > 1:
raise HTTPException(
status_code=409,
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
)
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
if isinstance(prior_specs, str):
try:
prior_specs = json.loads(prior_specs)
except (TypeError, ValueError):
prior_specs = {}
if not isinstance(prior_specs, dict):
prior_specs = {}
prior_specs["mobile_recorder"] = mobile_specs
if existing:
asset_id = int(existing[0]["id"])
rows = execute_query(
"""UPDATE hardware_assets
SET asset_type = 'mobile_recorder', brand = %s,
model = COALESCE(%s, model), internal_asset_id = %s,
recorder_number = %s, status = 'ready', hardware_specs = %s,
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id""",
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
)
action = "updated"
else:
rows = execute_query(
"""INSERT INTO hardware_assets
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id""",
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
)
action = "created"
asset_id = int(rows[0]["id"])
execute_query(
"""INSERT INTO hardware_ownership_history
(hardware_id, owner_type, start_date, notes)
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
(asset_id,),
fetch=False,
)
if not rows:
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
execute_query(
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
VALUES (%s, %s, %s)""",
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
fetch=False,
)
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
current = stack.pop()
if isinstance(current, dict):
for k, v in current.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(current, list):
for item in current:
if isinstance(item, (dict, list)):
stack.append(item)
return None
def _eset_extract_group_path(payload: dict) -> Optional[str]:
return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
def _eset_extract_group_name(payload: dict) -> Optional[str]:
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
name = group_path.split("/")[-1].strip()
return name or None
return group_path
def _eset_extract_company(payload: dict) -> Optional[str]:
company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
if company:
return company
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
return group_path.split("/")[-1].strip() or None
return None
def _eset_extract_login_candidates(payload: dict) -> List[str]:
raw = _eset_extract_first_str(
payload,
["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"]
)
if not raw:
return []
candidates: List[str] = []
def _add(value: str) -> None:
v = (value or "").strip().lower()
if v and v not in candidates:
candidates.append(v)
_add(raw)
if "\\" in raw:
_add(raw.split("\\")[-1])
if "/" in raw:
_add(raw.split("/")[-1])
if "@" in raw:
_add(raw.split("@", 1)[0])
return candidates
def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]:
if not login_candidate:
return None
if company:
scoped = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate, company),
)
if scoped:
return scoped[0]["id"]
scoped_local_part = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate, company),
)
if scoped_local_part:
return scoped_local_part[0]["id"]
by_email = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate,),
)
if by_email:
return by_email[0]["id"]
by_local_part = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
LIMIT 1
""",
(login_candidate,),
)
if by_local_part:
return by_local_part[0]["id"]
return None
def _eset_detect_asset_type(payload: dict) -> str:
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
if device_type:
val = device_type.lower()
if "server" in val:
return "server"
if "laptop" in val or "notebook" in val:
return "laptop"
return "pc"
def _match_customer_exact(name: str) -> Optional[int]:
if not name:
return None
result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,))
if len(result or []) == 1:
return result[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, id ASC
LIMIT 1
"""
result = execute_query(query, (contact_id,))
if result:
return result[0]["customer_id"]
return None
def _match_contact_by_name_and_company(full_name: str, company: str) -> Optional[int]:
if not full_name or not company:
return None
query = """
SELECT id
FROM contacts
WHERE LOWER(TRIM(first_name || ' ' || last_name)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
result = execute_query(query, (full_name, company))
if result:
return result[0]["id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
"""
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
# ============================================================================
# CRUD Endpoints for Hardware Assets
# ============================================================================
@router.get("/hardware", response_model=List[dict])
async def list_hardware(
customer_id: Optional[int] = None,
status: Optional[str] = None,
asset_type: Optional[str] = None,
q: Optional[str] = None
):
"""List all hardware with optional filters."""
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
params = []
if customer_id:
query += " AND current_owner_customer_id = %s"
params.append(customer_id)
if status:
query += " AND status = %s"
params.append(status)
if asset_type:
query += " AND asset_type = %s"
params.append(asset_type)
if q:
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
search_param = f"%{q}%"
params.extend([search_param, search_param, search_param])
query += " ORDER BY created_at DESC"
result = execute_query(query, tuple(params))
logger.info(f"✅ Listed {len(result) if result else 0} hardware assets")
return result or []
@router.get("/hardware/by-customer/{customer_id}", response_model=List[dict])
async def list_hardware_by_customer(customer_id: int):
"""List hardware assets owned by a customer."""
query = """
SELECT * FROM hardware_assets
WHERE deleted_at IS NULL AND current_owner_customer_id = %s
ORDER BY created_at DESC
"""
result = execute_query(query, (customer_id,))
return result or []
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int):
"""
List hardware assets linked directly to a contact.
Supports both hardware_assets (new) and hardware (legacy) tables.
"""
# Try new hardware_assets table via hardware_contacts
query_new = """
SELECT DISTINCT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
h.created_at,
'hardware_assets' as source_table
FROM hardware_assets h
JOIN hardware_contacts hc ON hc.hardware_id = h.id
WHERE hc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_new = execute_query(query_new, (contact_id,))
# Also look up hardware_assets by the contact's company (customer link)
query_by_customer = """
SELECT DISTINCT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
h.created_at,
'hardware_assets' as source_table
FROM hardware_assets h
WHERE h.current_owner_customer_id IN (
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
)
AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_customer = execute_query(query_by_customer, (contact_id,))
# Merge: hardware_contacts first (direct link), then customer-linked, dedup by id
seen = set()
all_results = []
for item in (result_new or []) + (result_customer or []):
if item["id"] not in seen:
seen.add(item["id"])
all_results.append(item)
return all_results
@router.get("/hardware/unassigned", response_model=List[dict])
async def list_unassigned_hardware(limit: int = 200):
"""List hardware assets not linked to any contact."""
query = """
SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number, h.status
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM hardware_contacts hc WHERE hc.hardware_id = h.id
)
ORDER BY h.created_at DESC
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.post("/hardware/{hardware_id}/assign-contact", response_model=dict)
async def assign_hardware_to_contact(hardware_id: int, payload: dict):
"""Link hardware asset to a contact."""
contact_id = payload.get("contact_id")
if not contact_id:
raise HTTPException(status_code=422, detail="contact_id is required")
hardware = execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
if not hardware:
raise HTTPException(status_code=404, detail="Hardware not found")
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
execute_query(
"""
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
""",
(hardware_id, contact_id, "primary", "manual"),
)
customer_id = _get_contact_customer(int(contact_id))
if customer_id:
execute_query(
"""
UPDATE hardware_assets
SET current_owner_customer_id = COALESCE(current_owner_customer_id, %s)
WHERE id = %s
""",
(customer_id, hardware_id),
)
return {"status": "ok"}
@router.post("/hardware", response_model=dict)
async def create_hardware(data: dict):
"""Create a new hardware asset."""
try:
query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
specs = data.get("hardware_specs")
if specs:
specs = Json(specs)
params = (
data.get("asset_type"),
data.get("brand"),
data.get("model"),
data.get("serial_number"),
data.get("customer_asset_id"),
data.get("current_location_id"),
data.get("internal_asset_id"),
data.get("notes"),
data.get("current_owner_type", "bmc"),
data.get("current_owner_customer_id"),
data.get("status", "active"),
data.get("status_reason"),
data.get("warranty_until"),
data.get("end_of_life"),
data.get("anydesk_id"),
data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
data.get("eset_group"),
data.get("rental_default_start_price"),
data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"),
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create hardware")
hardware = result[0]
logger.info(f"✅ Created hardware asset: {hardware['id']} - {hardware['brand']} {hardware['model']}")
# Create initial ownership record if owner specified
if data.get("current_owner_type"):
ownership_query = """
INSERT INTO hardware_ownership_history (
hardware_id, owner_type, owner_customer_id, start_date, notes
)
VALUES (%s, %s, %s, %s, %s)
"""
ownership_params = (
hardware['id'],
data.get("current_owner_type"),
data.get("current_owner_customer_id"),
date.today(),
"Initial ownership record"
)
execute_query(ownership_query, ownership_params)
return hardware
except Exception as e:
logger.error(f"❌ Failed to create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/hardware/quick", response_model=dict)
async def quick_create_hardware(data: dict):
"""Quick create hardware with minimal fields (name + AnyDesk info)."""
try:
name = (data.get("name") or "").strip()
customer_id = data.get("customer_id")
anydesk_id = (data.get("anydesk_id") or "").strip() or None
anydesk_link = (data.get("anydesk_link") or "").strip() or None
if not name:
raise HTTPException(status_code=400, detail="Name is required")
if not customer_id:
raise HTTPException(status_code=400, detail="Customer ID is required")
query = """
INSERT INTO hardware_assets (
asset_type, model, current_owner_type, current_owner_customer_id,
status, anydesk_id, anydesk_link, notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
"andet",
name,
"customer",
customer_id,
"active",
anydesk_id,
anydesk_link,
"Quick created from case/ticket flow",
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create hardware")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to quick-create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/hardware/{hardware_id}", response_model=dict)
async def get_hardware(hardware_id: int):
"""Get hardware details by ID."""
query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
result = execute_query(query, (hardware_id,))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
logger.info(f"✅ Retrieved hardware: {hardware_id}")
return result[0]
def _uisp_match_score(hardware: dict, device: dict) -> int:
"""Score explicit, human-reviewable UISP suggestions without auto-linking anything."""
specs = hardware.get("hardware_specs") or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
values = {
"serial": str(hardware.get("serial_number") or "").strip().lower(),
"model": str(hardware.get("model") or "").strip().lower(),
"name": str(hardware.get("brand") or "") + " " + str(hardware.get("model") or ""),
"mac": str((specs.get("uisp") or {}).get("mac_address") or specs.get("mac_address") or "").replace(":", "").lower(),
}
score = 0
if values["serial"] and values["serial"] == str(device.get("serial_number") or "").strip().lower():
score += 100
if values["mac"] and values["mac"] == str(device.get("mac_address") or "").replace(":", "").lower():
score += 90
device_name = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "model")).lower()
if values["model"] and values["model"] in device_name:
score += 20
if values["name"].strip() and values["name"].strip().lower() in device_name:
score += 10
return score
def _uisp_device_payload(row: dict) -> dict:
return {
"id": row.get("id"), "external_id": row.get("external_id"), "name": row.get("name"),
"display_name": row.get("display_name"), "hostname": row.get("hostname"), "mac_address": row.get("mac_address"),
"serial_number": row.get("serial_number"), "vendor": row.get("vendor"), "model": row.get("model"),
"platform": row.get("platform"), "device_type": row.get("device_type"), "device_role": row.get("device_role"),
"ip_addresses": row.get("ip_addresses") or [], "status": row.get("status"), "last_seen": row.get("last_seen"),
"device_link": row.get("device_link"), "raw_json": row.get("raw_json") or {}, "synced_at": row.get("synced_at"),
}
@router.get("/hardware/{hardware_id}/uisp-devices", response_model=dict)
async def list_uisp_devices_for_hardware(hardware_id: int, query: Optional[str] = Query(None)):
hardware_rows = execute_query("SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)) or []
if not hardware_rows:
raise HTTPException(status_code=404, detail="Hardware not found")
devices = execute_query(
"""SELECT d.*, link.hardware_id AS linked_hardware_id
FROM uisp_devices d
LEFT JOIN hardware_uisp_links link ON link.uisp_device_id = d.id
WHERE link.hardware_id IS NULL OR link.hardware_id = %s
ORDER BY d.name NULLS LAST, d.id""",
(hardware_id,),
) or []
needle = str(query or "").strip().lower()
candidates = []
for device in devices:
searchable = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "serial_number", "mac_address", "vendor", "model")).lower()
if needle and needle not in searchable:
continue
item = _uisp_device_payload(device)
item["match_score"] = _uisp_match_score(hardware_rows[0], device)
candidates.append(item)
candidates.sort(key=lambda item: (-item["match_score"], str(item.get("name") or "").lower()))
return {"devices": candidates}
@router.get("/hardware/{hardware_id}/uisp", response_model=dict)
async def get_hardware_uisp(hardware_id: int):
rows = execute_query(
"""SELECT d.* FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id
WHERE link.hardware_id = %s""",
(hardware_id,),
) or []
return {"device": _uisp_device_payload(rows[0]) if rows else None}
@router.post("/hardware/{hardware_id}/uisp", response_model=dict)
async def link_hardware_uisp(hardware_id: int, data: dict):
device_id = data.get("uisp_device_id")
if not device_id:
raise HTTPException(status_code=400, detail="UISP-enhed er påkrævet")
if not execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)):
raise HTTPException(status_code=404, detail="Hardware not found")
if not execute_query("SELECT id FROM uisp_devices WHERE id = %s", (device_id,)):
raise HTTPException(status_code=404, detail="UISP-enhed blev ikke fundet")
try:
rows = execute_query(
"""INSERT INTO hardware_uisp_links (hardware_id, uisp_device_id, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (hardware_id) DO UPDATE SET uisp_device_id = EXCLUDED.uisp_device_id, updated_at = NOW()
RETURNING id""",
(hardware_id, device_id),
) or []
except Exception as exc:
if "unique" in str(exc).lower():
raise HTTPException(status_code=409, detail="Denne UISP-enhed er allerede koblet til andet hardware") from exc
raise
# Apply cached technical identity immediately; the next UISP refresh adds current measurements.
device = execute_query("SELECT * FROM uisp_devices WHERE id = %s", (device_id,))[0]
raw = device.get("raw_json") or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
raw = {}
overview = raw.get("overview") if isinstance(raw, dict) and isinstance(raw.get("overview"), dict) else {}
firmware = raw.get("firmware") if isinstance(raw, dict) else None
uisp_specs = {
"uisp_device_id": device.get("external_id"), "name": device.get("name"), "hostname": device.get("hostname"),
"mac_address": device.get("mac_address"), "ip_addresses": device.get("ip_addresses") or [],
"platform": device.get("platform"), "type": device.get("device_type"), "role": device.get("device_role"),
"firmware": (firmware or {}).get("version") if isinstance(firmware, dict) else firmware,
"status": device.get("status"), "last_seen": str(device.get("last_seen") or ""), "overview": overview,
}
execute_query(
"""UPDATE hardware_assets SET brand = COALESCE(NULLIF(%s, ''), brand), model = COALESCE(NULLIF(%s, ''), model),
serial_number = COALESCE(NULLIF(%s, ''), serial_number),
hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW() WHERE id = %s""",
(device.get("vendor") or "", device.get("model") or "", device.get("serial_number") or "", Json({"uisp": uisp_specs}), hardware_id),
)
return {"id": rows[0]["id"], "device": _uisp_device_payload(device)}
@router.delete("/hardware/{hardware_id}/uisp", response_model=dict)
async def unlink_hardware_uisp(hardware_id: int):
rows = execute_query("DELETE FROM hardware_uisp_links WHERE hardware_id = %s RETURNING id", (hardware_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail="Ingen UISP-kobling fundet")
return {"deleted": True}
@router.post("/hardware/{hardware_id}/uisp/refresh", response_model=dict)
async def refresh_hardware_uisp(hardware_id: int):
link = execute_query("SELECT uisp_device_id FROM hardware_uisp_links WHERE hardware_id = %s", (hardware_id,)) or []
if not link:
raise HTTPException(status_code=404, detail="Hardware er ikke koblet til en UISP-enhed")
from app.modules.drift.backend.router import _run_uisp_sync_internal
result = _run_uisp_sync_internal()
if result.get("warning"):
raise HTTPException(status_code=502, detail=result["warning"])
return await get_hardware_uisp(hardware_id)
@router.get("/hardware/{hardware_id}/network-links", response_model=List[dict])
async def get_hardware_network_links(hardware_id: int):
"""Return physical network links where this hardware is either endpoint."""
return execute_query(
'''SELECT l.*, sb.brand AS source_brand, sb.model AS source_model,
tb.brand AS target_brand, tb.model AS target_model
FROM hardware_network_links l
JOIN hardware_assets sb ON sb.id = l.source_hardware_id
JOIN hardware_assets tb ON tb.id = l.target_hardware_id
WHERE l.deleted_at IS NULL AND (l.source_hardware_id = %s OR l.target_hardware_id = %s)
ORDER BY l.source_port, l.id''',
(hardware_id, hardware_id),
) or []
@router.post("/hardware/{hardware_id}/network-links", response_model=dict, status_code=201)
async def create_hardware_network_link(hardware_id: int, data: dict):
source_port = str(data.get('source_port') or '').strip()
target_hardware_id = data.get('target_hardware_id')
target_port = str(data.get('target_port') or '').strip() or None
if not source_port or not target_hardware_id:
raise HTTPException(status_code=400, detail='Kildeport og mål-hardware er påkrævet')
if int(target_hardware_id) == hardware_id:
raise HTTPException(status_code=400, detail='Hardware kan ikke forbindes til sig selv')
exists = execute_query('SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL', (target_hardware_id,)) or []
if not exists:
raise HTTPException(status_code=404, detail='Mål-hardware blev ikke fundet')
try:
rows = execute_query(
'''INSERT INTO hardware_network_links (source_hardware_id, source_port, target_hardware_id, target_port, notes)
VALUES (%s, %s, %s, %s, %s) RETURNING id''',
(hardware_id, source_port, target_hardware_id, target_port, data.get('notes') or None),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=409, detail='Denne switch-port er allerede forbundet. Fjern den eksisterende forbindelse først.') from exc
raise
return {'id': rows[0]['id']}
@router.delete("/hardware/{hardware_id}/network-links/{link_id}")
async def delete_hardware_network_link(hardware_id: int, link_id: int):
rows = execute_query(
'''UPDATE hardware_network_links SET deleted_at = NOW(), updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL AND (source_hardware_id = %s OR target_hardware_id = %s)
RETURNING id''',
(link_id, hardware_id, hardware_id),
) or []
if not rows:
raise HTTPException(status_code=404, detail='Forbindelsen blev ikke fundet')
return {'deleted': True}
@router.patch("/hardware/{hardware_id}", response_model=dict)
async def update_hardware(hardware_id: int, data: dict):
"""Update hardware asset."""
try:
# Build dynamic update query
update_fields = []
params = []
allowed_fields = [
"asset_type", "brand", "model", "serial_number", "customer_asset_id",
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price",
"location_display_order"
]
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
val = data[field]
if field == "hardware_specs" and val:
val = Json(val)
params.append(val)
if not update_fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
update_fields.append("updated_at = NOW()")
params.append(hardware_id)
query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s AND deleted_at IS NULL
RETURNING *
"""
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
logger.info(f"✅ Updated hardware: {hardware_id}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to update hardware {hardware_id}: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/hardware/{hardware_id}")
async def delete_hardware(hardware_id: int):
"""Soft-delete hardware asset."""
query = """
UPDATE hardware_assets
SET deleted_at = NOW()
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(query, (hardware_id,))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
logger.info(f"✅ Deleted hardware: {hardware_id}")
return {"message": "Hardware deleted successfully"}
# ============================================================================
# Ownership History Endpoints
# ============================================================================
@router.get("/hardware/{hardware_id}/ownership", response_model=List[dict])
async def get_ownership_history(hardware_id: int):
"""Get ownership history for hardware."""
query = """
SELECT * FROM hardware_ownership_history
WHERE hardware_id = %s AND deleted_at IS NULL
ORDER BY start_date DESC
"""
result = execute_query(query, (hardware_id,))
logger.info(f"✅ Retrieved ownership history for hardware: {hardware_id}")
return result or []
@router.post("/hardware/{hardware_id}/ownership", response_model=dict)
async def add_ownership_record(hardware_id: int, data: dict):
"""Add ownership record (auto-closes previous active ownership)."""
try:
# Close any active ownership records
close_query = """
UPDATE hardware_ownership_history
SET end_date = %s
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
"""
execute_query(close_query, (date.today(), hardware_id))
# Create new ownership record
insert_query = """
INSERT INTO hardware_ownership_history (
hardware_id, owner_type, owner_customer_id, start_date, notes
)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
params = (
hardware_id,
data.get("owner_type"),
data.get("owner_customer_id"),
data.get("start_date", date.today()),
data.get("notes")
)
result = execute_query(insert_query, params)
# Update current owner in hardware_assets
update_query = """
UPDATE hardware_assets
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(update_query, (data.get("owner_type"), data.get("owner_customer_id"), hardware_id))
logger.info(f"✅ Added ownership record for hardware: {hardware_id}")
return result[0]
except Exception as e:
logger.error(f"❌ Failed to add ownership record: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Location History Endpoints
# ============================================================================
@router.get("/hardware/{hardware_id}/locations", response_model=List[dict])
async def get_location_history(hardware_id: int):
"""Get location history for hardware."""
query = """
SELECT * FROM hardware_location_history
WHERE hardware_id = %s AND deleted_at IS NULL
ORDER BY start_date DESC
"""
result = execute_query(query, (hardware_id,))
logger.info(f"✅ Retrieved location history for hardware: {hardware_id}")
return result or []
@router.post("/hardware/{hardware_id}/locations", response_model=dict)
async def add_location_record(hardware_id: int, data: dict):
"""Add location record (auto-closes previous active location)."""
try:
# Close any active location records
close_query = """
UPDATE hardware_location_history
SET end_date = %s
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
"""
execute_query(close_query, (date.today(), hardware_id))
# Create new location record
insert_query = """
INSERT INTO hardware_location_history (
hardware_id, location_id, location_name, start_date, notes
)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
params = (
hardware_id,
data.get("location_id"),
data.get("location_name"),
data.get("start_date", date.today()),
data.get("notes")
)
result = execute_query(insert_query, params)
# Update current location in hardware_assets
update_query = """
UPDATE hardware_assets
SET current_location_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(update_query, (data.get("location_id"), hardware_id))
logger.info(f"✅ Added location record for hardware: {hardware_id}")
return result[0]
except Exception as e:
logger.error(f"❌ Failed to add location record: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Attachment Endpoints
# ============================================================================
@router.get("/hardware/{hardware_id}/attachments", response_model=List[dict])
async def get_attachments(hardware_id: int):
"""Get all attachments for hardware."""
query = """
SELECT * FROM hardware_attachments
WHERE hardware_id = %s AND deleted_at IS NULL
ORDER BY uploaded_at DESC
"""
result = execute_query(query, (hardware_id,))
logger.info(f"✅ Retrieved {len(result) if result else 0} attachments for hardware: {hardware_id}")
return result or []
@router.post("/hardware/{hardware_id}/attachments", response_model=dict)
async def upload_attachment(hardware_id: int, data: dict):
"""Upload attachment for hardware."""
try:
# Generate storage reference (in production, this would upload to cloud storage)
storage_ref = f"hardware/{hardware_id}/{uuid.uuid4()}_{data.get('file_name')}"
query = """
INSERT INTO hardware_attachments (
hardware_id, file_type, file_name, storage_ref,
file_size_bytes, mime_type, description, uploaded_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
hardware_id,
data.get("file_type", "other"),
data.get("file_name"),
storage_ref,
data.get("file_size_bytes"),
data.get("mime_type"),
data.get("description"),
data.get("uploaded_by_user_id")
)
result = execute_query(query, params)
logger.info(f"✅ Uploaded attachment for hardware: {hardware_id}")
return result[0]
except Exception as e:
logger.error(f"❌ Failed to upload attachment: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/hardware/{hardware_id}/attachments/{attachment_id}")
async def delete_attachment(hardware_id: int, attachment_id: int):
"""Soft-delete attachment."""
query = """
UPDATE hardware_attachments
SET deleted_at = NOW()
WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(query, (attachment_id, hardware_id))
if not result:
raise HTTPException(status_code=404, detail="Attachment not found")
logger.info(f"✅ Deleted attachment {attachment_id} for hardware: {hardware_id}")
return {"message": "Attachment deleted successfully"}
# ============================================================================
# Case Relations Endpoints
# ============================================================================
@router.get("/hardware/{hardware_id}/cases", response_model=List[dict])
async def get_related_cases(hardware_id: int):
"""Get all cases related to this hardware."""
query = """
SELECT hcr.*, s.titel, s.status, s.customer_id
FROM hardware_case_relations hcr
LEFT JOIN sag_sager s ON hcr.case_id = s.id
WHERE hcr.hardware_id = %s AND hcr.deleted_at IS NULL AND s.deleted_at IS NULL
ORDER BY hcr.created_at DESC
"""
result = execute_query(query, (hardware_id,))
logger.info(f"✅ Retrieved {len(result) if result else 0} related cases for hardware: {hardware_id}")
return result or []
@router.post("/hardware/{hardware_id}/cases", response_model=dict)
async def link_case(hardware_id: int, data: dict):
"""Link hardware to a case."""
try:
query = """
INSERT INTO hardware_case_relations (
hardware_id, case_id, relation_type
)
VALUES (%s, %s, %s)
RETURNING *
"""
params = (
hardware_id,
data.get("case_id"),
data.get("relation_type", "related")
)
result = execute_query(query, params)
logger.info(f"✅ Linked hardware {hardware_id} to case {data.get('case_id')}")
return result[0]
except Exception as e:
logger.error(f"❌ Failed to link case: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/hardware/{hardware_id}/cases/{case_id}")
async def unlink_case(hardware_id: int, case_id: int):
"""Unlink hardware from a case."""
query = """
UPDATE hardware_case_relations
SET deleted_at = NOW()
WHERE hardware_id = %s AND case_id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(query, (hardware_id, case_id))
if not result:
raise HTTPException(status_code=404, detail="Case relation not found")
logger.info(f"✅ Unlinked hardware {hardware_id} from case {case_id}")
return {"message": "Case unlinked successfully"}
# ============================================================================
# Tag Endpoints
# ============================================================================
@router.get("/hardware/{hardware_id}/tags", response_model=List[dict])
async def get_tags(hardware_id: int):
"""Get all tags for hardware."""
query = """
SELECT * FROM hardware_tags
WHERE hardware_id = %s AND deleted_at IS NULL
ORDER BY created_at DESC
"""
result = execute_query(query, (hardware_id,))
logger.info(f"✅ Retrieved {len(result) if result else 0} tags for hardware: {hardware_id}")
return result or []
@router.post("/hardware/{hardware_id}/tags", response_model=dict)
async def add_tag(hardware_id: int, data: dict):
"""Add tag to hardware."""
try:
query = """
INSERT INTO hardware_tags (
hardware_id, tag_name, tag_type
)
VALUES (%s, %s, %s)
RETURNING *
"""
params = (
hardware_id,
data.get("tag_name"),
data.get("tag_type", "manual")
)
result = execute_query(query, params)
logger.info(f"✅ Added tag '{data.get('tag_name')}' to hardware: {hardware_id}")
return result[0]
except Exception as e:
logger.error(f"❌ Failed to add tag: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/hardware/{hardware_id}/tags/{tag_id}")
async def delete_tag(hardware_id: int, tag_id: int):
"""Delete tag from hardware."""
query = """
UPDATE hardware_tags
SET deleted_at = NOW()
WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(query, (tag_id, hardware_id))
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
logger.info(f"✅ Deleted tag {tag_id} from hardware: {hardware_id}")
return {"message": "Tag deleted successfully"}
# ============================================================================
# Search Endpoint
# ============================================================================
@router.get("/search/hardware", response_model=List[dict])
async def search_hardware(q: str = Query(..., min_length=1)):
"""Search hardware by serial number, model, or brand."""
query = """
SELECT * FROM hardware_assets
WHERE deleted_at IS NULL
AND (
serial_number ILIKE %s
OR model ILIKE %s
OR brand ILIKE %s
OR customer_asset_id ILIKE %s
OR internal_asset_id ILIKE %s
)
ORDER BY created_at DESC
LIMIT 50
"""
search_param = f"%{q}%"
result = execute_query(query, (search_param, search_param, search_param, search_param, search_param))
logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
return result or []
@router.post("/hardware/{hardware_id}/sync-eset", response_model=dict)
async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)):
"""Sync hardware data from ESET."""
# Get current hardware
check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
result = execute_query(check_query, (hardware_id,))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
current = result[0]
# Determine UUID
uuid_to_use = eset_uuid or current.get("eset_uuid")
if not uuid_to_use:
raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.")
# Fetch from ESET
details = await eset_service.get_device_details(uuid_to_use)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
# Update hardware asset
update_data = {
"eset_uuid": uuid_to_use,
"hardware_specs": details
}
# We can perform the update directly here or call update_hardware if available
return await update_hardware(hardware_id, update_data)
@router.get("/hardware/eset/test", response_model=dict)
async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
"""Test ESET device lookup by UUID."""
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
return details
@router.get("/hardware/eset/test-one-pc-full", response_model=dict)
async def test_eset_one_pc_full(include_raw: bool = Query(False)):
"""Fetch one device from ESET and return full parsed test payload including software list."""
payload = await eset_service.list_devices(page_size=1)
if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET")
devices = payload.get("devices") or payload.get("items") or payload.get("results") or payload.get("data") or []
if not devices:
raise HTTPException(status_code=404, detail="No devices found in ESET list")
first_device = devices[0]
device_uuid = (
first_device.get("deviceUuid")
or first_device.get("uuid")
or first_device.get("id")
or ""
)
if not device_uuid:
raise HTTPException(status_code=404, detail="No device UUID found on first ESET device")
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device details not found in ESET")
software = eset_service.extract_installed_software(details)
identifier_fields = [
"userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser", "owner", "ownerUuid"
]
identifier_candidates = []
for field_name in identifier_fields:
value = _eset_extract_first_str(details, [field_name])
if value and value not in identifier_candidates:
identifier_candidates.append(value)
user_identifier = identifier_candidates[0] if identifier_candidates else None
response = {
"device_uuid": device_uuid,
"device_name": _eset_extract_first_str(details, ["displayName", "deviceName", "name"]),
"user_identifier": user_identifier,
"group": _eset_extract_group_path(details),
"serial": _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"]),
"identifier_candidates": identifier_candidates,
"installed_software_count": len(software),
"installed_software": software,
}
if include_raw:
response["raw"] = details
return response
@router.get("/hardware/eset/devices", response_model=dict)
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."""
payload = await eset_service.list_devices(page_size=page_size, page_token=page_token)
if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET")
return payload
@router.post("/hardware/eset/import", response_model=dict)
async def import_eset_device(data: dict):
"""Import ESET device into hardware assets and optionally link to contact."""
device_uuid = (data.get("device_uuid") or "").strip()
contact_id = data.get("contact_id")
if not device_uuid:
raise HTTPException(status_code=400, detail="device_uuid is required")
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"])
model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"])
group_path = _eset_extract_group_path(details)
group_name = _eset_extract_group_name(details)
company = _eset_extract_company(details)
login_candidates = _eset_extract_login_candidates(details)
full_name = _eset_extract_first_str(details, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"])
if contact_id:
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact_check:
raise HTTPException(status_code=404, detail="Contact not found")
if not contact_id:
contact_id = _match_contact_by_name_and_company(full_name, company)
if not contact_id:
for login_candidate in login_candidates:
contact_id = _match_contact_by_login(login_candidate, company)
if contact_id:
break
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company)
owner_type = "customer" if customer_id else "bmc"
conditions = ["eset_uuid = %s"]
params = [device_uuid]
if serial:
conditions.append("serial_number = %s")
params.append(serial)
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
existing = execute_query(lookup_query, tuple(params))
if existing:
hardware_id = existing[0]["id"]
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
update_params = [device_uuid, Json(details)]
if group_path:
update_fields.append("eset_group = %s")
update_params.append(group_path)
if not existing[0].get("serial_number") and serial:
update_fields.append("serial_number = %s")
update_params.append(serial)
if not existing[0].get("model") and model:
update_fields.append("model = %s")
update_params.append(model)
if not existing[0].get("brand") and brand:
update_fields.append("brand = %s")
update_params.append(brand)
if customer_id:
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_fields.append("current_owner_customer_id = %s")
update_params.append(customer_id)
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
hardware = execute_query(update_query, tuple(update_params))
hardware = hardware[0] if hardware else None
else:
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
current_owner_type, current_owner_customer_id,
notes, eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
insert_params = (
_eset_detect_asset_type(details),
brand,
model,
serial,
owner_type,
customer_id,
"Imported from ESET",
device_uuid,
Json(details),
group_path
)
hardware = execute_query(insert_query, insert_params)
hardware = hardware[0] if hardware else None
if hardware and contact_id:
_upsert_hardware_contact(hardware["id"], contact_id)
return hardware or {}
@router.get("/hardware/eset/matches", response_model=List[dict])
async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)):
"""List ESET-matched hardware with contact/customer info."""
query = """
SELECT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.eset_uuid,
h.eset_group,
h.updated_at,
hc.contact_id,
c.first_name,
c.last_name,
c.user_company,
cc.customer_id,
cust.name AS customer_name
FROM hardware_assets h
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
LEFT JOIN contacts c ON c.id = hc.contact_id
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
LEFT JOIN customers cust ON cust.id = cc.customer_id
WHERE h.deleted_at IS NULL
ORDER BY h.updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.get("/hardware/eset/incidents", response_model=List[dict])
async def list_eset_incidents(
severity: Optional[str] = Query("critical"),
limit: int = Query(200, ge=1, le=2000)
):
"""List cached ESET incidents by severity."""
severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()]
if not severity_list:
severity_list = ["critical"]
query = """
SELECT *
FROM eset_incidents
WHERE LOWER(COALESCE(severity, '')) = ANY(%s)
ORDER BY updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (severity_list, limit))
return result or []