diff --git a/app/modules/drift/backend/router.py b/app/modules/drift/backend/router.py index 6e82187..8da00d7 100644 --- a/app/modules/drift/backend/router.py +++ b/app/modules/drift/backend/router.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse import httpx from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, Field +from psycopg2.extras import Json from app.core.database import execute_query, execute_query_single @@ -738,6 +739,18 @@ def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str] return [] +def _fetch_uisp_device_detail(base_url: str, token: str, external_id: str) -> Any: + """Fetch interface telemetry for one linked UISP device.""" + return _fetch_json_from_candidates( + base_url, + token, + [ + f"nms/api/v2.1/devices/{external_id}/detail", + f"nms/api/v2/devices/{external_id}/detail", + ], + ) + + def _parse_prometheus_labels(raw: str) -> Dict[str, str]: labels: Dict[str, str] = {} if not raw.strip(): @@ -1068,6 +1081,112 @@ def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]: return [] +def _uisp_device_record(item: Dict[str, Any], base_url: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Normalize the useful UISP fields while retaining the complete source payload.""" + identification = item.get("identification") if isinstance(item.get("identification"), dict) else {} + overview = item.get("overview") if isinstance(item.get("overview"), dict) else {} + external_id = identification.get("id") or item.get("id") or item.get("device_id") + if not external_id: + return None + + def text(*values: Any) -> Optional[str]: + for value in values: + value = str(value or "").strip() + if value: + return value + return None + + ips: List[str] = [] + for value in (item.get("ipAddress"), item.get("ip"), identification.get("ipAddress"), overview.get("ipAddress")): + value = str(value or "").strip() + if value and value not in ips: + ips.append(value) + for key in ("ipAddressList", "ipv6AddressList", "ipv6LinkLocalList"): + for value in item.get(key) or []: + value = str(value or "").strip() + if value and value not in ips: + ips.append(value) + + last_seen = overview.get("lastSeen") + last_seen_dt = None + if last_seen: + try: + last_seen_dt = datetime.fromisoformat(str(last_seen).replace("Z", "+00:00")) + except ValueError: + pass + + return { + "external_id": str(external_id), + "name": text(identification.get("name"), identification.get("displayName"), item.get("name")), + "display_name": text(identification.get("displayName"), identification.get("name")), + "hostname": text(identification.get("hostname"), identification.get("systemName")), + "mac_address": text(identification.get("mac"), item.get("mac")), + "serial_number": text(identification.get("serialNumber"), item.get("serialNumber")), + "vendor": text(identification.get("vendorName"), identification.get("vendor")), + "model": text(identification.get("modelName"), identification.get("model")), + "platform": text(identification.get("platformName"), identification.get("platformId")), + "device_type": text(identification.get("type"), identification.get("category")), + "device_role": text(identification.get("role")), + "ip_addresses": ips, + "status": text(overview.get("status"), identification.get("status"), item.get("status")), + "last_seen": last_seen_dt, + "device_link": _extract_device_link(item, base_url, str(external_id)), + "raw_json": item, + } + + +def _upsert_uisp_devices(payload: Any, base_url: Optional[str] = None) -> int: + """Cache UISP devices and enrich every hardware asset explicitly linked to one.""" + count = 0 + for item in _parse_uisp_payload(payload): + device = _uisp_device_record(item, base_url) + if not device: + continue + rows = execute_query( + """INSERT INTO uisp_devices + (external_id, name, display_name, hostname, mac_address, serial_number, vendor, model, + platform, device_type, device_role, ip_addresses, status, last_seen, device_link, raw_json, synced_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (external_id) DO UPDATE SET + name = EXCLUDED.name, display_name = EXCLUDED.display_name, hostname = EXCLUDED.hostname, + mac_address = EXCLUDED.mac_address, serial_number = EXCLUDED.serial_number, vendor = EXCLUDED.vendor, + model = EXCLUDED.model, platform = EXCLUDED.platform, device_type = EXCLUDED.device_type, + device_role = EXCLUDED.device_role, ip_addresses = EXCLUDED.ip_addresses, status = EXCLUDED.status, + last_seen = EXCLUDED.last_seen, device_link = EXCLUDED.device_link, raw_json = EXCLUDED.raw_json, + synced_at = NOW(), updated_at = NOW() + RETURNING id""", + ( + device["external_id"], device["name"], device["display_name"], device["hostname"], device["mac_address"], + device["serial_number"], device["vendor"], device["model"], device["platform"], device["device_type"], + device["device_role"], Json(device["ip_addresses"]), device["status"], device["last_seen"], device["device_link"], Json(device["raw_json"]), + ), + ) or [] + if not rows: + continue + device_id = rows[0]["id"] + overview = item.get("overview") if isinstance(item.get("overview"), dict) else {} + uisp_specs = { + "uisp_device_id": device["external_id"], "name": device["name"], "hostname": device["hostname"], + "mac_address": device["mac_address"], "ip_addresses": device["ip_addresses"], "platform": device["platform"], + "type": device["device_type"], "role": device["device_role"], "firmware": (item.get("firmware") or {}).get("version") if isinstance(item.get("firmware"), dict) else item.get("firmware"), + "status": device["status"], "last_seen": str(device["last_seen"] or ""), + "overview": overview, + } + execute_query( + """UPDATE hardware_assets h + SET brand = COALESCE(NULLIF(%s, ''), h.brand), + model = COALESCE(NULLIF(%s, ''), h.model), + serial_number = COALESCE(NULLIF(%s, ''), h.serial_number), + hardware_specs = COALESCE(h.hardware_specs, '{}'::jsonb) || %s::jsonb, + updated_at = NOW() + FROM hardware_uisp_links link + WHERE link.hardware_id = h.id AND link.uisp_device_id = %s""", + (device["vendor"] or "", device["model"] or "", device["serial_number"] or "", Json({"uisp": uisp_specs}), device_id), + ) + count += 1 + return count + + def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]: items = _parse_uisp_payload(payload) events: List[Dict[str, Any]] = [] @@ -1283,6 +1402,21 @@ def _run_uisp_sync_internal() -> Dict[str, Any]: "api/v2/sites", ], ) + cached_devices = _upsert_uisp_devices(payload, base_url) + # The inventory endpoint does not contain switch interface telemetry. Fetch + # details only for explicitly linked hardware, keeping the 2-minute sync light. + linked_devices = execute_query( + """SELECT d.external_id FROM hardware_uisp_links link + JOIN uisp_devices d ON d.id = link.uisp_device_id""" + ) or [] + detailed_devices = 0 + for linked in linked_devices: + external_id = str(linked.get("external_id") or "").strip() + if not external_id: + continue + detail = _fetch_uisp_device_detail(base_url, token, external_id) + if isinstance(detail, dict) and detail: + detailed_devices += _upsert_uisp_devices([detail], base_url) events = _build_events_from_uisp_payload(payload, source.get("id"), base_url) except httpx.HTTPError as exc: logger.warning("⚠️ Drift UISP sync failed: %s", exc) @@ -1313,6 +1447,8 @@ def _run_uisp_sync_internal() -> Dict[str, Any]: "source": source.get("name"), "mode": "live", "blacklisted_skipped": skipped, + "cached_devices": cached_devices, + "detailed_devices": detailed_devices, } diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py index 78fc879..7659762 100644 --- a/app/modules/hardware/backend/router.py +++ b/app/modules/hardware/backend/router.py @@ -1,3 +1,4 @@ +import json import logging from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, UploadFile, File @@ -497,6 +498,202 @@ async def get_hardware(hardware_id: int): 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.""" @@ -512,7 +709,8 @@ async def update_hardware(hardware_id: int, data: dict): "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" + "rental_default_preparation_price", "rental_default_operations_monthly_price", + "location_display_order" ] for field in allowed_fields: diff --git a/app/modules/hardware/frontend/views.py b/app/modules/hardware/frontend/views.py index c541c45..83d8d5a 100644 --- a/app/modules/hardware/frontend/views.py +++ b/app/modules/hardware/frontend/views.py @@ -1,4 +1,6 @@ +import json import logging +import re from typing import Optional, Any from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends from fastapi.responses import HTMLResponse, RedirectResponse @@ -446,6 +448,116 @@ async def hardware_detail(request: Request, hardware_id: int): raise HTTPException(status_code=404, detail="Hardware not found") hardware = result[0] + + # Network switches expose their wall-outlet connections as a port map. + switch_ports = [] + if str(hardware.get('asset_type') or '').lower() == 'netværk': + connection_rows = execute_query( + '''SELECT o.id, o.switch_port, o.outlet_number, o.status, o.patch_panel, o.patch_port, + l.id AS location_id, l.name AS location_name + FROM locations_wall_outlets o + JOIN locations_locations l ON l.id = o.location_id + WHERE o.switch_hardware_id = %s AND o.deleted_at IS NULL AND o.is_active = TRUE + ORDER BY o.switch_port''', + (hardware_id,), + ) or [] + connections = {str(row.get('switch_port')): row for row in connection_rows if row.get('switch_port')} + specs = hardware.get('hardware_specs') or {} + if isinstance(specs, str): + try: + specs = json.loads(specs) + except (ValueError, TypeError): + specs = {} + port_count = int((specs or {}).get('port_count') or 0) + if port_count > 0: + switch_ports = [ + {'port_number': str(port), 'connection': connections.pop(str(port), None)} + for port in range(1, port_count + 1) + ] + switch_ports.extend( + {'port_number': port, 'connection': connection} + for port, connection in connections.items() + ) + switch_outlet_choices = [] + if str(hardware.get('asset_type') or '').lower() == 'netværk' and hardware.get('current_location_id'): + switch_outlet_choices = execute_query( + '''SELECT id, outlet_number, status, switch_hardware_id, switch_name, switch_port + FROM locations_wall_outlets + WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE + ORDER BY outlet_number''', + (hardware['current_location_id'],), + ) or [] + network_links = execute_query( + '''SELECT l.*, tb.brand AS target_brand, tb.model AS target_model, tb.serial_number AS target_serial, + sb.brand AS source_brand, sb.model AS source_model, sb.serial_number AS source_serial + 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 [] + uisp_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 [] + uisp_device = uisp_rows[0] if uisp_rows else None + if uisp_device: + raw = uisp_device.get('raw_json') or {} + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (TypeError, ValueError): + raw = {} + uisp_device['overview'] = raw.get('overview') if isinstance(raw, dict) else {} + uisp_device['firmware'] = (raw.get('firmware') or {}) if isinstance(raw, dict) and isinstance(raw.get('firmware'), dict) else {} + live_ports = {} + for interface in (raw.get('interfaces') or []) if isinstance(raw, dict) else []: + if not isinstance(interface, dict): + continue + identification = interface.get('identification') or {} + status = interface.get('status') or {} + statistics = interface.get('statistics') or {} + name = str(identification.get('name') or '') + match = re.fullmatch(r'(?:port|eth)(\d+)', name, flags=re.IGNORECASE) + if not match: + continue + port_number = str(int(match.group(1))) + live_ports[port_number] = { + 'plugged': bool(status.get('plugged')), + 'status': status.get('status'), + 'speed': status.get('currentSpeed') or status.get('speed'), + 'rxrate': statistics.get('rxrate'), 'txrate': statistics.get('txrate'), + 'poe_power': statistics.get('poePower'), 'errors': statistics.get('errors'), + } + for port in switch_ports: + port['live'] = live_ports.get(str(port['port_number'])) + outbound_links = {} + for link in network_links: + if int(link.get('source_hardware_id') or 0) == hardware_id and link.get('source_port'): + outbound_links[str(link['source_port'])] = link + elif int(link.get('target_hardware_id') or 0) == hardware_id and link.get('target_port'): + # Render the physical connection from the target switch's perspective too. + reverse_link = dict(link) + reverse_link.update({ + 'target_hardware_id': link.get('source_hardware_id'), + 'target_brand': link.get('source_brand'), + 'target_model': link.get('source_model'), + 'target_serial': link.get('source_serial'), + 'target_port': link.get('source_port'), + }) + outbound_links[str(link['target_port'])] = reverse_link + for port in switch_ports: + port['hardware_link'] = outbound_links.get(str(port['port_number'])) + available_network_hardware = execute_query( + '''SELECT id, brand, model, serial_number, asset_type + FROM hardware_assets + WHERE current_location_id = %s AND id <> %s AND deleted_at IS NULL + ORDER BY brand, model, serial_number''', + (hardware.get('current_location_id') or -1, hardware_id), + ) or [] # Get customer name if applicable if hardware.get('current_owner_customer_id'): @@ -481,6 +593,19 @@ async def hardware_detail(request: Request, hardware_id: int): ORDER BY start_date DESC """ locations = execute_query(location_query, (hardware_id,)) + + # current_location_id is the authoritative placement. Hardware created from a + # location can legitimately have no history row yet, so do not hide its location. + current_location = None + if hardware.get('current_location_id'): + current_location_rows = execute_query( + """SELECT id AS location_id, name AS location_name + FROM locations_locations + WHERE id = %s AND deleted_at IS NULL""", + (hardware['current_location_id'],), + ) or [] + if current_location_rows: + current_location = current_location_rows[0] # Get attachments attachment_query = """ @@ -670,6 +795,7 @@ async def hardware_detail(request: Request, hardware_id: int): "hardware": hardware, "ownership": ownership or [], "locations": locations or [], + "current_location": current_location, "attachments": attachments or [], "cases": cases or [], "tags": tags or [], @@ -679,6 +805,11 @@ async def hardware_detail(request: Request, hardware_id: int): "owner_contacts": owner_contacts or [], "location_tree": location_tree or [], "eset_specs": extract_eset_specs_summary(hardware), + "switch_ports": switch_ports, + "switch_outlet_choices": switch_outlet_choices, + "network_links": network_links, + "uisp_device": uisp_device, + "available_network_hardware": available_network_hardware, "rental_stats": rental_stats, "recent_rentals": recent_rentals or [], }) diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index 42011bd..af326a5 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -54,6 +54,19 @@ font-size: 1.5rem; } + .switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; } + .switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; } + .switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } + .switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); } + .switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; } + .switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; } + .switch-port-button.live-up { box-shadow: inset 0 -5px 0 #20c997; } + .switch-port-button.live-down { box-shadow: inset 0 -5px 0 #dc3545; } + .switch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; } + .switch-port-live { font-size:.55rem; font-weight:800; letter-spacing:.03em; } + @media (max-width: 1100px) { .switch-port-grid { grid-template-columns: repeat(12, minmax(44px, 1fr)); } } + @media (max-width: 700px) { .switch-port-grid { grid-template-columns: repeat(6, minmax(44px, 1fr)); } } + /* Timeline Styling */ .timeline { position: relative; @@ -223,8 +236,8 @@ {% endif %} - {% set current_loc = locations[0] if locations else None %} - {% if current_loc and not current_loc.end_date %} + {% set current_loc = current_location or (locations[0] if locations else None) %} + {% if current_loc and (current_location or not current_loc.end_date) %}
Lokation: {{ current_loc.location_name }} @@ -409,11 +422,11 @@
- {% if current_loc and not current_loc.end_date %} + {% if current_loc and (current_location or not current_loc.end_date) %}
{{ current_loc.location_name }}
-

Siden: {{ current_loc.start_date }}

+

{% if current_loc.start_date %}Siden: {{ current_loc.start_date }}{% else %}Aktuel placering{% endif %}

{% if current_loc.notes %}
"{{ current_loc.notes }}"
{% endif %} @@ -675,6 +688,64 @@
+ {% if switch_ports %} +
+
+
Switch-porte
+ {{ switch_ports | length }} porte +
+
+
+ {% for port in switch_ports %} + + {% endfor %} +
+
+
+ {% endif %} + +
+
+
UISP live-data
{{ 'Koblet til UISP-enhed' if uisp_device else 'Ingen UISP-enhed koblet endnu' }}
+
{% if uisp_device %}{% else %}{% endif %}
+
+
+ {% if uisp_device %} + {% set overview = uisp_device.overview or {} %} +
+
Status{{ uisp_device.status or 'Ukendt' }}
+
IP-adresser{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}
+
MAC{{ uisp_device.mac_address or '—' }}
+
Senest set{{ uisp_device.last_seen or '—' }}
+
Firmware{{ uisp_device.firmware.version or uisp_device.firmware.name or '—' }}
+
Platform / rolle{{ uisp_device.platform or '—' }}{% if uisp_device.device_role %} · {{ uisp_device.device_role }}{% endif %}
+
Uptime{{ overview.uptime or overview.serviceUptime or '—' }}
+
CPU / RAM{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}
+
Temperatur{{ overview.temperature or '—' }}
+
Signal{{ overview.signal or overview.signalMax or '—' }}
+
Kapacitet{{ overview.totalCapacity or overview.uplinkCapacity or '—' }}
+
Synkroniseret{{ uisp_device.synced_at or '—' }}
+
+
{{ uisp_device.vendor or '' }} {{ uisp_device.model or '' }}{% if uisp_device.serial_number %} · {{ uisp_device.serial_number }}{% endif %}{% if uisp_device.device_link %}Åbn i UISP{% endif %}
+ {% else %}Kobl en synkroniseret UISP-enhed for at se live-status og tekniske data her.{% endif %} +
+
+ + {% if hardware.asset_type == 'netværk' %} +
+
Hardwareforbindelser
+
+
+ {% endif %} + {% if hardware.hardware_specs %}
@@ -1234,6 +1305,21 @@
+ + + + + + {% endblock %} {% block extra_js %} @@ -1251,6 +1337,131 @@ } } + document.addEventListener('DOMContentLoaded', function() { + const modalElement = document.getElementById('switchPortAssignModal'); + const modal = modalElement ? new bootstrap.Modal(modalElement) : null; + const uispLinkModalElement = document.getElementById('uispLinkModal'); + const uispLinkModal = uispLinkModalElement ? new bootstrap.Modal(uispLinkModalElement) : null; + const uispDeviceSelect = document.getElementById('uispDeviceSelect'); + let uispSearchTimer = null; + + async function loadUispDevices(search = '') { + if (!uispDeviceSelect) return; + uispDeviceSelect.innerHTML = ''; + const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/uisp-devices?query=${encodeURIComponent(search)}`); + if (!response.ok) { uispDeviceSelect.innerHTML = ''; return; } + const data = await response.json(); + const devices = data.devices || []; + uispDeviceSelect.innerHTML = ''; + if (!devices.length) { + uispDeviceSelect.innerHTML = ''; + return; + } + devices.forEach(device => { + const option = document.createElement('option'); + option.value = String(device.id); + const identity = [device.vendor, device.model, device.serial_number, device.mac_address].filter(Boolean).join(' · '); + const suggestion = device.match_score ? ` — forslag (${device.match_score})` : ''; + option.textContent = `${device.name || device.hostname || device.external_id}${suggestion}\n${identity || device.external_id} · ${device.status || 'ukendt'}`; + uispDeviceSelect.appendChild(option); + }); + } + + document.getElementById('linkUispBtn')?.addEventListener('click', async () => { await loadUispDevices(); uispLinkModal?.show(); }); + document.getElementById('uispDeviceSearch')?.addEventListener('input', event => { + clearTimeout(uispSearchTimer); + uispSearchTimer = setTimeout(() => loadUispDevices(event.target.value), 200); + }); + document.getElementById('uispLinkForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const uispDeviceId = Number(uispDeviceSelect?.value || 0); + if (!uispDeviceId) return; + const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({uisp_device_id: uispDeviceId})}); + if (response.ok) location.reload(); + else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Kunne ikke koble UISP-enheden'); } + }); + document.getElementById('unlinkUispBtn')?.addEventListener('click', async () => { + if (!confirm('Fjern UISP-koblingen fra dette hardware?')) return; + const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'DELETE'}); + if (response.ok) location.reload(); + else alert('Kunne ikke fjerne UISP-koblingen'); + }); + document.getElementById('refreshUispBtn')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; button.innerHTML = 'Opdaterer'; + const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp/refresh', {method: 'POST'}); + if (response.ok) location.reload(); + else { const error = await response.json().catch(() => ({})); alert(error.detail || 'UISP kunne ikke opdateres'); button.disabled = false; button.innerHTML = 'Opdatér nu'; } + }); + const switchHardware = {{ {'id': hardware.id, 'brand': hardware.brand, 'model': hardware.model, 'serial_number': hardware.serial_number} | tojson }}; + const outlets = {{ switch_outlet_choices | tojson }}; + const switchName = [switchHardware.brand, switchHardware.model, switchHardware.serial_number].filter(Boolean).join(' · '); + const outletSelect = document.getElementById('switchPortAssignOutlet'); + + function populateOutletSelect(selectedOutletId) { + outletSelect.innerHTML = ''; + outlets.forEach(outlet => { + const option = document.createElement('option'); + option.value = String(outlet.id); + const connectedElsewhere = outlet.switch_port && Number(outlet.switch_hardware_id) === Number(switchHardware.id) + ? ` — nu på port ${outlet.switch_port}` : ''; + option.textContent = `${outlet.outlet_number} (${outlet.status})${connectedElsewhere}`; + option.selected = String(outlet.id) === String(selectedOutletId || ''); + outletSelect.appendChild(option); + }); + } + + document.querySelectorAll('.switch-port-button').forEach(button => button.addEventListener('click', () => { + const port = button.dataset.switchPort; + document.getElementById('switchPortAssignPort').value = port; + document.getElementById('switchPortAssignNumber').textContent = port; + populateOutletSelect(button.dataset.outletId || null); + modal?.show(); + })); + + document.getElementById('switchPortAssignForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const outletId = outletSelect.value; + const port = document.getElementById('switchPortAssignPort').value; + if (!outletId || !port) return; + const selectedOutlet = outlets.find(outlet => String(outlet.id) === String(outletId)); + if (selectedOutlet?.switch_port && String(selectedOutlet.switch_port) !== String(port)) { + if (!confirm(`${selectedOutlet.outlet_number} er allerede koblet på port ${selectedOutlet.switch_port}. Flyt forbindelsen til port ${port}?`)) return; + } + const response = await fetch(`/api/v1/locations/outlets/${outletId}`, { + method: 'PATCH', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({switch_hardware_id: switchHardware.id, switch_name: switchName, switch_port: port, replace_existing_switch_port: true}) + }); + if (response.ok) location.reload(); + else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); } + }); + + const hardwareLinkModalElement = document.getElementById('hardwareLinkModal'); + const hardwareLinkModal = hardwareLinkModalElement ? new bootstrap.Modal(hardwareLinkModalElement) : null; + document.getElementById('addHardwareLinkBtn')?.addEventListener('click', () => hardwareLinkModal?.show()); + document.getElementById('switchPortAssignHardwareLink')?.addEventListener('click', () => { + const sourcePort = document.getElementById('switchPortAssignPort').value; + if (!sourcePort) return; + modal?.hide(); + document.getElementById('hardwareLinkSourcePort').value = sourcePort; + hardwareLinkModal?.show(); + }); + document.getElementById('hardwareLinkForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const response = await fetch('/api/v1/hardware/{{ hardware.id }}/network-links', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({source_port: document.getElementById('hardwareLinkSourcePort').value.trim(), target_hardware_id: Number(document.getElementById('hardwareLinkTarget').value), target_port: document.getElementById('hardwareLinkTargetPort').value.trim() || null}) + }); + if (response.ok) location.reload(); + else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); } + }); + document.querySelectorAll('.delete-network-link-btn').forEach(button => button.addEventListener('click', async () => { + if (!confirm('Fjern hardwareforbindelsen?')) return; + const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/network-links/${button.dataset.linkId}`, {method: 'DELETE'}); + if (response.ok) location.reload(); + })); + }); + async function submitQuickRent() { const customerId = Number(document.getElementById('quickRentCustomerId').value || 0); const sagId = Number(document.getElementById('quickRentSagId').value || 0); diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index 89a1413..5bdc6be 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -41,7 +41,8 @@ from app.modules.locations.models.schemas import ( Capacity, CapacityCreate, CapacityUpdate, BulkUpdateRequest, BulkDeleteRequest, LocationStats, LocationWizardCreateRequest, LocationWizardCreateResponse, - WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate + WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate, + CrossFieldPortLabelsUpdate ) router = APIRouter() @@ -612,6 +613,25 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): _OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site') +def _cross_field_port_insert_sql() -> str: + """Generate physical labels in a stable order, e.g. 1A, 1B, 2A, 2B.""" + return ''' + INSERT INTO locations_cross_field_ports (cross_field_id, port_number, port_order) + SELECT %s, + CASE WHEN %s = 'paired' + THEN (%s + ((port_no + 1) / 2) - 1)::TEXT || CASE WHEN port_no %% 2 = 1 THEN 'A' ELSE 'B' END + ELSE (%s + port_no - 1)::TEXT + END, + port_no + FROM generate_series(%s, %s) AS port_no + ''' + + +def _validate_cross_field_layout(port_count: int, label_format: str) -> None: + if label_format == 'paired' and port_count % 2: + raise HTTPException(status_code=400, detail='Parrede A/B-porte kræver et lige antal porte') + + @router.get('/locations/cross-fields', response_model=List[CrossField]) async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)): where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE' @@ -619,9 +639,9 @@ async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)): if location_id: where += ' AND cf.location_id = %s' params = (location_id,) - fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.name''', params) or [] + fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.display_order, cf.id''', params) or [] for field in fields: - field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or [] + field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or [] return [CrossField(**field) for field in fields] @@ -635,7 +655,7 @@ async def list_cross_field_ports(): JOIN locations_locations l ON l.id = cf.location_id LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL - ORDER BY l.name, cf.name, p.port_number + ORDER BY l.name, cf.name, p.port_order ''') or [] @@ -646,19 +666,22 @@ async def create_cross_field(data: CrossFieldCreate): raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet') if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'): raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt') + _validate_cross_field_layout(data.port_count, data.port_label_format) try: - created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, notes) VALUES (%s, %s, %s, %s) RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.notes)) or [] + created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes) + VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, (SELECT COALESCE(MAX(display_order), 0) + 1 FROM locations_cross_fields WHERE location_id = %s)), %s) + RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.port_label_format, data.start_port_number, data.panel_row_size, data.display_order, data.location_id, data.notes)) or [] if not created: raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes') field = created[0] - execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(1, %s)''', (field['id'], data.port_count)) + execute_query(_cross_field_port_insert_sql(), (field['id'], data.port_label_format, data.start_port_number, data.start_port_number, 1, data.port_count)) except HTTPException: raise except Exception as exc: if 'unique' in str(exc).lower(): raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc raise - field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or [] + field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or [] return CrossField(**field) @@ -682,12 +705,56 @@ async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate): raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc raise if requested_ports and requested_ports > field['port_count']: - execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(%s, %s)''', (cross_field_id, field['port_count'] + 1, requested_ports)) + start_number = field.get('start_port_number', 1) + execute_query(_cross_field_port_insert_sql(), (cross_field_id, field.get('port_label_format', 'numeric'), start_number, start_number, field['port_count'] + 1, requested_ports)) field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0] - field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (cross_field_id,)) or [] + field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (cross_field_id,)) or [] return CrossField(**field) +@router.patch('/locations/cross-fields/{cross_field_id}/port-labels') +async def update_cross_field_port_labels(cross_field_id: int, data: CrossFieldPortLabelsUpdate): + """Rename physical port labels without changing the linked outlet/port identity.""" + existing = execute_query( + '''SELECT id FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', + (cross_field_id,), + ) or [] + if not existing: + raise HTTPException(status_code=404, detail='Krydsfeltet eller dets porte blev ikke fundet') + + submitted = {item.id: item.port_number.strip() for item in data.ports} + existing_ids = {row['id'] for row in existing} + if set(submitted) != existing_ids: + raise HTTPException(status_code=400, detail='Alle porte skal have en mærkning') + if any(not label for label in submitted.values()): + raise HTTPException(status_code=400, detail='Portmærkning må ikke være tom') + labels_lower = [label.casefold() for label in submitted.values()] + if len(labels_lower) != len(set(labels_lower)): + raise HTTPException(status_code=400, detail='Hver portmærkning skal være unik i panelet') + + # Use temporary labels first, so labels can safely be swapped (e.g. 1A ↔ 1B). + placeholders = ', '.join(['%s'] * len(existing_ids)) + execute_query( + f'''UPDATE locations_cross_field_ports SET port_number = '__tmp__' || id::TEXT + WHERE cross_field_id = %s AND id IN ({placeholders})''', + (cross_field_id, *existing_ids), + fetch=False, + ) + values_sql = ', '.join(['(%s::INTEGER, %s::VARCHAR)'] * len(submitted)) + params = [] + for port_id, label in submitted.items(): + params.extend((port_id, label)) + updated = execute_query( + f'''UPDATE locations_cross_field_ports AS p + SET port_number = incoming.port_number + FROM (VALUES {values_sql}) AS incoming(id, port_number) + WHERE p.id = incoming.id AND p.cross_field_id = %s + RETURNING p.id, p.port_number, p.port_order''', + tuple(params) + (cross_field_id,), + ) or [] + return {'updated': len(updated), 'ports': updated} + + def _outlet_location(location_id: int) -> dict: rows = execute_query( "SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL", @@ -703,10 +770,12 @@ def _outlet_location(location_id: int) -> dict: _OUTLET_SELECT = """ SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, + outlet_customer.name AS outlet_customer_name, COALESCE(path.hierarchy_path, l.name) AS hierarchy_path FROM locations_wall_outlets o JOIN locations_locations l ON l.id = o.location_id LEFT JOIN customers c ON c.id = l.customer_id + LEFT JOIN customers outlet_customer ON outlet_customer.id = o.customer_id LEFT JOIN LATERAL ( WITH RECURSIVE ancestors AS ( SELECT id, name, parent_location_id, name::text AS hierarchy_path @@ -744,15 +813,62 @@ async def list_wall_outlets( return [WallOutlet(**row) for row in rows] +def _replace_switch_port_if_confirmed( + *, switch_hardware_id: Optional[int], switch_name: Optional[str], switch_port: Optional[str], + exclude_outlet_id: Optional[int], confirmed: bool, +) -> None: + """Guard the one-to-one physical switch-port assignment.""" + if not switch_port or not (switch_hardware_id or switch_name): + return + where = ['deleted_at IS NULL', 'is_active = TRUE', 'switch_port = %s'] + params: List[Any] = [switch_port] + if switch_hardware_id: + where.append('switch_hardware_id = %s') + params.append(switch_hardware_id) + else: + where.append('LOWER(COALESCE(switch_name, \'\')) = LOWER(%s)') + params.append(switch_name) + if exclude_outlet_id: + where.append('id <> %s') + params.append(exclude_outlet_id) + conflicts = execute_query( + f'''SELECT id, outlet_number FROM locations_wall_outlets WHERE {' AND '.join(where)}''', + tuple(params), + ) or [] + if not conflicts: + return + if not confirmed: + raise HTTPException( + status_code=409, + detail=f"Switch-porten bruges allerede af vægstik {conflicts[0]['outlet_number']}. Bekræft overskrivning for at flytte forbindelsen.", + ) + conflict_ids = tuple(row['id'] for row in conflicts) + placeholders = ', '.join(['%s'] * len(conflict_ids)) + execute_query( + f'''UPDATE locations_wall_outlets + SET switch_hardware_id = NULL, switch_name = NULL, switch_port = NULL, updated_at = NOW() + WHERE id IN ({placeholders})''', + conflict_ids, + fetch=False, + ) + + @router.post('/locations/outlets', response_model=WallOutlet, status_code=201) async def create_wall_outlet(data: WallOutletCreate): _outlet_location(data.location_id) + _replace_switch_port_if_confirmed( + switch_hardware_id=data.switch_hardware_id, + switch_name=data.switch_name, + switch_port=data.switch_port, + exclude_outlet_id=None, + confirmed=data.replace_existing_switch_port, + ) try: rows = execute_query( """INSERT INTO locations_wall_outlets - (location_id, outlet_number, category, patch_panel, patch_port, cross_field_port_id, switch_name, switch_port, status, notes, is_active) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", - (data.location_id, data.outlet_number.strip(), data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active), + (location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", + (data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active), ) or [] except Exception as exc: if 'unique' in str(exc).lower(): @@ -766,10 +882,25 @@ async def create_wall_outlet(data: WallOutletCreate): @router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet) async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate): changes = data.model_dump(exclude_unset=True) + replace_existing_switch_port = changes.pop('replace_existing_switch_port', False) if not changes: raise HTTPException(status_code=400, detail='Ingen ændringer sendt') if 'outlet_number' in changes: - changes['outlet_number'] = changes['outlet_number'].strip() + changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None + current = execute_query( + '''SELECT switch_hardware_id, switch_name, switch_port + FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''', + (outlet_id,), + ) or [] + if not current: + raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet') + _replace_switch_port_if_confirmed( + switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')), + switch_name=changes.get('switch_name', current[0].get('switch_name')), + switch_port=changes.get('switch_port', current[0].get('switch_port')), + exclude_outlet_id=outlet_id, + confirmed=replace_existing_switch_port, + ) fields = ', '.join(f'{field} = %s' for field in changes) try: rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or [] diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 10bf225..811d1dc 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -21,6 +21,7 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request from fastapi.responses import HTMLResponse, RedirectResponse from jinja2 import Environment, FileSystemLoader, TemplateNotFound from pathlib import Path as PathlibPath +import json import logging from typing import Optional from app.core.database import execute_query, execute_update @@ -596,17 +597,17 @@ def detail_location_view(id: int = Path(..., gt=0)): hardware = execute_query( """ - SELECT id, asset_type, brand, model, serial_number, status + SELECT id, asset_type, brand, model, serial_number, status, hardware_specs, location_display_order FROM hardware_assets WHERE current_location_id = %s AND deleted_at IS NULL - ORDER BY brand ASC, model ASC, serial_number ASC + ORDER BY location_display_order NULLS LAST, brand ASC, model ASC, serial_number ASC """, (id,) ) wall_outlets = execute_query( """ - SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active + SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active FROM locations_wall_outlets WHERE location_id = %s AND deleted_at IS NULL ORDER BY outlet_number @@ -614,22 +615,75 @@ def detail_location_view(id: int = Path(..., gt=0)): (id,), ) + # Render the same physical port map directly under each switch on the location page. + hardware_link_map = {} + hardware_ids = [hw['id'] for hw in (hardware or [])] + if hardware_ids: + hardware_link_rows = execute_query( + """SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port, + target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial, + source.brand AS source_brand, source.model AS source_model, source.serial_number AS source_serial + FROM hardware_network_links l + JOIN hardware_assets target ON target.id = l.target_hardware_id + JOIN hardware_assets source ON source.id = l.source_hardware_id + WHERE (l.source_hardware_id = ANY(%s) OR l.target_hardware_id = ANY(%s)) AND l.deleted_at IS NULL + ORDER BY l.id""", + (hardware_ids, hardware_ids), + ) or [] + for row in hardware_link_rows: + if row.get('source_port'): + hardware_link_map[(row['source_hardware_id'], str(row['source_port']))] = row + if row.get('target_port'): + reverse_row = dict(row) + reverse_row.update({ + 'target_hardware_id': row.get('source_hardware_id'), + 'target_brand': row.get('source_brand'), + 'target_model': row.get('source_model'), + 'target_serial': row.get('source_serial'), + 'target_port': row.get('source_port'), + }) + hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row + for hw in hardware or []: + hw['switch_ports'] = [] + if str(hw.get('asset_type') or '').lower() != 'netværk': + continue + specs = hw.get('hardware_specs') or {} + if isinstance(specs, str): + try: + specs = json.loads(specs) + except (TypeError, ValueError): + specs = {} + port_count = int((specs or {}).get('port_count') or 0) + linked = { + str(outlet.get('switch_port')): outlet + for outlet in (wall_outlets or []) + if outlet.get('switch_hardware_id') == hw.get('id') and outlet.get('switch_port') + } + hw['switch_ports'] = [ + { + 'port_number': str(port), + 'outlet': linked.get(str(port)), + 'hardware_link': hardware_link_map.get((hw['id'], str(port))), + } + for port in range(1, port_count + 1) + ] + cross_fields = execute_query( - """SELECT id, name, port_count, notes, is_active + """SELECT id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes, is_active FROM locations_cross_fields WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE - ORDER BY name""", + ORDER BY display_order, id""", (id,), ) for cross_field in cross_fields or []: cross_field["ports"] = execute_query( - """SELECT p.id, p.port_number, p.is_active, + """SELECT p.id, p.port_number, p.port_order, p.is_active, o.id AS outlet_id, o.outlet_number, o.status AS outlet_status, l.name AS outlet_location_name FROM locations_cross_field_ports p LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL LEFT JOIN locations_locations l ON l.id = o.location_id - WHERE p.cross_field_id = %s ORDER BY p.port_number""", + WHERE p.cross_field_id = %s ORDER BY p.port_order""", (cross_field["id"],), ) or [] diff --git a/app/modules/locations/models/schemas.py b/app/modules/locations/models/schemas.py index 96c9142..37e2e26 100644 --- a/app/modules/locations/models/schemas.py +++ b/app/modules/locations/models/schemas.py @@ -112,11 +112,13 @@ OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'} class WallOutletBase(BaseModel): location_id: int = Field(..., ge=1) - outlet_number: str = Field(..., min_length=1, max_length=100) + outlet_number: Optional[str] = Field(None, max_length=100) + customer_id: Optional[int] = Field(None, ge=1) category: Optional[str] = Field(None, max_length=50) patch_panel: Optional[str] = Field(None, max_length=255) patch_port: Optional[str] = Field(None, max_length=100) cross_field_port_id: Optional[int] = Field(None, ge=1) + switch_hardware_id: Optional[int] = Field(None, ge=1) switch_name: Optional[str] = Field(None, max_length=255) switch_port: Optional[str] = Field(None, max_length=100) status: str = Field('unknown') @@ -132,20 +134,23 @@ class WallOutletBase(BaseModel): class WallOutletCreate(WallOutletBase): - pass + replace_existing_switch_port: bool = False class WallOutletUpdate(BaseModel): - outlet_number: Optional[str] = Field(None, min_length=1, max_length=100) + outlet_number: Optional[str] = Field(None, max_length=100) + customer_id: Optional[int] = Field(None, ge=1) category: Optional[str] = Field(None, max_length=50) patch_panel: Optional[str] = Field(None, max_length=255) patch_port: Optional[str] = Field(None, max_length=100) cross_field_port_id: Optional[int] = Field(None, ge=1) + switch_hardware_id: Optional[int] = Field(None, ge=1) switch_name: Optional[str] = Field(None, max_length=255) switch_port: Optional[str] = Field(None, max_length=100) status: Optional[str] = None notes: Optional[str] = None is_active: Optional[bool] = None + replace_existing_switch_port: bool = False @field_validator('status') @classmethod @@ -163,6 +168,7 @@ class WallOutlet(WallOutletBase): location_name: Optional[str] = None location_type: Optional[str] = None customer_name: Optional[str] = None + outlet_customer_name: Optional[str] = None hierarchy_path: Optional[str] = None @@ -170,26 +176,47 @@ class CrossFieldCreate(BaseModel): location_id: int = Field(..., ge=1) name: str = Field(..., min_length=1, max_length=100) port_count: int = Field(..., ge=1, le=999) + port_label_format: str = Field(default='numeric', pattern='^(numeric|paired)$') + start_port_number: int = Field(default=1, ge=1, le=9999) + panel_row_size: int = Field(default=24, ge=1, le=48) + display_order: Optional[int] = Field(default=None, ge=1, le=9999) notes: Optional[str] = None class CrossFieldUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) port_count: Optional[int] = Field(None, ge=1, le=999) + start_port_number: Optional[int] = Field(None, ge=1, le=9999) + panel_row_size: Optional[int] = Field(None, ge=1, le=48) + display_order: Optional[int] = Field(None, ge=1, le=9999) notes: Optional[str] = None class CrossFieldPort(BaseModel): id: int - port_number: int + port_number: str + port_order: int is_active: bool +class CrossFieldPortLabelUpdate(BaseModel): + id: int = Field(..., ge=1) + port_number: str = Field(..., min_length=1, max_length=20) + + +class CrossFieldPortLabelsUpdate(BaseModel): + ports: List[CrossFieldPortLabelUpdate] = Field(..., min_length=1, max_length=999) + + class CrossField(BaseModel): id: int location_id: int name: str port_count: int + port_label_format: str = 'numeric' + start_port_number: int = 1 + display_order: int = 1 + panel_row_size: int = 24 notes: Optional[str] = None is_active: bool created_at: datetime diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html index 5a389e6..b4ba5df 100644 --- a/app/modules/locations/templates/detail.html +++ b/app/modules/locations/templates/detail.html @@ -9,6 +9,7 @@ .patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; } .patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; } + .patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; } .patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; } .patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; } .patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; } @@ -809,7 +810,7 @@ {% elif location.wall_outlets %}
{% for outlet in location.wall_outlets %} - + {% endfor %}
StikStatusPatchpanelSwitch
{{ outlet.outlet_number }}{% if outlet.category %}
{{ outlet.category }}
{% endif %}
{{ outlet.status }}{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}
{{ outlet.outlet_number or 'Ikke navngivet' }}{% if outlet.category %}
{{ outlet.category }}
{% endif %}
{{ outlet.status }}{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}
{% else %}Ingen vægstik registreret endnu.{% endif %} @@ -865,8 +866,8 @@
{% for field in location.cross_fields %} -
{{ field.name }} {{ field.port_count }} porte
-
{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}{% endfor %}
+
{{ field.name }} Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}
+
{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}{% endfor %}
{% else %}Ingen krydsfelter oprettet endnu.{% endfor %}
@@ -883,12 +884,14 @@ {% if location.hardware %}
{% for hw in location.hardware %} -
-
-
{{ hw.brand }} {{ hw.model }}
-
{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}
+
+
+
{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}
+
Rækkefølge
{{ hw.status }}
- {{ hw.status }} + {% if hw.switch_ports %} +
Switch-porte ({{ hw.switch_ports | length }})
Lilla porte er forbundet med hardware; grønne porte går til et vægstik.
+ {% endif %}
{% endfor %}
@@ -1051,11 +1054,23 @@
+ + @@ -339,6 +347,34 @@
+ + - - - - -
- -
-
-
-
- -
-
- -
- - - - - -
- -
-
-
-
-
- -
-

{{ case.titel }}

- - -
- -
- -
- - -
-
-
- -
- -
-
-
-
{{ case.titel }}
- -
-
- -
-
{{ case.beskrivelse or '' }}
- {% if not case.beskrivelse %} -
-

Ingen opgavebeskrivelse tilføjet endnu.

- Dobbeltklik for at tilføje -
- {% endif %} -
- - -
- -
- Ctrl+Enter for at gemme · Esc for at annullere -
- - - -
-
-
- - -
- -
-
-
- Indlæser... -
-
-
-
-
-
- -
-
-
Kommentarer
- {{ comments|length if comments else 0 }} -
-
-
- -
- {% if comments %} - {% for comment in comments %} - {% if comment.er_system_besked or comment.forfatter == 'System' %} -
- {% elif comment.er_intern %} -
- {% else %} -
- {% endif %} -
- {{ (comment.forfatter or 'Bruger')[:2]|upper }} - {{ comment.forfatter }} - {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }} -
-
{{ comment.indhold|replace('\n', '
')|safe }}
-
- {% endfor %} - {% else %} -

Ingen kommentarer endnu.

- {% endif %} -
- -
- -
- -
- - -
- -
-
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - -
-
-
-
-
-
Relationer
- -
-
- - -
-
-
- {% macro render_relation_rows(nodes, level=0) %} - {% for node in nodes %} - - {{ level }} - - #{{ node.case.id }} - - - {% if node.is_current %} - {{ node.case.titel }} - Aktuel - {% else %} - {{ node.case.titel }} - {% endif %} - - - {% set row_status = (node.case.status or '')|lower %} - - {{ node.case.status or '-' }} - - - - {{ node.case.template_key or node.case.type or 'ticket' }} - - - {% set rel_raw = (node.relation_type or '')|trim %} - {% set rel_key = rel_raw|lower %} - {% if node.is_current %} - Rodsag - {% elif rel_key == 'afledt af' %} - Kommer fra -
(Afledt af)
- {% elif rel_key == 'årsag til' %} - Skaber følge-sag -
(Årsag til)
- {% elif rel_key == 'blokkerer' %} - Blokerer - {% else %} - Koblet til -
(Relateret til)
- {% endif %} - {% if node.is_repeated %} - - {% endif %} - - -
- {% if node.relation_id %} - - {% endif %} - - -
- - - {% if node.children %} - {{ render_relation_rows(node.children, level + 1) }} - {% endif %} - {% endfor %} - {% endmacro %} - - {% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %} - {% if has_relations %} -
- - - - - - - - - - - - - - {{ render_relation_rows(relation_tree) }} - -
Niv.SagTitelStatusTypeSammenhængHandling
-
- {% else %} -

Ingen relaterede sager

- {% endif %} -
-
-
-
- - -
- -
-
-
-
Filer & Dokumenter
- - -
- -
-
- -

Træk filer hertil for at uploade

-
-
-
Ingen filer fundet...
-
-
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
Tid & Fakturering
-
- -
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - -
- - - - - - - - - - - {% for entry in time_entries %} - - - - - - - {% else %} - - - - {% endfor %} - -
DatoBeskrivelseBrugerTimer
{{ entry.worked_date }}{{ entry.description or '-' }}{{ entry.user_name }}{{ entry.original_hours }}
- Ingen tid registreret endnu -
-
- - - {% if prepaid_cards %} -
-
Aktive Klippekort
-
- {% for card in prepaid_cards %} -
-
-
Kort #{{ card.card_number or card.id }}
-
{{ '%.2f' % card.remaining_hours }} timer tilbage
-
-
- {% endfor %} -
-
- {% endif %} -
-
- -
-
-
-
-
-
-
-
Lokationer
- -
-
-
-
Henter lokationer...
-
-
-
- -
-
-
TAGS
- -
-
-
- {% if tags and tags|length > 0 %} - {% for tag in tags %} - - {{ tag.tag_navn or tag.name or 'Tag' }} - - {% endfor %} - {% else %} -
Ingen tags paa sagen endnu
- {% endif %} -
-
-
Forslag
-
-
Indlaeser forslag...
-
-
-
-
- -
-
-
Kunder
- -
-
- {% if customers %} -
- Navn - Rolle - E-mail - Slet -
- {% for customer in customers %} -
- - {{ customer.role or '-' }} - {{ customer.customer_email or '-' }} - -
- {% endfor %} - {% else %} -

Ingen kunder

- {% endif %} -
-
- -
-
-
Buzzwords
-
- - - -
-
-
-
- {% if buzzwords and buzzwords|length > 0 %} - {% for item in buzzwords %} - - {{ item.word }} - - - {% endfor %} - {% else %} -
Ingen buzzwords paa sagen endnu
- {% endif %} -
-
-
- -
-
-
Kontakter
- -
-
- {% if contacts %} -
- Navn - Titel - Kunde - Slet -
- {% for contact in contacts %} -
-
{{ contact.contact_name }}
- {{ contact.title or '-' }} - {{ contact.customer_name or '-' }} - -
- {% endfor %} - {% else %} -

Ingen kontakter

- {% endif %} -
-
- -
-
-
Hardware
-
- - -
-
-
-
-
Henter hardware...
-
-
-
- -
-
-
Salgspipeline
-
- - -
-
-
-
-
-
-
-
Stage
-
- {% set ns = namespace(selected_stage=None) %} - {% for stage in pipeline_stages or [] %} - {% if case.pipeline_stage_id == stage.id %} - {% set ns.selected_stage = stage %} - {% endif %} - {% endfor %} - {% if ns.selected_stage %} - {{ ns.selected_stage.name }} - {% else %} - Ikke sat - {% endif %} -
-
-
-
-
-
Sandsynlighed
-
{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%
-
-
-
-
-
Beløb
-
- {% if case.pipeline_amount is not none %} - {{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr. - {% else %} - Ikke sat - {% endif %} -
-
-
-
-
-
-
Beskrivelse
-
{{ case.pipeline_description or 'Ingen beskrivelse' }}
-
-
-
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
- -
-
-
Opkaldshistorik
- - - -
-
- {% if call_history and call_history|length > 0 %} -
- - - - - - - - - - - - {% for call in call_history %} - - - - - - - - {% endfor %} - -
DatoRetningNummerBrugerVarighed
{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}{{ 'Udgående' if call.direction == 'outbound' else 'Indgående' }} - {% if call.ekstern_nummer %} -
- {{ call.ekstern_nummer }} - -
- {% else %} - - - {% endif %} -
{{ call.full_name or call.username or '-' }} - {% if call.duration_sec is not none %} - {{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }} - {% elif call.ended_at %} - - - {% else %} - I gang - {% endif %} -
-
- {% else %} -
Ingen opkald linket til denne sag
- {% endif %} -
-
- -
-
-
Todo-opgaver
- -
-
-
- - -
- - -
-
-
-
Ingen opgaver endnu
-
-
-
- -
-
-
Kunde-wiki
-
-
-
- -
-
-
Henter wiki...
-
-
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if nextcloud_instance %} - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -{% endblock %} + \ No newline at end of file diff --git a/app/services/email_activity_logger.py b/app/services/email_activity_logger.py index e9d7e9d..f7b2414 100644 --- a/app/services/email_activity_logger.py +++ b/app/services/email_activity_logger.py @@ -45,7 +45,8 @@ class EmailActivityLogger: log_id = execute_insert( """INSERT INTO email_activity_log (email_id, event_type, event_category, description, metadata, user_id, created_by) - VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)""", + VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s) + RETURNING id""", (email_id, event_type, category, description, metadata_json, user_id, created_by) ) diff --git a/app/services/ollama_service.py b/app/services/ollama_service.py index 061e387..4680e90 100644 --- a/app/services/ollama_service.py +++ b/app/services/ollama_service.py @@ -148,10 +148,10 @@ Din opgave er at renskrive en rå tekst til klart, professionelt og venligt dans Teksten kan være enten en e-mail eller en sagsbeskrivelse. Regler: -1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer. -2. Ret stavefejl, tegnsætning og grammatik. -3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information. -4. Fjern fyldord, gentagelser og intern støj. +1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer uændret. +2. Ret kun stavefejl, tegnsætning, grammatik og tydelig formulering. +3. Tilføj, gæt eller udled ALDRIG nye oplysninger. Ændr heller ikke rækkefølge, betydning, ansvar, omfang eller tekniske detaljer. +4. Fjern kun en gentagelse, hvis den er helt identisk; behold ellers hele indholdet. 5. Bevar tone og intention: neutral, serviceminded og professionel. 6. Opfind aldrig nye oplysninger. 7. Hvis input er e-mail: returner i formatet: @@ -259,6 +259,100 @@ Output: except Exception as e: logger.error("❌ Ollama text rewrite failed: %s", e) return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0} + + async def rewrite_case_creation(self, title: str, description: str) -> Dict: + """Create a conservative, structured rewrite for the new-case form. + + This intentionally does not use the configurable generic rewrite prompt: + case creation needs a reliable title and must never make up case facts. + """ + clean_description = (description or "").strip() + if not clean_description: + return {"error": "Input text is empty"} + + system_prompt = """Du renskriver sagsbeskrivelser for et IT-system. + +Du skal returnere KUN gyldig JSON på præcis denne form: +{"title":"...", "description":"..."} + +ABSOLUTTE REGLER FOR description: +- Bevar alle fakta, navne, tal, datoer, versioner, IP-adresser, tekniske termer og usikkerheder præcist. +- Ret kun stavning, tegnsætning, grammatik og åbenlyst uklare formuleringer. +- Tilføj, gæt, forklar eller udled aldrig noget, der ikke står i input. +- Fjern ikke detaljer. Bevar også ønsker, spørgsmål, fejl og forbehold. +- Brug korte afsnit eller punktopstilling kun når input allerede tydeligt indeholder flere separate punkter. + +REGLER FOR title: +- Skriv en kort, konkret titel på 4-10 ord, der beskriver det faktiske arbejde eller problem i teksten. +- Brug de mest specifikke ord fra teksten, f.eks. produkt, funktion, fejl eller handling. +- Brug ikke tomme titler som "Support", "Henvendelse", "Problem" eller "Ny sag" alene. +- Opfind ikke kunde, produkt, årsag eller løsning. Hvis teksten ikke giver nok grundlag, behold den eksisterende titel (kun med stavning rettet). +""" + user_message = ( + "Eksisterende titel (kan være tom):\n" + f"{(title or '').strip()}\n\n" + "Sagsbeskrivelse, som er eneste kilde til fakta:\n" + f"{clean_description}" + ) + + try: + import httpx + + model_normalized = (self.model or "").strip().lower() + use_chat_api = model_normalized.startswith("qwen") + async with httpx.AsyncClient(timeout=120.0) as client: + if use_chat_api: + response = await client.post( + f"{self.endpoint}/api/chat", + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + "stream": False, + "format": "json", + "options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200}, + }, + ) + else: + response = await client.post( + f"{self.endpoint}/api/generate", + json={ + "model": self.model, + "prompt": f"{system_prompt}\n\nBrugerinput:\n{user_message}", + "stream": False, + "format": "json", + "options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200}, + }, + ) + + if response.status_code != 200: + return {"error": f"Ollama returned status {response.status_code}: {response.text[:300]}"} + + payload = response.json() + if use_chat_api: + raw = str(((payload.get("message") or {}).get("content") or "")).strip() + else: + raw = str((payload or {}).get("response") or "").strip() + raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip() + structured = json.loads(raw) + result_title = str(structured.get("title") or "").strip() + result_description = str(structured.get("description") or "").strip() + if not result_title or not result_description: + return {"error": "Ollama returned an incomplete case rewrite"} + + return { + "title": result_title[:200], + "description": result_description, + "model": self.model, + } + except (TypeError, ValueError, json.JSONDecodeError) as e: + logger.warning("⚠️ Ollama returned invalid case-create rewrite: %s", e) + return {"error": "Ollama returned an invalid case rewrite"} + except Exception as e: + logger.error("❌ Ollama case-create rewrite failed: %s", e) + return {"error": f"Ollama rewrite failed: {str(e)}"} async def extract_from_text(self, text: str) -> Dict: """ diff --git a/app/system/backend/sync_router.py b/app/system/backend/sync_router.py index 271a2ce..eb43579 100644 --- a/app/system/backend/sync_router.py +++ b/app/system/backend/sync_router.py @@ -7,7 +7,7 @@ SYNC ARCHITECTURE - Field Ownership: E-CONOMIC owns and syncs: - economic_customer_number (primary key from e-conomic) -- address, city, postal_code, country (physical address) +- name, phone, address, city, postal_code, country (company and physical address) - email_domain, website (contact information) - cvr_number (company metadata; may be shared by several customers) @@ -163,6 +163,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] country = eco_customer.get('country', 'DK') email = eco_customer.get('email', '') website = eco_customer.get('website', '') + phone = eco_customer.get('phone') or eco_customer.get('telephone') or '' if not customer_number or not name: skipped_count += 1 @@ -192,7 +193,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] # Strict matching: ONLY match by economic_customer_number existing = execute_query( """ - SELECT id, name, cvr_number, email_domain, address, city, postal_code, country, website + SELECT id, name, phone, cvr_number, email_domain, address, city, postal_code, country, website FROM customers WHERE economic_customer_number = %s ORDER BY id @@ -221,6 +222,8 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] if existing: target_customer_id = existing[0]['id'] current_values = { + "name": existing[0].get("name"), + "phone": existing[0].get("phone"), "cvr_number": existing[0].get("cvr_number"), "email_domain": existing[0].get("email_domain"), "address": existing[0].get("address"), @@ -230,6 +233,8 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] "website": existing[0].get("website"), } proposed_values = { + "name": name, + "phone": phone, "cvr_number": cvr, "email_domain": email_domain, "address": address, @@ -254,6 +259,8 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] update_query = """ UPDATE customers SET economic_customer_number = %s, + name = %s, + phone = %s, cvr_number = %s, email_domain = %s, address = %s, @@ -265,7 +272,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] WHERE id = %s """ execute_query(update_query, ( - customer_number, cvr, email_domain, address, city, zip_code, country, website, target_customer_id + customer_number, name, phone, cvr, email_domain, address, city, zip_code, country, website, target_customer_id )) logger.info( "✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", @@ -279,6 +286,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] else: would_create.append({ "name": name, + "phone": phone, "economic_customer_number": customer_number, "cvr_number": cvr, "email_domain": email_domain, @@ -292,13 +300,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any] if apply_changes: insert_query = """ INSERT INTO customers - (name, economic_customer_number, cvr_number, email_domain, + (name, phone, economic_customer_number, cvr_number, email_domain, address, city, postal_code, country, website, last_synced_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) RETURNING id """ result = execute_query(insert_query, ( - name, customer_number, cvr, email_domain, address, city, zip_code, country, website + name, phone, customer_number, cvr, email_domain, address, city, zip_code, country, website )) if result: logger.info( diff --git a/migrations/159_repair_sag_email_threading_schema.sql b/migrations/159_repair_sag_email_threading_schema.sql index 549d485..1aa26f1 100644 --- a/migrations/159_repair_sag_email_threading_schema.sql +++ b/migrations/159_repair_sag_email_threading_schema.sql @@ -18,6 +18,16 @@ ALTER TABLE email_messages ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500); -- Cleanup duplicates before adding unique constraint/PK +-- Old installations can contain links to emails or cases that have since +-- been deleted. Remove those orphan links before enforcing foreign keys. +DELETE FROM sag_emails se +WHERE NOT EXISTS ( + SELECT 1 FROM email_messages em WHERE em.id = se.email_id +) +OR NOT EXISTS ( + SELECT 1 FROM sag_sager s WHERE s.id = se.sag_id +); + WITH ranked AS ( SELECT ctid, ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn diff --git a/migrations/220_locations_cross_field_panel_layout.sql b/migrations/220_locations_cross_field_panel_layout.sql new file mode 100644 index 0000000..01ed3ba --- /dev/null +++ b/migrations/220_locations_cross_field_panel_layout.sql @@ -0,0 +1,39 @@ +-- Support physical patch-panel layouts such as 1A, 1B … 24A, 24B. +ALTER TABLE locations_cross_fields + ADD COLUMN IF NOT EXISTS port_label_format VARCHAR(20) NOT NULL DEFAULT 'numeric', + ADD COLUMN IF NOT EXISTS panel_row_size INTEGER NOT NULL DEFAULT 24; + +ALTER TABLE locations_cross_fields + DROP CONSTRAINT IF EXISTS locations_cross_fields_port_label_format_check; +ALTER TABLE locations_cross_fields + ADD CONSTRAINT locations_cross_fields_port_label_format_check + CHECK (port_label_format IN ('numeric', 'paired')); + +ALTER TABLE locations_cross_fields + DROP CONSTRAINT IF EXISTS locations_cross_fields_panel_row_size_check; +ALTER TABLE locations_cross_fields + ADD CONSTRAINT locations_cross_fields_panel_row_size_check + CHECK (panel_row_size BETWEEN 1 AND 48); + +-- Port labels are physical labels, not necessarily numbers (for example 1A/1B). +ALTER TABLE locations_cross_field_ports + DROP CONSTRAINT IF EXISTS locations_cross_field_ports_port_number_check; + +ALTER TABLE locations_cross_field_ports + ALTER COLUMN port_number TYPE VARCHAR(20) USING port_number::VARCHAR; + +ALTER TABLE locations_cross_field_ports + ADD COLUMN IF NOT EXISTS port_order INTEGER; + +UPDATE locations_cross_field_ports +SET port_order = CASE + WHEN port_number ~ '^[0-9]+$' THEN port_number::INTEGER + ELSE id +END +WHERE port_order IS NULL; + +ALTER TABLE locations_cross_field_ports + ALTER COLUMN port_order SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_field_ports_field_order + ON locations_cross_field_ports(cross_field_id, port_order); diff --git a/migrations/221_locations_cross_field_start_number.sql b/migrations/221_locations_cross_field_start_number.sql new file mode 100644 index 0000000..e2969bc --- /dev/null +++ b/migrations/221_locations_cross_field_start_number.sql @@ -0,0 +1,9 @@ +-- A physical patch panel may continue the labelling from the preceding panel. +ALTER TABLE locations_cross_fields + ADD COLUMN IF NOT EXISTS start_port_number INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE locations_cross_fields + DROP CONSTRAINT IF EXISTS locations_cross_fields_start_port_number_check; +ALTER TABLE locations_cross_fields + ADD CONSTRAINT locations_cross_fields_start_port_number_check + CHECK (start_port_number BETWEEN 1 AND 9999); diff --git a/migrations/222_locations_cross_field_display_order.sql b/migrations/222_locations_cross_field_display_order.sql new file mode 100644 index 0000000..0617d6f --- /dev/null +++ b/migrations/222_locations_cross_field_display_order.sql @@ -0,0 +1,20 @@ +-- Physical panels may be displayed in a different order than their names. +ALTER TABLE locations_cross_fields + ADD COLUMN IF NOT EXISTS display_order INTEGER; + +WITH ordered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY location_id ORDER BY name, id) AS row_number + FROM locations_cross_fields + WHERE display_order IS NULL +) +UPDATE locations_cross_fields cf +SET display_order = ordered.row_number +FROM ordered +WHERE cf.id = ordered.id; + +ALTER TABLE locations_cross_fields + ALTER COLUMN display_order SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_cross_fields_location_display_order + ON locations_cross_fields(location_id, display_order) + WHERE deleted_at IS NULL; diff --git a/migrations/223_wall_outlet_switch_hardware_link.sql b/migrations/223_wall_outlet_switch_hardware_link.sql new file mode 100644 index 0000000..40503c0 --- /dev/null +++ b/migrations/223_wall_outlet_switch_hardware_link.sql @@ -0,0 +1,8 @@ +-- Link a wall outlet to the actual switch hardware, rather than only its display name. +ALTER TABLE locations_wall_outlets + ADD COLUMN IF NOT EXISTS switch_hardware_id INTEGER + REFERENCES hardware_assets(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_wall_outlets_switch_hardware_port + ON locations_wall_outlets(switch_hardware_id, switch_port) + WHERE deleted_at IS NULL; diff --git a/migrations/224_wall_outlet_optional_label_and_customer.sql b/migrations/224_wall_outlet_optional_label_and_customer.sql new file mode 100644 index 0000000..2577595 --- /dev/null +++ b/migrations/224_wall_outlet_optional_label_and_customer.sql @@ -0,0 +1,11 @@ +-- A patch/switch port can be registered before the wall-outlet label is known. +ALTER TABLE locations_wall_outlets + ALTER COLUMN outlet_number DROP NOT NULL; + +ALTER TABLE locations_wall_outlets + ADD COLUMN IF NOT EXISTS customer_id INTEGER + REFERENCES customers(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_wall_outlets_customer_id + ON locations_wall_outlets(customer_id) + WHERE deleted_at IS NULL; diff --git a/migrations/225_hardware_network_links.sql b/migrations/225_hardware_network_links.sql new file mode 100644 index 0000000..31ab57b --- /dev/null +++ b/migrations/225_hardware_network_links.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS hardware_network_links ( + id SERIAL PRIMARY KEY, + source_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE, + source_port VARCHAR(100) NOT NULL, + target_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE, + target_port VARCHAR(100), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP, + CHECK (source_hardware_id <> target_hardware_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_hardware_network_links_source_port_active + ON hardware_network_links(source_hardware_id, source_port) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_hardware_network_links_target_active + ON hardware_network_links(target_hardware_id) + WHERE deleted_at IS NULL; diff --git a/migrations/226_hardware_location_display_order.sql b/migrations/226_hardware_location_display_order.sql new file mode 100644 index 0000000..ae996b1 --- /dev/null +++ b/migrations/226_hardware_location_display_order.sql @@ -0,0 +1,16 @@ +ALTER TABLE hardware_assets + ADD COLUMN IF NOT EXISTS location_display_order INTEGER; + +WITH ordered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY current_location_id ORDER BY brand, model, serial_number, id) AS row_number + FROM hardware_assets + WHERE current_location_id IS NOT NULL AND location_display_order IS NULL +) +UPDATE hardware_assets h +SET location_display_order = ordered.row_number +FROM ordered +WHERE h.id = ordered.id; + +CREATE INDEX IF NOT EXISTS idx_hardware_assets_location_display_order + ON hardware_assets(current_location_id, location_display_order) + WHERE deleted_at IS NULL; diff --git a/migrations/227_hardware_uisp_devices.sql b/migrations/227_hardware_uisp_devices.sql new file mode 100644 index 0000000..90f7d7e --- /dev/null +++ b/migrations/227_hardware_uisp_devices.sql @@ -0,0 +1,40 @@ +-- Cached UISP inventory and the one-to-one link to a hardware asset. +CREATE TABLE IF NOT EXISTS uisp_devices ( + id SERIAL PRIMARY KEY, + external_id VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255), + display_name VARCHAR(255), + hostname VARCHAR(255), + mac_address VARCHAR(64), + serial_number VARCHAR(255), + vendor VARCHAR(255), + model VARCHAR(255), + platform VARCHAR(255), + device_type VARCHAR(100), + device_role VARCHAR(100), + ip_addresses JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(100), + last_seen TIMESTAMPTZ, + device_link TEXT, + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_uisp_devices_name ON uisp_devices(name); +CREATE INDEX IF NOT EXISTS idx_uisp_devices_serial_number ON uisp_devices(serial_number); +CREATE INDEX IF NOT EXISTS idx_uisp_devices_mac_address ON uisp_devices(mac_address); + +CREATE TABLE IF NOT EXISTS hardware_uisp_links ( + id SERIAL PRIMARY KEY, + hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE, + uisp_device_id INTEGER NOT NULL REFERENCES uisp_devices(id) ON DELETE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + linked_by_user_id INTEGER, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hardware_id), + UNIQUE (uisp_device_id) +); + +CREATE INDEX IF NOT EXISTS idx_hardware_uisp_links_hardware ON hardware_uisp_links(hardware_id);