feat(email): add RETURNING id to email activity log insert

feat(ollama): implement case creation rewrite with strict rules

fix(sync): include phone number in economic customer sync

fix(migrations): clean up orphan links before enforcing foreign keys in email threading schema

feat(migrations): add support for physical patch-panel layouts and update related constraints

feat(migrations): add start port number for physical patch panels

feat(migrations): introduce display order for physical panels

feat(migrations): link wall outlets to switch hardware

feat(migrations): allow optional label and customer for wall outlets

feat(migrations): create hardware network links table

feat(migrations): add location display order for hardware assets

feat(migrations): create UISP devices and link to hardware assets
This commit is contained in:
Christian 2026-07-18 10:10:31 +02:00
parent d4d9ac22a5
commit be68148448
23 changed files with 1543 additions and 16681 deletions

View File

@ -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,
}

View File

@ -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:

View File

@ -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 [],
})

View File

@ -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 %}
<!-- Location (Current) -->
{% 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) %}
<div class="quick-info-item">
<span class="quick-info-label">Lokation:</span>
<span>{{ current_loc.location_name }}</span>
@ -409,11 +422,11 @@
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
</div>
<div class="card-body">
{% if current_loc and not current_loc.end_date %}
{% if current_loc and (current_location or not current_loc.end_date) %}
<div class="text-center py-3">
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
<p class="text-muted small mb-0">{% if current_loc.start_date %}Siden: {{ current_loc.start_date }}{% else %}Aktuel placering{% endif %}</p>
{% if current_loc.notes %}
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
{% endif %}
@ -675,6 +688,64 @@
</div>
</div>
{% if switch_ports %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-hdd-network me-2"></i>Switch-porte</h6>
<span class="text-muted small">{{ switch_ports | length }} porte</span>
</div>
<div class="card-body">
<div class="switch-port-panel"><div class="switch-port-grid">
{% for port in switch_ports %}
<button type="button" class="switch-port-button {% if port.hardware_link %}hardware-linked{% elif port.connection %}connected{% endif %}{% if port.live %} {{ 'live-up' if port.live.plugged else 'live-down' }}{% endif %}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.connection.id if port.connection else '' }}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.connection %}{{ port.connection.outlet_number }} · klik for at ændre{% else %}Ledig port — klik for at tilknytte vægstik{% endif %}">
<span>Port {{ port.port_number }}</span>
{% if port.live %}<span class="switch-port-live">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% endif %}
{% if port.hardware_link %}<span class="switch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.connection %}<span class="switch-port-outlet">{{ port.connection.outlet_number }}</span>{% else %}<span class="switch-port-outlet">Ledig</span>{% endif %}
</button>
{% endfor %}
</div></div>
</div>
</div>
{% endif %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<div><h6 class="text-primary mb-0"><i class="bi bi-broadcast-pin me-2"></i>UISP live-data</h6><div class="small text-muted">{{ 'Koblet til UISP-enhed' if uisp_device else 'Ingen UISP-enhed koblet endnu' }}</div></div>
<div class="d-flex gap-2">{% if uisp_device %}<button type="button" class="btn btn-sm btn-outline-primary" id="refreshUispBtn"><i class="bi bi-arrow-repeat me-1"></i>Opdatér nu</button><button type="button" class="btn btn-sm btn-outline-danger" id="unlinkUispBtn">Fjern kobling</button>{% else %}<button type="button" class="btn btn-sm btn-primary" id="linkUispBtn"><i class="bi bi-link-45deg me-1"></i>Kobl UISP-enhed</button>{% endif %}</div>
</div>
<div class="card-body">
{% if uisp_device %}
{% set overview = uisp_device.overview or {} %}
<div class="row g-3 small">
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp_device.last_seen or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp_device.firmware.version or uisp_device.firmware.name or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp_device.platform or '—' }}{% if uisp_device.device_role %} · {{ uisp_device.device_role }}{% endif %}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Signal</span><strong>{{ overview.signal or overview.signalMax or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Kapacitet</span><strong>{{ overview.totalCapacity or overview.uplinkCapacity or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp_device.synced_at or '—' }}</strong></div>
</div>
<div class="mt-3 d-flex justify-content-between align-items-center"><span class="small text-muted">{{ uisp_device.vendor or '' }} {{ uisp_device.model or '' }}{% if uisp_device.serial_number %} · {{ uisp_device.serial_number }}{% endif %}</span>{% if uisp_device.device_link %}<a href="{{ uisp_device.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
{% else %}<span class="text-muted">Kobl en synkroniseret UISP-enhed for at se live-status og tekniske data her.</span>{% endif %}
</div>
</div>
{% if hardware.asset_type == 'netværk' %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"><h6 class="text-primary mb-0"><i class="bi bi-diagram-3 me-2"></i>Hardwareforbindelser</h6><button type="button" class="btn btn-sm btn-outline-primary" id="addHardwareLinkBtn"><i class="bi bi-plus-lg me-1"></i>Forbind hardware</button></div>
<div class="card-body"><div class="list-group list-group-flush" id="hardwareNetworkLinksList">
{% for link in network_links %}
<div class="list-group-item d-flex justify-content-between align-items-center px-0"><div><strong>Port {{ link.source_port }}</strong> → {{ link.target_brand or '' }} {{ link.target_model }}{% if link.target_serial %} · {{ link.target_serial }}{% endif %}{% if link.target_port %}<span class="text-muted"> · port {{ link.target_port }}</span>{% endif %}</div><button class="btn btn-sm btn-outline-danger delete-network-link-btn" data-link-id="{{ link.id }}"><i class="bi bi-x"></i></button></div>
{% else %}<span class="text-muted small">Ingen hardwareforbindelser registreret endnu.</span>{% endfor %}
</div></div>
</div>
{% endif %}
{% if hardware.hardware_specs %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
@ -1234,6 +1305,21 @@
</div>
</div>
<div class="modal fade" id="switchPortAssignModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><form class="modal-content" id="switchPortAssignForm">
<div class="modal-header"><h5 class="modal-title">Tilknyt vægstik til port <span id="switchPortAssignNumber"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<input type="hidden" id="switchPortAssignPort">
<div class="mb-3"><label class="form-label">Vægstik</label><select id="switchPortAssignOutlet" class="form-select" required></select><div class="form-text">Vælges et stik, der allerede sidder på en anden switch-port, bliver du bedt om at bekræfte flytningen.</div></div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-outline-primary me-auto" id="switchPortAssignHardwareLink"><i class="bi bi-diagram-3 me-1"></i>Forbind hardware / switch</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem vægstik</button></div>
</form></div>
</div>
<div class="modal fade" id="hardwareLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="hardwareLinkForm"><div class="modal-header"><h5 class="modal-title">Forbind switch til hardware</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Switch-port</label><input id="hardwareLinkSourcePort" class="form-control" required placeholder="Fx 1 eller Gi1/0/1"></div><div class="mb-3"><label class="form-label">Tilsluttet hardware</label><select id="hardwareLinkTarget" class="form-select" required><option value="">Vælg hardware</option>{% for item in available_network_hardware %}<option value="{{ item.id }}">{{ item.brand or '' }} {{ item.model }}{% if item.serial_number %} · {{ item.serial_number }}{% endif %}</option>{% endfor %}</select></div><div class="mb-3"><label class="form-label">Port på mål-hardware</label><input id="hardwareLinkTargetPort" class="form-control" placeholder="Valgfri, fx WAN eller 0"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem forbindelse</button></div></form></div></div>
<div class="modal fade" id="uispLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg"><form class="modal-content" id="uispLinkForm"><div class="modal-header"><h5 class="modal-title">Kobl UISP-enhed</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Søg UISP-enhed</label><input id="uispDeviceSearch" class="form-control" placeholder="Navn, MAC, serienummer eller model"></div><div class="form-text mb-2">Forslag med højest match vises først. Koblingen bekræftes først når du gemmer.</div><select id="uispDeviceSelect" class="form-select" size="8" required><option value="">Indlæser UISP-enheder…</option></select></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Kobl enhed</button></div></form></div></div>
{% 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 = '<option value="">Indlæser…</option>';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/uisp-devices?query=${encodeURIComponent(search)}`);
if (!response.ok) { uispDeviceSelect.innerHTML = '<option value="">Kunne ikke indlæse UISP-enheder</option>'; return; }
const data = await response.json();
const devices = data.devices || [];
uispDeviceSelect.innerHTML = '';
if (!devices.length) {
uispDeviceSelect.innerHTML = '<option value="">Ingen ledige UISP-enheder fundet</option>';
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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<i class="bi bi-arrow-repeat me-1"></i>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 = '<option value="">Vælg vægstik</option>';
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);

View File

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

View File

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

View File

@ -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

View File

@ -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 %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %}
</tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
@ -865,8 +866,8 @@
</div>
<div class="card-body">
{% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">{{ field.port_count }} porte</span></div><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div>
<div class="patch-panel"><div class="patch-panel-grid">{% 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 '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% 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 '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div>
</div>
@ -883,12 +884,14 @@
{% if location.hardware %}
<div class="list-group">
{% for hw in location.hardware %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div>
<div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center gap-3">
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
</div>
<span class="badge bg-secondary">{{ hw.status }}</span>
{% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
{% endif %}
</div>
{% endfor %}
</div>
@ -1051,11 +1054,23 @@
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
<input type="hidden" id="crossFieldId">
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 24"></div>
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 48"></div>
<div class="mb-3"><label class="form-label">Portmærkning</label><select class="form-select" id="crossFieldPortLabelFormat"><option value="numeric">1, 2, 3 …</option><option value="paired">1A, 1B, 2A, 2B …</option></select><div class="form-text">A/B-par kræver et lige antal porte, fx 48 porte = 1A24B.</div></div>
<div class="mb-3"><label class="form-label">Startnummer</label><input class="form-control" type="number" id="crossFieldStartPortNumber" min="1" max="9999" value="1"><div class="form-text">Næste panel kan fx starte ved 25, så det bliver 25A, 25B …</div></div>
<div class="mb-3"><label class="form-label">Porte pr. række</label><input class="form-control" type="number" id="crossFieldPanelRowSize" min="1" max="48" value="24"><div class="form-text">Sæt fx 24 for samme brede panelopstilling som på billedet.</div></div>
<div class="mb-3"><label class="form-label">Visningsrækkefølge</label><input class="form-control" type="number" id="crossFieldDisplayOrder" min="1" max="9999" value="1"><div class="form-text">Laveste nummer vises øverst. Ændr fx et panel til 1 og et andet til 2.</div></div>
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
</div>
<div class="modal fade" id="crossFieldPortLabelsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="crossFieldPortLabelsForm">
<div class="modal-header"><h5 class="modal-title">Rediger portnumre: <span id="crossFieldPortLabelsName"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><p class="small text-muted">Ændr de fysiske mærkninger frit, fx <code>1A</code>, <code>Kontor-12</code> eller <code>Rack2-07</code>. Forbindelser bevares på den samme port.</p><div id="crossFieldPortLabelsList" class="row g-2"></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem portnumre</button></div>
</form></div>
</div>
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
@ -1063,13 +1078,14 @@
<form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer *</label><input class="form-control" id="outletNumber" required placeholder="Fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" placeholder="Fx Switch 3"></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><input class="form-control" id="outletSwitchPort" placeholder="Fx Gi1/0/12"></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div>
@ -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 = '<option value="">Vælg port</option>';
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 = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(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;
}
}));
});
</script>
{% endblock %}

View File

@ -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."""

View File

@ -212,9 +212,17 @@
</div>
<div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label>
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="beskrivelse" class="form-label mb-0">Beskrivelse</label>
<button type="button" id="caseCreateRewriteBtn" class="btn btn-sm btn-outline-primary" title="Renskriv kun det, du allerede har skrevet">
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div>
<div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
</div>
</div>
</div>
@ -339,6 +347,34 @@
</div>
</div>
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="caseCreateRewriteModalLabel"><i class="bi bi-magic me-2"></i>AI-forslag til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">
Gennemgå forslaget før du bruger det. AI må kun have rettet formulering og foreslået en titel ud fra din egen tekst.
</div>
<div class="mb-3">
<label for="caseCreateSuggestedTitle" class="form-label">Foreslået titel</label>
<input id="caseCreateSuggestedTitle" class="form-control" type="text">
</div>
<div>
<label for="caseCreateSuggestedDescription" class="form-label">Renskrevet beskrivelse</label>
<textarea id="caseCreateSuggestedDescription" class="form-control" rows="10"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="caseCreateApplyRewriteBtn" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Brug forslag</button>
</div>
</div>
</div>
</div>
<script>
let selectedCustomer = null;
let selectedContacts = {};
@ -444,6 +480,76 @@
});
}
// --- AI renskrivning ved oprettelse ---
// Forslaget bliver altid vist først; formularens titel og beskrivelse ændres
// først når brugeren aktivt vælger "Brug forslag".
async function requestCaseCreateRewrite() {
const descriptionInput = document.getElementById('beskrivelse');
const titleInput = document.getElementById('titel');
const button = document.getElementById('caseCreateRewriteBtn');
const source = (descriptionInput?.value || '').trim();
if (!source) {
descriptionInput?.focus();
alert('Skriv en beskrivelse først.');
return;
}
const originalButton = button?.innerHTML || '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const response = await fetch('/api/v1/sag/rewrite-case-create', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleInput?.value || '', description: source })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.detail || `HTTP ${response.status}`);
}
const suggestion = {
title: String(payload?.title || '').trim(),
description: String(payload?.description || '').trim()
};
if (!suggestion.description) {
throw new Error('AI returnerede ikke en beskrivelse');
}
document.getElementById('caseCreateSuggestedTitle').value = suggestion.title || titleInput?.value || '';
document.getElementById('caseCreateSuggestedDescription').value = suggestion.description;
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalButton;
}
}
}
document.getElementById('caseCreateRewriteBtn')?.addEventListener('click', requestCaseCreateRewrite);
document.getElementById('caseCreateApplyRewriteBtn')?.addEventListener('click', () => {
const suggestedTitle = document.getElementById('caseCreateSuggestedTitle').value.trim();
const suggestedDescription = document.getElementById('caseCreateSuggestedDescription').value.trim();
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input'));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
// --- Search Logic ---
function initializeSearch() {
// Customer Search

File diff suppressed because it is too large Load Diff

View File

@ -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)
)

View File

@ -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 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 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:
"""

View File

@ -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(

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);