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 @@
Ændre
- {% 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 %}
+
+
+
+
+ {% for port in switch_ports %}
+
+ Port {{ port.port_number }}
+ {% if port.live %}{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %} {% endif %}
+ {% if port.hardware_link %}{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %} {% elif port.connection %}{{ port.connection.outlet_number }} {% else %}Ledig {% endif %}
+
+ {% endfor %}
+
+
+
+ {% 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' %}
+
+
+
+ {% for link in network_links %}
+
Port {{ link.source_port }} → {{ link.target_brand or '' }} {{ link.target_model }}{% if link.target_serial %} · {{ link.target_serial }}{% endif %}{% if link.target_port %} · port {{ link.target_port }} {% endif %}
+ {% else %}
Ingen hardwareforbindelser registreret endnu. {% endfor %}
+
+
+ {% endif %}
+
{% if hardware.hardware_specs %}
+
+
+
+
+
+
{% 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 = 'Indlæser… ';
+ const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/uisp-devices?query=${encodeURIComponent(search)}`);
+ if (!response.ok) { uispDeviceSelect.innerHTML = 'Kunne ikke indlæse UISP-enheder '; return; }
+ const data = await response.json();
+ const devices = data.devices || [];
+ uispDeviceSelect.innerHTML = '';
+ if (!devices.length) {
+ uispDeviceSelect.innerHTML = 'Ingen ledige UISP-enheder fundet ';
+ 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 = 'Vælg vægstik ';
+ 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 %}
Stik Status Patchpanel Switch
{% for outlet in location.wall_outlets %}
- {{ 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 %}
{% endfor %}
{% else %}Ingen vægstik registreret endnu. {% endif %}
@@ -865,8 +866,8 @@
{% for field in location.cross_fields %}
-
{{ field.name }} {{ field.port_count }} porte
Rediger
-
{% 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 '') %}{{ port.port_number }} {% if port.outlet_id %}{{ port.outlet_number }} {% endif %} {% endfor %}
+
{{ field.name }} Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}
Portnumre Rediger
+
{% 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 '') %}{{ port.port_number }} {% if port.outlet_id %}{{ port.outlet_number }} {% endif %} {% 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 %}
+
-
{{ 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 @@
+
+
@@ -1063,13 +1078,14 @@
@@ -1106,6 +1122,8 @@
document.addEventListener('DOMContentLoaded', function() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}';
+ const locationHardware = {{ location.hardware | tojson }};
+ const locationWallOutlets = {{ location.wall_outlets | tojson }};
const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId');
@@ -1397,14 +1415,56 @@ document.addEventListener('DOMContentLoaded', function() {
const crossFieldButton = document.getElementById('addCrossFieldBtn');
const crossFieldModalElement = document.getElementById('crossFieldModal');
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
- if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
- document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
+ if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldPortLabelFormat').disabled = false; document.getElementById('crossFieldStartPortNumber').disabled = false; document.getElementById('crossFieldStartPortNumber').value = 1; document.getElementById('crossFieldPanelRowSize').value = 24; document.getElementById('crossFieldDisplayOrder').value = 1; document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
+ document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldPortLabelFormat').value = button.dataset.labelFormat || 'numeric'; document.getElementById('crossFieldStartPortNumber').value = button.dataset.startNumber || 1; document.getElementById('crossFieldPanelRowSize').value = button.dataset.rowSize || 24; document.getElementById('crossFieldDisplayOrder').value = button.dataset.displayOrder || 1; document.getElementById('crossFieldPortLabelFormat').disabled = true; document.getElementById('crossFieldStartPortNumber').disabled = true; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const crossFieldId = document.getElementById('crossFieldId').value;
- const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), notes: document.getElementById('crossFieldNotes').value || null})});
+ const payload = {location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), panel_row_size: Number(document.getElementById('crossFieldPanelRowSize').value), display_order: Number(document.getElementById('crossFieldDisplayOrder').value), notes: document.getElementById('crossFieldNotes').value || null};
+ if (!crossFieldId) { payload.port_label_format = document.getElementById('crossFieldPortLabelFormat').value; payload.start_port_number = Number(document.getElementById('crossFieldStartPortNumber').value); }
+ const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
+ const crossFieldPortLabelsModalElement = document.getElementById('crossFieldPortLabelsModal');
+ const crossFieldPortLabelsModal = crossFieldPortLabelsModalElement ? new bootstrap.Modal(crossFieldPortLabelsModalElement) : null;
+ let activeCrossFieldPortLabelsId = null;
+ document.querySelectorAll('.edit-port-labels-btn').forEach(button => button.addEventListener('click', async () => {
+ try {
+ const response = await fetch(`/api/v1/locations/cross-fields?location_id=${locationId}`);
+ const fields = await response.json();
+ const field = Array.isArray(fields) ? fields.find(item => Number(item.id) === Number(button.dataset.id)) : null;
+ if (!response.ok || !field) throw new Error('Krydsfeltet kunne ikke indlæses');
+ activeCrossFieldPortLabelsId = field.id;
+ document.getElementById('crossFieldPortLabelsName').textContent = field.name;
+ const list = document.getElementById('crossFieldPortLabelsList');
+ list.innerHTML = '';
+ field.ports.forEach(port => {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'col-md-4';
+ const label = document.createElement('label');
+ label.className = 'form-label small mb-1';
+ label.textContent = `Port ${port.port_number}`;
+ const input = document.createElement('input');
+ input.className = 'form-control form-control-sm cross-field-port-label-input';
+ input.maxLength = 20;
+ input.required = true;
+ input.value = port.port_number;
+ input.dataset.portId = port.id;
+ wrapper.append(label, input);
+ list.appendChild(wrapper);
+ });
+ crossFieldPortLabelsModal.show();
+ } catch (error) {
+ alert(error.message || 'Kunne ikke indlæse portnumre');
+ }
+ }));
+ document.getElementById('crossFieldPortLabelsForm')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ if (!activeCrossFieldPortLabelsId) return;
+ const ports = Array.from(document.querySelectorAll('.cross-field-port-label-input')).map(input => ({id: Number(input.dataset.portId), port_number: input.value.trim()}));
+ const response = await fetch(`/api/v1/locations/cross-fields/${activeCrossFieldPortLabelsId}/port-labels`, {method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ports})});
+ if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Portnumrene kunne ikke gemmes'); }
+ });
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
@@ -1414,6 +1474,80 @@ document.addEventListener('DOMContentLoaded', function() {
const outletForm = document.getElementById('outletForm');
const outletValue = (id) => document.getElementById(id).value.trim() || null;
+ function switchDisplayName(hardware) {
+ return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
+ }
+
+ function switchPortCount(hardware) {
+ let specs = hardware.hardware_specs || {};
+ if (typeof specs === 'string') {
+ try { specs = JSON.parse(specs); } catch (_) { specs = {}; }
+ }
+ const count = Number(specs?.port_count || specs?.ports || 0);
+ return Number.isInteger(count) && count > 0 ? count : 0;
+ }
+
+ function selectedSwitchHardwareId() {
+ const selectedName = document.getElementById('outletSwitch').value;
+ const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
+ return match ? Number(match.id) : null;
+ }
+
+ function normalizeSwitchName(value) {
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
+ }
+
+ function switchPortConflict(switchHardware, switchName, portNumber, currentOutletId = null) {
+ if (!portNumber) return null;
+ return (locationWallOutlets || []).find(outlet => {
+ if (currentOutletId && Number(outlet.id) === Number(currentOutletId)) return false;
+ if (String(outlet.switch_port || '') !== String(portNumber)) return false;
+ if (switchHardware?.id && outlet.switch_hardware_id) return Number(outlet.switch_hardware_id) === Number(switchHardware.id);
+ return normalizeSwitchName(outlet.switch_name) === normalizeSwitchName(switchName);
+ }) || null;
+ }
+
+ function loadSwitchChoices(selectedName = '', selectedPort = '', currentOutletId = null) {
+ const switchInput = document.getElementById('outletSwitch');
+ const switchOptions = document.getElementById('outletSwitchOptions');
+ const portOptions = document.getElementById('outletSwitchPort');
+ const portHelp = document.getElementById('outletSwitchPortHelp');
+ const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
+ switchOptions.innerHTML = '';
+ switches.forEach(item => {
+ const option = document.createElement('option');
+ option.value = switchDisplayName(item);
+ option.label = switchPortCount(item) ? `${switchPortCount(item)} porte` : 'Antal porte ikke angivet';
+ switchOptions.appendChild(option);
+ });
+ switchInput.value = selectedName || '';
+
+ const selectedSwitch = switches.find(item => switchDisplayName(item) === switchInput.value);
+ const count = selectedSwitch ? switchPortCount(selectedSwitch) : 0;
+ portOptions.innerHTML = '
Vælg port ';
+ if (count) {
+ for (let number = 1; number <= count; number += 1) {
+ const option = document.createElement('option');
+ option.value = String(number);
+ const conflict = switchPortConflict(selectedSwitch, switchInput.value, number, currentOutletId);
+ option.textContent = conflict
+ ? `Port ${number} — OPTAGET: ${conflict.outlet_number} (${conflict.status || 'ukendt'})`
+ : `Port ${number} — ledig`;
+ portOptions.appendChild(option);
+ }
+ portHelp.textContent = `${count} porte på den valgte switch.`;
+ } else if (switchInput.value) {
+ portHelp.textContent = 'Ingen portliste på switchen — du kan skrive porten manuelt.';
+ } else {
+ portHelp.textContent = switches.length ? 'Vælg en switch for at se dens porte.' : 'Ingen registrerede switches på denne lokation endnu.';
+ }
+ document.getElementById('outletSwitchPort').value = selectedPort || '';
+ }
+
+ document.getElementById('outletSwitch')?.addEventListener('input', () => {
+ loadSwitchChoices(document.getElementById('outletSwitch').value, '', document.getElementById('outletId').value || null);
+ });
+
async function loadCrossFieldPorts() {
const select = document.getElementById('outletCrossFieldPort');
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
@@ -1428,16 +1562,28 @@ document.addEventListener('DOMContentLoaded', function() {
select.value = String(locationId);
}
+ async function loadOutletCustomers(selectedCustomerId = null) {
+ const select = document.getElementById('outletCustomerId');
+ const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
+ select.innerHTML = '
Ingen specifik kunde / brug lokationens kunde ';
+ (customers || []).forEach(customer => {
+ const option = document.createElement('option');
+ option.value = String(customer.id);
+ option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
+ option.selected = String(customer.id) === String(selectedCustomerId || '');
+ select.appendChild(option);
+ });
+ }
+
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
- await Promise.all([loadCrossFieldPorts(), loadOutletLocations()]);
+ await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || '';
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
- document.getElementById('outletSwitch').value = outlet?.switchName || '';
- document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
+ loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletNotes').value = outlet?.notes || '';
if (selectedPort) {
@@ -1457,7 +1603,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
- id: btn.dataset.id, number: btn.dataset.number, category: btn.dataset.category, panel: btn.dataset.panel,
+ id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
status: btn.dataset.status, notes: btn.dataset.notes
})));
@@ -1466,15 +1612,33 @@ document.addEventListener('DOMContentLoaded', function() {
event.preventDefault();
const outletId = document.getElementById('outletId').value;
const payload = {
- location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), category: outletValue('outletCategory'),
+ location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), customer_id: document.getElementById('outletCustomerId').value ? Number(document.getElementById('outletCustomerId').value) : null, category: outletValue('outletCategory'),
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
- switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
+ switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
+ const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
+ const conflict = switchPortConflict(selectedSwitch, payload.switch_name, payload.switch_port, outletId || null);
+ if (conflict) {
+ const message = `Switch-port ${payload.switch_port} er allerede registreret på vægstik ${conflict.outlet_number}. Vil du flytte forbindelsen til dette vægstik?`;
+ if (!confirm(message)) return;
+ payload.replace_existing_switch_port = true;
+ }
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
+ if (response.status === 409) {
+ const error = await response.json().catch(() => ({}));
+ if (confirm(`${error.detail || 'Porten er allerede i brug.'}\n\nVil du overskrive forbindelsen?`)) {
+ payload.replace_existing_switch_port = true;
+ const retry = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
+ method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
+ });
+ if (retry.ok) { location.reload(); return; }
+ }
+ return;
+ }
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme vægstik');
@@ -1490,6 +1654,27 @@ document.addEventListener('DOMContentLoaded', function() {
if (response.ok) location.reload();
else alert('Kunne ikke slette vægstik');
});
+
+ document.querySelectorAll('.save-hardware-order-btn').forEach(button => button.addEventListener('click', async () => {
+ const hardwareId = button.dataset.hardwareId;
+ const input = document.querySelector(`.hardware-display-order[data-hardware-id="${hardwareId}"]`);
+ const order = Number(input?.value);
+ if (!Number.isInteger(order) || order < 1) {
+ alert('Angiv et positivt heltal for rækkefølgen.');
+ return;
+ }
+ button.disabled = true;
+ const response = await fetch(`/api/v1/hardware/${hardwareId}`, {
+ method: 'PATCH', headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({location_display_order: order})
+ });
+ if (response.ok) location.reload();
+ else {
+ const error = await response.json().catch(() => ({}));
+ alert(error.detail || 'Kunne ikke gemme rækkefølgen');
+ button.disabled = false;
+ }
+ }));
});
{% endblock %}
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 62dab79..9f73295 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -311,6 +311,17 @@ class RewriteTextResponse(BaseModel):
context: Optional[str] = None
+class CaseCreateRewriteRequest(BaseModel):
+ title: str = Field(default="", max_length=500)
+ description: str = Field(..., min_length=1, max_length=10000)
+
+
+class CaseCreateRewriteResponse(BaseModel):
+ title: str
+ description: str
+ model: Optional[str] = None
+
+
class SagSendEmailRequest(BaseModel):
to: List[str]
subject: str = Field(..., min_length=1, max_length=998)
@@ -731,6 +742,19 @@ async def analyze_quick_create(request: QuickCreateRequest):
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
+@router.post("/sag/rewrite-case-create", response_model=CaseCreateRewriteResponse)
+async def rewrite_case_create(request: CaseCreateRewriteRequest):
+ """Return a fact-preserving title and description suggestion for a new case."""
+ result = await ollama_service.rewrite_case_creation(request.title, request.description)
+ if not result or result.get("error"):
+ raise HTTPException(status_code=502, detail=(result or {}).get("error") or "Could not rewrite case")
+ return CaseCreateRewriteResponse(
+ title=result.get("title", ""),
+ description=result.get("description", ""),
+ model=result.get("model"),
+ )
+
+
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
async def rewrite_sag_text(request: RewriteTextRequest):
"""Rewrite case/email text using Ollama with configurable prompt."""
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index f12c93c..b2516f0 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -212,9 +212,17 @@
-
Beskrivelse
+
+ Beskrivelse
+
+ AI renskriv
+
+
-
0 tegn
+
+ AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.
+ 0 tegn
+
@@ -339,6 +347,34 @@
+
+
-
-
-
-
-
- Sagsdetaljer
-
-
-
-
-
- Løsning
-
- {% if solution %}
-
- {% endif %}
-
-
-
-
- E-mail
-
-
-
-
-
-
- Varekøb & Salg
-
-
-
-
-
- Leverandør
-
-
-
-
-
- Tidsforbrug
-
-
-
-
-
- Abonnement
-
-
-
-
-
- Påmindelser
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ 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
-
-
- Annuller
-
-
- Renskriv med AI
-
-
- Gem
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% 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 %}
-
-
-
-
- Niv.
- Sag
- Titel
- Status
- Type
- Sammenhæng
- Handling
-
-
-
- {{ render_relation_rows(relation_tree) }}
-
-
-
- {% else %}
-
Ingen relaterede sager
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Træk filer hertil for at uploade
-
-
-
Ingen filer fundet...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
1. Søg og vælg sag
-
-
-
-
-
-
-
- 2. Vælg relationstype
-
- Vælg hvordan sagerne er relateret...
- 🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed
- ↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden
- ➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden
- ⛔ Blokerer - Den anden kan ikke videre før denne er løst
-
-
-
-
-
-
-
Betydning i praksis
-
Koblet til (Relateret) : Faglig sammenhæng, men ingen direkte afhængighed.
-
Kommer fra (Afledt af) : Sagen er opstået pga. en anden sag.
-
Skaber følge-sag (Årsag til) : Sagen skaber behovet for en anden sag.
-
Blokkerer : Bruges når løsning i én sag er nødvendig før den anden kan videre.
-
-
-
-
-
- Tip: Brug pile (↑↓) til at navigere i søgeresultater, Enter til at vælge.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Dato
-
-
- +1 dag
- +1 uge
- +1 mnd
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Dato
-
-
- +1 dag
- +1 uge
- +1 mnd
-
-
-
Start når anden sag har status
-
- Vælg relateret sag
- {% for rc in related_case_options %}
-
- #{{ rc.id }} – {{ rc.titel }}
-
- {% endfor %}
-
- {% set deferred_status_values = (case.deferred_until_status or '').lower().replace(', ', ',').split(',') %}
-
-
- Ved statusskifte fra nuværende
-
- {% for st in status_options %}
-
- {{ st }}
-
- {% endfor %}
-
-
Du kan vælge flere statuser (Cmd/Ctrl + klik).
-
- Når valgt sag lukkes
- Når valgt sag er løst
- Ved statusskifte
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Dato
-
-
- I dag
- +1 dag
- +1 uge
- +1 mnd
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
AnyDesk ID
-
-
ID gemmes automatisk på sagen når du klikker forbind.
-
-
-
-
Gemte IDs på sagen
-
- Indlæser...
-
-
-
-
-
Relatér til hardware (valgfri)
-
- Ingen hardware valgt
-
-
Sagens hardware vises først. Derefter hardware hos kunden.
-
-
-
- Kontakt (valgfri)
-
- Ingen kontakt
- {% for contact in contacts %}
- {{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}
- {% endfor %}
-
-
-
- Notat (valgfri)
-
-
-
- Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dato
- Beskrivelse
- Bruger
- Timer
-
-
-
- {% for entry in time_entries %}
-
- {{ entry.worked_date }}
- {{ entry.description or '-' }}
- {{ entry.user_name }}
- {{ entry.original_hours }}
-
- {% else %}
-
-
- Ingen tid registreret endnu
-
-
- {% endfor %}
-
-
-
-
-
- {% 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 %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% if customers %}
-
- {% for customer in customers %}
-
-
-
{{ customer.role or '-' }}
-
{{ customer.customer_email or '-' }}
-
✕
-
- {% endfor %}
- {% else %}
-
Ingen kunder
- {% endif %}
-
-
-
-
-
-
-
- {% if buzzwords and buzzwords|length > 0 %}
- {% for item in buzzwords %}
-
- {{ item.word }}
-
-
- {% endfor %}
- {% else %}
-
Ingen buzzwords paa sagen endnu
- {% endif %}
-
-
-
-
-
-
-
- {% if contacts %}
-
- {% for contact in contacts %}
-
- {% endfor %}
- {% else %}
-
Ingen kontakter
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
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' }}
-
-
-
-
-
-
-
- Stage
-
- Ikke sat
- {% for stage in pipeline_stages or [] %}
- {{ stage.name }}
- {% endfor %}
-
-
-
- Sandsynlighed (%)
-
-
-
- Beløb (kr.)
-
-
-
- Beskrivelse
- {{ case.pipeline_description or '' }}
-
-
-
- Annuller
- Gem pipeline
-
-
-
-
-
-
-
-
- {% if call_history and call_history|length > 0 %}
-
-
-
-
- Dato
- Retning
- Nummer
- Bruger
- Varighed
-
-
-
- {% for call in call_history %}
-
- {{ 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 }}
-
- Ring op
-
-
- {% 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 %}
-
-
- {% endfor %}
-
-
-
- {% else %}
-
Ingen opkald linket til denne sag
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Alle vedhæftninger
- Med vedhæftning
- Uden vedhæftning
-
-
-
-
- Alle læsestatus
- Ulæste
- Læste
-
-
-
-
Tip: Træk .msg/.eml fil hertil for at importere direkte på sagen.
-
-
-
-
-
- Mail tråd
- 0
-
-
-
Ingen tråde fundet...
-
-
-
-
-
-
-
- Mails i tråden
- 0
-
-
-
Ingen e-mails linket...
-
-
-
-
-
-
-
- Vælg en e-mail i listen for at se indhold og vedhæftninger
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% if is_nextcloud %}
-
-
-
- {% if nextcloud_instance %}
-
-
-
-
- Admin Konto
- {{ nextcloud_instance.username }}
-
-
-
-
-
-
Handlinger
-
-
- Opret bruger
-
-
- Luk bruger
-
-
- Reset kode
-
-
- Send guide
-
-
-
- {% else %}
-
-
- Kunden mangler Nextcloud konfiguration
-
- {% endif %}
-
-
- {% endif %}
-
-
-
- {% if solution %}
-
-
Titel
-
{{ solution.title }}
-
-
-
-
Type
-
{{ solution.solution_type }}
-
-
-
Resultat
-
{{ solution.result }}
-
-
-
-
Beskrivelse
-
{{ solution.description }}
-
- {% else %}
-
-
-
Ingen løsning registreret endnu.
-
- Opret Løsning
-
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dato
- Beskrivelse
- Antal
- Enhed
- Enhedspris
- Linjesum
- Kilde-sag
- Status
- Handlinger
-
-
-
-
- Indlæser salgslinjer...
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dato
- Beskrivelse
- Antal
- Enhed
- Enhedspris
- Linjesum
- Formål
- Kilde-sag
- Status
- Handlinger
-
-
-
-
- Indlæser indkøbslinjer...
-
-
-
-
-
-
-
-
-
-
-
-
- Total salg
- -
-
-
- Netto
- -
-
-
-
-
-
-
-
-
- Timer (total)
- -
-
-
- Timer (fakturerbar)
- -
-
-
- Inkluderer alle under-sager
-
-
-
-
-
-
-
-
-
-
- Dato
- Timer
- Kilde-sag
-
-
-
-
- Indlæser tid...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Type
-
- Vælg type...
- Varekøb
- Købt ydelse
-
-
-
- AI confidence
-
-
-
-
Forslag
-
Ingen forslag endnu
-
-
-
-
-
-
-
-
-
-
-
-
- Beskrivelse
- Beløb
- Handling
- Gem
-
-
-
- Indlæser...
-
-
-
-
-
-
-
-
-
-
-
- Ydelsestype
-
- Husleje
- Rengøring
- Konsulent
- Abonnement
-
-
-
- Periode fra
-
-
-
- Periode til
-
-
-
- Beløb
-
-
-
-
-
- Direkte omkostning
-
-
- Fordel på kunder
-
-
- Gentagende ydelse
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Faktura
- Beløb
- Status
- Actions
-
-
-
- Indlæser...
-
-
-
-
-
-
-
-
-
-
-
-
-
- Aktiv timer:
- Du har en aktiv timer.
-
-
- Pause
- Stop og registrer
-
-
-
-
-
-
-
-
- Medarbejder
-
- Mig (nuværende bruger)
- {% for user in assignment_users %}
- {{ user.display_name }}
- {% endfor %}
-
-
-
- Dato
-
-
-
- Start
-
-
-
- Slut
-
-
-
- Minutt.
-
-
-
- Beskrivelse
-
-
-
-
- Type
-
- Ukendt
- Manuel
- Opkald
- Mail
- AnyDesk
-
-
-
- Status
-
- Kladde
- Afventer
- Godkendt
-
-
-
-
-
-
-
-
- Faktisk tid gemmes internt. Fakturerbar tid afrundes per blok (standard 30 min).
-
-
-
-
-
-
-
-
-
-
Indlæser tidslinje...
-
-
-
-
-
-
-
-
Indlæser sammendrag...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Ingen abonnement oprettet endnu.
-
-
-
-
-
-
-
-
- Produkt
- Beskrivelse
- Antal
- Enhedspris
- Linjesum
-
-
-
-
- Ingen linjer
-
-
-
-
-
-
-
-
-
-
-
- Interval *
-
- Daglig
- Hver 14. dag
- Maaned
- Kvartal
- Aar
-
-
-
- Faktura dag *
-
-
-
- Startdato *
-
-
-
-
Varelinjer *
-
-
-
- Tilfoej linje
-
-
- Opret produkt
-
-
Total: 0,00 kr
-
-
-
- Noter
-
-
-
-
-
- Opret abonnement
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Navn *
-
-
-
- Type
-
-
-
- Status
-
- Aktiv
- Inaktiv
-
-
-
- Salgspris
-
-
-
- Faktureringsinterval
-
- -
- Daglig
- Hver 14. dag
- Maaned
- Kvartal
- Aar
- Engang
-
-
-
- Kort beskrivelse
-
-
-
- Netværksprodukt
-
- Ingen provisioning
- Internet adgang
- IP-allokering
-
-
-
- Download Mbps
-
-
-
- Upload Mbps
-
-
-
- IP prefix
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Indlæser reminders...
-
-
-
-
-
-
-
-
-
Børnesager
-
-
Indlæser børnesager...
-
-
-
-
-
-
-
-
-
-
Reminders følger brugerens standardindstillinger (email, Mattermost og popup), medmindre du vælger at overskrive dem på reminderen.
-
- Tip: Brug "Status ændring" hvis reminderen skal trigges af status.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Titel *
-
-
-
- Prioritet
-
- Lav
- Normal
- Høj
- Kritisk
-
-
-
- Aftaletype
-
- Reminder
- Moede
- Teknikerbesoeg
- OBS
- Deadline
-
-
-
- Besked
-
-
-
- Trigger type
-
- Tidspunkt
- Status ændring
-
-
-
- Tidspunkt
-
-
-
- Status (target)
-
- Vælg status
- {% for status in status_options %}
- {{ status }}
- {% endfor %}
-
-
-
- Gentagelse
-
- Kun én gang
- Dagligt
- Ugentligt
- Månedligt
-
-
-
- Ugedag
-
- Mandag
- Tirsdag
- Onsdag
- Torsdag
- Fredag
- Lørdag
- Søndag
-
-
-
- Dag i måned
-
-
-
-
-
-
- Overskriv brugerens standardindstillinger
-
-
-
-
- Mangler bruger-id. Log ind igen eller opdater siden.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Titel *
-
-
-
-
- Type
-
- Support
- Drift
- Konsulent
- Infrastruktur
-
-
-
- Resultat
-
- Løst
- Delvist
- Workaround
- Ej løst
-
-
-
-
- Beskrivelse
-
-
-
-
-
-
- Registrer tid med det samme
-
-
-
-
- Dato
-
-
-
-
Tid brugt
-
-
- :
-
-
-
Total: 0.00 timer
-
-
- Beskrivelse
-
-
-
- Multiplier
-
- Ingen (x1.00)
-
-
-
-
-
-
- Skjul for kunde (intern registrering)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Type *
-
- Salg
- Køb
-
-
-
- Status *
-
- Kladde
- Bekræftet
- Annulleret
-
-
-
- Dato
-
-
-
- Beskrivelse *
-
-
-
- Antal
-
-
-
- Enhed
-
-
-
- Enhedspris
-
-
-
- Linjesum *
-
-
-
- Valuta
-
-
-
- Reference
-
-
-
-
-
- Indkøbsformål
-
- Vælg formål
- Salg
- Lager
- Asset
- Intern brug
- Retur/reklamation
- Projektomkostning
-
-
-
- Leverandørfaktura ID
-
-
-
- Fakturalinje ID
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dato *
-
-
-
-
Tid brugt *
-
- Min.
-
-
-
-
- Starttid
-
-
-
- Sluttid
-
-
-
-
- Type
-
- Support
- Fejlsøgning
- Udvikling
- Kørsel / On-site
- Møde
- Andet
-
-
-
- Afregning
-
- Faktura
- {% if prepaid_cards %}
-
- {% for card in prepaid_cards %}
- 💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})
- {% endfor %}
-
- {% endif %}
- {% if fixed_price_agreements %}
-
- {% for agr in fixed_price_agreements %}
- 📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)
- {% endfor %}
-
- {% endif %}
- Internt / Ingen faktura
- Garanti / Reklamation
-
-
-
- Beskrivelse
-
-
-
-
Multiplier preset
-
- Ingen (x1.00)
-
-
Preset kan sætte tekst automatisk og gemme multiplier med registreringen.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% if hovedkontakt %}
-
-
Navn
-
{{ hovedkontakt.first_name }} {{ hovedkontakt.last_name }}
-
-
-
-
-
Mobil
-
- {% if hovedkontakt.mobile %}
-
- {% else %}
-
Ingen mobil
- {% endif %}
-
-
- {% if hovedkontakt.title %}
-
-
Titel
-
{{ hovedkontakt.title }}
-
- {% endif %}
- {% else %}
-
Ingen kontakt tilknyttet
- {% endif %}
-
-
-
-
-
-
-
-
-
-
- {% if nextcloud_instance %}
-
-
-
-
-
-
-
-
- E-mail
-
-
-
- Visningsnavn
-
-
-
-
Bruger ID (UID)
-
-
Bruges til login. Oftest email.
-
-
-
Grupper
-
-
Komma-separeret liste af grupper
-
-
-
-
- Send velkomst-email med kode
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Bruger ID (UID)
-
-
-
- Brugeren vil ikke længere kunne logge ind, men data bevares.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Bruger ID (UID)
-
-
- Sender en email med start-guide til Nextcloud til brugerens registrerede mail.
-
-
-
-
-
-
-
-
-
-
-
- Tilføj
-
-
-
-
-
-
-
Vælg et aktivt modul for at tilføje data.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% 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);
')|safe }}
Ingen kommentarer endnu.
- {% endif %} -