bmc_hub/app/modules/internet_connections/backend/router.py
Christian 3311b8e590 Add comprehensive tests for internet connections module, invoice parsing, and subscription provisioning
- Implement tests for the internet connections module, covering routes, IP range creation, and connection validation.
- Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices.
- Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations.
- Include validation checks for subtotal mismatches and ensure error handling for missing IP selections.
2026-07-09 23:44:30 +02:00

3123 lines
122 KiB
Python

import ipaddress
import logging
import re
from datetime import date
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.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",
)
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 = []
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block):
normalized = raw.strip()
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 any(normalized == cidr.split("/")[0] for cidr in cidr_blocks):
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:
if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
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 _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:
value_label = "Mangler klassifikation"
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
return normalized
def _connection_select_sql(where_sql: str = "") -> str:
return f"""
SELECT
ic.id,
ic.parent_id,
ic.name,
ic.provider,
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.subscription_id,
sub.subscription_number,
sub.product_name AS subscription_product_name,
subc.name AS subscription_customer_name,
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 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 (
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
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
class ConnectionUpdatePayload(BaseModel):
name: Optional[str] = None
provider: Optional[str] = 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
class PricingCreatePayload(BaseModel):
effective_from: date
purchase_price: float = 0
sales_price: float = 0
notes: Optional[str] = None
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
start_date: date
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
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", 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),
):
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')
)
"""
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.warning("Failed to load internet connections: %s", exc)
return []
return [_decorate_connection_row(dict(row)) for row in rows]
@router.post("/internet-connections", response_model=dict)
async def create_connection(payload: ConnectionCreatePayload):
normalized = _normalize_connection_payload(payload.model_dump())
try:
rows = execute_query(
"""
INSERT INTO internet_connections_connections (
parent_id, name, provider, 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
)
VALUES (%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("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"),
),
)
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"),
},
)
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)
set_parts = []
params: list[object] = []
changed: dict[str, object] = {}
allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "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,
)
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}/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")):
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])
rows = execute_query(
f"""
SELECT
s.id,
s.subscription_number,
s.product_name,
s.customer_id,
c.name AS customer_name,
s.status
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/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.allocation_model = 'shared' 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()
_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.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.allocation_model = 'shared' AND ic.parent_id IS NULL "),
(connection_id,),
)
if not head_row:
raise HTTPException(status_code=404, detail="Delt hovedforbindelse 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")
ip_product = None
ip_profile = {}
selected_ip_address = 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")
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": float(payload.internet_unit_price if payload.internet_unit_price is not None else (internet_product.get("sales_price") or 0)),
}]
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": float(payload.ip_unit_price if payload.ip_unit_price is not None else (ip_product.get("sales_price") or 0)),
})
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),
"start_date": 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.warning("Failed to load IP ranges: %s", exc)
return []
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\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,
}