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:
parent
d4d9ac22a5
commit
be68148448
@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single
|
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 []
|
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]:
|
def _parse_prometheus_labels(raw: str) -> Dict[str, str]:
|
||||||
labels: Dict[str, str] = {}
|
labels: Dict[str, str] = {}
|
||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
@ -1068,6 +1081,112 @@ def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]:
|
|||||||
return []
|
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]]:
|
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)
|
items = _parse_uisp_payload(payload)
|
||||||
events: List[Dict[str, Any]] = []
|
events: List[Dict[str, Any]] = []
|
||||||
@ -1283,6 +1402,21 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
|
|||||||
"api/v2/sites",
|
"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)
|
events = _build_events_from_uisp_payload(payload, source.get("id"), base_url)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
logger.warning("⚠️ Drift UISP sync failed: %s", 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"),
|
"source": source.get("name"),
|
||||||
"mode": "live",
|
"mode": "live",
|
||||||
"blacklisted_skipped": skipped,
|
"blacklisted_skipped": skipped,
|
||||||
|
"cached_devices": cached_devices,
|
||||||
|
"detailed_devices": detailed_devices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||||
@ -497,6 +498,202 @@ async def get_hardware(hardware_id: int):
|
|||||||
return result[0]
|
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)
|
@router.patch("/hardware/{hardware_id}", response_model=dict)
|
||||||
async def update_hardware(hardware_id: int, data: dict):
|
async def update_hardware(hardware_id: int, data: dict):
|
||||||
"""Update hardware asset."""
|
"""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",
|
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
||||||
"eset_uuid", "hardware_specs", "eset_group",
|
"eset_uuid", "hardware_specs", "eset_group",
|
||||||
"rental_default_start_price", "rental_default_freight_price",
|
"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:
|
for field in allowed_fields:
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
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")
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
||||||
|
|
||||||
hardware = result[0]
|
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
|
# Get customer name if applicable
|
||||||
if hardware.get('current_owner_customer_id'):
|
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
|
ORDER BY start_date DESC
|
||||||
"""
|
"""
|
||||||
locations = execute_query(location_query, (hardware_id,))
|
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
|
# Get attachments
|
||||||
attachment_query = """
|
attachment_query = """
|
||||||
@ -670,6 +795,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
"hardware": hardware,
|
"hardware": hardware,
|
||||||
"ownership": ownership or [],
|
"ownership": ownership or [],
|
||||||
"locations": locations or [],
|
"locations": locations or [],
|
||||||
|
"current_location": current_location,
|
||||||
"attachments": attachments or [],
|
"attachments": attachments or [],
|
||||||
"cases": cases or [],
|
"cases": cases or [],
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
@ -679,6 +805,11 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
"owner_contacts": owner_contacts or [],
|
"owner_contacts": owner_contacts or [],
|
||||||
"location_tree": location_tree or [],
|
"location_tree": location_tree or [],
|
||||||
"eset_specs": extract_eset_specs_summary(hardware),
|
"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,
|
"rental_stats": rental_stats,
|
||||||
"recent_rentals": recent_rentals or [],
|
"recent_rentals": recent_rentals or [],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -54,6 +54,19 @@
|
|||||||
font-size: 1.5rem;
|
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 Styling */
|
||||||
.timeline {
|
.timeline {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -223,8 +236,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Location (Current) -->
|
<!-- Location (Current) -->
|
||||||
{% set current_loc = locations[0] if locations else None %}
|
{% set current_loc = current_location or (locations[0] if locations else None) %}
|
||||||
{% if current_loc and not current_loc.end_date %}
|
{% if current_loc and (current_location or not current_loc.end_date) %}
|
||||||
<div class="quick-info-item">
|
<div class="quick-info-item">
|
||||||
<span class="quick-info-label">Lokation:</span>
|
<span class="quick-info-label">Lokation:</span>
|
||||||
<span>{{ current_loc.location_name }}</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>
|
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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="text-center py-3">
|
||||||
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
||||||
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
|
<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 %}
|
{% if current_loc.notes %}
|
||||||
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -675,6 +688,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if hardware.hardware_specs %}
|
||||||
<div class="card mt-4 shadow-sm border-0">
|
<div class="card mt-4 shadow-sm border-0">
|
||||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||||
@ -1234,6 +1305,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% 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() {
|
async function submitQuickRent() {
|
||||||
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
|
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
|
||||||
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
|
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
|
||||||
|
|||||||
@ -41,7 +41,8 @@ from app.modules.locations.models.schemas import (
|
|||||||
Capacity, CapacityCreate, CapacityUpdate,
|
Capacity, CapacityCreate, CapacityUpdate,
|
||||||
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
||||||
LocationWizardCreateRequest, LocationWizardCreateResponse,
|
LocationWizardCreateRequest, LocationWizardCreateResponse,
|
||||||
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
|
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate,
|
||||||
|
CrossFieldPortLabelsUpdate
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -612,6 +613,25 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
|
_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])
|
@router.get('/locations/cross-fields', response_model=List[CrossField])
|
||||||
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
|
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'
|
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:
|
if location_id:
|
||||||
where += ' AND cf.location_id = %s'
|
where += ' AND cf.location_id = %s'
|
||||||
params = (location_id,)
|
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:
|
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]
|
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
|
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
|
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
|
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 []
|
''') or []
|
||||||
|
|
||||||
|
|
||||||
@ -646,19 +666,22 @@ async def create_cross_field(data: CrossFieldCreate):
|
|||||||
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
|
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
|
||||||
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
|
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')
|
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:
|
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:
|
if not created:
|
||||||
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
|
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
|
||||||
field = created[0]
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if 'unique' in str(exc).lower():
|
if 'unique' in str(exc).lower():
|
||||||
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||||
raise
|
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)
|
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 HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||||
raise
|
raise
|
||||||
if requested_ports and requested_ports > field['port_count']:
|
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 = (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)
|
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:
|
def _outlet_location(location_id: int) -> dict:
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
|
"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 = """
|
_OUTLET_SELECT = """
|
||||||
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
|
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
|
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
|
||||||
FROM locations_wall_outlets o
|
FROM locations_wall_outlets o
|
||||||
JOIN locations_locations l ON l.id = o.location_id
|
JOIN locations_locations l ON l.id = o.location_id
|
||||||
LEFT JOIN customers c ON c.id = l.customer_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 (
|
LEFT JOIN LATERAL (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
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]
|
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)
|
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
|
||||||
async def create_wall_outlet(data: WallOutletCreate):
|
async def create_wall_outlet(data: WallOutletCreate):
|
||||||
_outlet_location(data.location_id)
|
_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:
|
try:
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""INSERT INTO locations_wall_outlets
|
"""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)
|
(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) RETURNING id""",
|
VALUES (%s, %s, %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),
|
(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 []
|
) or []
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if 'unique' in str(exc).lower():
|
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)
|
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
|
||||||
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
||||||
changes = data.model_dump(exclude_unset=True)
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
replace_existing_switch_port = changes.pop('replace_existing_switch_port', False)
|
||||||
if not changes:
|
if not changes:
|
||||||
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
|
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
|
||||||
if 'outlet_number' in changes:
|
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)
|
fields = ', '.join(f'{field} = %s' for field in changes)
|
||||||
try:
|
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 []
|
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 []
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
||||||
from pathlib import Path as PathlibPath
|
from pathlib import Path as PathlibPath
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.database import execute_query, execute_update
|
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(
|
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
|
FROM hardware_assets
|
||||||
WHERE current_location_id = %s AND deleted_at IS NULL
|
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,)
|
(id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
wall_outlets = execute_query(
|
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
|
FROM locations_wall_outlets
|
||||||
WHERE location_id = %s AND deleted_at IS NULL
|
WHERE location_id = %s AND deleted_at IS NULL
|
||||||
ORDER BY outlet_number
|
ORDER BY outlet_number
|
||||||
@ -614,22 +615,75 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
(id,),
|
(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(
|
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
|
FROM locations_cross_fields
|
||||||
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
||||||
ORDER BY name""",
|
ORDER BY display_order, id""",
|
||||||
(id,),
|
(id,),
|
||||||
)
|
)
|
||||||
for cross_field in cross_fields or []:
|
for cross_field in cross_fields or []:
|
||||||
cross_field["ports"] = execute_query(
|
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,
|
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
|
||||||
l.name AS outlet_location_name
|
l.name AS outlet_location_name
|
||||||
FROM locations_cross_field_ports p
|
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_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
|
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"],),
|
(cross_field["id"],),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
|
|||||||
@ -112,11 +112,13 @@ OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
|
|||||||
|
|
||||||
class WallOutletBase(BaseModel):
|
class WallOutletBase(BaseModel):
|
||||||
location_id: int = Field(..., ge=1)
|
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)
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
patch_panel: Optional[str] = Field(None, max_length=255)
|
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||||
patch_port: Optional[str] = Field(None, max_length=100)
|
patch_port: Optional[str] = Field(None, max_length=100)
|
||||||
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
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_name: Optional[str] = Field(None, max_length=255)
|
||||||
switch_port: Optional[str] = Field(None, max_length=100)
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
status: str = Field('unknown')
|
status: str = Field('unknown')
|
||||||
@ -132,20 +134,23 @@ class WallOutletBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WallOutletCreate(WallOutletBase):
|
class WallOutletCreate(WallOutletBase):
|
||||||
pass
|
replace_existing_switch_port: bool = False
|
||||||
|
|
||||||
|
|
||||||
class WallOutletUpdate(BaseModel):
|
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)
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
patch_panel: Optional[str] = Field(None, max_length=255)
|
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||||
patch_port: Optional[str] = Field(None, max_length=100)
|
patch_port: Optional[str] = Field(None, max_length=100)
|
||||||
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
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_name: Optional[str] = Field(None, max_length=255)
|
||||||
switch_port: Optional[str] = Field(None, max_length=100)
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
replace_existing_switch_port: bool = False
|
||||||
|
|
||||||
@field_validator('status')
|
@field_validator('status')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -163,6 +168,7 @@ class WallOutlet(WallOutletBase):
|
|||||||
location_name: Optional[str] = None
|
location_name: Optional[str] = None
|
||||||
location_type: Optional[str] = None
|
location_type: Optional[str] = None
|
||||||
customer_name: Optional[str] = None
|
customer_name: Optional[str] = None
|
||||||
|
outlet_customer_name: Optional[str] = None
|
||||||
hierarchy_path: Optional[str] = None
|
hierarchy_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@ -170,26 +176,47 @@ class CrossFieldCreate(BaseModel):
|
|||||||
location_id: int = Field(..., ge=1)
|
location_id: int = Field(..., ge=1)
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
port_count: int = Field(..., ge=1, le=999)
|
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
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CrossFieldUpdate(BaseModel):
|
class CrossFieldUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
port_count: Optional[int] = Field(None, ge=1, le=999)
|
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
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CrossFieldPort(BaseModel):
|
class CrossFieldPort(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
port_number: int
|
port_number: str
|
||||||
|
port_order: int
|
||||||
is_active: bool
|
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):
|
class CrossField(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
location_id: int
|
location_id: int
|
||||||
name: str
|
name: str
|
||||||
port_count: int
|
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
|
notes: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@ -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%; }
|
.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; }
|
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.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.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
|
||||||
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||||||
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||||||
@ -809,7 +810,7 @@
|
|||||||
{% elif location.wall_outlets %}
|
{% 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>
|
<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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
||||||
@ -865,8 +866,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% for field in location.cross_fields %}
|
{% 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="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">{% 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="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 %}
|
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -883,12 +884,14 @@
|
|||||||
{% if location.hardware %}
|
{% if location.hardware %}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for hw in location.hardware %}
|
{% for hw in location.hardware %}
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
<div class="list-group-item">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||||
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div>
|
<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="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</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>
|
</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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-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>
|
<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">
|
<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 = 1A–24B.</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><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 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>
|
||||||
|
|
||||||
|
<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="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">
|
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
||||||
@ -1063,13 +1078,14 @@
|
|||||||
<form id="outletForm"><div class="modal-body">
|
<form id="outletForm"><div class="modal-body">
|
||||||
<input type="hidden" id="outletId"><div class="row g-3">
|
<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-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-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</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-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-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</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><input class="form-control" id="outletSwitchPort" placeholder="Fx Gi1/0/12"></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-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 class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
@ -1106,6 +1122,8 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
const locationId = '{{ location.id }}';
|
const locationId = '{{ location.id }}';
|
||||||
|
const locationHardware = {{ location.hardware | tojson }};
|
||||||
|
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||||
const existingContactIdInput = document.getElementById('existingContactId');
|
const existingContactIdInput = document.getElementById('existingContactId');
|
||||||
@ -1397,14 +1415,56 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const crossFieldButton = document.getElementById('addCrossFieldBtn');
|
const crossFieldButton = document.getElementById('addCrossFieldBtn');
|
||||||
const crossFieldModalElement = document.getElementById('crossFieldModal');
|
const crossFieldModalElement = document.getElementById('crossFieldModal');
|
||||||
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
|
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(); });
|
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('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger 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) => {
|
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const crossFieldId = document.getElementById('crossFieldId').value;
|
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'); }
|
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'));
|
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
|
||||||
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
|
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'); });
|
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 outletForm = document.getElementById('outletForm');
|
||||||
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
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() {
|
async function loadCrossFieldPorts() {
|
||||||
const select = document.getElementById('outletCrossFieldPort');
|
const select = document.getElementById('outletCrossFieldPort');
|
||||||
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
|
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);
|
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) {
|
async function openOutletModal(outlet = null, selectedPort = null) {
|
||||||
if (!outletModal) return;
|
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('outletId').value = outlet?.id || '';
|
||||||
document.getElementById('outletNumber').value = outlet?.number || '';
|
document.getElementById('outletNumber').value = outlet?.number || '';
|
||||||
document.getElementById('outletCategory').value = outlet?.category || '';
|
document.getElementById('outletCategory').value = outlet?.category || '';
|
||||||
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
|
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
|
||||||
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||||||
document.getElementById('outletSwitch').value = outlet?.switchName || '';
|
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
||||||
document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
|
|
||||||
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||||||
document.getElementById('outletNotes').value = outlet?.notes || '';
|
document.getElementById('outletNotes').value = outlet?.notes || '';
|
||||||
if (selectedPort) {
|
if (selectedPort) {
|
||||||
@ -1457,7 +1603,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
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('[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({
|
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,
|
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
|
||||||
status: btn.dataset.status, notes: btn.dataset.notes
|
status: btn.dataset.status, notes: btn.dataset.notes
|
||||||
})));
|
})));
|
||||||
@ -1466,15 +1612,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const outletId = document.getElementById('outletId').value;
|
const outletId = document.getElementById('outletId').value;
|
||||||
const payload = {
|
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'),
|
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||||||
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
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')
|
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', {
|
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)
|
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) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({}));
|
const error = await response.json().catch(() => ({}));
|
||||||
alert(error.detail || 'Kunne ikke gemme vægstik');
|
alert(error.detail || 'Kunne ikke gemme vægstik');
|
||||||
@ -1490,6 +1654,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (response.ok) location.reload();
|
if (response.ok) location.reload();
|
||||||
else alert('Kunne ikke slette vægstik');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -311,6 +311,17 @@ class RewriteTextResponse(BaseModel):
|
|||||||
context: Optional[str] = None
|
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):
|
class SagSendEmailRequest(BaseModel):
|
||||||
to: List[str]
|
to: List[str]
|
||||||
subject: str = Field(..., min_length=1, max_length=998)
|
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)}")
|
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)
|
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
|
||||||
async def rewrite_sag_text(request: RewriteTextRequest):
|
async def rewrite_sag_text(request: RewriteTextRequest):
|
||||||
"""Rewrite case/email text using Ollama with configurable prompt."""
|
"""Rewrite case/email text using Ollama with configurable prompt."""
|
||||||
|
|||||||
@ -212,9 +212,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -339,6 +347,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
let selectedCustomer = null;
|
let selectedCustomer = null;
|
||||||
let selectedContacts = {};
|
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 ---
|
// --- Search Logic ---
|
||||||
function initializeSearch() {
|
function initializeSearch() {
|
||||||
// Customer Search
|
// Customer Search
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -45,7 +45,8 @@ class EmailActivityLogger:
|
|||||||
log_id = execute_insert(
|
log_id = execute_insert(
|
||||||
"""INSERT INTO email_activity_log
|
"""INSERT INTO email_activity_log
|
||||||
(email_id, event_type, event_category, description, metadata, user_id, created_by)
|
(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)
|
(email_id, event_type, category, description, metadata_json, user_id, created_by)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
Teksten kan være enten en e-mail eller en sagsbeskrivelse.
|
||||||
|
|
||||||
Regler:
|
Regler:
|
||||||
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer.
|
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer uændret.
|
||||||
2. Ret stavefejl, tegnsætning og grammatik.
|
2. Ret kun stavefejl, tegnsætning, grammatik og tydelig formulering.
|
||||||
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information.
|
3. Tilføj, gæt eller udled ALDRIG nye oplysninger. Ændr heller ikke rækkefølge, betydning, ansvar, omfang eller tekniske detaljer.
|
||||||
4. Fjern fyldord, gentagelser og intern støj.
|
4. Fjern kun en gentagelse, hvis den er helt identisk; behold ellers hele indholdet.
|
||||||
5. Bevar tone og intention: neutral, serviceminded og professionel.
|
5. Bevar tone og intention: neutral, serviceminded og professionel.
|
||||||
6. Opfind aldrig nye oplysninger.
|
6. Opfind aldrig nye oplysninger.
|
||||||
7. Hvis input er e-mail: returner i formatet:
|
7. Hvis input er e-mail: returner i formatet:
|
||||||
@ -259,6 +259,100 @@ Output:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Ollama text rewrite failed: %s", e)
|
logger.error("❌ Ollama text rewrite failed: %s", e)
|
||||||
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
|
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
|
||||||
|
|
||||||
|
async def rewrite_case_creation(self, title: str, description: str) -> Dict:
|
||||||
|
"""Create a conservative, structured rewrite for the new-case form.
|
||||||
|
|
||||||
|
This intentionally does not use the configurable generic rewrite prompt:
|
||||||
|
case creation needs a reliable title and must never make up case facts.
|
||||||
|
"""
|
||||||
|
clean_description = (description or "").strip()
|
||||||
|
if not clean_description:
|
||||||
|
return {"error": "Input text is empty"}
|
||||||
|
|
||||||
|
system_prompt = """Du renskriver sagsbeskrivelser for et IT-system.
|
||||||
|
|
||||||
|
Du skal returnere KUN gyldig JSON på præcis denne form:
|
||||||
|
{"title":"...", "description":"..."}
|
||||||
|
|
||||||
|
ABSOLUTTE REGLER FOR description:
|
||||||
|
- Bevar alle fakta, navne, tal, datoer, versioner, IP-adresser, tekniske termer og usikkerheder præcist.
|
||||||
|
- Ret kun stavning, tegnsætning, grammatik og åbenlyst uklare formuleringer.
|
||||||
|
- Tilføj, gæt, forklar eller udled aldrig noget, der ikke står i input.
|
||||||
|
- Fjern ikke detaljer. Bevar også ønsker, spørgsmål, fejl og forbehold.
|
||||||
|
- Brug korte afsnit eller punktopstilling kun når input allerede tydeligt indeholder flere separate punkter.
|
||||||
|
|
||||||
|
REGLER FOR title:
|
||||||
|
- Skriv en kort, konkret titel på 4-10 ord, der beskriver det faktiske arbejde eller problem i teksten.
|
||||||
|
- Brug de mest specifikke ord fra teksten, f.eks. produkt, funktion, fejl eller handling.
|
||||||
|
- Brug ikke tomme titler som "Support", "Henvendelse", "Problem" eller "Ny sag" alene.
|
||||||
|
- Opfind ikke kunde, produkt, årsag eller løsning. Hvis teksten ikke giver nok grundlag, behold den eksisterende titel (kun med stavning rettet).
|
||||||
|
"""
|
||||||
|
user_message = (
|
||||||
|
"Eksisterende titel (kan være tom):\n"
|
||||||
|
f"{(title or '').strip()}\n\n"
|
||||||
|
"Sagsbeskrivelse, som er eneste kilde til fakta:\n"
|
||||||
|
f"{clean_description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
model_normalized = (self.model or "").strip().lower()
|
||||||
|
use_chat_api = model_normalized.startswith("qwen")
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
if use_chat_api:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": f"{system_prompt}\n\nBrugerinput:\n{user_message}",
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return {"error": f"Ollama returned status {response.status_code}: {response.text[:300]}"}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
if use_chat_api:
|
||||||
|
raw = str(((payload.get("message") or {}).get("content") or "")).strip()
|
||||||
|
else:
|
||||||
|
raw = str((payload or {}).get("response") or "").strip()
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip()
|
||||||
|
structured = json.loads(raw)
|
||||||
|
result_title = str(structured.get("title") or "").strip()
|
||||||
|
result_description = str(structured.get("description") or "").strip()
|
||||||
|
if not result_title or not result_description:
|
||||||
|
return {"error": "Ollama returned an incomplete case rewrite"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": result_title[:200],
|
||||||
|
"description": result_description,
|
||||||
|
"model": self.model,
|
||||||
|
}
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("⚠️ Ollama returned invalid case-create rewrite: %s", e)
|
||||||
|
return {"error": "Ollama returned an invalid case rewrite"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Ollama case-create rewrite failed: %s", e)
|
||||||
|
return {"error": f"Ollama rewrite failed: {str(e)}"}
|
||||||
|
|
||||||
async def extract_from_text(self, text: str) -> Dict:
|
async def extract_from_text(self, text: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -7,7 +7,7 @@ SYNC ARCHITECTURE - Field Ownership:
|
|||||||
|
|
||||||
E-CONOMIC owns and syncs:
|
E-CONOMIC owns and syncs:
|
||||||
- economic_customer_number (primary key from e-conomic)
|
- 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)
|
- email_domain, website (contact information)
|
||||||
- cvr_number (company metadata; may be shared by several customers)
|
- 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')
|
country = eco_customer.get('country', 'DK')
|
||||||
email = eco_customer.get('email', '')
|
email = eco_customer.get('email', '')
|
||||||
website = eco_customer.get('website', '')
|
website = eco_customer.get('website', '')
|
||||||
|
phone = eco_customer.get('phone') or eco_customer.get('telephone') or ''
|
||||||
|
|
||||||
if not customer_number or not name:
|
if not customer_number or not name:
|
||||||
skipped_count += 1
|
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
|
# Strict matching: ONLY match by economic_customer_number
|
||||||
existing = execute_query(
|
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
|
FROM customers
|
||||||
WHERE economic_customer_number = %s
|
WHERE economic_customer_number = %s
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@ -221,6 +222,8 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
if existing:
|
if existing:
|
||||||
target_customer_id = existing[0]['id']
|
target_customer_id = existing[0]['id']
|
||||||
current_values = {
|
current_values = {
|
||||||
|
"name": existing[0].get("name"),
|
||||||
|
"phone": existing[0].get("phone"),
|
||||||
"cvr_number": existing[0].get("cvr_number"),
|
"cvr_number": existing[0].get("cvr_number"),
|
||||||
"email_domain": existing[0].get("email_domain"),
|
"email_domain": existing[0].get("email_domain"),
|
||||||
"address": existing[0].get("address"),
|
"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"),
|
"website": existing[0].get("website"),
|
||||||
}
|
}
|
||||||
proposed_values = {
|
proposed_values = {
|
||||||
|
"name": name,
|
||||||
|
"phone": phone,
|
||||||
"cvr_number": cvr,
|
"cvr_number": cvr,
|
||||||
"email_domain": email_domain,
|
"email_domain": email_domain,
|
||||||
"address": address,
|
"address": address,
|
||||||
@ -254,6 +259,8 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
update_query = """
|
update_query = """
|
||||||
UPDATE customers SET
|
UPDATE customers SET
|
||||||
economic_customer_number = %s,
|
economic_customer_number = %s,
|
||||||
|
name = %s,
|
||||||
|
phone = %s,
|
||||||
cvr_number = %s,
|
cvr_number = %s,
|
||||||
email_domain = %s,
|
email_domain = %s,
|
||||||
address = %s,
|
address = %s,
|
||||||
@ -265,7 +272,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
execute_query(update_query, (
|
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(
|
logger.info(
|
||||||
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
"✏️ 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:
|
else:
|
||||||
would_create.append({
|
would_create.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"phone": phone,
|
||||||
"economic_customer_number": customer_number,
|
"economic_customer_number": customer_number,
|
||||||
"cvr_number": cvr,
|
"cvr_number": cvr,
|
||||||
"email_domain": email_domain,
|
"email_domain": email_domain,
|
||||||
@ -292,13 +300,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
if apply_changes:
|
if apply_changes:
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO customers
|
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)
|
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
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
result = execute_query(insert_query, (
|
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:
|
if result:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -18,6 +18,16 @@ ALTER TABLE email_messages
|
|||||||
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
|
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
|
||||||
|
|
||||||
-- Cleanup duplicates before adding unique constraint/PK
|
-- 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 (
|
WITH ranked AS (
|
||||||
SELECT ctid,
|
SELECT ctid,
|
||||||
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn
|
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn
|
||||||
|
|||||||
39
migrations/220_locations_cross_field_panel_layout.sql
Normal file
39
migrations/220_locations_cross_field_panel_layout.sql
Normal 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);
|
||||||
9
migrations/221_locations_cross_field_start_number.sql
Normal file
9
migrations/221_locations_cross_field_start_number.sql
Normal 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);
|
||||||
20
migrations/222_locations_cross_field_display_order.sql
Normal file
20
migrations/222_locations_cross_field_display_order.sql
Normal 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;
|
||||||
8
migrations/223_wall_outlet_switch_hardware_link.sql
Normal file
8
migrations/223_wall_outlet_switch_hardware_link.sql
Normal 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;
|
||||||
11
migrations/224_wall_outlet_optional_label_and_customer.sql
Normal file
11
migrations/224_wall_outlet_optional_label_and_customer.sql
Normal 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;
|
||||||
20
migrations/225_hardware_network_links.sql
Normal file
20
migrations/225_hardware_network_links.sql
Normal 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;
|
||||||
16
migrations/226_hardware_location_display_order.sql
Normal file
16
migrations/226_hardware_location_display_order.sql
Normal 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;
|
||||||
40
migrations/227_hardware_uisp_devices.sql
Normal file
40
migrations/227_hardware_uisp_devices.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user