bmc_hub/app/modules/internet_connections/backend/router.py

4311 lines
175 KiB
Python
Raw Normal View History

import ipaddress
import hashlib
import io
import logging
import re
import zipfile
import xml.etree.ElementTree as ET
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel
from app.core.config import settings
from app.core.database import (
execute_insert,
execute_query,
execute_query_single,
get_db_connection,
release_db_connection,
table_has_column,
)
from psycopg2.extras import Json, RealDictCursor
from app.modules.internet_connections.backend.provisioning_utils import (
build_network_product_profile,
summarize_subscription_network_requirements,
)
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
from app.services.ollama_service import ollama_service
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_ALLOCATION_MODELS = {"dedicated", "shared"}
ALLOWED_VALUE_TYPES = {"subscription", "bmc_networks", "delefiber", "other"}
INTERNET_CUSTOMER_DOC_EXTENSIONS = {".txt", ".csv", ".log", ".md"}
INTERNET_RESEARCH_KEYWORDS = (
"internet",
"bredbaand",
"bredbånd",
"broadband",
"fiber",
"wan",
"mpls",
"vpn",
"ip",
"ipv4",
"ipv6",
"ip-adresse",
"globalconnect",
"global connect",
"sentia",
"dsl",
)
REFERENCE_PATTERNS = [
r"\bNKA[- ]?\d{4,}\b",
r"\bDSL[- ]?[A-Z0-9]+\b",
r"\bEB\d{4,}\b",
r"\bHB\d{4,}\b",
r"\bMPLS\b",
]
SOCKET_PATTERNS = [
r"\bstik(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
r"\budtag(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
r"\bport[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
]
GENERIC_SEGMENT_TITLE_PATTERNS = (
"ip informationer",
"ip information",
"host is up",
"not in use",
"untagged native management",
)
IP_NORDIC_IMPORT_HEADERS = {
"Company", "Name", "Startdate", "Salgspris", "Kostpris", "InstallationAddress"
}
def _excel_column_name(cell_reference: str) -> str:
match = re.match(r"[A-Z]+", str(cell_reference or "").upper())
return match.group(0) if match else ""
def _parse_ip_nordic_xlsx(content: bytes) -> List[Dict[str, Any]]:
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=413, detail="Excel-filen må højst fylde 10 MB")
namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
shared_strings: List[str] = []
if "xl/sharedStrings.xml" in archive.namelist():
shared_root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
shared_strings = [
"".join(node.text or "" for node in item.iter(f"{namespace}t"))
for item in shared_root.findall(f"{namespace}si")
]
sheet_root = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
except (KeyError, zipfile.BadZipFile, ET.ParseError) as exc:
raise HTTPException(status_code=400, detail="Filen er ikke en gyldig IP Nordic Excel-fil") from exc
raw_rows: List[Dict[str, str]] = []
for row in sheet_root.findall(f".//{namespace}sheetData/{namespace}row"):
values: Dict[str, str] = {}
for cell in row.findall(f"{namespace}c"):
column = _excel_column_name(cell.attrib.get("r", ""))
value_node = cell.find(f"{namespace}v")
value = value_node.text if value_node is not None and value_node.text is not None else ""
if cell.attrib.get("t") == "s" and value:
value = shared_strings[int(value)]
elif cell.attrib.get("t") == "inlineStr":
value = "".join(node.text or "" for node in cell.iter(f"{namespace}t"))
values[column] = value
raw_rows.append(values)
if not raw_rows:
raise HTTPException(status_code=400, detail="Excel-filen er tom")
headers = {column: str(value).strip() for column, value in raw_rows[0].items()}
if not IP_NORDIC_IMPORT_HEADERS.issubset(set(headers.values())):
raise HTTPException(status_code=400, detail="Excel-filen mangler de forventede IP Nordic-kolonner")
columns = {header: column for column, header in headers.items()}
grouped: Dict[tuple[str, str], Dict[str, Any]] = {}
for row_number, raw in enumerate(raw_rows[1:], start=2):
address = re.sub(r"\s+", " ", str(raw.get(columns["InstallationAddress"], "")).strip())
company_number = str(raw.get(columns["Company"], "")).strip()
reported_company = str(raw.get(columns["Name"], "")).strip()
if not address:
continue
key = (company_number, _normalize_service_location(address))
item = grouped.setdefault(key, {
"company_number": company_number,
"reported_company": reported_company,
"address": address,
"start_date": None,
"sales_price": Decimal("0"),
"monthly_cost": Decimal("0"),
"line_count": 0,
})
item["line_count"] += 1
date_value = str(raw.get(columns["Startdate"], "")).strip()
if date_value:
try:
parsed_date = (datetime(1899, 12, 30) + timedelta(days=float(date_value))).date()
if item["start_date"] is None or parsed_date < item["start_date"]:
item["start_date"] = parsed_date
except ValueError:
raise HTTPException(status_code=400, detail=f"Ugyldig startdato på række {row_number}")
for header, target in (("Salgspris", "sales_price"), ("Kostpris", "monthly_cost")):
raw_amount = str(raw.get(columns[header], "")).strip()
if raw_amount and raw_amount.upper() != "NULL":
try:
item[target] += Decimal(raw_amount)
except InvalidOperation as exc:
raise HTTPException(status_code=400, detail=f"Ugyldigt beløb på række {row_number}") from exc
return list(grouped.values())
def _normalize_service_location(value: Optional[str]) -> str:
normalized = str(value or "").lower()
normalized = normalized.replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
return re.sub(r"[^a-z0-9]+", "", normalized)
def _address_match_components(value: Optional[str]) -> Dict[str, Any]:
text = str(value or "").strip().lower()
text = text.replace("boulevard", "blv").replace("allé", "alle")
postal_match = re.search(r"\b(\d{4})\b", text)
postal_code = postal_match.group(1) if postal_match else ""
street_part = text.split(postal_code, 1)[0] if postal_code else text
house_numbers = [int(number) for number in re.findall(r"\b(\d{1,4})\b", street_part)]
street_name = re.sub(r"\b\d{1,4}\b", " ", street_part)
street_name = re.sub(r"\b(st|sal|th|tv|mf)\b", " ", street_name)
return {
"postal_code": postal_code,
"street_name": _normalize_service_location(street_name),
"house_numbers": house_numbers,
}
class InvoiceSyncReviewRequest(BaseModel):
line_number: int
action: str
connection_id: Optional[int] = None
note: Optional[str] = None
def _internet_customer_doc_dir() -> Path:
base = Path(settings.UPLOAD_DIR).resolve()
target = base / "internet_customer_docs"
target.mkdir(parents=True, exist_ok=True)
return target
def _sanitize_upload_name(filename: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", str(filename or "").strip())
cleaned = cleaned.strip("._")
return cleaned or "upload.txt"
def _normalize_text_for_match(value: Optional[str]) -> str:
text = str(value or "").strip().lower()
return (
text.replace("æ", "ae")
.replace("ø", "oe")
.replace("å", "aa")
.replace("\n", " ")
.replace("\r", " ")
)
def _tokenize_search_text(*parts: Optional[str]) -> List[str]:
combined = " ".join(str(part or "") for part in parts).strip().lower()
normalized = _normalize_text_for_match(combined)
tokens = []
for token in re.split(r"[^a-z0-9./+-]+", normalized):
token = token.strip()
if len(token) >= 2 and token not in tokens:
tokens.append(token)
return tokens
def _build_research_terms(customer_name: str, query: Optional[str]) -> Dict[str, List[str]]:
customer_terms = [token for token in _tokenize_search_text(customer_name) if len(token) >= 3]
query_terms = [token for token in _tokenize_search_text(query) if len(token) >= 2]
internet_terms = list(INTERNET_RESEARCH_KEYWORDS)
combined = []
for term in customer_terms + query_terms + internet_terms:
if term not in combined:
combined.append(term)
return {
"customer_terms": customer_terms,
"query_terms": query_terms,
"internet_terms": internet_terms,
"all_terms": combined,
}
def _count_term_hits(text: str, terms: List[str]) -> int:
normalized = _normalize_text_for_match(text)
return sum(1 for term in terms if term and term in normalized)
def _extract_text_snippets(text: str, terms: List[str], limit: int = 4) -> List[str]:
normalized_terms = [term for term in (_normalize_text_for_match(term) for term in terms) if term]
if not text.strip():
return []
snippets: List[str] = []
seen = set()
lines = [line.strip() for line in text.splitlines() if line.strip()]
for idx, line in enumerate(lines):
line_normalized = _normalize_text_for_match(line)
score = sum(1 for term in normalized_terms if term in line_normalized)
if score <= 0:
continue
start = max(idx - 1, 0)
end = min(idx + 2, len(lines))
snippet = " | ".join(lines[start:end])
snippet = re.sub(r"\s+", " ", snippet).strip()
if snippet and snippet not in seen:
seen.add(snippet)
snippets.append((score, snippet))
if not snippets and lines:
fallback = " | ".join(lines[: min(3, len(lines))])
return [re.sub(r"\s+", " ", fallback).strip()]
snippets.sort(key=lambda item: (-item[0], len(item[1])))
return [snippet for _, snippet in snippets[:limit]]
def _is_generic_segment_title(title: Optional[str]) -> bool:
normalized_title = _normalize_text_for_match(title)
if not normalized_title:
return False
return any(pattern in normalized_title for pattern in GENERIC_SEGMENT_TITLE_PATTERNS)
def _split_text_into_blocks(text: str) -> List[str]:
if not str(text or "").strip():
return []
raw_blocks = re.split(r"\n\s*\n+", str(text))
blocks: List[str] = []
for block in raw_blocks:
cleaned = re.sub(r"\s+\n", "\n", block).strip()
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
if not cleaned:
continue
if len(cleaned) > 1800:
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
chunk: List[str] = []
chunk_len = 0
for line in lines:
if chunk and chunk_len + len(line) > 1200:
blocks.append("\n".join(chunk))
chunk = [line]
chunk_len = len(line)
else:
chunk.append(line)
chunk_len += len(line)
if chunk:
blocks.append("\n".join(chunk))
else:
blocks.append(cleaned)
return blocks
def _extract_segment_entities(block: str) -> Dict[str, List[str]]:
cidr_blocks = []
ip_addresses = []
references = []
socket_numbers = []
raw_cidr_values = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\s*/\s*\d{1,2}\b", block)
cidr_literal_ips = {re.sub(r"\s+", "", raw).split("/", 1)[0] for raw in raw_cidr_values}
for raw in raw_cidr_values:
try:
normalized = str(ipaddress.ip_network(re.sub(r"\s+", "", raw), strict=False))
except ValueError:
continue
if normalized not in cidr_blocks:
cidr_blocks.append(normalized)
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block):
normalized = raw.strip()
if normalized in cidr_literal_ips:
continue
try:
normalized_ip = _normalize_ip_address(normalized)
except HTTPException:
continue
if normalized_ip not in ip_addresses:
ip_addresses.append(normalized_ip)
for pattern in REFERENCE_PATTERNS:
for match in re.findall(pattern, block, flags=re.IGNORECASE):
normalized = re.sub(r"\s+", "", str(match).strip()).upper()
if normalized not in references:
references.append(normalized)
for pattern in SOCKET_PATTERNS:
for match in re.findall(pattern, block, flags=re.IGNORECASE):
normalized = str(match).strip()
if normalized and normalized not in socket_numbers:
socket_numbers.append(normalized)
return {
"ip_addresses": ip_addresses,
"cidr_blocks": cidr_blocks,
"references": references,
"socket_numbers": socket_numbers,
}
def _segment_title(block: str, block_index: int) -> str:
first_line = next((line.strip() for line in block.splitlines() if line.strip()), "")
return (first_line[:120] if first_line else f"Blok {block_index + 1}") or f"Blok {block_index + 1}"
def _index_customer_document_segments(document_id: int, extracted_text: str) -> int:
execute_query(
"""
DELETE FROM internet_connections_customer_document_segments
WHERE document_id = %s
""",
(document_id,),
)
created = 0
for idx, block in enumerate(_split_text_into_blocks(extracted_text)):
entities = _extract_segment_entities(block)
execute_query(
"""
INSERT INTO internet_connections_customer_document_segments (
document_id,
block_index,
block_title,
content,
ip_addresses,
cidr_blocks,
references_json,
socket_numbers
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
document_id,
idx,
_segment_title(block, idx),
block,
Json(entities["ip_addresses"]),
Json(entities["cidr_blocks"]),
Json(entities["references"]),
Json(entities["socket_numbers"]),
),
)
created += 1
return created
def _get_document_segment_count(document_id: int) -> int:
row = execute_query_single(
"""
SELECT COUNT(*) AS total
FROM internet_connections_customer_document_segments
WHERE document_id = %s
""",
(document_id,),
)
return int((row or {}).get("total") or 0)
def _ensure_document_segments(document_id: int, extracted_text: Optional[str]) -> int:
existing_count = _get_document_segment_count(document_id)
if existing_count > 0:
return existing_count
if not str(extracted_text or "").strip():
return 0
try:
return _index_customer_document_segments(document_id, str(extracted_text or ""))
except Exception as exc:
logger.warning("Could not auto-index document segments for document %s: %s", document_id, exc)
return 0
async def _read_invoice_text(file_path: Optional[str]) -> str:
if not file_path:
return ""
candidate = Path(file_path)
if not candidate.is_absolute():
candidate = Path(settings.UPLOAD_DIR).resolve() / candidate
if not candidate.exists() or not candidate.is_file():
return ""
try:
return await ollama_service._extract_text_from_file(candidate)
except Exception as exc:
logger.warning("Could not extract invoice text from %s: %s", candidate, exc)
return ""
async def _build_invoice_hits(customer_id: int, customer_name: str, query: Optional[str], limit: int = 8) -> List[Dict[str, Any]]:
terms = _build_research_terms(customer_name, query)
customer_filter_sql = "NULL::INTEGER AS linked_customer_id"
customer_where_sql = ""
params: List[Any] = []
if table_has_column("supplier_invoices", "linked_customer_id"):
customer_filter_sql = "si.linked_customer_id"
customer_where_sql = "OR si.linked_customer_id = %s"
params.append(customer_id)
invoice_rows = execute_query(
f"""
SELECT
si.id,
si.invoice_number,
si.invoice_date,
si.total_amount,
si.currency,
COALESCE(v.name, si.vendor_name, 'Ukendt leverandør') AS vendor_name,
COALESCE(si.description, '') AS description,
COALESCE(si.notes, '') AS notes,
COALESCE(inf.file_path, si.file_path) AS file_path,
COALESCE(inf.original_filename, si.file_path, '') AS source_filename,
{customer_filter_sql}
FROM supplier_invoices si
LEFT JOIN vendors v ON v.id = si.vendor_id
LEFT JOIN extractions ext ON ext.extraction_id = si.extraction_id
LEFT JOIN incoming_files inf ON inf.file_id = ext.file_id
WHERE si.invoice_date >= (CURRENT_DATE - INTERVAL '18 months')
AND (
COALESCE(si.description, '') <> ''
OR COALESCE(si.notes, '') <> ''
OR COALESCE(si.file_path, '') <> ''
OR COALESCE(inf.file_path, '') <> ''
{customer_where_sql}
)
ORDER BY si.invoice_date DESC, si.id DESC
LIMIT 30
""",
tuple(params) if params else None,
) or []
hits: List[Dict[str, Any]] = []
for row in invoice_rows:
extracted_text = await _read_invoice_text(row.get("file_path"))
combined_text = "\n".join(
part for part in [row.get("description"), row.get("notes"), extracted_text] if str(part or "").strip()
)
searchable_text = " ".join(
str(part or "") for part in [row.get("vendor_name"), row.get("invoice_number"), combined_text]
)
customer_hits = _count_term_hits(searchable_text, terms["customer_terms"])
query_hits = _count_term_hits(searchable_text, terms["query_terms"])
internet_hits = _count_term_hits(searchable_text, terms["internet_terms"])
directly_linked = row.get("linked_customer_id") == customer_id
if not directly_linked and customer_hits == 0 and query_hits == 0:
continue
if internet_hits == 0:
continue
snippets = _extract_text_snippets(
combined_text or searchable_text,
terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"],
limit=2,
)
score = (6 if directly_linked else 0) + (customer_hits * 3) + (query_hits * 4) + internet_hits
hits.append(
{
"invoice_id": row.get("id"),
"invoice_number": row.get("invoice_number"),
"invoice_date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
"vendor_name": row.get("vendor_name"),
"total_amount": float(row.get("total_amount") or 0),
"currency": row.get("currency") or "DKK",
"source_filename": row.get("source_filename"),
"directly_linked": directly_linked,
"score": score,
"snippets": snippets,
}
)
hits.sort(key=lambda item: (-item["score"], item.get("invoice_date") or ""))
return hits[:limit]
async def _build_customer_document_hits(customer_id: int, customer_name: str, query: Optional[str]) -> Dict[str, Any]:
document_rows = execute_query(
"""
SELECT
id,
customer_id,
connection_id,
original_filename,
filename,
file_size,
mime_type,
extracted_text,
notes,
created_at
FROM internet_connections_customer_documents
WHERE (customer_id = %s OR customer_id IS NULL)
AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
""",
(customer_id,),
) or []
terms = _build_research_terms(customer_name, query)
documents = []
snippets: List[str] = []
matched_segments: List[Dict[str, Any]] = []
doc_by_id: Dict[int, Dict[str, Any]] = {}
for row in document_rows:
doc_id = int(row["id"])
row_dict = dict(row)
row_dict["segment_count"] = _ensure_document_segments(doc_id, row_dict.get("extracted_text"))
doc_by_id[doc_id] = row_dict
segment_rows = execute_query(
"""
SELECT
seg.id,
seg.document_id,
seg.block_index,
seg.block_title,
seg.content,
seg.ip_addresses,
seg.cidr_blocks,
seg.references_json,
seg.socket_numbers
FROM internet_connections_customer_document_segments seg
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
WHERE doc.deleted_at IS NULL
AND (doc.customer_id = %s OR doc.customer_id IS NULL)
ORDER BY doc.created_at DESC, seg.block_index ASC
""",
(customer_id,),
) or []
has_query = bool(terms["query_terms"])
normalized_query = _normalize_text_for_match(query) if query else ""
match_terms = terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"]
doc_segment_count: Dict[int, int] = {}
for row in segment_rows:
content = str(row.get("content") or "")
title = str(row.get("block_title") or "")
references = [str(item) for item in (row.get("references_json") or [])]
sockets = [str(item) for item in (row.get("socket_numbers") or [])]
cidrs = [str(item) for item in (row.get("cidr_blocks") or [])]
ips = [str(item) for item in (row.get("ip_addresses") or [])]
searchable = " ".join([title, content, " ".join(references), " ".join(sockets), " ".join(cidrs), " ".join(ips)])
customer_hits = _count_term_hits(searchable, terms["customer_terms"])
query_hits = _count_term_hits(searchable, terms["query_terms"])
internet_hits = _count_term_hits(searchable, terms["internet_terms"])
explicit_entity_hit = 0
phrase_hit = 0
title_hit = 0
if terms["query_terms"]:
explicit_entity_hit = sum(
1 for token in terms["query_terms"]
if token in _normalize_text_for_match(" ".join(ips + cidrs + references + sockets))
)
phrase_hit = 1 if normalized_query and normalized_query in _normalize_text_for_match(searchable) else 0
title_hit = _count_term_hits(title, terms["query_terms"])
if has_query:
# A block is only relevant when it contains every word from the user's
# search. A query such as "sales management" must not return a block
# containing just one of the two words.
all_query_terms_match = all(
term in _normalize_text_for_match(searchable)
for term in terms["query_terms"]
)
if not all_query_terms_match:
continue
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
continue
score = (customer_hits * 2) + (query_hits * 8) + internet_hits + (explicit_entity_hit * 10) + (phrase_hit * 12) + (title_hit * 6)
if _is_generic_segment_title(title) and explicit_entity_hit == 0 and phrase_hit == 0:
score -= 6
if has_query and query_hits > 0 and customer_hits == 0:
score += 4
if score <= 0:
continue
doc_id = int(row["document_id"])
doc_segment_count[doc_id] = doc_segment_count.get(doc_id, 0) + 1
segment_snippets = _extract_text_snippets(content, match_terms, limit=1)
snippet = segment_snippets[0] if segment_snippets else re.sub(r"\s+", " ", content[:240]).strip()
matched_segments.append(
{
"segment_id": int(row["id"]),
"document_id": doc_id,
"block_index": int(row.get("block_index") or 0),
"title": title or f"Blok {int(row.get('block_index') or 0) + 1}",
"score": score,
"snippet": snippet,
"ip_addresses": ips,
"cidr_blocks": cidrs,
"references": references,
"socket_numbers": sockets,
}
)
snippets.append(f"{doc_by_id.get(doc_id, {}).get('original_filename')}: {snippet}")
matched_segments.sort(key=lambda item: (-item["score"], item["document_id"], item["block_index"]))
for row in document_rows:
doc_id = int(row["id"])
row_dict = doc_by_id.get(doc_id, dict(row))
text = str(row_dict.get("extracted_text") or "")
doc_snippets = [seg["snippet"] for seg in matched_segments if seg["document_id"] == doc_id][:2]
matched_count = doc_segment_count.get(doc_id, 0)
if not doc_snippets and not has_query:
doc_snippets = _extract_text_snippets(text, match_terms, limit=2)
if has_query and matched_count <= 0:
continue
if matched_count <= 0 and row_dict.get("customer_id") != customer_id:
continue
documents.append(
{
"id": doc_id,
"connection_id": row_dict.get("connection_id"),
"original_filename": row_dict.get("original_filename"),
"file_size": row_dict.get("file_size"),
"mime_type": row_dict.get("mime_type"),
"notes": row_dict.get("notes"),
"created_at": row_dict.get("created_at").isoformat() if row_dict.get("created_at") else None,
"snippet_count": matched_count if has_query else doc_segment_count.get(doc_id, row_dict.get("segment_count", len(doc_snippets))),
"snippets": doc_snippets[:2],
}
)
return {"documents": documents, "snippets": snippets[:12], "segments": matched_segments[:16]}
def _validate_cidr(cidr: str):
try:
return ipaddress.ip_network(cidr, strict=False)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid CIDR notation") from exc
def _normalize_ip_address(ip_address: str) -> str:
try:
return str(ipaddress.ip_address(str(ip_address or "").strip()))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid IP address") from exc
def _normalize_match_text(value: Optional[str]) -> str:
normalized = str(value or "").strip().upper()
normalized = normalized.replace("Æ", "AE").replace("Ø", "OE").replace("Å", "AA")
return "".join(ch for ch in normalized if ch.isalnum())
def _build_ip_range_payload(range_row: dict, address_rows: Optional[list[dict]] = None) -> dict:
payload = dict(range_row)
network = None
cidr = range_row.get("cidr")
if cidr:
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError:
network = None
payload["network_address"] = str(network.network_address) if network else None
payload["broadcast_address"] = str(network.broadcast_address) if network else None
payload["prefix_length"] = int(network.prefixlen) if network else None
payload["total_hosts"] = int(network.num_addresses) if network else 0
if network and network.num_addresses > 1:
usable_hosts = max(network.num_addresses - 2, 0)
elif network:
usable_hosts = 1 if network.num_addresses == 1 else 0
else:
usable_hosts = 0
payload["usable_hosts"] = usable_hosts
range_addresses = address_rows or []
payload["used_addresses"] = sum(
1 for row in range_addresses if str(row.get("status", "available")).lower() != "available"
)
payload["available_addresses"] = max(usable_hosts - payload["used_addresses"], 0)
return payload
def _range_alignment_for_connection(connection: Dict[str, Any], range_item: Dict[str, Any]) -> Dict[str, Any]:
connection_address = _normalize_match_text(connection.get("address"))
service_address = _normalize_match_text(range_item.get("service_address"))
connection_customer_id = connection.get("customer_id")
range_customer_id = range_item.get("customer_id")
is_shared_head = bool(connection.get("allocation_model") == "shared" and not connection.get("parent_id"))
if is_shared_head:
return {"belongs_to_connection": True, "alignment_reason": "shared_head", "alignment_warning": None}
if connection_address and service_address and connection_address == service_address:
return {"belongs_to_connection": True, "alignment_reason": "service_address", "alignment_warning": None}
if connection_customer_id and range_customer_id and int(connection_customer_id) == int(range_customer_id):
return {"belongs_to_connection": True, "alignment_reason": "customer", "alignment_warning": None}
if not service_address and not range_customer_id:
return {"belongs_to_connection": True, "alignment_reason": "unassigned", "alignment_warning": None}
warning = "Range matcher ikke forbindelsens adresse/kunde"
if range_item.get("service_address"):
warning = f"Range ligger paa anden serviceadresse: {range_item.get('service_address')}"
elif range_item.get("customer_name"):
warning = f"Range er bundet til anden kunde: {range_item.get('customer_name')}"
return {"belongs_to_connection": False, "alignment_reason": "mismatch", "alignment_warning": warning}
def _create_history_entry(connection_id: int, event_type: str, summary: str, details: Optional[dict] = None):
execute_query(
"""
INSERT INTO internet_connections_history (connection_id, event_type, summary, details)
VALUES (%s, %s, %s, %s)
""",
(connection_id, event_type, summary, Json(details or {})),
)
def _sync_bmcnet_parent_classification(parent_connection_id: Optional[int]) -> bool:
"""Derive a head connection's classification from its non-deleted BMCnet children."""
if not parent_connection_id:
return False
parent = execute_query_single(
"""
SELECT id, allocation_model, value_type, value_label, is_manual_shared
FROM internet_connections_connections
WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL
LIMIT 1
""",
(parent_connection_id,),
)
if not parent:
return False
child_stats = execute_query_single(
"""
SELECT COUNT(*) AS child_count
FROM internet_connections_connections
WHERE parent_id = %s
AND deleted_at IS NULL
AND (
value_type = 'subscription'
OR LOWER(COALESCE(value_label, '')) IN ('bmcnet', 'bmc networks')
)
""",
(parent_connection_id,),
) or {}
child_count = int(child_stats.get("child_count") or 0)
should_be_shared = child_count > 0 or bool(parent.get("is_manual_shared"))
allocation_model = "shared" if should_be_shared else "dedicated"
value_type = "delefiber" if should_be_shared else "other"
value_label = None if should_be_shared else "Internetforbindelse"
if (
parent.get("allocation_model") == allocation_model
and parent.get("value_type") == value_type
and parent.get("value_label") == value_label
):
return False
execute_query(
"""
UPDATE internet_connections_connections
SET allocation_model = %s, value_type = %s, value_label = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL
""",
(allocation_model, value_type, value_label, parent_connection_id),
)
_create_history_entry(
int(parent_connection_id),
"bmcnet_classification_changed",
"Hovedforbindelsen blev klassificeret som delefiber" if should_be_shared
else "Hovedforbindelsen blev klassificeret som dedikeret",
{
"bmcnet_child_count": child_count,
"allocation_model": allocation_model,
"value_type": value_type,
},
)
return True
def _create_ip_addresses_for_range(range_id: int, cidr: str):
network = _validate_cidr(cidr)
addresses = []
for host in list(network.hosts()):
addresses.append(str(host))
if not addresses:
return []
created = []
for ip_address in addresses:
existing = execute_query(
"""
SELECT *
FROM internet_connections_ip_addresses
WHERE ip_address = %s
AND deleted_at IS NULL
LIMIT 1
""",
(ip_address,),
) or []
if existing:
continue
rows = execute_query(
"""
INSERT INTO internet_connections_ip_addresses (range_id, ip_address, status, assigned_to, assigned_type, comment)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(range_id, ip_address, "available", None, None, "Auto-generated from CIDR"),
) or []
if rows:
created.append(dict(rows[0]))
return created
def _get_contract_status(contract_end):
if not contract_end:
return "unknown"
if isinstance(contract_end, str):
try:
contract_end = date.fromisoformat(contract_end)
except ValueError:
return "unknown"
elif hasattr(contract_end, "date"):
contract_end = contract_end.date()
return "active" if contract_end >= date.today() else "expired"
def _value_type_label(value_type: Optional[str]) -> str:
labels = {
"subscription": "Abonnement",
"bmc_networks": "BMC Networks",
"delefiber": "Delefiber",
"other": "Anden",
}
return labels.get(str(value_type or "").lower(), "Anden")
def _allocation_model_label(allocation_model: Optional[str]) -> str:
labels = {
"dedicated": "Dedikeret",
"shared": "Delt",
}
return labels.get(str(allocation_model or "").lower(), "Dedikeret")
def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(payload)
address = str(normalized.get("address") or "").strip() or None
allocation_model = str(normalized.get("allocation_model") or "dedicated").strip().lower()
value_type = str(normalized.get("value_type") or "other").strip().lower()
value_label = str(normalized.get("value_label") or "").strip() or None
subscription_id = normalized.get("subscription_id")
if not address:
raise HTTPException(status_code=400, detail="address is required for internet connections")
if allocation_model not in ALLOWED_ALLOCATION_MODELS:
raise HTTPException(status_code=400, detail="allocation_model must be dedicated or shared")
if value_type not in ALLOWED_VALUE_TYPES:
raise HTTPException(status_code=400, detail="value_type must be subscription, bmc_networks, delefiber or other")
if value_type == "subscription":
if not subscription_id:
raise HTTPException(status_code=400, detail="subscription_id is required when value_type is subscription")
else:
subscription_id = None
if value_type == "other":
if not value_label:
raise HTTPException(status_code=400, detail="value_label is required when value_type is other")
else:
value_label = None
normalized["address"] = address
normalized["allocation_model"] = allocation_model
normalized["value_type"] = value_type
normalized["value_label"] = value_label
normalized["subscription_id"] = subscription_id
normalized["is_manual_shared"] = bool(
normalized.get("is_manual_shared")
and not normalized.get("parent_id")
and allocation_model == "shared"
and value_type == "delefiber"
)
return normalized
def _connection_select_sql(where_sql: str = "") -> str:
return f"""
SELECT
ic.id,
ic.parent_id,
ic.name,
ic.provider,
ic.vendor_id,
vendor.name AS vendor_name,
ic.customer_id,
c.name AS customer_name,
parent.name AS parent_name,
parent.allocation_model AS parent_allocation_model,
parent.value_type AS parent_value_type,
ic.address,
ic.status,
ic.monthly_cost,
ic.sales_price,
(COALESCE(ic.sales_price, 0) - COALESCE(ic.monthly_cost, 0)) AS margin_amount,
ic.technology,
ic.connection_type,
ic.circuit_number,
ic.speed_mbps,
ic.upload_mbps,
ic.download_mbps,
ic.monitoring_url,
ic.contract_start,
ic.contract_end,
ic.allocation_model,
ic.value_type,
ic.value_label,
ic.is_manual_shared,
ic.subscription_id,
ic.sla_subscription_id,
sub.subscription_number,
sub.product_name AS subscription_product_name,
subc.name AS subscription_customer_name,
sla.subscription_number AS sla_subscription_number,
sla.product_name AS sla_product_name,
sla.price AS sla_price,
sla.status AS sla_status,
COALESCE(ip_stats.range_count, 0) AS ip_range_count,
COALESCE(ip_stats.total_addresses, 0) AS total_ip_addresses,
COALESCE(ip_stats.in_use_addresses, 0) AS in_use_ip_addresses,
COALESCE(ip_stats.reserved_addresses, 0) AS reserved_ip_addresses,
COALESCE(ip_stats.available_addresses, 0) AS available_ip_addresses,
COALESCE(child_stats.bmcnet_child_count, 0) AS bmcnet_child_count,
COALESCE(child_stats.bmcnet_child_active_count, 0) AS bmcnet_child_active_count,
COALESCE(child_stats.bmcnet_child_sales_price, 0) AS bmcnet_child_sales_price,
COALESCE(child_stats.bmcnet_child_monthly_cost, 0) AS bmcnet_child_monthly_cost,
COALESCE(child_stats.bmcnet_child_margin_amount, 0) AS bmcnet_child_margin_amount,
COALESCE(child_stats.bmcnet_child_ip_count, 0) AS bmcnet_child_ip_count
FROM internet_connections_connections ic
LEFT JOIN customers c ON c.id = ic.customer_id
LEFT JOIN vendors vendor ON vendor.id = ic.vendor_id
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
LEFT JOIN customers subc ON subc.id = sub.customer_id
LEFT JOIN sag_subscriptions sla ON sla.id = ic.sla_subscription_id
LEFT JOIN (
SELECT
ir.connection_id,
COUNT(DISTINCT ir.id) FILTER (WHERE ir.deleted_at IS NULL) AS range_count,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses
FROM internet_connections_ip_ranges ir
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
WHERE ir.deleted_at IS NULL
GROUP BY ir.connection_id
) ip_stats ON ip_stats.connection_id = ic.id
LEFT JOIN (
SELECT
child.parent_id AS connection_id,
COUNT(*) FILTER (WHERE child.deleted_at IS NULL AND child.value_type = 'subscription') AS bmcnet_child_count,
COUNT(*) FILTER (
WHERE child.deleted_at IS NULL
AND child.value_type = 'subscription'
AND child.status = 'active'
) AS bmcnet_child_active_count,
COALESCE(SUM(child.sales_price) FILTER (
WHERE child.deleted_at IS NULL
AND child.value_type = 'subscription'
), 0) AS bmcnet_child_sales_price,
COALESCE(SUM(child.monthly_cost) FILTER (
WHERE child.deleted_at IS NULL
AND child.value_type = 'subscription'
), 0) AS bmcnet_child_monthly_cost,
COALESCE(SUM(COALESCE(child.sales_price, 0) - COALESCE(child.monthly_cost, 0)) FILTER (
WHERE child.deleted_at IS NULL
AND child.value_type = 'subscription'
), 0) AS bmcnet_child_margin_amount,
COALESCE(SUM(
COALESCE(child_ip_stats.in_use_addresses, 0) + COALESCE(child_ip_stats.reserved_addresses, 0)
) FILTER (
WHERE child.deleted_at IS NULL
AND child.value_type = 'subscription'
), 0) AS bmcnet_child_ip_count
FROM internet_connections_connections child
LEFT JOIN (
SELECT
ir.connection_id,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses
FROM internet_connections_ip_ranges ir
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
WHERE ir.deleted_at IS NULL
GROUP BY ir.connection_id
) child_ip_stats ON child_ip_stats.connection_id = child.id
WHERE child.parent_id IS NOT NULL
GROUP BY child.parent_id
) child_stats ON child_stats.connection_id = ic.id
WHERE ic.deleted_at IS NULL
{where_sql}
"""
def _decorate_connection_row(row: dict) -> dict:
item = dict(row)
value_label = str(item.get("value_label") or "").strip().lower()
is_bmcnet = bool(
item.get("parent_id")
and str(item.get("parent_allocation_model") or "").lower() == "shared"
and (
item.get("value_type") == "subscription"
or value_label == "bmcnet"
or value_label == "bmc networks"
)
)
item["value_type_label"] = _value_type_label(item.get("value_type"))
item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
item["is_shared_head"] = bool(item.get("allocation_model") == "shared" and not item.get("parent_id"))
item["is_bmcnet_connection"] = is_bmcnet
return item
def _load_bmcnet_children(parent_connection_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
_connection_select_sql(
"""
AND ic.parent_id = %s
AND parent.allocation_model = 'shared'
AND (
ic.value_type = 'subscription'
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
)
"""
)
+ " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
(parent_connection_id,),
) or []
return [_decorate_connection_row(dict(row)) for row in rows]
def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]:
return {
"connection_count": len(children),
"active_connection_count": sum(1 for child in children if str(child.get("status") or "").lower() == "active"),
"sales_price": float(sum(float(child.get("sales_price") or 0) for child in children)),
"monthly_cost": float(sum(float(child.get("monthly_cost") or 0) for child in children)),
"margin_amount": float(sum(float(child.get("margin_amount") or 0) for child in children)),
"ip_in_use_count": int(
sum(
int(child.get("in_use_ip_addresses") or 0) + int(child.get("reserved_ip_addresses") or 0)
for child in children
)
),
}
class ConnectionCreatePayload(BaseModel):
name: str
provider: Optional[str] = None
vendor_id: Optional[int] = None
customer_id: Optional[int] = None
parent_id: Optional[int] = None
address: Optional[str] = None
status: str = "active"
monthly_cost: float = 0
sales_price: float = 0
technology: Optional[str] = None
connection_type: str = "fiber"
circuit_number: Optional[str] = None
speed_mbps: Optional[int] = None
upload_mbps: Optional[int] = None
download_mbps: Optional[int] = None
monitoring_url: Optional[str] = None
contract_start: Optional[date] = None
contract_end: Optional[date] = None
notes: Optional[str] = None
allocation_model: str = "dedicated"
value_type: str = "other"
value_label: Optional[str] = None
subscription_id: Optional[int] = None
sla_subscription_id: Optional[int] = None
is_manual_shared: bool = False
class ConnectionUpdatePayload(BaseModel):
name: Optional[str] = None
provider: Optional[str] = None
vendor_id: Optional[int] = None
customer_id: Optional[int] = None
parent_id: Optional[int] = None
address: Optional[str] = None
status: Optional[str] = None
monthly_cost: Optional[float] = None
sales_price: Optional[float] = None
technology: Optional[str] = None
connection_type: Optional[str] = None
circuit_number: Optional[str] = None
speed_mbps: Optional[int] = None
upload_mbps: Optional[int] = None
download_mbps: Optional[int] = None
monitoring_url: Optional[str] = None
contract_start: Optional[date] = None
contract_end: Optional[date] = None
notes: Optional[str] = None
allocation_model: Optional[str] = None
value_type: Optional[str] = None
value_label: Optional[str] = None
subscription_id: Optional[int] = None
sla_subscription_id: Optional[int] = None
is_manual_shared: Optional[bool] = None
class PricingCreatePayload(BaseModel):
effective_from: date
purchase_price: float = 0
sales_price: float = 0
notes: Optional[str] = None
def _apply_internet_vendor(normalized: dict) -> dict:
vendor_id = normalized.get("vendor_id")
if not vendor_id:
normalized["vendor_id"] = None
return normalized
vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s AND is_active = TRUE AND is_internet_provider = TRUE",
(vendor_id,),
)
if not vendor:
raise HTTPException(status_code=409, detail="Den valgte leverandør er ikke markeret som internetleverandør")
normalized["vendor_id"] = int(vendor["id"])
normalized["provider"] = vendor["name"]
return normalized
class IpRangeCreatePayload(BaseModel):
name: str
cidr: str
description: Optional[str] = None
provider_reference: Optional[str] = None
contract_number: Optional[str] = None
customer_id: Optional[int] = None
service_address: Optional[str] = None
monthly_cost: float = 0
sales_price: float = 0
class IpRangeUpdatePayload(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
provider_reference: Optional[str] = None
contract_number: Optional[str] = None
customer_id: Optional[int] = None
service_address: Optional[str] = None
monthly_cost: Optional[float] = None
sales_price: Optional[float] = None
class IpAddressCreatePayload(BaseModel):
range_id: int
ip_address: str
status: str = "available"
assigned_to: Optional[str] = None
assigned_type: Optional[str] = None
assigned_customer_id: Optional[int] = None
assigned_connection_id: Optional[int] = None
comment: Optional[str] = None
class IpAddressUpdatePayload(BaseModel):
status: str = "available"
assigned_to: Optional[str] = None
assigned_type: Optional[str] = None
assigned_customer_id: Optional[int] = None
assigned_connection_id: Optional[int] = None
comment: Optional[str] = None
class SubscriptionIpAllocationPayload(BaseModel):
subscription_item_id: int
range_id: int
requested_cidr: Optional[str] = None
class SubscriptionProvisionPayload(BaseModel):
shared_connection_id: int
internet_item_id: Optional[int] = None
ip_allocations: List[SubscriptionIpAllocationPayload] = []
class QuickBmcnetCreatePayload(BaseModel):
customer_id: int
case_title: Optional[str] = None
service_label: Optional[str] = None
address: Optional[str] = None
billing_interval: str = "monthly"
billing_day: int = 1
billing_schedule_type: str = "fixed_day"
billing_direction: str = "forward"
advance_months: int = 1
billing_lead_months: int = 0
first_invoice_policy: str = "start_date"
start_date: date
period_start: Optional[date] = None
first_full_period_start: Optional[date] = None
end_date: Optional[date] = None
notice_period_days: int = 30
binding_months: int = 0
binding_start_date: Optional[date] = None
internet_product_id: int
internet_unit_price: Optional[float] = None
ip_product_id: Optional[int] = None
ip_unit_price: Optional[float] = None
notes: Optional[str] = None
range_id: Optional[int] = None
ip_address_id: Optional[int] = None
mark_gateway: bool = False
class DelefiberProductPricePayload(BaseModel):
product_id: int
monthly_price: float
notes: Optional[str] = None
def _load_subscription(subscription_id: int) -> Dict[str, Any]:
subscription = execute_query_single(
"""
SELECT
s.id,
s.subscription_number,
s.sag_id,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.price,
s.status,
s.start_date
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
""",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
return dict(subscription)
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
p.type AS product_type,
p.attributes_json,
i.description,
i.quantity,
i.unit_price,
i.line_total
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription_id,),
) or []
return [dict(row) for row in rows]
def _load_subscription_connection(subscription_id: int) -> Optional[Dict[str, Any]]:
rows = execute_query(
_connection_select_sql(" AND ic.subscription_id = %s ") + " ORDER BY ic.id ASC LIMIT 1",
(subscription_id,),
) or []
return _decorate_connection_row(dict(rows[0])) if rows else None
def _resolve_group_id_by_name_tokens(tokens: List[str]) -> Optional[int]:
lowered = [str(token or "").strip().lower() for token in tokens if str(token or "").strip()]
if not lowered:
return None
clauses = " OR ".join(["LOWER(name) LIKE %s" for _ in lowered])
params = tuple(f"%{token}%" for token in lowered)
row = execute_query_single(
f"""
SELECT id
FROM groups
WHERE {clauses}
ORDER BY id
LIMIT 1
""",
params,
)
return int(row["id"]) if row and row.get("id") else None
def _load_connection_ranges(connection_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
ir.*,
c.name AS customer_name,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses
FROM internet_connections_ip_ranges ir
LEFT JOIN customers c ON c.id = ir.customer_id
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
WHERE ir.connection_id = %s
AND ir.deleted_at IS NULL
GROUP BY ir.id, c.name
ORDER BY ir.id ASC
""",
(connection_id,),
) or []
payload: List[Dict[str, Any]] = []
for row in rows:
item = _build_ip_range_payload(dict(row))
item["is_fully_available"] = bool(
item.get("total_addresses", 0) > 0 and item.get("available_addresses", 0) == item.get("total_addresses", 0)
)
payload.append(item)
return payload
def _build_provisioning_candidates(range_item: Dict[str, Any], required_prefixes: List[int]) -> List[Dict[str, Any]]:
if not required_prefixes:
return []
if range_item.get("customer_id") is not None:
return []
if not range_item.get("is_fully_available"):
return []
cidr = str(range_item.get("cidr") or "").strip()
if not cidr:
return []
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError:
return []
candidates: List[Dict[str, Any]] = []
seen: set[tuple[int, str]] = set()
for requested_prefix in required_prefixes:
try:
prefix_value = int(requested_prefix)
except (TypeError, ValueError):
continue
if prefix_value < network.prefixlen:
continue
if prefix_value == network.prefixlen:
candidate = dict(range_item)
candidate["range_id"] = int(range_item["id"])
candidate["source_range_id"] = int(range_item["id"])
candidate["source_cidr"] = cidr
candidate["requested_cidr"] = cidr
candidate["is_derived_candidate"] = False
key = (candidate["source_range_id"], candidate["requested_cidr"])
if key not in seen:
candidates.append(candidate)
seen.add(key)
continue
for subnet in network.subnets(new_prefix=prefix_value):
requested_cidr = str(subnet)
key = (int(range_item["id"]), requested_cidr)
if key in seen:
continue
usable_hosts = max(subnet.num_addresses - 2, 0) if subnet.num_addresses > 1 else int(subnet.num_addresses)
candidates.append(
{
"id": f"{range_item['id']}:{requested_cidr}",
"range_id": int(range_item["id"]),
"source_range_id": int(range_item["id"]),
"source_cidr": cidr,
"requested_cidr": requested_cidr,
"cidr": requested_cidr,
"name": f"{range_item.get('name') or 'Range'} -> {requested_cidr}",
"description": range_item.get("description"),
"provider_reference": range_item.get("provider_reference"),
"contract_number": range_item.get("contract_number"),
"customer_id": None,
"customer_name": None,
"service_address": range_item.get("service_address"),
"monthly_cost": 0,
"sales_price": 0,
"network_address": str(subnet.network_address),
"broadcast_address": str(subnet.broadcast_address),
"prefix_length": int(subnet.prefixlen),
"total_hosts": int(subnet.num_addresses),
"usable_hosts": usable_hosts,
"total_addresses": usable_hosts,
"available_addresses": usable_hosts,
"reserved_addresses": 0,
"in_use_addresses": 0,
"used_addresses": 0,
"is_fully_available": True,
"is_derived_candidate": True,
}
)
seen.add(key)
return candidates
def _load_shared_heads_for_provisioning(required_prefixes: List[int]) -> List[Dict[str, Any]]:
rows = execute_query(
_connection_select_sql(
"""
AND ic.allocation_model = 'shared'
AND ic.parent_id IS NULL
"""
)
+ " ORDER BY ic.address ASC NULLS LAST, ic.name ASC",
) or []
heads: List[Dict[str, Any]] = []
for row in rows:
head = _decorate_connection_row(dict(row))
ranges = _load_connection_ranges(int(head["id"]))
matching_ranges = []
for range_item in ranges:
matching_ranges.extend(_build_provisioning_candidates(range_item, required_prefixes))
head["available_matching_ranges"] = matching_ranges
head["available_matching_range_count"] = len(matching_ranges)
heads.append(head)
return heads
@router.get("/internet-connections/health")
async def internet_connections_health():
return {"status": "healthy", "service": "internet-connections-module"}
@router.get("/internet-connections/invoice-sync-runs")
async def list_internet_invoice_sync_runs(
status: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
):
"""Show GlobalConnect invoices and their latest internet-sync outcome."""
rows = execute_query(
"""
WITH raw_invoice_keys AS (
SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id,
si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name,
si.invoice_date, si.total_amount, si.currency, si.created_at
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%'
UNION ALL
SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number,
run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at
FROM internet_connections_invoice_sync_runs run
WHERE NOT EXISTS (
SELECT 1 FROM supplier_invoices si
WHERE si.id = run.supplier_invoice_id
OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number
AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, ''))
)
),
invoice_keys AS (
SELECT DISTINCT ON (
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, '')))
) *
FROM raw_invoice_keys
ORDER BY
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, ''))),
supplier_invoice_id NULLS LAST,
created_at DESC
)
SELECT key.*,
COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) AS processing_status,
latest.id AS run_id, latest.connections_synced, latest.connections_created,
latest.connections_updated, latest.ip_ranges_synced, latest.total_lines,
latest.actionable_lines, latest.skipped_lines,
COALESCE(latest.error_message, file.error_message) AS error_message,
COALESCE(latest.processed_at, file.processed_at, key.created_at) AS processed_at,
legacy.connection_count AS legacy_connection_count,
COALESCE(review.resolved_lines, 0) AS resolved_lines,
GREATEST(COALESCE(latest.skipped_lines, 0) - COALESCE(review.resolved_lines, 0), 0) AS unresolved_lines,
COALESCE(review.decisions, '[]'::jsonb) AS review_decisions
FROM invoice_keys key
LEFT JOIN incoming_files file ON file.file_id = key.file_id
LEFT JOIN LATERAL (
SELECT run.*
FROM internet_connections_invoice_sync_runs run
WHERE (key.supplier_invoice_id IS NOT NULL AND run.supplier_invoice_id = key.supplier_invoice_id)
OR (key.extraction_id IS NOT NULL AND run.extraction_id = key.extraction_id)
OR (run.invoice_number = key.invoice_number
AND COALESCE(run.vendor_name, '') = COALESCE(key.vendor_name, ''))
ORDER BY run.processed_at DESC, run.id DESC
LIMIT 1
) latest ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(DISTINCT history.connection_id)::integer AS connection_count
FROM internet_connections_history history
WHERE key.invoice_number IS NOT NULL
AND history.summary ILIKE ('%%' || key.invoice_number || '%%')
AND history.event_type IN (
'connection_created_from_supplier_invoice', 'supplier_invoice_sync_changed',
'supplier_invoice_ip_range_changed', 'supplier_invoice_ip_range_created'
)
) legacy ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS resolved_lines,
COALESCE(
jsonb_agg(jsonb_build_object(
'line_number', decision.line_number,
'action', decision.action,
'connection_id', decision.connection_id,
'note', decision.note,
'resolved_at', decision.resolved_at
) ORDER BY decision.line_number),
'[]'::jsonb
) AS decisions
FROM internet_connections_invoice_review_decisions decision
WHERE decision.run_id = latest.id
) review ON TRUE
WHERE (%s IS NULL OR COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) = %s)
ORDER BY COALESCE(latest.processed_at, file.processed_at, key.created_at) DESC
LIMIT %s
""",
(status, status, limit),
) or []
items = [dict(row) for row in rows]
return {
"items": items,
"summary": {
"total": len(items),
"success": sum(item.get("processing_status") in {"success", "legacy_success"} for item in items),
"warnings": sum(item.get("processing_status") in {"warning", "skipped", "not_logged"} for item in items),
"errors": sum(item.get("processing_status") == "error" for item in items),
},
}
@router.get("/internet-connections/invoice-sync-runs/{run_id:int}")
async def get_internet_invoice_sync_run(run_id: int):
run = execute_query_single(
"""
SELECT id, invoice_number, status, skipped_lines, result_json
FROM internet_connections_invoice_sync_runs
WHERE id = %s
""",
(run_id,),
)
if not run:
raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet")
decisions = execute_query(
"""
SELECT line_number, action, connection_id, note, resolved_at
FROM internet_connections_invoice_review_decisions
WHERE run_id = %s
ORDER BY line_number
""",
(run_id,),
) or []
payload = dict(run)
payload["review_decisions"] = [dict(item) for item in decisions]
payload["resolved_lines"] = len(decisions)
payload["unresolved_lines"] = max(int(run.get("skipped_lines") or 0) - len(decisions), 0)
return payload
@router.post("/internet-connections/invoice-sync-runs/reconcile")
async def reconcile_internet_invoice_reviews():
"""Resolve stale IP review lines when the exact range is already allocated."""
runs = execute_query(
"""
SELECT id, result_json
FROM internet_connections_invoice_sync_runs
WHERE status IN ('warning', 'skipped')
ORDER BY processed_at DESC, id DESC
"""
) or []
resolved = 0
completed_runs = 0
for run in runs:
result = run.get("result_json") or {}
skipped_items = result.get("skipped_items") or []
for item in skipped_items:
if item.get("classification") != "ip_range":
continue
cidr = str(item.get("ip_address") or "").strip()
line_number = int(item.get("line_number") or 0)
if not cidr or not line_number:
continue
try:
cidr = str(ipaddress.ip_network(cidr, strict=False))
except ValueError:
continue
existing = execute_query_single(
"""
SELECT ir.connection_id
FROM internet_connections_ip_ranges ir
JOIN internet_connections_connections ic
ON ic.id = ir.connection_id AND ic.deleted_at IS NULL
WHERE ir.deleted_at IS NULL AND HOST(ir.cidr::cidr) = HOST(%s::cidr)
AND MASKLEN(ir.cidr::cidr) = MASKLEN(%s::cidr)
ORDER BY ir.id DESC
LIMIT 1
""",
(cidr, cidr),
)
if not existing:
continue
prior = execute_query_single(
"""
SELECT 1 FROM internet_connections_invoice_review_decisions
WHERE run_id = %s AND line_number = %s
""",
(run["id"], line_number),
)
if prior:
continue
execute_query(
"""
INSERT INTO internet_connections_invoice_review_decisions
(run_id, line_number, action, connection_id, note)
VALUES (%s, %s, 'link_existing', %s, %s)
ON CONFLICT (run_id, line_number) DO NOTHING
""",
(run["id"], line_number, existing["connection_id"], "Automatisk løst: IP-rangen er allerede allokeret."),
fetch=False,
)
resolved += 1
decision_count = execute_query_single(
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
(run["id"],),
) or {"count": 0}
if skipped_items and int(decision_count.get("count") or 0) >= len(skipped_items):
execute_query(
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
(run["id"],),
fetch=False,
)
completed_runs += 1
return {"resolved_lines": resolved, "completed_runs": completed_runs}
@router.post("/internet-connections/invoice-sync-runs/{run_id}/review")
async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest):
if data.action not in {"ignore", "link_existing", "create_separate"}:
raise HTTPException(status_code=400, detail="Ugyldig kontrolhandling")
if data.action == "ignore" and not str(data.note or "").strip():
raise HTTPException(status_code=400, detail="Angiv en årsag, når en linje ignoreres")
run = execute_query_single(
"SELECT * FROM internet_connections_invoice_sync_runs WHERE id = %s",
(run_id,),
)
if not run:
raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet")
result = run.get("result_json") or {}
skipped_items = result.get("skipped_items") or []
audit_item = next(
(item for item in skipped_items if int(item.get("line_number") or -1) == data.line_number),
None,
)
if not audit_item:
raise HTTPException(status_code=404, detail="Kontrollinjen blev ikke fundet")
target_connection_id = data.connection_id
if data.action in {"link_existing", "create_separate"}:
if data.action == "link_existing":
if not target_connection_id:
raise HTTPException(status_code=400, detail="Vælg en eksisterende forbindelse")
target = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL",
(target_connection_id,),
)
if not target:
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
else:
reference = str(audit_item.get("provider_reference") or "").strip()
service_address = str(audit_item.get("service_address") or "").strip()
target_connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, address, status, circuit_number, notes,
allocation_model, value_type
)
VALUES (%s, 'GlobalConnect A/S', %s, 'pending', %s, %s, 'dedicated', 'other')
RETURNING id
""",
(
f"Afventer kontrol · {reference or 'ukendt reference'}",
service_address or None,
reference or None,
f"Oprettet manuelt under kontrol af faktura {run.get('invoice_number')}.",
),
)
if audit_item.get("classification") == "ip_range":
extraction_line = execute_query_single(
"""
SELECT *
FROM extraction_lines
WHERE extraction_id = %s AND line_number = %s
ORDER BY line_id DESC
LIMIT 1
""",
(run.get("extraction_id"), data.line_number),
)
if not extraction_line:
raise HTTPException(status_code=404, detail="Den oprindelige fakturalinje blev ikke fundet")
from app.billing.backend.supplier_invoices import _upsert_globalconnect_ip_range
range_id = _upsert_globalconnect_ip_range(
int(target_connection_id),
dict(extraction_line),
str(run.get("invoice_number") or "ukendt"),
)
if not range_id:
raise HTTPException(status_code=400, detail="IP-rangen kunne ikke forbindes")
execute_query(
"""
INSERT INTO internet_connections_invoice_review_decisions (
run_id, line_number, action, connection_id, note
)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (run_id, line_number) DO UPDATE
SET action = EXCLUDED.action,
connection_id = EXCLUDED.connection_id,
note = EXCLUDED.note,
resolved_at = CURRENT_TIMESTAMP
""",
(run_id, data.line_number, data.action, target_connection_id, (data.note or "").strip() or None),
fetch=False,
)
resolved = execute_query_single(
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
(run_id,),
) or {"count": 0}
skipped_count = len(skipped_items)
remaining = max(skipped_count - int(resolved.get("count") or 0), 0)
if remaining == 0:
execute_query(
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
(run_id,),
fetch=False,
)
return {
"status": "resolved",
"run_id": run_id,
"line_number": data.line_number,
"action": data.action,
"connection_id": target_connection_id,
"remaining": remaining,
}
@router.get("/internet-connections", response_model=List[dict])
async def list_connections(
q: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None),
provider: Optional[str] = Query(None),
status: Optional[str] = Query(None),
allocation_model: Optional[str] = Query(None),
value_type: Optional[str] = Query(None),
shared_only: bool = Query(False),
bmcnet_only: bool = Query(False),
unallocated_only: bool = Query(False),
allocated_only: bool = Query(False),
):
query = _connection_select_sql()
params: list[object] = []
if q:
query += " AND (ic.name ILIKE %s OR ic.provider ILIKE %s OR ic.address ILIKE %s OR c.name ILIKE %s OR ic.circuit_number ILIKE %s OR sub.subscription_number ILIKE %s OR sub.product_name ILIKE %s)"
term = f"%{q}%"
params.extend([term, term, term, term, term, term, term])
if customer_id:
query += " AND ic.customer_id = %s"
params.append(customer_id)
if provider:
query += " AND ic.provider ILIKE %s"
params.append(f"%{provider}%")
if status:
query += " AND ic.status = %s"
params.append(status)
if allocation_model:
query += " AND ic.allocation_model = %s"
params.append(allocation_model)
if value_type:
query += " AND ic.value_type = %s"
params.append(value_type)
if shared_only:
query += " AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL"
if bmcnet_only:
query += """
AND ic.parent_id IS NOT NULL
AND parent.allocation_model = 'shared'
AND (
ic.value_type = 'subscription'
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
)
"""
if unallocated_only:
query += " AND ic.customer_id IS NULL"
if allocated_only:
query += " AND ic.customer_id IS NOT NULL"
query += " ORDER BY c.name ASC NULLS LAST, ic.name ASC"
try:
rows = execute_query(query, tuple(params) if params else ()) or []
except Exception as exc:
logger.exception("Failed to load internet connections")
raise HTTPException(status_code=500, detail="Kunne ikke hente internetforbindelser") from exc
return [_decorate_connection_row(dict(row)) for row in rows]
@router.post("/internet-connections/import/ip-nordic")
async def import_ip_nordic_connections(file: UploadFile = File(...), commit: bool = Form(False)):
filename = str(file.filename or "")
if not filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Vælg en .xlsx-fil fra IP Nordic")
content = await file.read()
import_key = hashlib.sha256(content).hexdigest()
items = _parse_ip_nordic_xlsx(content)
created_count = 0
updated_count = 0
skipped_count = 0
change_case_ids: set[int] = set()
case_errors: List[Dict[str, Any]] = []
preview_items: List[Dict[str, Any]] = []
for item in items:
existing = execute_query_single(
"""
SELECT id, name, address, customer_id, monthly_cost, sales_price,
provider, status, technology, connection_type, circuit_number
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND LOWER(COALESCE(provider, '')) = LOWER(%s)
AND regexp_replace(
replace(replace(replace(LOWER(COALESCE(address, '')), 'æ', 'ae'), 'ø', 'oe'), 'å', 'aa'),
'[^a-z0-9]', '', 'g'
) = %s
LIMIT 1
""",
("IP Nordic", _normalize_service_location(item["address"])),
)
changes: Dict[str, Dict[str, Any]] = {}
if existing:
desired = {
"monthly_cost": item["monthly_cost"],
"sales_price": item["sales_price"],
}
for field, after in desired.items():
before = existing.get(field)
if Decimal(str(before or 0)) != Decimal(str(after or 0)):
changes[field] = {"from": before, "to": after}
action = "update" if changes else ("skip" if existing else "create")
connection_id = int(existing["id"]) if existing else None
if commit and existing and changes:
execute_query(
"""UPDATE internet_connections_connections
SET monthly_cost=%s, sales_price=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s""",
(item["monthly_cost"], item["sales_price"], connection_id),
fetch=False,
)
_create_history_entry(
connection_id,
"ip_nordic_import_changed",
f"Opdateret fra IP Nordic-filen {filename}",
{"source_file": filename, "import_key": import_key, "changes": changes},
)
outcome = ensure_external_change_case(
connection_id=connection_id,
source_type="ip_nordic_spreadsheet",
source_key=import_key,
source_label=f"IP Nordic import {filename}",
source_url="/economy/internet-connections",
changes=changes,
connection_name=str(existing.get("name") or f"IP Nordic · {item['address']}"),
reference=str(existing.get("circuit_number") or item["address"]),
provider="IP Nordic",
owner_customer_id=existing.get("customer_id"),
)
if outcome.get("case_id"):
change_case_ids.add(int(outcome["case_id"]))
if outcome.get("error"):
case_errors.append({"connection_id": connection_id, "address": item["address"], "error": outcome["error"]})
updated_count += 1
elif commit and not existing:
notes = (
f"Importeret fra {filename}. Leverandørens firmanr.: {item['company_number']}. "
f"Rapporteret firma: {item['reported_company']}. {item['line_count']} regnearkslinje(r) samlet. "
"Kunde tildeles aldrig automatisk. Kredsløbsnummer og teknologi kræver manuel kontrol."
)
rows = execute_query(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, address, status, monthly_cost, sales_price,
technology, connection_type, contract_start, notes,
allocation_model, value_type, value_label
) VALUES (%s, %s, NULL, %s, 'pending', %s, %s, 'Ukendt', 'other', %s, %s,
'dedicated', 'other', 'Internetforbindelse')
RETURNING id
""",
(
f"IP Nordic · {item['address']}", "IP Nordic", item["address"],
item["monthly_cost"], item["sales_price"], item["start_date"], notes,
),
) or []
if not rows:
raise HTTPException(status_code=500, detail=f"Kunne ikke importere {item['address']}")
connection_id = int(rows[0]["id"])
_create_history_entry(
connection_id,
"spreadsheet_connection_created",
"IP Nordic-forbindelse oprettet fra leverandørliste",
{"source_file": filename, "address": item["address"], "customer_auto_assigned": False},
)
created_count += 1
else:
skipped_count += 1 if existing else 0
preview_items.append({
"action": action,
"existing_connection_id": connection_id if existing else None,
"connection_id": connection_id,
"company_number": item["company_number"],
"reported_company": item["reported_company"],
"address": item["address"],
"start_date": item["start_date"].isoformat() if item["start_date"] else None,
"sales_price": float(item["sales_price"]),
"monthly_cost": float(item["monthly_cost"]),
"line_count": item["line_count"],
"changes": changes,
})
return {
"committed": commit,
"provider": "IP Nordic",
"items": preview_items,
"total": len(preview_items),
"create_count": sum(1 for item in preview_items if item["action"] == "create"),
"existing_count": sum(1 for item in preview_items if item["action"] in {"skip", "update"}),
"created_count": created_count,
"updated_count": updated_count,
"skipped_count": skipped_count,
"change_case_ids": sorted(change_case_ids),
"case_errors": case_errors,
"requires_manual_review": bool(case_errors),
"import_key": import_key,
"customer_auto_assignment": False,
}
@router.post("/internet-connections", response_model=dict)
async def create_connection(payload: ConnectionCreatePayload):
normalized = _normalize_connection_payload(payload.model_dump())
normalized = _apply_internet_vendor(normalized)
if normalized.get("allocation_model") == "shared" and normalized.get("value_type") == "delefiber" and not normalized.get("parent_id"):
normalized["is_manual_shared"] = True
try:
rows = execute_query(
"""
INSERT INTO internet_connections_connections (
parent_id, name, provider, vendor_id, customer_id, address, status, monthly_cost, sales_price, technology,
connection_type, circuit_number, speed_mbps, upload_mbps, download_mbps, monitoring_url,
contract_start, contract_end, notes, allocation_model, value_type, value_label, subscription_id,
sla_subscription_id, is_manual_shared
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
normalized.get("parent_id"),
normalized.get("name"),
normalized.get("provider"),
normalized.get("vendor_id"),
normalized.get("customer_id"),
normalized.get("address"),
normalized.get("status"),
normalized.get("monthly_cost"),
normalized.get("sales_price"),
normalized.get("technology"),
normalized.get("connection_type"),
normalized.get("circuit_number"),
normalized.get("speed_mbps"),
normalized.get("upload_mbps"),
normalized.get("download_mbps"),
normalized.get("monitoring_url"),
normalized.get("contract_start"),
normalized.get("contract_end"),
normalized.get("notes"),
normalized.get("allocation_model"),
normalized.get("value_type"),
normalized.get("value_label"),
normalized.get("subscription_id"),
normalized.get("sla_subscription_id"),
normalized.get("is_manual_shared", False),
),
)
except HTTPException:
raise
except Exception as exc:
logger.exception("Failed to create internet connection")
raise HTTPException(status_code=500, detail=f"Kunne ikke oprette forbindelse: {exc}") from exc
if not rows:
raise HTTPException(status_code=500, detail="Failed to create connection")
connection = dict(rows[0])
_create_history_entry(
int(connection["id"]),
"connection_created",
f"Oprettede forbindelse {payload.name}",
{
"provider": normalized.get("provider"),
"customer_id": normalized.get("customer_id"),
"parent_id": normalized.get("parent_id"),
"status": normalized.get("status"),
"allocation_model": normalized.get("allocation_model"),
"value_type": normalized.get("value_type"),
"subscription_id": normalized.get("subscription_id"),
},
)
_sync_bmcnet_parent_classification(normalized.get("parent_id"))
return connection
@router.put("/internet-connections/{connection_id:int}", response_model=dict)
async def update_connection(connection_id: int, payload: ConnectionUpdatePayload):
existing_rows = execute_query(
_connection_select_sql(" AND ic.id = %s "),
(connection_id,),
) or []
if not existing_rows:
raise HTTPException(status_code=404, detail="Connection not found")
existing = dict(existing_rows[0])
update_values = payload.model_dump(exclude_unset=True)
if not update_values:
return _decorate_connection_row(existing)
merged_values = dict(existing)
merged_values.update(update_values)
if (
str(merged_values.get("value_type") or "").strip().lower() == "other"
and not str(merged_values.get("value_label") or "").strip()
):
merged_values["value_label"] = "Mangler klassifikation"
normalized = _normalize_connection_payload(merged_values)
normalized = _apply_internet_vendor(normalized)
if normalized.get("sla_subscription_id"):
sla = execute_query_single(
"""
SELECT id, customer_id, product_name, status
FROM sag_subscriptions WHERE id = %s
""",
(normalized["sla_subscription_id"],),
)
if not sla:
raise HTTPException(status_code=404, detail="SLA-aftalen blev ikke fundet")
if "sla" not in str(sla.get("product_name") or "").lower():
raise HTTPException(status_code=409, detail="Det valgte abonnement er ikke en SLA-aftale")
if normalized.get("customer_id") and int(sla["customer_id"]) != int(normalized["customer_id"]):
raise HTTPException(status_code=409, detail="SLA-aftalen tilhører en anden kunde")
if normalized.get("value_type") == "subscription":
subscription = execute_query_single(
"SELECT id, customer_id FROM sag_subscriptions WHERE id = %s",
(normalized.get("subscription_id"),),
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet blev ikke fundet")
if normalized.get("customer_id") and subscription.get("customer_id") and int(normalized["customer_id"]) != int(subscription["customer_id"]):
raise HTTPException(status_code=409, detail="Abonnementet tilhører en anden kunde")
already_linked = execute_query_single(
"""
SELECT id FROM internet_connections_connections
WHERE subscription_id = %s AND id <> %s AND deleted_at IS NULL
LIMIT 1
""",
(normalized.get("subscription_id"), connection_id),
)
if already_linked:
raise HTTPException(status_code=409, detail=f"Abonnementet er allerede koblet til forbindelse #{already_linked['id']}")
set_parts = []
params: list[object] = []
changed: dict[str, object] = {}
allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id", "sla_subscription_id"}
for field in allowed_fields:
value = normalized.get(field)
set_parts.append(f"{field} = %s")
params.append(value)
if existing.get(field) != value:
changed[field] = value
params.append(connection_id)
try:
rows = execute_query(
f"""
UPDATE internet_connections_connections
SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING *
""",
tuple(params),
) or []
except HTTPException:
raise
except Exception as exc:
logger.exception("Failed to update internet connection %s", connection_id)
raise HTTPException(status_code=500, detail=f"Kunne ikke gemme forbindelse: {exc}") from exc
if not rows:
raise HTTPException(status_code=500, detail="Failed to update connection")
if changed:
_create_history_entry(
connection_id,
"connection_updated",
f"Opdaterede forbindelse {rows[0].get('name')}",
changed,
)
previous_parent_id = existing.get("parent_id")
current_parent_id = normalized.get("parent_id")
_sync_bmcnet_parent_classification(previous_parent_id)
if current_parent_id != previous_parent_id:
_sync_bmcnet_parent_classification(current_parent_id)
_sync_bmcnet_parent_classification(connection_id)
return dict(rows[0])
@router.get("/internet-connections/contracts")
async def list_contracts(status: Optional[str] = Query(None), provider: Optional[str] = Query(None)):
query = """
SELECT
id,
name,
provider,
contract_start,
contract_end,
status,
sales_price,
allocation_model,
value_type,
subscription_id
FROM internet_connections_connections
WHERE deleted_at IS NULL
"""
params: list[object] = []
if status:
query += " AND status = %s"
params.append(status)
if provider:
query += " AND provider ILIKE %s"
params.append(f"%{provider}%")
query += " ORDER BY contract_end IS NULL, contract_end ASC, name ASC"
try:
rows = execute_query(query, tuple(params) if params else ()) or []
except Exception as exc:
logger.warning("Failed to load contract overview: %s", exc)
return []
payload = []
for row in rows:
item = dict(row)
item["contract_status"] = _get_contract_status(item.get("contract_end"))
item["value_type_label"] = _value_type_label(item.get("value_type"))
item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
payload.append(item)
return payload
@router.get("/internet-connections/{connection_id:int}", response_model=dict)
async def get_connection(connection_id: int):
rows = execute_query(
_connection_select_sql(" AND ic.id = %s "),
(connection_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Connection not found")
connection = _decorate_connection_row(dict(rows[0]))
if connection.get("is_shared_head"):
children = _load_bmcnet_children(connection_id)
connection["bmcnet_summary"] = _build_bmcnet_summary(children)
else:
connection["bmcnet_summary"] = _build_bmcnet_summary([])
return connection
@router.get("/internet-connections/{connection_id:int}/cases", response_model=List[dict])
async def list_connection_cases(connection_id: int):
"""Direct case history only; this never infers or changes the connection's customer."""
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Connection not found")
return execute_query(
"""SELECT s.id,s.titel,s.status,s.template_key,s.customer_id,s.updated_at,s.created_at,
c.name AS customer_name,
COALESCE(NULLIF(TRIM(u.full_name),''),u.username) AS responsible_name,
link.created_at AS linked_at
FROM sag_internet_connections link
JOIN sag_sager s ON s.id=link.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE link.connection_id=%s
ORDER BY s.updated_at DESC NULLS LAST,s.id DESC""", (connection_id,),
) or []
@router.get("/internet-connections/{connection_id:int}/cross-field-ports")
async def get_connection_cross_field_ports(connection_id: int):
"""Find cross-field ports related by customer or service-location address."""
connection = execute_query_single(
"""
SELECT ic.id, ic.customer_id, ic.address, parent.address AS parent_address
FROM internet_connections_connections ic
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
WHERE ic.id = %s AND ic.deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
rows = execute_query(
"""
SELECT o.id AS outlet_id, o.outlet_number, o.category, o.patch_panel,
o.patch_port, o.switch_name, o.switch_port, o.status,
o.is_active, o.is_wan, o.customer_id,
p.id AS cross_field_port_id, p.port_number, p.port_order,
cf.id AS cross_field_id, cf.name AS cross_field_name,
l.id AS location_id, l.name AS location_name, l.customer_id AS location_customer_id,
l.address_street, l.address_postal_code, l.address_city,
h.id AS switch_hardware_id, h.model AS switch_hardware_model
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id AND l.deleted_at IS NULL
LEFT JOIN locations_cross_field_ports p ON p.id = o.cross_field_port_id
LEFT JOIN locations_cross_fields cf ON cf.id = p.cross_field_id AND cf.deleted_at IS NULL
LEFT JOIN hardware h ON h.id = o.switch_hardware_id AND h.deleted_at IS NULL
WHERE o.deleted_at IS NULL
AND o.is_active = TRUE
AND o.cross_field_port_id IS NOT NULL
ORDER BY l.name, cf.display_order, p.port_order, o.id
"""
) or []
customer_id = int(connection.get("customer_id") or 0)
target_addresses = {
_normalize_service_location(value)
for value in (connection.get("address"), connection.get("parent_address"))
if value
}
target_addresses.discard("")
ports = []
for row in rows:
item = dict(row)
def clean_location_part(value: object) -> str:
text = str(value or "").strip()
return "" if text.lower() in {"none", "null", "-"} else text
location_address = ", ".join(filter(None, [
clean_location_part(item.get("address_street")),
" ".join(filter(None, [
clean_location_part(item.get("address_postal_code")),
clean_location_part(item.get("address_city")),
])).strip(),
]))
normalized_location = _normalize_service_location(location_address)
customer_match = bool(
customer_id
and customer_id in {
int(item.get("customer_id") or 0),
int(item.get("location_customer_id") or 0),
}
)
address_match = bool(
normalized_location
and any(
target == normalized_location
or (len(normalized_location) >= 5 and target.startswith(normalized_location))
for target in target_addresses
)
)
if not customer_match and not address_match:
continue
item["network_role"] = "wan" if item.get("is_wan") else "lan"
item["is_faulty"] = str(item.get("status") or "").lower() == "faulty"
item["location_address"] = location_address or None
ports.append(item)
return {
"items": ports,
"summary": {
"total": len(ports),
"lan": sum(item["network_role"] == "lan" for item in ports),
"wan": sum(item["network_role"] == "wan" for item in ports),
"faulty": sum(bool(item["is_faulty"]) for item in ports),
},
}
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
where_parts = [" AND ic.parent_id = %s "]
if bmcnet_only:
where_parts.append(
"""
AND parent.allocation_model = 'shared'
AND (
ic.value_type = 'subscription'
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
)
"""
)
rows = execute_query(
_connection_select_sql("".join(where_parts)) + " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
(connection_id,),
) or []
return [_decorate_connection_row(dict(row)) for row in rows]
@router.get("/internet-connections/pricing/summary")
async def pricing_summary():
try:
rows = execute_query(
"""
SELECT
COUNT(*) AS total_connections,
COUNT(*) FILTER (WHERE status = 'active') AS active_connections,
COUNT(*) FILTER (WHERE allocation_model = 'shared' AND parent_id IS NULL) AS shared_head_connections,
COALESCE(SUM(monthly_cost), 0) AS total_purchase_cost,
COALESCE(SUM(sales_price), 0) AS total_sales_price,
COALESCE(SUM(COALESCE(sales_price, 0) - COALESCE(monthly_cost, 0)), 0) AS total_margin
FROM internet_connections_connections
WHERE deleted_at IS NULL
""",
) or []
except Exception as exc:
logger.warning("Failed to load pricing summary: %s", exc)
return {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
return dict(rows[0]) if rows else {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
@router.get("/internet-connections/subscription-options", response_model=List[dict])
async def subscription_options(
q: Optional[str] = Query(None),
status: str = Query("active"),
customer_id: Optional[int] = Query(None),
):
params: List[Any] = []
where = ["1=1"]
if status and status != "all":
where.append("s.status = %s")
params.append(status)
if q:
term = f"%{q}%"
where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)")
params.extend([term, term, term])
if customer_id:
where.append("s.customer_id = %s")
params.append(customer_id)
rows = execute_query(
f"""
SELECT
s.id,
s.subscription_number,
s.product_name,
s.customer_id,
c.name AS customer_name,
s.status,
s.price
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE {" AND ".join(where)}
ORDER BY s.start_date DESC NULLS LAST, s.id DESC
LIMIT 50
""",
tuple(params) if params else (),
) or []
return [dict(row) for row in rows]
@router.get("/internet-connections/{connection_id:int}/allocation-suggestions")
async def connection_allocation_suggestions(connection_id: int):
connection = execute_query_single(
"""
SELECT id, address, customer_id
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
if connection.get("customer_id"):
return {"connection_id": connection_id, "address": connection.get("address"), "items": []}
target_address = str(connection.get("address") or "").strip()
target_normalized = _normalize_service_location(target_address)
target_components = _address_match_components(target_address)
rows = execute_query(
"""
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(c.address), ''),
NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address,
'customer' AS address_source, NULL::text AS location_name
FROM customers c
WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE
UNION ALL
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''),
NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address,
'location' AS address_source, l.name AS location_name
FROM locations_locations l
JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL
WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE
""",
(),
) or []
suggestions: Dict[int, Dict[str, Any]] = {}
for row in rows:
candidate_address = str(row.get("candidate_address") or "").strip()
candidate_normalized = _normalize_service_location(candidate_address)
if not candidate_normalized:
continue
candidate_components = _address_match_components(candidate_address)
score = 100 if candidate_normalized == target_normalized else 0
if (
not score
and target_components["postal_code"]
and candidate_components["postal_code"] == target_components["postal_code"]
and candidate_components["street_name"] == target_components["street_name"]
):
score = 90
target_numbers = target_components["house_numbers"]
candidate_numbers = candidate_components["house_numbers"]
if target_numbers and candidate_numbers:
target_number = target_numbers[0]
if target_number not in candidate_numbers and not (
len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_number <= max(candidate_numbers)
):
score = 0
if score < 90:
continue
customer_id = int(row["customer_id"])
existing = suggestions.get(customer_id)
candidate = {
"customer_id": customer_id,
"customer_name": row.get("customer_name"),
"address": candidate_address,
"address_source": row.get("address_source"),
"location_name": row.get("location_name"),
"match_score": score,
}
if not existing or score > int(existing.get("match_score") or 0):
suggestions[customer_id] = candidate
items = sorted(suggestions.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower()))
return {"connection_id": connection_id, "address": target_address, "items": items}
@router.get("/internet-connections/allocation-overview")
async def internet_connection_allocation_overview():
"""Return compact address suggestions for the unallocated work queue."""
connections = execute_query(
"""
SELECT id, address
FROM internet_connections_connections
WHERE deleted_at IS NULL AND customer_id IS NULL
AND NOT (parent_id IS NULL AND allocation_model = 'shared' AND value_type = 'delefiber')
ORDER BY address, id
"""
) or []
candidate_rows = execute_query(
"""
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(c.address), ''),
NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address,
'customer' AS address_source, NULL::text AS location_name
FROM customers c
WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE
UNION ALL
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''),
NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address,
'location' AS address_source, l.name AS location_name
FROM locations_locations l
JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL
WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE
"""
) or []
items = []
for connection in connections:
target_address = str(connection.get("address") or "").strip()
target_normalized = _normalize_service_location(target_address)
target_components = _address_match_components(target_address)
matches: Dict[int, Dict[str, Any]] = {}
for row in candidate_rows:
candidate_address = str(row.get("candidate_address") or "").strip()
candidate_normalized = _normalize_service_location(candidate_address)
if not target_normalized or not candidate_normalized:
continue
candidate_components = _address_match_components(candidate_address)
score = 100 if candidate_normalized == target_normalized else 0
if (
not score and target_components["postal_code"]
and candidate_components["postal_code"] == target_components["postal_code"]
and candidate_components["street_name"] == target_components["street_name"]
):
score = 90
target_numbers = target_components["house_numbers"]
candidate_numbers = candidate_components["house_numbers"]
if target_numbers and candidate_numbers and target_numbers[0] not in candidate_numbers and not (
len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_numbers[0] <= max(candidate_numbers)
):
score = 0
if score < 90:
continue
customer_id = int(row["customer_id"])
candidate = {
"customer_id": customer_id, "customer_name": row.get("customer_name"),
"address": candidate_address, "address_source": row.get("address_source"),
"location_name": row.get("location_name"), "match_score": score,
}
if customer_id not in matches or score > int(matches[customer_id].get("match_score") or 0):
matches[customer_id] = candidate
candidates = sorted(matches.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower()))
items.append({
"connection_id": int(connection["id"]),
"address": connection.get("address"),
"suggestions": candidates[:5],
"unique_suggestion": candidates[0] if len(candidates) == 1 else None,
})
return {"items": items}
@router.get("/internet-connections/subscriptions/{subscription_id}/provisioning")
async def get_subscription_provisioning(subscription_id: int):
subscription = _load_subscription(subscription_id)
line_items = _load_subscription_line_items(subscription_id)
requirements = summarize_subscription_network_requirements(line_items)
existing_connection = _load_subscription_connection(subscription_id)
return {
"subscription": subscription,
"line_items": line_items,
"network_provisioning": {
**requirements,
"existing_connection_id": existing_connection.get("id") if existing_connection else None,
"is_provisioned": bool(existing_connection),
},
"existing_connection": existing_connection,
"current_allocated_ranges": _load_connection_ranges(int(existing_connection["id"])) if existing_connection else [],
"shared_heads": _load_shared_heads_for_provisioning(requirements.get("required_ip_prefixes") or []),
}
@router.post("/internet-connections/subscriptions/{subscription_id}/provision")
async def provision_subscription_connection(subscription_id: int, payload: SubscriptionProvisionPayload):
subscription = _load_subscription(subscription_id)
line_items = _load_subscription_line_items(subscription_id)
requirements = summarize_subscription_network_requirements(line_items)
if not requirements["requires_provisioning"]:
raise HTTPException(status_code=400, detail="Subscription does not require network provisioning")
line_item_map = {int(item["id"]): item for item in line_items if item.get("id") is not None}
internet_item = None
if payload.internet_item_id is not None:
internet_item = line_item_map.get(int(payload.internet_item_id))
if not internet_item:
raise HTTPException(status_code=400, detail="Selected internet product is not part of the subscription")
internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
if internet_profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Selected internet product is not an internet access line")
elif requirements.get("primary_internet_item"):
internet_item = line_item_map.get(int(requirements["primary_internet_item"]["subscription_item_id"]))
internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
else:
internet_profile = {}
requested_ip_items = requirements.get("ip_items") or []
requested_ip_item_ids = {int(item["subscription_item_id"]) for item in requested_ip_items if item.get("subscription_item_id") is not None}
provided_ip_item_ids = {int(item.subscription_item_id) for item in payload.ip_allocations}
if requested_ip_item_ids != provided_ip_item_ids:
raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje")
shared_connection = execute_query_single(
_connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "),
(payload.shared_connection_id,),
)
if not shared_connection:
raise HTTPException(status_code=404, detail="Shared head connection not found")
shared_connection = _decorate_connection_row(dict(shared_connection))
if not shared_connection.get("address"):
raise HTTPException(status_code=409, detail="Den valgte hovedforbindelse mangler adresse og kan ikke bruges")
existing_connection = _load_subscription_connection(subscription_id)
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT *
FROM internet_connections_connections
WHERE id = %s
AND deleted_at IS NULL
FOR UPDATE
""",
(payload.shared_connection_id,),
)
locked_shared_connection = cursor.fetchone()
if not locked_shared_connection:
raise HTTPException(status_code=404, detail="Shared head connection not found")
existing_row = None
existing_connection_id = None
previous_parent_id = payload.shared_connection_id
if existing_connection:
existing_connection_id = int(existing_connection["id"])
cursor.execute(
"""
SELECT *
FROM internet_connections_connections
WHERE id = %s
AND deleted_at IS NULL
FOR UPDATE
""",
(existing_connection_id,),
)
existing_row = cursor.fetchone()
previous_parent_id = int(existing_row.get("parent_id") or payload.shared_connection_id)
selected_range_ids = [int(item.range_id) for item in payload.ip_allocations]
allocation_by_item = {
int(item.subscription_item_id): {
"range_id": int(item.range_id),
"requested_cidr": str(item.requested_cidr or "").strip() or None,
}
for item in payload.ip_allocations
}
for ip_item in requested_ip_items:
item_id = int(ip_item["subscription_item_id"])
allocation = allocation_by_item[item_id]
range_id = int(allocation["range_id"])
cursor.execute(
"""
SELECT
ir.*
FROM internet_connections_ip_ranges ir
WHERE ir.id = %s
AND ir.connection_id = %s
AND ir.deleted_at IS NULL
FOR UPDATE
""",
(range_id, payload.shared_connection_id),
)
range_row = cursor.fetchone()
if not range_row:
raise HTTPException(status_code=409, detail=f"IP-range #{range_id} er ikke ledigt paa den valgte hovedforbindelse")
cursor.execute(
"""
SELECT
COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total_addresses,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'available') AS available_addresses
FROM internet_connections_ip_addresses
WHERE range_id = %s
""",
(range_id,),
)
range_stats = cursor.fetchone() or {}
prefix_length = _build_ip_range_payload(dict(range_row)).get("prefix_length")
expected_prefix = ip_item.get("ip_prefix_length")
requested_cidr = allocation.get("requested_cidr") or str(range_row.get("cidr") or "")
requested_network = _validate_cidr(requested_cidr)
source_network = _validate_cidr(str(range_row.get("cidr") or ""))
total_addresses = int(range_stats.get("total_addresses") or 0)
available_addresses = int(range_stats.get("available_addresses") or 0)
if total_addresses <= 0:
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} har ingen adresser")
if not requested_network.subnet_of(source_network):
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} ligger ikke i {range_row.get('cidr')}")
if expected_prefix is not None and int(requested_network.prefixlen) != int(expected_prefix):
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} matcher ikke /{expected_prefix}")
if prefix_length is not None and int(requested_network.prefixlen) < int(prefix_length):
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} er stoerre end kildeblokken {range_row.get('cidr')}")
if requested_cidr == str(range_row.get("cidr") or ""):
if available_addresses != total_addresses:
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er ikke laengere ledigt")
if range_row.get("customer_id") is not None:
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er allerede bundet til en anden kunde")
current_range_rows: List[Dict[str, Any]] = []
if existing_connection_id:
cursor.execute(
"""
SELECT id
FROM internet_connections_ip_ranges
WHERE connection_id = %s
AND deleted_at IS NULL
FOR UPDATE
""",
(existing_connection_id,),
)
current_range_rows = [dict(row) for row in cursor.fetchall() or []]
connection_name = internet_item.get("description") if internet_item else subscription.get("product_name")
connection_name = str(connection_name or subscription.get("subscription_number") or "Internetforbindelse").strip()
sales_price = 0.0
if internet_item:
sales_price += float(internet_item.get("line_total") or 0)
for ip_item in requested_ip_items:
sales_price += float(ip_item.get("line_total") or 0)
connection_fields = (
connection_name,
locked_shared_connection.get("provider"),
subscription.get("customer_id"),
payload.shared_connection_id,
locked_shared_connection.get("address"),
"active",
sales_price,
locked_shared_connection.get("technology"),
internet_profile.get("connection_type") or locked_shared_connection.get("connection_type"),
internet_profile.get("speed_mbps"),
internet_profile.get("upload_mbps"),
internet_profile.get("download_mbps"),
"dedicated",
"subscription",
None,
subscription_id,
)
if existing_row:
cursor.execute(
"""
UPDATE internet_connections_connections
SET name = %s,
provider = %s,
customer_id = %s,
parent_id = %s,
address = %s,
status = %s,
sales_price = %s,
technology = %s,
connection_type = %s,
speed_mbps = %s,
upload_mbps = %s,
download_mbps = %s,
allocation_model = %s,
value_type = %s,
value_label = %s,
subscription_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
""",
connection_fields + (existing_connection_id,),
)
connection_row = cursor.fetchone()
else:
cursor.execute(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, parent_id, address, status, sales_price, technology,
connection_type, speed_mbps, upload_mbps, download_mbps,
allocation_model, value_type, value_label, subscription_id
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
""",
connection_fields,
)
connection_row = cursor.fetchone()
existing_connection_id = int(connection_row["id"])
released_range_ids: List[int] = []
for current_range in current_range_rows:
current_range_id = int(current_range["id"])
if current_range_id in selected_range_ids:
continue
cursor.execute(
"""
UPDATE internet_connections_ip_ranges
SET connection_id = %s,
customer_id = NULL,
service_address = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(previous_parent_id, current_range_id),
)
cursor.execute(
"""
UPDATE internet_connections_ip_addresses
SET status = 'available',
assigned_to = NULL,
assigned_type = NULL,
assigned_customer_id = NULL,
assigned_connection_id = NULL,
comment = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE range_id = %s
AND deleted_at IS NULL
""",
(current_range_id,),
)
released_range_ids.append(current_range_id)
allocated_ranges: List[Dict[str, Any]] = []
for ip_item in requested_ip_items:
item_id = int(ip_item["subscription_item_id"])
allocation = allocation_by_item[item_id]
range_id = int(allocation["range_id"])
requested_cidr = allocation.get("requested_cidr")
cursor.execute(
"""
SELECT *
FROM internet_connections_ip_ranges
WHERE id = %s
AND deleted_at IS NULL
FOR UPDATE
""",
(range_id,),
)
source_range = cursor.fetchone()
if not source_range:
raise HTTPException(status_code=404, detail=f"IP-range #{range_id} blev ikke fundet")
source_cidr = str(source_range.get("cidr") or "")
effective_cidr = requested_cidr or source_cidr
if effective_cidr == source_cidr:
cursor.execute(
"""
UPDATE internet_connections_ip_ranges
SET connection_id = %s,
customer_id = %s,
service_address = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
""",
(existing_connection_id, subscription.get("customer_id"), locked_shared_connection.get("address"), range_id),
)
allocated_range = cursor.fetchone()
cursor.execute(
"""
UPDATE internet_connections_ip_addresses
SET status = 'reserved',
assigned_to = %s,
assigned_type = 'subscription',
assigned_customer_id = %s,
assigned_connection_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE range_id = %s
AND deleted_at IS NULL
""",
(subscription.get("subscription_number"), subscription.get("customer_id"), existing_connection_id, range_id),
)
else:
requested_network = _validate_cidr(effective_cidr)
host_addresses = [str(host) for host in requested_network.hosts()]
if not host_addresses:
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} har ingen brugbare adresser")
cursor.execute(
"""
SELECT id, ip_address, status
FROM internet_connections_ip_addresses
WHERE range_id = %s
AND deleted_at IS NULL
FOR UPDATE
""",
(range_id,),
)
source_addresses = cursor.fetchall() or []
source_address_map = {str(row.get("ip_address")): dict(row) for row in source_addresses}
missing_addresses = [ip for ip in host_addresses if ip not in source_address_map]
if missing_addresses:
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} mangler adresser i lageret")
busy_addresses = [
ip for ip in host_addresses
if str(source_address_map[ip].get("status") or "available").lower() != "available"
]
if busy_addresses:
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} er ikke laengere ledigt")
cursor.execute(
"""
INSERT INTO internet_connections_ip_ranges (
connection_id, name, cidr, description, provider_reference,
contract_number, customer_id, service_address, monthly_cost, sales_price
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 0, 0)
RETURNING *
""",
(
existing_connection_id,
f"{source_range.get('name') or 'IP-range'} -> {effective_cidr}",
effective_cidr,
source_range.get("description"),
source_range.get("provider_reference"),
source_range.get("contract_number"),
subscription.get("customer_id"),
locked_shared_connection.get("address"),
),
)
allocated_range = cursor.fetchone()
allocated_range_id = int(allocated_range["id"])
cursor.execute(
"""
UPDATE internet_connections_ip_addresses
SET range_id = %s,
status = 'reserved',
assigned_to = %s,
assigned_type = 'subscription',
assigned_customer_id = %s,
assigned_connection_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY(%s)
""",
(
allocated_range_id,
subscription.get("subscription_number"),
subscription.get("customer_id"),
existing_connection_id,
[int(source_address_map[ip]["id"]) for ip in host_addresses],
),
)
allocated_ranges.append(_build_ip_range_payload(dict(allocated_range)))
conn.commit()
_sync_bmcnet_parent_classification(previous_parent_id)
if int(previous_parent_id) != int(payload.shared_connection_id):
_sync_bmcnet_parent_classification(payload.shared_connection_id)
_create_history_entry(
existing_connection_id,
"subscription_provisioned",
f"Provisionerede forbindelse fra abonnement {subscription.get('subscription_number')}",
{
"subscription_id": subscription_id,
"shared_connection_id": payload.shared_connection_id,
"internet_item_id": payload.internet_item_id,
"allocated_range_ids": selected_range_ids,
"released_range_ids": released_range_ids,
},
)
_create_history_entry(
payload.shared_connection_id,
"shared_capacity_allocated",
f"Allokerede kapacitet til abonnement {subscription.get('subscription_number')}",
{
"subscription_id": subscription_id,
"connection_id": existing_connection_id,
"allocated_range_ids": selected_range_ids,
},
)
return {
"subscription_id": subscription_id,
"connection": _load_subscription_connection(subscription_id),
"allocated_ranges": _load_connection_ranges(existing_connection_id),
"released_range_ids": released_range_ids,
"reused_existing_connection": bool(existing_connection),
}
finally:
release_db_connection(conn)
@router.get("/internet-connections/{connection_id}/product-prices", response_model=List[dict])
async def list_delefiber_product_prices(connection_id: int):
head = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
(connection_id,),
)
if not head:
raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
return execute_query(
"""SELECT price.id, price.product_id, price.monthly_price, price.notes, price.is_active,
price.updated_at, product.name AS product_name, product.sales_price AS product_sales_price
FROM internet_connections_delefiber_product_prices price
JOIN products product ON product.id=price.product_id AND product.deleted_at IS NULL
WHERE price.connection_id=%s AND price.is_active=true
ORDER BY product.name""",
(connection_id,),
) or []
@router.post("/internet-connections/{connection_id}/product-prices", response_model=dict)
async def upsert_delefiber_product_price(connection_id: int, payload: DelefiberProductPricePayload):
head = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
(connection_id,),
)
if not head:
raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
product = execute_query_single("SELECT id FROM products WHERE id=%s AND deleted_at IS NULL", (payload.product_id,))
if not product:
raise HTTPException(status_code=404, detail="Produktet blev ikke fundet")
if payload.monthly_price < 0:
raise HTTPException(status_code=400, detail="Prisen må ikke være negativ")
row = execute_query_single(
"""INSERT INTO internet_connections_delefiber_product_prices
(connection_id,product_id,monthly_price,notes,is_active)
VALUES (%s,%s,%s,%s,true)
ON CONFLICT (connection_id,product_id) DO UPDATE
SET monthly_price=EXCLUDED.monthly_price, notes=EXCLUDED.notes,
is_active=true, updated_at=NOW()
RETURNING id,connection_id,product_id,monthly_price,notes,is_active,updated_at""",
(connection_id, payload.product_id, payload.monthly_price, (payload.notes or '').strip() or None),
)
return dict(row)
@router.delete("/internet-connections/{connection_id}/product-prices/{product_id}", response_model=dict)
async def remove_delefiber_product_price(connection_id: int, product_id: int):
updated = execute_query(
"""UPDATE internet_connections_delefiber_product_prices
SET is_active=false, updated_at=NOW()
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, product_id), fetch=False,
)
if not updated:
raise HTTPException(status_code=404, detail="Prislinjen blev ikke fundet")
return {"deleted": True}
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single(
_connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "),
(connection_id,),
)
if not head_row:
raise HTTPException(status_code=404, detail="Hovedforbindelsen blev ikke fundet")
head = _decorate_connection_row(dict(head_row))
customer = execute_query_single(
"""
SELECT id, name
FROM customers
WHERE id = %s
LIMIT 1
""",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
address = str(payload.address or head.get("address") or "").strip()
if not address:
raise HTTPException(status_code=400, detail="Adresse er påkrævet for BMCnet-forbindelsen")
customer_name = str(customer.get("name") or "").strip() or f"Kunde #{payload.customer_id}"
service_label = str(payload.service_label or "").strip() or f"BMCnet - {customer_name}"
wizard_notes = str(payload.notes or "").strip() or None
product_ids = [int(payload.internet_product_id)]
if payload.ip_product_id:
product_ids.append(int(payload.ip_product_id))
product_rows = execute_query(
"""
SELECT id, name, short_description, sales_price, attributes_json, type
FROM products
WHERE id = ANY(%s)
AND deleted_at IS NULL
""",
(product_ids,),
) or []
product_map = {int(row["id"]): dict(row) for row in product_rows if row.get("id") is not None}
internet_product = product_map.get(int(payload.internet_product_id))
if not internet_product:
raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet")
internet_profile = build_network_product_profile(internet_product, fallback_text=internet_product.get("short_description"))
if internet_profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt")
# A local delefiber price is the default offer at this address. An explicit
# value in the wizard remains a negotiated customer-specific override.
local_price_row = execute_query_single(
"""SELECT monthly_price FROM internet_connections_delefiber_product_prices
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, int(payload.internet_product_id)),
)
resolved_internet_unit_price = (
float(payload.internet_unit_price)
if payload.internet_unit_price is not None
else float(local_price_row["monthly_price"])
if local_price_row and local_price_row.get("monthly_price") is not None
else float(internet_product.get("sales_price") or 0)
)
ip_product = None
ip_profile = {}
selected_ip_address = None
resolved_ip_unit_price = None
if payload.ip_product_id:
ip_product = product_map.get(int(payload.ip_product_id))
if not ip_product:
raise HTTPException(status_code=404, detail="IP-produktet blev ikke fundet")
ip_profile = build_network_product_profile(ip_product, fallback_text=ip_product.get("short_description"))
if ip_profile.get("kind") != "ip_allocation":
raise HTTPException(status_code=400, detail="Det valgte IP-produkt er ikke et IP-allokeringsprodukt")
is_static_wan_ip = bool(
int(ip_profile.get("ip_prefix_length") or 0) == 32
or "statisk wan" in f"{ip_product.get('name') or ''} {ip_product.get('short_description') or ''}".lower()
)
if is_static_wan_ip:
if not payload.ip_address_id:
raise HTTPException(status_code=400, detail="Vælg en ledig IP-adresse til Statisk WAN IP")
selected_ip_address = execute_query_single(
"""
SELECT
ipa.id,
ipa.ip_address,
ipa.range_id,
ipa.status,
ir.connection_id,
ir.service_address,
ir.cidr,
child.name AS connection_name,
child.parent_id AS connection_parent_id
FROM internet_connections_ip_addresses ipa
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
LEFT JOIN internet_connections_connections child ON child.id = ir.connection_id
WHERE ipa.id = %s
AND ipa.deleted_at IS NULL
AND ir.deleted_at IS NULL
LIMIT 1
""",
(payload.ip_address_id,),
)
if not selected_ip_address:
raise HTTPException(status_code=404, detail="Den valgte IP-adresse blev ikke fundet")
if int(selected_ip_address.get("connection_id") or 0) != int(connection_id):
if int(selected_ip_address.get("connection_parent_id") or 0) == int(connection_id):
child_name = str(selected_ip_address.get("connection_name") or "").strip()
child_label = child_name or f"Forbindelse #{selected_ip_address.get('connection_id')}"
raise HTTPException(
status_code=409,
detail=f"Den valgte IP-adresse er allerede reserveret på {child_label}. Opdater siden før du opretter igen.",
)
raise HTTPException(status_code=409, detail="Den valgte IP-adresse ligger ikke på den valgte hovedforbindelse")
if str(selected_ip_address.get("status") or "").lower() != "available":
raise HTTPException(status_code=409, detail="Den valgte IP-adresse er ikke længere ledig")
elif not payload.range_id:
raise HTTPException(status_code=400, detail="Vælg et IP-range til IP-produktet")
elif payload.range_id or payload.ip_address_id:
raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt")
if ip_product:
local_ip_price = execute_query_single(
"""SELECT monthly_price FROM internet_connections_delefiber_product_prices
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, int(ip_product["id"])),
)
resolved_ip_unit_price = (
float(payload.ip_unit_price)
if payload.ip_unit_price is not None
else float(local_ip_price["monthly_price"])
if local_ip_price and local_ip_price.get("monthly_price") is not None
else float(ip_product.get("sales_price") or 0)
)
case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}"
description_lines = [
f"Hovedforbindelse: {head.get('name') or connection_id}",
f"Service: {service_label}",
f"Adresse: {address}",
f"Internetprodukt: {internet_product.get('name') or '-'}",
]
if ip_product:
description_lines.append(f"IP-produkt: {ip_product.get('name') or '-'}")
if wizard_notes:
description_lines.append("")
description_lines.append(wizard_notes)
assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])
created_case = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
case_title,
"\n".join(description_lines),
"abonnement",
"åben",
payload.customer_id,
assigned_group_id,
1,
),
)
if not created_case or not created_case.get("id"):
raise HTTPException(status_code=500, detail="Kunne ikke oprette sag")
line_items = [{
"product_id": int(internet_product["id"]),
"description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(),
"quantity": 1,
"unit_price": resolved_internet_unit_price,
}]
if ip_product:
line_items.append({
"product_id": int(ip_product["id"]),
"description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(),
"quantity": 1,
"unit_price": resolved_ip_unit_price,
})
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
subscription = await create_sag_subscription({
"sag_id": int(created_case["id"]),
"billing_interval": payload.billing_interval,
"billing_day": int(payload.billing_day),
"billing_schedule_type": payload.billing_schedule_type,
"billing_direction": payload.billing_direction,
"advance_months": int(payload.advance_months),
"billing_lead_months": int(payload.billing_lead_months),
"first_invoice_policy": payload.first_invoice_policy,
"start_date": payload.start_date.isoformat(),
"period_start": (payload.period_start or payload.start_date).isoformat(),
"first_full_period_start": (payload.first_full_period_start.isoformat() if payload.first_full_period_start else None),
"end_date": (payload.end_date.isoformat() if payload.end_date else None),
"notice_period_days": int(payload.notice_period_days),
"binding_months": int(payload.binding_months),
"binding_start_date": (payload.binding_start_date or payload.period_start or payload.start_date).isoformat(),
"notes": wizard_notes,
"line_items": line_items,
})
subscription_id = int(subscription["id"])
line_items_created = _load_subscription_line_items(subscription_id)
requirements = summarize_subscription_network_requirements(line_items_created)
internet_item = requirements.get("primary_internet_item")
if not internet_item:
raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men internetlinjen kunne ikke findes")
ip_allocations: List[SubscriptionIpAllocationPayload] = []
if ip_product:
ip_item = next((item for item in requirements.get("ip_items") or [] if int(item.get("product_id") or 0) == int(ip_product["id"])), None)
if not ip_item:
raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men IP-linjen kunne ikke findes")
requested_cidr = None
allocation_range_id = int(payload.range_id or 0)
if selected_ip_address:
allocation_range_id = int(selected_ip_address["range_id"])
requested_cidr = f"{selected_ip_address['ip_address']}/32"
elif payload.range_id:
range_row = execute_query_single(
"SELECT cidr FROM internet_connections_ip_ranges WHERE id = %s AND deleted_at IS NULL",
(payload.range_id,),
)
if range_row and range_row.get("cidr"):
requested_cidr = str(range_row.get("cidr"))
ip_allocations.append(SubscriptionIpAllocationPayload(
subscription_item_id=int(ip_item["subscription_item_id"]),
range_id=allocation_range_id,
requested_cidr=requested_cidr,
))
provisioned = await provision_subscription_connection(
subscription_id,
SubscriptionProvisionPayload(
shared_connection_id=connection_id,
internet_item_id=int(internet_item["subscription_item_id"]),
ip_allocations=ip_allocations,
),
)
connection = provisioned.get("connection") or _load_subscription_connection(subscription_id)
if connection:
execute_query(
"""
UPDATE internet_connections_connections
SET name = %s,
address = %s,
notes = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
case_title,
address,
f"Service: {service_label}" if not wizard_notes else f"Service: {service_label}\n{wizard_notes}",
int(connection["id"]),
),
)
if payload.range_id:
execute_query(
"""
UPDATE internet_connections_ip_ranges
SET service_address = %s,
updated_at = CURRENT_TIMESTAMP
WHERE connection_id = %s
AND deleted_at IS NULL
""",
(address, int(connection["id"])),
)
if selected_ip_address and payload.mark_gateway:
execute_query(
"""
UPDATE internet_connections_ip_addresses ipa
SET assigned_type = 'bmcnet_gateway',
comment = %s,
updated_at = CURRENT_TIMESTAMP
FROM internet_connections_ip_ranges ir
WHERE ipa.range_id = ir.id
AND ir.connection_id = %s
AND ipa.ip_address = %s
AND ipa.deleted_at IS NULL
""",
("BMCnet kunde gateway", int(connection["id"]), str(selected_ip_address["ip_address"])),
)
connection = _load_subscription_connection(subscription_id)
_create_history_entry(
int(connection["id"]),
"bmcnet_connection_created",
f"Oprettede BMCnet-forbindelse via wizard fra sag #{created_case['id']}",
{
"parent_connection_id": connection_id,
"customer_id": payload.customer_id,
"service_label": service_label,
"subscription_id": subscription_id,
"sag_id": int(created_case["id"]),
},
)
return {
"sag": created_case,
"subscription": subscription,
"connection": connection,
"allocated_range_id": int(payload.range_id) if payload.range_id else None,
"allocated_ip_address_id": int(selected_ip_address["id"]) if selected_ip_address else None,
}
@router.get("/internet-connections/{connection_id}/ip-ranges")
async def list_ip_ranges(connection_id: int):
try:
connection_rows = execute_query(
_connection_select_sql(" AND ic.id = %s "),
(connection_id,),
) or []
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
range_rows = execute_query(
"""
SELECT
ir.*,
c.name AS customer_name
FROM internet_connections_ip_ranges ir
LEFT JOIN customers c ON c.id = ir.customer_id
WHERE ir.connection_id = %s AND ir.deleted_at IS NULL
ORDER BY id ASC
""",
(connection_id,),
) or []
address_rows = execute_query(
"""
SELECT ipa.id, ipa.range_id, ipa.ip_address, ipa.status
FROM internet_connections_ip_addresses ipa
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
WHERE ir.connection_id = %s AND ipa.deleted_at IS NULL
ORDER BY ipa.ip_address ASC
""",
(connection_id,),
) or []
except Exception as exc:
logger.exception("Failed to load IP ranges for connection %s", connection_id)
raise HTTPException(status_code=500, detail="Kunne ikke hente IP-ranges") from exc
address_map: dict[int, list[dict]] = {}
for address in address_rows:
address_map.setdefault(int(address.get("range_id")), []).append(address)
payload = []
for row in range_rows:
item = _build_ip_range_payload(dict(row), address_map.get(int(row.get("id")), []))
if connection:
item.update(_range_alignment_for_connection(connection, item))
payload.append(item)
return payload
@router.post("/internet-connections/{connection_id}/ip-ranges")
async def create_ip_range(connection_id: int, payload: IpRangeCreatePayload):
cidr = payload.cidr
if not cidr:
raise HTTPException(status_code=400, detail="CIDR is required")
network = _validate_cidr(str(cidr))
duplicate_range = execute_query(
"""
SELECT ir.id, ir.connection_id, ir.cidr, ic.name AS connection_name
FROM internet_connections_ip_ranges ir
LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
WHERE ir.cidr = %s
AND ir.deleted_at IS NULL
LIMIT 1
""",
(str(network),),
) or []
if duplicate_range:
row = duplicate_range[0]
raise HTTPException(
status_code=409,
detail=f"IP-range {row.get('cidr')} findes allerede på forbindelse {row.get('connection_name') or row.get('connection_id')}",
)
host_ips = [str(host) for host in network.hosts()]
if host_ips:
conflicting_rows = execute_query(
"""
SELECT
ipa.ip_address,
ir.cidr,
ic.id AS connection_id,
ic.name AS connection_name
FROM internet_connections_ip_addresses ipa
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
WHERE ipa.deleted_at IS NULL
AND ipa.ip_address = ANY(%s)
ORDER BY ipa.ip_address ASC
LIMIT 5
""",
(host_ips,),
) or []
if conflicting_rows:
examples = ", ".join(
f"{row.get('ip_address')} ({row.get('connection_name') or row.get('connection_id')} / {row.get('cidr')})"
for row in conflicting_rows
)
raise HTTPException(
status_code=409,
detail=f"IP-range {network} overlapper eksisterende IP-adresser: {examples}",
)
try:
rows = execute_query(
"""
INSERT INTO internet_connections_ip_ranges (
connection_id, name, cidr, description, provider_reference,
contract_number, customer_id, service_address, monthly_cost, sales_price
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
connection_id,
payload.name,
str(network),
payload.description,
payload.provider_reference,
payload.contract_number,
payload.customer_id,
payload.service_address,
payload.monthly_cost,
payload.sales_price,
),
) or []
except HTTPException:
raise
except Exception as exc:
logger.exception("Failed to create IP range on connection %s", connection_id)
raise HTTPException(status_code=500, detail=f"Kunne ikke oprette IP-range: {exc}") from exc
if not rows:
raise HTTPException(status_code=500, detail="Failed to create IP range")
range_row = dict(rows[0])
try:
_create_ip_addresses_for_range(int(range_row["id"]), str(network))
except HTTPException:
raise
except Exception as exc:
logger.exception("Failed to auto-create IP addresses for range %s", range_row.get("id"))
execute_query(
"""
UPDATE internet_connections_ip_ranges
SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(range_row["id"],),
)
raise HTTPException(status_code=500, detail=f"IP-range blev oprettet men IP-adresser kunne ikke genereres: {exc}") from exc
_create_history_entry(
connection_id,
"ip_range_created",
f"Oprettede IP-range {payload.name}",
{
"range_name": payload.name,
"cidr": payload.cidr,
"provider_reference": payload.provider_reference,
"contract_number": payload.contract_number,
"customer_id": payload.customer_id,
},
)
return range_row
@router.put("/internet-connections/{connection_id}/ip-ranges/{range_id}")
async def update_ip_range(connection_id: int, range_id: int, payload: IpRangeUpdatePayload):
existing_rows = execute_query(
"""
SELECT *
FROM internet_connections_ip_ranges
WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
LIMIT 1
""",
(range_id, connection_id),
) or []
if not existing_rows:
raise HTTPException(status_code=404, detail="IP-range not found")
existing = dict(existing_rows[0])
update_values = payload.model_dump(exclude_unset=True)
if not update_values:
return _build_ip_range_payload(existing)
merged = dict(existing)
merged.update(update_values)
merged["name"] = str(merged.get("name") or "").strip() or str(existing.get("cidr") or "")
merged["description"] = str(merged.get("description") or "").strip() or None
merged["provider_reference"] = str(merged.get("provider_reference") or "").strip() or None
merged["contract_number"] = str(merged.get("contract_number") or "").strip() or None
merged["service_address"] = str(merged.get("service_address") or "").strip() or None
if not merged["name"]:
raise HTTPException(status_code=400, detail="Navn er påkrævet")
changed = {}
fields = [
"name",
"description",
"provider_reference",
"contract_number",
"customer_id",
"service_address",
"monthly_cost",
"sales_price",
]
params: list[object] = []
set_parts: list[str] = []
for field in fields:
value = merged.get(field)
set_parts.append(f"{field} = %s")
params.append(value)
if existing.get(field) != value:
changed[field] = value
try:
rows = execute_query(
f"""
UPDATE internet_connections_ip_ranges
SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
RETURNING *
""",
tuple(params + [range_id, connection_id]),
) or []
except Exception as exc:
logger.exception("Failed to update IP range %s on connection %s", range_id, connection_id)
raise HTTPException(status_code=500, detail=f"Kunne ikke gemme IP-range: {exc}") from exc
if not rows:
raise HTTPException(status_code=500, detail="Failed to update IP range")
if changed:
_create_history_entry(
connection_id,
"ip_range_updated",
f"Opdaterede IP-range {rows[0].get('name') or rows[0].get('cidr')}",
{
"range_id": range_id,
"cidr": rows[0].get("cidr"),
"changes": changed,
},
)
address_rows = execute_query(
"""
SELECT *
FROM internet_connections_ip_addresses
WHERE range_id = %s AND deleted_at IS NULL
ORDER BY ip_address ASC
""",
(range_id,),
) or []
return _build_ip_range_payload(dict(rows[0]), [dict(row) for row in address_rows])
@router.get("/internet-connections/{connection_id}/ip-addresses")
async def list_ip_addresses(connection_id: int):
connection_rows = execute_query(
_connection_select_sql(" AND ic.id = %s "),
(connection_id,),
) or []
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
where_sql = "ir.connection_id = %s"
params: tuple[object, ...]
if connection and connection.get("is_shared_head"):
where_sql = """
(
ir.connection_id = %s
OR ir.connection_id IN (
SELECT child.id
FROM internet_connections_connections child
WHERE child.parent_id = %s
AND child.deleted_at IS NULL
)
)
"""
params = (connection_id, connection_id)
else:
params = (connection_id,)
rows = execute_query(
f"""
SELECT
ipa.*,
ir.name AS range_name,
ir.cidr AS range_cidr,
ir.connection_id AS range_connection_id,
ir.service_address AS range_service_address,
ir.customer_id AS range_customer_id,
rc.name AS range_customer_name,
c.name AS assigned_customer_name,
ic.name AS assigned_connection_name,
owner.name AS range_connection_name,
sub.id AS assigned_subscription_id,
sub.subscription_number AS assigned_subscription_number,
sub.sag_id AS assigned_subscription_sag_id
FROM internet_connections_ip_addresses ipa
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
LEFT JOIN customers rc ON rc.id = ir.customer_id
LEFT JOIN customers c ON c.id = ipa.assigned_customer_id
LEFT JOIN internet_connections_connections ic ON ic.id = ipa.assigned_connection_id
LEFT JOIN internet_connections_connections owner ON owner.id = ir.connection_id
LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
WHERE {where_sql} AND ipa.deleted_at IS NULL
ORDER BY ipa.ip_address ASC
""",
params,
) or []
payload = []
for row in rows:
item = dict(row)
if connection:
item.update(
_range_alignment_for_connection(
connection,
{
"service_address": item.get("range_service_address"),
"customer_id": item.get("range_customer_id"),
"customer_name": item.get("range_customer_name"),
},
)
)
status = str(item.get("status", "available")).lower()
if status == "in_use":
item["status_label"] = "I brug"
item["badge_class"] = "bg-primary"
elif status == "reserved":
item["status_label"] = "Reserveret"
item["badge_class"] = "bg-warning"
else:
item["status_label"] = "Tilgængelig"
item["badge_class"] = "bg-success"
payload.append(item)
return payload
@router.get("/internet-connections/{connection_id}/ip-addresses/summary")
async def summarize_ip_addresses(connection_id: int):
connection_rows = execute_query(
_connection_select_sql(" AND ic.id = %s "),
(connection_id,),
) or []
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
where_sql = "ir.connection_id = %s"
params: tuple[object, ...]
if connection and connection.get("is_shared_head"):
where_sql = """
(
ir.connection_id = %s
OR ir.connection_id IN (
SELECT child.id
FROM internet_connections_connections child
WHERE child.parent_id = %s
AND child.deleted_at IS NULL
)
)
"""
params = (connection_id, connection_id)
else:
params = (connection_id,)
rows = execute_query(
f"""
SELECT
COUNT(*) FILTER (WHERE status = 'available') AS available,
COUNT(*) FILTER (WHERE status = 'in_use') AS in_use,
COUNT(*) FILTER (WHERE status = 'reserved') AS reserved
FROM internet_connections_ip_addresses ipa
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
WHERE {where_sql} AND ipa.deleted_at IS NULL
""",
params,
) or []
if not rows:
return {"available": 0, "in_use": 0, "reserved": 0}
return dict(rows[0])
@router.post("/internet-connections/{connection_id}/ip-addresses")
async def create_ip_address(connection_id: int, payload: IpAddressCreatePayload):
normalized_ip = _normalize_ip_address(payload.ip_address)
duplicate = execute_query(
"""
SELECT id, range_id
FROM internet_connections_ip_addresses
WHERE ip_address = %s
AND deleted_at IS NULL
LIMIT 1
""",
(normalized_ip,),
) or []
if duplicate:
raise HTTPException(status_code=409, detail=f"IP-adressen {normalized_ip} findes allerede")
rows = execute_query(
"""
INSERT INTO internet_connections_ip_addresses (
range_id, ip_address, status, assigned_to, assigned_type,
assigned_customer_id, assigned_connection_id, comment
)
SELECT ir.id, %s, %s, %s, %s, %s, %s, %s
FROM internet_connections_ip_ranges ir
WHERE ir.connection_id = %s AND ir.id = %s AND ir.deleted_at IS NULL
RETURNING *
""",
(
normalized_ip,
payload.status,
payload.assigned_to,
payload.assigned_type,
payload.assigned_customer_id,
payload.assigned_connection_id,
payload.comment,
connection_id,
payload.range_id,
),
) or []
if not rows:
raise HTTPException(status_code=404, detail="No IP range available for this connection")
_create_history_entry(
connection_id,
"ip_address_created",
f"Oprettede IP-adresse {normalized_ip}",
{
"ip_address": normalized_ip,
"status": payload.status,
"assigned_customer_id": payload.assigned_customer_id,
"assigned_connection_id": payload.assigned_connection_id,
},
)
return dict(rows[0])
@router.put("/internet-connections/{connection_id}/ip-addresses/{address_id}")
async def update_ip_address(connection_id: int, address_id: int, payload: IpAddressUpdatePayload):
rows = execute_query(
"""
UPDATE internet_connections_ip_addresses ipa
SET status = %s,
assigned_to = %s,
assigned_type = %s,
assigned_customer_id = %s,
assigned_connection_id = %s,
comment = %s,
updated_at = CURRENT_TIMESTAMP
FROM internet_connections_ip_ranges ir
WHERE ipa.range_id = ir.id
AND ir.connection_id = %s
AND ipa.id = %s
AND ipa.deleted_at IS NULL
RETURNING ipa.*
""",
(
payload.status,
payload.assigned_to,
payload.assigned_type,
payload.assigned_customer_id,
payload.assigned_connection_id,
payload.comment,
connection_id,
address_id,
),
) or []
if not rows:
raise HTTPException(status_code=404, detail="IP address not found")
item = dict(rows[0])
_create_history_entry(
connection_id,
"ip_address_updated",
f"Opdaterede IP-adresse {item.get('ip_address')}",
{
"ip_address": item.get("ip_address"),
"status": item.get("status"),
"assigned_customer_id": item.get("assigned_customer_id"),
"assigned_connection_id": item.get("assigned_connection_id"),
},
)
return item
@router.post("/internet-connections/{connection_id}/pricing", response_model=dict)
async def create_connection_pricing(connection_id: int, payload: PricingCreatePayload):
rows = execute_query(
"""
INSERT INTO internet_connections_pricing (
connection_id, effective_from, purchase_price, sales_price, notes
)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
""",
(connection_id, payload.effective_from, payload.purchase_price, payload.sales_price, payload.notes),
) or []
if not rows:
raise HTTPException(status_code=500, detail="Failed to create pricing entry")
execute_query(
"""
UPDATE internet_connections_connections
SET monthly_cost = %s, sales_price = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
""",
(payload.purchase_price, payload.sales_price, connection_id),
)
_create_history_entry(
connection_id,
"pricing_updated",
f"Opdaterede pris til {payload.sales_price} kr.",
{"effective_from": payload.effective_from.isoformat(), "purchase_price": payload.purchase_price, "sales_price": payload.sales_price},
)
return dict(rows[0])
@router.get("/internet-connections/{connection_id}/pricing")
async def get_connection_pricing(connection_id: int):
rows = execute_query(
"""
SELECT
monthly_cost,
sales_price,
connection_type,
speed_mbps,
upload_mbps,
download_mbps,
contract_start,
contract_end,
status
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Connection not found")
payload = dict(rows[0])
payload["contract_status"] = _get_contract_status(payload.get("contract_end"))
return payload
@router.get("/internet-connections/{connection_id}/pricing/history")
async def list_pricing_history(connection_id: int):
rows = execute_query(
"""
SELECT *
FROM internet_connections_pricing
WHERE connection_id = %s
ORDER BY effective_from DESC, created_at DESC
""",
(connection_id,),
) or []
return [dict(row) for row in rows]
@router.get("/internet-connections/{connection_id}/history")
async def list_history(connection_id: int):
rows = execute_query(
"""
SELECT *
FROM internet_connections_history
WHERE connection_id = %s
ORDER BY created_at DESC
""",
(connection_id,),
) or []
return [dict(row) for row in rows]
@router.get("/internet-connections/customer-documents")
async def list_customer_documents(customer_id: Optional[int] = Query(None, ge=1)):
if customer_id:
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
payload = await _build_customer_document_hits(int(customer["id"]), str(customer["name"] or ""), None)
return {
"customer": {"id": int(customer["id"]), "name": customer["name"]},
"documents": payload["documents"],
"segments": payload["segments"],
}
rows = execute_query(
"""
SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
FROM internet_connections_customer_documents
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 100
"""
) or []
documents = []
for row in rows:
item = dict(row)
item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
item.pop("extracted_text", None)
documents.append(item)
return {"documents": documents, "customer": None}
@router.post("/internet-connections/customer-documents/upload")
async def upload_customer_document(
customer_id: Optional[int] = Form(None),
connection_id: Optional[int] = Form(None),
notes: Optional[str] = Form(None),
file: UploadFile = File(...),
):
customer = None
if customer_id:
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
if connection_id:
connection = execute_query_single(
"""
SELECT id, customer_id
FROM internet_connections_connections
WHERE id = %s
AND deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
if customer_id and connection.get("customer_id") not in (None, customer_id):
raise HTTPException(status_code=400, detail="Connection belongs to a different customer")
original_filename = str(file.filename or "").strip()
suffix = Path(original_filename).suffix.lower()
if suffix not in INTERNET_CUSTOMER_DOC_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Kun tekstfiler er tilladt ({', '.join(sorted(INTERNET_CUSTOMER_DOC_EXTENSIONS))})",
)
upload_dir = _internet_customer_doc_dir()
safe_name = _sanitize_upload_name(original_filename)
customer_prefix = str(customer_id) if customer_id else "shared"
stored_name = f"{customer_prefix}_{int(date.today().strftime('%Y%m%d'))}_{safe_name}"
target = upload_dir / stored_name
total_size = 0
max_size = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
try:
with open(target, "wb") as handle:
while chunk := await file.read(8192):
total_size += len(chunk)
if total_size > max_size:
target.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=f"Fil for stor (max {settings.EMAIL_MAX_UPLOAD_SIZE_MB} MB)",
)
handle.write(chunk)
checksum = ollama_service.calculate_file_checksum(target)
existing = execute_query_single(
"""
SELECT id
FROM internet_connections_customer_documents
WHERE customer_id = %s
AND checksum = %s
AND deleted_at IS NULL
LIMIT 1
""",
(customer_id, checksum),
)
if existing and customer_id:
target.unlink(missing_ok=True)
return {
"status": "duplicate",
"document_id": int(existing["id"]),
"message": "Filen er allerede uploadet paa denne kunde",
}
if not customer_id:
existing_shared = execute_query_single(
"""
SELECT id
FROM internet_connections_customer_documents
WHERE customer_id IS NULL
AND checksum = %s
AND deleted_at IS NULL
LIMIT 1
""",
(checksum,),
)
if existing_shared:
target.unlink(missing_ok=True)
return {
"status": "duplicate",
"document_id": int(existing_shared["id"]),
"message": "Filen er allerede uploadet i det delte arkiv",
}
extracted_text = await ollama_service._extract_text_from_file(target)
mime_type = ollama_service._get_mime_type(target)
document_id = execute_insert(
"""
INSERT INTO internet_connections_customer_documents (
customer_id,
connection_id,
filename,
original_filename,
file_path,
file_size,
mime_type,
checksum,
extracted_text,
notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
customer_id,
connection_id,
stored_name,
original_filename,
str(target),
total_size,
mime_type,
checksum,
extracted_text,
str(notes or "").strip() or None,
),
)
segment_count = _index_customer_document_segments(int(document_id), extracted_text)
return {
"status": "uploaded",
"document_id": int(document_id),
"filename": original_filename,
"segment_count": segment_count,
"customer": {"id": int(customer["id"]), "name": customer["name"]} if customer else None,
}
except HTTPException:
raise
except Exception as exc:
target.unlink(missing_ok=True)
logger.exception("Customer document upload failed")
raise HTTPException(status_code=500, detail=f"Upload failed: {exc}") from exc
@router.get("/internet-connections/migration-wizard-v2/context")
async def get_migration_wizard_v2_context(
customer_id: Optional[int] = Query(None, ge=1),
query: Optional[str] = Query(None),
):
customer = None
customer_name = ""
if customer_id:
customer = execute_query_single(
"""
SELECT id, name
FROM customers
WHERE id = %s
AND is_active = true
""",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
customer_name = str(customer.get("name") or "")
if customer_id:
document_payload = await _build_customer_document_hits(customer_id, customer_name, query)
invoice_hits = await _build_invoice_hits(customer_id, customer_name, query)
else:
rows = execute_query(
"""
SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
FROM internet_connections_customer_documents
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 50
"""
) or []
documents = []
for row in rows:
item = dict(row)
item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
item.pop("extracted_text", None)
documents.append(item)
segment_rows = execute_query(
"""
SELECT
seg.id AS segment_id,
seg.document_id,
seg.block_index,
seg.block_title AS title,
seg.content,
seg.ip_addresses,
seg.cidr_blocks,
seg.references_json AS references,
seg.socket_numbers
FROM internet_connections_customer_document_segments seg
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
WHERE doc.deleted_at IS NULL
ORDER BY doc.created_at DESC, seg.block_index ASC
LIMIT 30
"""
) or []
segment_hits = []
for row in segment_rows:
snippet = re.sub(r"\s+", " ", str(row.get("content") or "")[:240]).strip()
segment_hits.append(
{
"segment_id": int(row["segment_id"]),
"document_id": int(row["document_id"]),
"block_index": int(row.get("block_index") or 0),
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
"score": 0,
"snippet": snippet,
"ip_addresses": [str(item) for item in (row.get("ip_addresses") or [])],
"cidr_blocks": [str(item) for item in (row.get("cidr_blocks") or [])],
"references": [str(item) for item in (row.get("references") or [])],
"socket_numbers": [str(item) for item in (row.get("socket_numbers") or [])],
}
)
document_payload = {"documents": documents, "snippets": [], "segments": segment_hits}
invoice_hits = []
summary_source_parts = []
if document_payload["snippets"]:
summary_source_parts.append("Kundefiler:\n" + "\n".join(document_payload["snippets"][:8]))
if invoice_hits:
invoice_lines = []
for item in invoice_hits[:5]:
base = f"{item['invoice_date']} {item['vendor_name']} {item['invoice_number']}"
if item["snippets"]:
invoice_lines.append(base + ": " + " | ".join(item["snippets"]))
else:
invoice_lines.append(base)
summary_source_parts.append("Fakturaer:\n" + "\n".join(invoice_lines))
ai_summary = ""
if summary_source_parts:
summary_input = (
f"Kunde: {customer_name}\n"
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n"
"VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, "
"eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\n\n"
+ "\n\n".join(summary_source_parts)
)
ai_summary = await ollama_service.generate_summary(summary_input)
return {
"customer": {"id": int(customer["id"]), "name": customer_name} if customer else None,
"query": query,
"documents": document_payload["documents"],
"document_snippets": document_payload["snippets"],
"segment_hits": document_payload["segments"],
"invoice_hits": invoice_hits,
"ai_summary": ai_summary,
}
@router.get("/internet-connections/customer-documents/segments/{segment_id}")
async def get_customer_document_segment(segment_id: int):
"""Return the complete, indexed text block for the migration wizard."""
row = execute_query_single(
"""
SELECT
seg.id AS segment_id,
seg.document_id,
seg.block_index,
seg.block_title AS title,
seg.content,
doc.original_filename
FROM internet_connections_customer_document_segments seg
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
WHERE seg.id = %s
AND doc.deleted_at IS NULL
""",
(segment_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Text block not found")
return {
"segment_id": int(row["segment_id"]),
"document_id": int(row["document_id"]),
"block_index": int(row.get("block_index") or 0),
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
"original_filename": row.get("original_filename") or "Tekstfil",
"content": str(row.get("content") or ""),
}