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