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 []