- Implement tests for the internet connections module, covering routes, IP range creation, and connection validation. - Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices. - Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations. - Include validation checks for subtotal mismatches and ensure error handling for missing IP selections.
3123 lines
122 KiB
Python
3123 lines
122 KiB
Python
import ipaddress
|
|
import logging
|
|
import re
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import (
|
|
execute_insert,
|
|
execute_query,
|
|
execute_query_single,
|
|
get_db_connection,
|
|
release_db_connection,
|
|
table_has_column,
|
|
)
|
|
from psycopg2.extras import Json, RealDictCursor
|
|
from app.modules.internet_connections.backend.provisioning_utils import (
|
|
build_network_product_profile,
|
|
summarize_subscription_network_requirements,
|
|
)
|
|
from app.services.ollama_service import ollama_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
ALLOWED_ALLOCATION_MODELS = {"dedicated", "shared"}
|
|
ALLOWED_VALUE_TYPES = {"subscription", "bmc_networks", "delefiber", "other"}
|
|
INTERNET_CUSTOMER_DOC_EXTENSIONS = {".txt", ".csv", ".log", ".md"}
|
|
INTERNET_RESEARCH_KEYWORDS = (
|
|
"internet",
|
|
"bredbaand",
|
|
"bredbånd",
|
|
"broadband",
|
|
"fiber",
|
|
"wan",
|
|
"mpls",
|
|
"vpn",
|
|
"ip",
|
|
"ipv4",
|
|
"ipv6",
|
|
"ip-adresse",
|
|
"globalconnect",
|
|
"global connect",
|
|
"sentia",
|
|
"dsl",
|
|
)
|
|
REFERENCE_PATTERNS = [
|
|
r"\bNKA[- ]?\d{4,}\b",
|
|
r"\bDSL[- ]?[A-Z0-9]+\b",
|
|
r"\bEB\d{4,}\b",
|
|
r"\bHB\d{4,}\b",
|
|
r"\bMPLS\b",
|
|
]
|
|
SOCKET_PATTERNS = [
|
|
r"\bstik(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
|
|
r"\budtag(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
|
|
r"\bport[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
|
|
]
|
|
GENERIC_SEGMENT_TITLE_PATTERNS = (
|
|
"ip informationer",
|
|
"ip information",
|
|
"host is up",
|
|
"not in use",
|
|
"untagged native management",
|
|
)
|
|
|
|
|
|
def _internet_customer_doc_dir() -> Path:
|
|
base = Path(settings.UPLOAD_DIR).resolve()
|
|
target = base / "internet_customer_docs"
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
return target
|
|
|
|
|
|
def _sanitize_upload_name(filename: str) -> str:
|
|
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", str(filename or "").strip())
|
|
cleaned = cleaned.strip("._")
|
|
return cleaned or "upload.txt"
|
|
|
|
|
|
def _normalize_text_for_match(value: Optional[str]) -> str:
|
|
text = str(value or "").strip().lower()
|
|
return (
|
|
text.replace("æ", "ae")
|
|
.replace("ø", "oe")
|
|
.replace("å", "aa")
|
|
.replace("\n", " ")
|
|
.replace("\r", " ")
|
|
)
|
|
|
|
|
|
def _tokenize_search_text(*parts: Optional[str]) -> List[str]:
|
|
combined = " ".join(str(part or "") for part in parts).strip().lower()
|
|
normalized = _normalize_text_for_match(combined)
|
|
tokens = []
|
|
for token in re.split(r"[^a-z0-9./+-]+", normalized):
|
|
token = token.strip()
|
|
if len(token) >= 2 and token not in tokens:
|
|
tokens.append(token)
|
|
return tokens
|
|
|
|
|
|
def _build_research_terms(customer_name: str, query: Optional[str]) -> Dict[str, List[str]]:
|
|
customer_terms = [token for token in _tokenize_search_text(customer_name) if len(token) >= 3]
|
|
query_terms = [token for token in _tokenize_search_text(query) if len(token) >= 2]
|
|
internet_terms = list(INTERNET_RESEARCH_KEYWORDS)
|
|
combined = []
|
|
for term in customer_terms + query_terms + internet_terms:
|
|
if term not in combined:
|
|
combined.append(term)
|
|
return {
|
|
"customer_terms": customer_terms,
|
|
"query_terms": query_terms,
|
|
"internet_terms": internet_terms,
|
|
"all_terms": combined,
|
|
}
|
|
|
|
|
|
def _count_term_hits(text: str, terms: List[str]) -> int:
|
|
normalized = _normalize_text_for_match(text)
|
|
return sum(1 for term in terms if term and term in normalized)
|
|
|
|
|
|
def _extract_text_snippets(text: str, terms: List[str], limit: int = 4) -> List[str]:
|
|
normalized_terms = [term for term in (_normalize_text_for_match(term) for term in terms) if term]
|
|
if not text.strip():
|
|
return []
|
|
|
|
snippets: List[str] = []
|
|
seen = set()
|
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
for idx, line in enumerate(lines):
|
|
line_normalized = _normalize_text_for_match(line)
|
|
score = sum(1 for term in normalized_terms if term in line_normalized)
|
|
if score <= 0:
|
|
continue
|
|
start = max(idx - 1, 0)
|
|
end = min(idx + 2, len(lines))
|
|
snippet = " | ".join(lines[start:end])
|
|
snippet = re.sub(r"\s+", " ", snippet).strip()
|
|
if snippet and snippet not in seen:
|
|
seen.add(snippet)
|
|
snippets.append((score, snippet))
|
|
|
|
if not snippets and lines:
|
|
fallback = " | ".join(lines[: min(3, len(lines))])
|
|
return [re.sub(r"\s+", " ", fallback).strip()]
|
|
|
|
snippets.sort(key=lambda item: (-item[0], len(item[1])))
|
|
return [snippet for _, snippet in snippets[:limit]]
|
|
|
|
|
|
def _is_generic_segment_title(title: Optional[str]) -> bool:
|
|
normalized_title = _normalize_text_for_match(title)
|
|
if not normalized_title:
|
|
return False
|
|
return any(pattern in normalized_title for pattern in GENERIC_SEGMENT_TITLE_PATTERNS)
|
|
|
|
|
|
def _split_text_into_blocks(text: str) -> List[str]:
|
|
if not str(text or "").strip():
|
|
return []
|
|
raw_blocks = re.split(r"\n\s*\n+", str(text))
|
|
blocks: List[str] = []
|
|
for block in raw_blocks:
|
|
cleaned = re.sub(r"\s+\n", "\n", block).strip()
|
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
if not cleaned:
|
|
continue
|
|
if len(cleaned) > 1800:
|
|
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
|
|
chunk: List[str] = []
|
|
chunk_len = 0
|
|
for line in lines:
|
|
if chunk and chunk_len + len(line) > 1200:
|
|
blocks.append("\n".join(chunk))
|
|
chunk = [line]
|
|
chunk_len = len(line)
|
|
else:
|
|
chunk.append(line)
|
|
chunk_len += len(line)
|
|
if chunk:
|
|
blocks.append("\n".join(chunk))
|
|
else:
|
|
blocks.append(cleaned)
|
|
return blocks
|
|
|
|
|
|
def _extract_segment_entities(block: str) -> Dict[str, List[str]]:
|
|
cidr_blocks = []
|
|
ip_addresses = []
|
|
references = []
|
|
socket_numbers = []
|
|
|
|
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block):
|
|
normalized = raw.strip()
|
|
if normalized not in cidr_blocks:
|
|
cidr_blocks.append(normalized)
|
|
|
|
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block):
|
|
normalized = raw.strip()
|
|
if any(normalized == cidr.split("/")[0] for cidr in cidr_blocks):
|
|
continue
|
|
try:
|
|
normalized_ip = _normalize_ip_address(normalized)
|
|
except HTTPException:
|
|
continue
|
|
if normalized_ip not in ip_addresses:
|
|
ip_addresses.append(normalized_ip)
|
|
|
|
for pattern in REFERENCE_PATTERNS:
|
|
for match in re.findall(pattern, block, flags=re.IGNORECASE):
|
|
normalized = re.sub(r"\s+", "", str(match).strip()).upper()
|
|
if normalized not in references:
|
|
references.append(normalized)
|
|
|
|
for pattern in SOCKET_PATTERNS:
|
|
for match in re.findall(pattern, block, flags=re.IGNORECASE):
|
|
normalized = str(match).strip()
|
|
if normalized and normalized not in socket_numbers:
|
|
socket_numbers.append(normalized)
|
|
|
|
return {
|
|
"ip_addresses": ip_addresses,
|
|
"cidr_blocks": cidr_blocks,
|
|
"references": references,
|
|
"socket_numbers": socket_numbers,
|
|
}
|
|
|
|
|
|
def _segment_title(block: str, block_index: int) -> str:
|
|
first_line = next((line.strip() for line in block.splitlines() if line.strip()), "")
|
|
return (first_line[:120] if first_line else f"Blok {block_index + 1}") or f"Blok {block_index + 1}"
|
|
|
|
|
|
def _index_customer_document_segments(document_id: int, extracted_text: str) -> int:
|
|
execute_query(
|
|
"""
|
|
DELETE FROM internet_connections_customer_document_segments
|
|
WHERE document_id = %s
|
|
""",
|
|
(document_id,),
|
|
)
|
|
created = 0
|
|
for idx, block in enumerate(_split_text_into_blocks(extracted_text)):
|
|
entities = _extract_segment_entities(block)
|
|
execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_customer_document_segments (
|
|
document_id,
|
|
block_index,
|
|
block_title,
|
|
content,
|
|
ip_addresses,
|
|
cidr_blocks,
|
|
references_json,
|
|
socket_numbers
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
document_id,
|
|
idx,
|
|
_segment_title(block, idx),
|
|
block,
|
|
Json(entities["ip_addresses"]),
|
|
Json(entities["cidr_blocks"]),
|
|
Json(entities["references"]),
|
|
Json(entities["socket_numbers"]),
|
|
),
|
|
)
|
|
created += 1
|
|
return created
|
|
|
|
|
|
def _get_document_segment_count(document_id: int) -> int:
|
|
row = execute_query_single(
|
|
"""
|
|
SELECT COUNT(*) AS total
|
|
FROM internet_connections_customer_document_segments
|
|
WHERE document_id = %s
|
|
""",
|
|
(document_id,),
|
|
)
|
|
return int((row or {}).get("total") or 0)
|
|
|
|
|
|
def _ensure_document_segments(document_id: int, extracted_text: Optional[str]) -> int:
|
|
existing_count = _get_document_segment_count(document_id)
|
|
if existing_count > 0:
|
|
return existing_count
|
|
if not str(extracted_text or "").strip():
|
|
return 0
|
|
try:
|
|
return _index_customer_document_segments(document_id, str(extracted_text or ""))
|
|
except Exception as exc:
|
|
logger.warning("Could not auto-index document segments for document %s: %s", document_id, exc)
|
|
return 0
|
|
|
|
|
|
async def _read_invoice_text(file_path: Optional[str]) -> str:
|
|
if not file_path:
|
|
return ""
|
|
candidate = Path(file_path)
|
|
if not candidate.is_absolute():
|
|
candidate = Path(settings.UPLOAD_DIR).resolve() / candidate
|
|
if not candidate.exists() or not candidate.is_file():
|
|
return ""
|
|
try:
|
|
return await ollama_service._extract_text_from_file(candidate)
|
|
except Exception as exc:
|
|
logger.warning("Could not extract invoice text from %s: %s", candidate, exc)
|
|
return ""
|
|
|
|
|
|
async def _build_invoice_hits(customer_id: int, customer_name: str, query: Optional[str], limit: int = 8) -> List[Dict[str, Any]]:
|
|
terms = _build_research_terms(customer_name, query)
|
|
customer_filter_sql = "NULL::INTEGER AS linked_customer_id"
|
|
customer_where_sql = ""
|
|
params: List[Any] = []
|
|
if table_has_column("supplier_invoices", "linked_customer_id"):
|
|
customer_filter_sql = "si.linked_customer_id"
|
|
customer_where_sql = "OR si.linked_customer_id = %s"
|
|
params.append(customer_id)
|
|
|
|
invoice_rows = execute_query(
|
|
f"""
|
|
SELECT
|
|
si.id,
|
|
si.invoice_number,
|
|
si.invoice_date,
|
|
si.total_amount,
|
|
si.currency,
|
|
COALESCE(v.name, si.vendor_name, 'Ukendt leverandør') AS vendor_name,
|
|
COALESCE(si.description, '') AS description,
|
|
COALESCE(si.notes, '') AS notes,
|
|
COALESCE(inf.file_path, si.file_path) AS file_path,
|
|
COALESCE(inf.original_filename, si.file_path, '') AS source_filename,
|
|
{customer_filter_sql}
|
|
FROM supplier_invoices si
|
|
LEFT JOIN vendors v ON v.id = si.vendor_id
|
|
LEFT JOIN extractions ext ON ext.extraction_id = si.extraction_id
|
|
LEFT JOIN incoming_files inf ON inf.file_id = ext.file_id
|
|
WHERE si.invoice_date >= (CURRENT_DATE - INTERVAL '18 months')
|
|
AND (
|
|
COALESCE(si.description, '') <> ''
|
|
OR COALESCE(si.notes, '') <> ''
|
|
OR COALESCE(si.file_path, '') <> ''
|
|
OR COALESCE(inf.file_path, '') <> ''
|
|
{customer_where_sql}
|
|
)
|
|
ORDER BY si.invoice_date DESC, si.id DESC
|
|
LIMIT 30
|
|
""",
|
|
tuple(params) if params else None,
|
|
) or []
|
|
|
|
hits: List[Dict[str, Any]] = []
|
|
for row in invoice_rows:
|
|
extracted_text = await _read_invoice_text(row.get("file_path"))
|
|
combined_text = "\n".join(
|
|
part for part in [row.get("description"), row.get("notes"), extracted_text] if str(part or "").strip()
|
|
)
|
|
searchable_text = " ".join(
|
|
str(part or "") for part in [row.get("vendor_name"), row.get("invoice_number"), combined_text]
|
|
)
|
|
customer_hits = _count_term_hits(searchable_text, terms["customer_terms"])
|
|
query_hits = _count_term_hits(searchable_text, terms["query_terms"])
|
|
internet_hits = _count_term_hits(searchable_text, terms["internet_terms"])
|
|
directly_linked = row.get("linked_customer_id") == customer_id
|
|
if not directly_linked and customer_hits == 0 and query_hits == 0:
|
|
continue
|
|
if internet_hits == 0:
|
|
continue
|
|
|
|
snippets = _extract_text_snippets(
|
|
combined_text or searchable_text,
|
|
terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"],
|
|
limit=2,
|
|
)
|
|
score = (6 if directly_linked else 0) + (customer_hits * 3) + (query_hits * 4) + internet_hits
|
|
hits.append(
|
|
{
|
|
"invoice_id": row.get("id"),
|
|
"invoice_number": row.get("invoice_number"),
|
|
"invoice_date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
|
|
"vendor_name": row.get("vendor_name"),
|
|
"total_amount": float(row.get("total_amount") or 0),
|
|
"currency": row.get("currency") or "DKK",
|
|
"source_filename": row.get("source_filename"),
|
|
"directly_linked": directly_linked,
|
|
"score": score,
|
|
"snippets": snippets,
|
|
}
|
|
)
|
|
|
|
hits.sort(key=lambda item: (-item["score"], item.get("invoice_date") or ""))
|
|
return hits[:limit]
|
|
|
|
|
|
async def _build_customer_document_hits(customer_id: int, customer_name: str, query: Optional[str]) -> Dict[str, Any]:
|
|
document_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
id,
|
|
customer_id,
|
|
connection_id,
|
|
original_filename,
|
|
filename,
|
|
file_size,
|
|
mime_type,
|
|
extracted_text,
|
|
notes,
|
|
created_at
|
|
FROM internet_connections_customer_documents
|
|
WHERE (customer_id = %s OR customer_id IS NULL)
|
|
AND deleted_at IS NULL
|
|
ORDER BY created_at DESC, id DESC
|
|
""",
|
|
(customer_id,),
|
|
) or []
|
|
|
|
terms = _build_research_terms(customer_name, query)
|
|
documents = []
|
|
snippets: List[str] = []
|
|
matched_segments: List[Dict[str, Any]] = []
|
|
doc_by_id: Dict[int, Dict[str, Any]] = {}
|
|
for row in document_rows:
|
|
doc_id = int(row["id"])
|
|
row_dict = dict(row)
|
|
row_dict["segment_count"] = _ensure_document_segments(doc_id, row_dict.get("extracted_text"))
|
|
doc_by_id[doc_id] = row_dict
|
|
|
|
segment_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
seg.id,
|
|
seg.document_id,
|
|
seg.block_index,
|
|
seg.block_title,
|
|
seg.content,
|
|
seg.ip_addresses,
|
|
seg.cidr_blocks,
|
|
seg.references_json,
|
|
seg.socket_numbers
|
|
FROM internet_connections_customer_document_segments seg
|
|
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
|
|
WHERE doc.deleted_at IS NULL
|
|
AND (doc.customer_id = %s OR doc.customer_id IS NULL)
|
|
ORDER BY doc.created_at DESC, seg.block_index ASC
|
|
""",
|
|
(customer_id,),
|
|
) or []
|
|
|
|
has_query = bool(terms["query_terms"])
|
|
normalized_query = _normalize_text_for_match(query) if query else ""
|
|
match_terms = terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"]
|
|
doc_segment_count: Dict[int, int] = {}
|
|
for row in segment_rows:
|
|
content = str(row.get("content") or "")
|
|
title = str(row.get("block_title") or "")
|
|
references = [str(item) for item in (row.get("references_json") or [])]
|
|
sockets = [str(item) for item in (row.get("socket_numbers") or [])]
|
|
cidrs = [str(item) for item in (row.get("cidr_blocks") or [])]
|
|
ips = [str(item) for item in (row.get("ip_addresses") or [])]
|
|
searchable = " ".join([title, content, " ".join(references), " ".join(sockets), " ".join(cidrs), " ".join(ips)])
|
|
customer_hits = _count_term_hits(searchable, terms["customer_terms"])
|
|
query_hits = _count_term_hits(searchable, terms["query_terms"])
|
|
internet_hits = _count_term_hits(searchable, terms["internet_terms"])
|
|
explicit_entity_hit = 0
|
|
phrase_hit = 0
|
|
title_hit = 0
|
|
if terms["query_terms"]:
|
|
explicit_entity_hit = sum(
|
|
1 for token in terms["query_terms"]
|
|
if token in _normalize_text_for_match(" ".join(ips + cidrs + references + sockets))
|
|
)
|
|
phrase_hit = 1 if normalized_query and normalized_query in _normalize_text_for_match(searchable) else 0
|
|
title_hit = _count_term_hits(title, terms["query_terms"])
|
|
|
|
if has_query:
|
|
if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
|
|
continue
|
|
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
|
|
continue
|
|
|
|
score = (customer_hits * 2) + (query_hits * 8) + internet_hits + (explicit_entity_hit * 10) + (phrase_hit * 12) + (title_hit * 6)
|
|
if _is_generic_segment_title(title) and explicit_entity_hit == 0 and phrase_hit == 0:
|
|
score -= 6
|
|
if has_query and query_hits > 0 and customer_hits == 0:
|
|
score += 4
|
|
if score <= 0:
|
|
continue
|
|
|
|
doc_id = int(row["document_id"])
|
|
doc_segment_count[doc_id] = doc_segment_count.get(doc_id, 0) + 1
|
|
segment_snippets = _extract_text_snippets(content, match_terms, limit=1)
|
|
snippet = segment_snippets[0] if segment_snippets else re.sub(r"\s+", " ", content[:240]).strip()
|
|
matched_segments.append(
|
|
{
|
|
"segment_id": int(row["id"]),
|
|
"document_id": doc_id,
|
|
"block_index": int(row.get("block_index") or 0),
|
|
"title": title or f"Blok {int(row.get('block_index') or 0) + 1}",
|
|
"score": score,
|
|
"snippet": snippet,
|
|
"ip_addresses": ips,
|
|
"cidr_blocks": cidrs,
|
|
"references": references,
|
|
"socket_numbers": sockets,
|
|
}
|
|
)
|
|
snippets.append(f"{doc_by_id.get(doc_id, {}).get('original_filename')}: {snippet}")
|
|
|
|
matched_segments.sort(key=lambda item: (-item["score"], item["document_id"], item["block_index"]))
|
|
|
|
for row in document_rows:
|
|
doc_id = int(row["id"])
|
|
row_dict = doc_by_id.get(doc_id, dict(row))
|
|
text = str(row_dict.get("extracted_text") or "")
|
|
doc_snippets = [seg["snippet"] for seg in matched_segments if seg["document_id"] == doc_id][:2]
|
|
matched_count = doc_segment_count.get(doc_id, 0)
|
|
if not doc_snippets and not has_query:
|
|
doc_snippets = _extract_text_snippets(text, match_terms, limit=2)
|
|
if has_query and matched_count <= 0:
|
|
continue
|
|
if matched_count <= 0 and row_dict.get("customer_id") != customer_id:
|
|
continue
|
|
documents.append(
|
|
{
|
|
"id": doc_id,
|
|
"connection_id": row_dict.get("connection_id"),
|
|
"original_filename": row_dict.get("original_filename"),
|
|
"file_size": row_dict.get("file_size"),
|
|
"mime_type": row_dict.get("mime_type"),
|
|
"notes": row_dict.get("notes"),
|
|
"created_at": row_dict.get("created_at").isoformat() if row_dict.get("created_at") else None,
|
|
"snippet_count": matched_count if has_query else doc_segment_count.get(doc_id, row_dict.get("segment_count", len(doc_snippets))),
|
|
"snippets": doc_snippets[:2],
|
|
}
|
|
)
|
|
|
|
return {"documents": documents, "snippets": snippets[:12], "segments": matched_segments[:16]}
|
|
|
|
|
|
def _validate_cidr(cidr: str):
|
|
try:
|
|
return ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid CIDR notation") from exc
|
|
|
|
|
|
def _normalize_ip_address(ip_address: str) -> str:
|
|
try:
|
|
return str(ipaddress.ip_address(str(ip_address or "").strip()))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid IP address") from exc
|
|
|
|
|
|
def _normalize_match_text(value: Optional[str]) -> str:
|
|
normalized = str(value or "").strip().upper()
|
|
normalized = normalized.replace("Æ", "AE").replace("Ø", "OE").replace("Å", "AA")
|
|
return "".join(ch for ch in normalized if ch.isalnum())
|
|
|
|
|
|
def _build_ip_range_payload(range_row: dict, address_rows: Optional[list[dict]] = None) -> dict:
|
|
payload = dict(range_row)
|
|
network = None
|
|
cidr = range_row.get("cidr")
|
|
if cidr:
|
|
try:
|
|
network = ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError:
|
|
network = None
|
|
|
|
payload["network_address"] = str(network.network_address) if network else None
|
|
payload["broadcast_address"] = str(network.broadcast_address) if network else None
|
|
payload["prefix_length"] = int(network.prefixlen) if network else None
|
|
payload["total_hosts"] = int(network.num_addresses) if network else 0
|
|
|
|
if network and network.num_addresses > 1:
|
|
usable_hosts = max(network.num_addresses - 2, 0)
|
|
elif network:
|
|
usable_hosts = 1 if network.num_addresses == 1 else 0
|
|
else:
|
|
usable_hosts = 0
|
|
|
|
payload["usable_hosts"] = usable_hosts
|
|
range_addresses = address_rows or []
|
|
payload["used_addresses"] = sum(
|
|
1 for row in range_addresses if str(row.get("status", "available")).lower() != "available"
|
|
)
|
|
payload["available_addresses"] = max(usable_hosts - payload["used_addresses"], 0)
|
|
return payload
|
|
|
|
|
|
def _range_alignment_for_connection(connection: Dict[str, Any], range_item: Dict[str, Any]) -> Dict[str, Any]:
|
|
connection_address = _normalize_match_text(connection.get("address"))
|
|
service_address = _normalize_match_text(range_item.get("service_address"))
|
|
connection_customer_id = connection.get("customer_id")
|
|
range_customer_id = range_item.get("customer_id")
|
|
is_shared_head = bool(connection.get("allocation_model") == "shared" and not connection.get("parent_id"))
|
|
|
|
if is_shared_head:
|
|
return {"belongs_to_connection": True, "alignment_reason": "shared_head", "alignment_warning": None}
|
|
if connection_address and service_address and connection_address == service_address:
|
|
return {"belongs_to_connection": True, "alignment_reason": "service_address", "alignment_warning": None}
|
|
if connection_customer_id and range_customer_id and int(connection_customer_id) == int(range_customer_id):
|
|
return {"belongs_to_connection": True, "alignment_reason": "customer", "alignment_warning": None}
|
|
if not service_address and not range_customer_id:
|
|
return {"belongs_to_connection": True, "alignment_reason": "unassigned", "alignment_warning": None}
|
|
|
|
warning = "Range matcher ikke forbindelsens adresse/kunde"
|
|
if range_item.get("service_address"):
|
|
warning = f"Range ligger paa anden serviceadresse: {range_item.get('service_address')}"
|
|
elif range_item.get("customer_name"):
|
|
warning = f"Range er bundet til anden kunde: {range_item.get('customer_name')}"
|
|
return {"belongs_to_connection": False, "alignment_reason": "mismatch", "alignment_warning": warning}
|
|
|
|
|
|
def _create_history_entry(connection_id: int, event_type: str, summary: str, details: Optional[dict] = None):
|
|
execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_history (connection_id, event_type, summary, details)
|
|
VALUES (%s, %s, %s, %s)
|
|
""",
|
|
(connection_id, event_type, summary, Json(details or {})),
|
|
)
|
|
|
|
|
|
def _create_ip_addresses_for_range(range_id: int, cidr: str):
|
|
network = _validate_cidr(cidr)
|
|
addresses = []
|
|
for host in list(network.hosts()):
|
|
addresses.append(str(host))
|
|
|
|
if not addresses:
|
|
return []
|
|
|
|
created = []
|
|
for ip_address in addresses:
|
|
existing = execute_query(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_ip_addresses
|
|
WHERE ip_address = %s
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(ip_address,),
|
|
) or []
|
|
if existing:
|
|
continue
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_ip_addresses (range_id, ip_address, status, assigned_to, assigned_type, comment)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(range_id, ip_address, "available", None, None, "Auto-generated from CIDR"),
|
|
) or []
|
|
if rows:
|
|
created.append(dict(rows[0]))
|
|
return created
|
|
|
|
|
|
def _get_contract_status(contract_end):
|
|
if not contract_end:
|
|
return "unknown"
|
|
if isinstance(contract_end, str):
|
|
try:
|
|
contract_end = date.fromisoformat(contract_end)
|
|
except ValueError:
|
|
return "unknown"
|
|
elif hasattr(contract_end, "date"):
|
|
contract_end = contract_end.date()
|
|
return "active" if contract_end >= date.today() else "expired"
|
|
|
|
|
|
def _value_type_label(value_type: Optional[str]) -> str:
|
|
labels = {
|
|
"subscription": "Abonnement",
|
|
"bmc_networks": "BMC Networks",
|
|
"delefiber": "Delefiber",
|
|
"other": "Anden",
|
|
}
|
|
return labels.get(str(value_type or "").lower(), "Anden")
|
|
|
|
|
|
def _allocation_model_label(allocation_model: Optional[str]) -> str:
|
|
labels = {
|
|
"dedicated": "Dedikeret",
|
|
"shared": "Delt",
|
|
}
|
|
return labels.get(str(allocation_model or "").lower(), "Dedikeret")
|
|
|
|
|
|
def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
normalized = dict(payload)
|
|
address = str(normalized.get("address") or "").strip() or None
|
|
allocation_model = str(normalized.get("allocation_model") or "dedicated").strip().lower()
|
|
value_type = str(normalized.get("value_type") or "other").strip().lower()
|
|
value_label = str(normalized.get("value_label") or "").strip() or None
|
|
subscription_id = normalized.get("subscription_id")
|
|
|
|
if not address:
|
|
raise HTTPException(status_code=400, detail="address is required for internet connections")
|
|
|
|
if allocation_model not in ALLOWED_ALLOCATION_MODELS:
|
|
raise HTTPException(status_code=400, detail="allocation_model must be dedicated or shared")
|
|
if value_type not in ALLOWED_VALUE_TYPES:
|
|
raise HTTPException(status_code=400, detail="value_type must be subscription, bmc_networks, delefiber or other")
|
|
|
|
if value_type == "subscription":
|
|
if not subscription_id:
|
|
raise HTTPException(status_code=400, detail="subscription_id is required when value_type is subscription")
|
|
else:
|
|
subscription_id = None
|
|
|
|
if value_type == "other":
|
|
if not value_label:
|
|
value_label = "Mangler klassifikation"
|
|
else:
|
|
value_label = None
|
|
|
|
normalized["address"] = address
|
|
normalized["allocation_model"] = allocation_model
|
|
normalized["value_type"] = value_type
|
|
normalized["value_label"] = value_label
|
|
normalized["subscription_id"] = subscription_id
|
|
return normalized
|
|
|
|
|
|
def _connection_select_sql(where_sql: str = "") -> str:
|
|
return f"""
|
|
SELECT
|
|
ic.id,
|
|
ic.parent_id,
|
|
ic.name,
|
|
ic.provider,
|
|
ic.customer_id,
|
|
c.name AS customer_name,
|
|
parent.name AS parent_name,
|
|
parent.allocation_model AS parent_allocation_model,
|
|
parent.value_type AS parent_value_type,
|
|
ic.address,
|
|
ic.status,
|
|
ic.monthly_cost,
|
|
ic.sales_price,
|
|
(COALESCE(ic.sales_price, 0) - COALESCE(ic.monthly_cost, 0)) AS margin_amount,
|
|
ic.technology,
|
|
ic.connection_type,
|
|
ic.circuit_number,
|
|
ic.speed_mbps,
|
|
ic.upload_mbps,
|
|
ic.download_mbps,
|
|
ic.monitoring_url,
|
|
ic.contract_start,
|
|
ic.contract_end,
|
|
ic.allocation_model,
|
|
ic.value_type,
|
|
ic.value_label,
|
|
ic.subscription_id,
|
|
sub.subscription_number,
|
|
sub.product_name AS subscription_product_name,
|
|
subc.name AS subscription_customer_name,
|
|
COALESCE(ip_stats.range_count, 0) AS ip_range_count,
|
|
COALESCE(ip_stats.total_addresses, 0) AS total_ip_addresses,
|
|
COALESCE(ip_stats.in_use_addresses, 0) AS in_use_ip_addresses,
|
|
COALESCE(ip_stats.reserved_addresses, 0) AS reserved_ip_addresses,
|
|
COALESCE(ip_stats.available_addresses, 0) AS available_ip_addresses,
|
|
COALESCE(child_stats.bmcnet_child_count, 0) AS bmcnet_child_count,
|
|
COALESCE(child_stats.bmcnet_child_active_count, 0) AS bmcnet_child_active_count,
|
|
COALESCE(child_stats.bmcnet_child_sales_price, 0) AS bmcnet_child_sales_price,
|
|
COALESCE(child_stats.bmcnet_child_monthly_cost, 0) AS bmcnet_child_monthly_cost,
|
|
COALESCE(child_stats.bmcnet_child_margin_amount, 0) AS bmcnet_child_margin_amount,
|
|
COALESCE(child_stats.bmcnet_child_ip_count, 0) AS bmcnet_child_ip_count
|
|
FROM internet_connections_connections ic
|
|
LEFT JOIN customers c ON c.id = ic.customer_id
|
|
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
|
|
LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
|
|
LEFT JOIN customers subc ON subc.id = sub.customer_id
|
|
LEFT JOIN (
|
|
SELECT
|
|
ir.connection_id,
|
|
COUNT(DISTINCT ir.id) FILTER (WHERE ir.deleted_at IS NULL) AS range_count,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses
|
|
FROM internet_connections_ip_ranges ir
|
|
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
|
|
WHERE ir.deleted_at IS NULL
|
|
GROUP BY ir.connection_id
|
|
) ip_stats ON ip_stats.connection_id = ic.id
|
|
LEFT JOIN (
|
|
SELECT
|
|
child.parent_id AS connection_id,
|
|
COUNT(*) FILTER (WHERE child.deleted_at IS NULL AND child.value_type = 'subscription') AS bmcnet_child_count,
|
|
COUNT(*) FILTER (
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.value_type = 'subscription'
|
|
AND child.status = 'active'
|
|
) AS bmcnet_child_active_count,
|
|
COALESCE(SUM(child.sales_price) FILTER (
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.value_type = 'subscription'
|
|
), 0) AS bmcnet_child_sales_price,
|
|
COALESCE(SUM(child.monthly_cost) FILTER (
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.value_type = 'subscription'
|
|
), 0) AS bmcnet_child_monthly_cost,
|
|
COALESCE(SUM(COALESCE(child.sales_price, 0) - COALESCE(child.monthly_cost, 0)) FILTER (
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.value_type = 'subscription'
|
|
), 0) AS bmcnet_child_margin_amount,
|
|
COALESCE(SUM(
|
|
COALESCE(child_ip_stats.in_use_addresses, 0) + COALESCE(child_ip_stats.reserved_addresses, 0)
|
|
) FILTER (
|
|
WHERE child.deleted_at IS NULL
|
|
AND child.value_type = 'subscription'
|
|
), 0) AS bmcnet_child_ip_count
|
|
FROM internet_connections_connections child
|
|
LEFT JOIN (
|
|
SELECT
|
|
ir.connection_id,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses
|
|
FROM internet_connections_ip_ranges ir
|
|
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
|
|
WHERE ir.deleted_at IS NULL
|
|
GROUP BY ir.connection_id
|
|
) child_ip_stats ON child_ip_stats.connection_id = child.id
|
|
WHERE child.parent_id IS NOT NULL
|
|
GROUP BY child.parent_id
|
|
) child_stats ON child_stats.connection_id = ic.id
|
|
WHERE ic.deleted_at IS NULL
|
|
{where_sql}
|
|
"""
|
|
|
|
|
|
def _decorate_connection_row(row: dict) -> dict:
|
|
item = dict(row)
|
|
value_label = str(item.get("value_label") or "").strip().lower()
|
|
is_bmcnet = bool(
|
|
item.get("parent_id")
|
|
and str(item.get("parent_allocation_model") or "").lower() == "shared"
|
|
and (
|
|
item.get("value_type") == "subscription"
|
|
or value_label == "bmcnet"
|
|
or value_label == "bmc networks"
|
|
)
|
|
)
|
|
item["value_type_label"] = _value_type_label(item.get("value_type"))
|
|
item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
|
|
item["is_shared_head"] = bool(item.get("allocation_model") == "shared" and not item.get("parent_id"))
|
|
item["is_bmcnet_connection"] = is_bmcnet
|
|
return item
|
|
|
|
|
|
def _load_bmcnet_children(parent_connection_id: int) -> List[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
_connection_select_sql(
|
|
"""
|
|
AND ic.parent_id = %s
|
|
AND parent.allocation_model = 'shared'
|
|
AND (
|
|
ic.value_type = 'subscription'
|
|
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
|
|
)
|
|
"""
|
|
)
|
|
+ " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
|
|
(parent_connection_id,),
|
|
) or []
|
|
return [_decorate_connection_row(dict(row)) for row in rows]
|
|
|
|
|
|
def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
return {
|
|
"connection_count": len(children),
|
|
"active_connection_count": sum(1 for child in children if str(child.get("status") or "").lower() == "active"),
|
|
"sales_price": float(sum(float(child.get("sales_price") or 0) for child in children)),
|
|
"monthly_cost": float(sum(float(child.get("monthly_cost") or 0) for child in children)),
|
|
"margin_amount": float(sum(float(child.get("margin_amount") or 0) for child in children)),
|
|
"ip_in_use_count": int(
|
|
sum(
|
|
int(child.get("in_use_ip_addresses") or 0) + int(child.get("reserved_ip_addresses") or 0)
|
|
for child in children
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
class ConnectionCreatePayload(BaseModel):
|
|
name: str
|
|
provider: Optional[str] = None
|
|
customer_id: Optional[int] = None
|
|
parent_id: Optional[int] = None
|
|
address: Optional[str] = None
|
|
status: str = "active"
|
|
monthly_cost: float = 0
|
|
sales_price: float = 0
|
|
technology: Optional[str] = None
|
|
connection_type: str = "fiber"
|
|
circuit_number: Optional[str] = None
|
|
speed_mbps: Optional[int] = None
|
|
upload_mbps: Optional[int] = None
|
|
download_mbps: Optional[int] = None
|
|
monitoring_url: Optional[str] = None
|
|
contract_start: Optional[date] = None
|
|
contract_end: Optional[date] = None
|
|
notes: Optional[str] = None
|
|
allocation_model: str = "dedicated"
|
|
value_type: str = "other"
|
|
value_label: Optional[str] = None
|
|
subscription_id: Optional[int] = None
|
|
|
|
|
|
class ConnectionUpdatePayload(BaseModel):
|
|
name: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
customer_id: Optional[int] = None
|
|
parent_id: Optional[int] = None
|
|
address: Optional[str] = None
|
|
status: Optional[str] = None
|
|
monthly_cost: Optional[float] = None
|
|
sales_price: Optional[float] = None
|
|
technology: Optional[str] = None
|
|
connection_type: Optional[str] = None
|
|
circuit_number: Optional[str] = None
|
|
speed_mbps: Optional[int] = None
|
|
upload_mbps: Optional[int] = None
|
|
download_mbps: Optional[int] = None
|
|
monitoring_url: Optional[str] = None
|
|
contract_start: Optional[date] = None
|
|
contract_end: Optional[date] = None
|
|
notes: Optional[str] = None
|
|
allocation_model: Optional[str] = None
|
|
value_type: Optional[str] = None
|
|
value_label: Optional[str] = None
|
|
subscription_id: Optional[int] = None
|
|
|
|
|
|
class PricingCreatePayload(BaseModel):
|
|
effective_from: date
|
|
purchase_price: float = 0
|
|
sales_price: float = 0
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class IpRangeCreatePayload(BaseModel):
|
|
name: str
|
|
cidr: str
|
|
description: Optional[str] = None
|
|
provider_reference: Optional[str] = None
|
|
contract_number: Optional[str] = None
|
|
customer_id: Optional[int] = None
|
|
service_address: Optional[str] = None
|
|
monthly_cost: float = 0
|
|
sales_price: float = 0
|
|
|
|
|
|
class IpRangeUpdatePayload(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
provider_reference: Optional[str] = None
|
|
contract_number: Optional[str] = None
|
|
customer_id: Optional[int] = None
|
|
service_address: Optional[str] = None
|
|
monthly_cost: Optional[float] = None
|
|
sales_price: Optional[float] = None
|
|
|
|
|
|
class IpAddressCreatePayload(BaseModel):
|
|
range_id: int
|
|
ip_address: str
|
|
status: str = "available"
|
|
assigned_to: Optional[str] = None
|
|
assigned_type: Optional[str] = None
|
|
assigned_customer_id: Optional[int] = None
|
|
assigned_connection_id: Optional[int] = None
|
|
comment: Optional[str] = None
|
|
|
|
|
|
class IpAddressUpdatePayload(BaseModel):
|
|
status: str = "available"
|
|
assigned_to: Optional[str] = None
|
|
assigned_type: Optional[str] = None
|
|
assigned_customer_id: Optional[int] = None
|
|
assigned_connection_id: Optional[int] = None
|
|
comment: Optional[str] = None
|
|
|
|
|
|
class SubscriptionIpAllocationPayload(BaseModel):
|
|
subscription_item_id: int
|
|
range_id: int
|
|
requested_cidr: Optional[str] = None
|
|
|
|
|
|
class SubscriptionProvisionPayload(BaseModel):
|
|
shared_connection_id: int
|
|
internet_item_id: Optional[int] = None
|
|
ip_allocations: List[SubscriptionIpAllocationPayload] = []
|
|
|
|
|
|
class QuickBmcnetCreatePayload(BaseModel):
|
|
customer_id: int
|
|
case_title: Optional[str] = None
|
|
service_label: Optional[str] = None
|
|
address: Optional[str] = None
|
|
billing_interval: str = "monthly"
|
|
billing_day: int = 1
|
|
start_date: date
|
|
internet_product_id: int
|
|
internet_unit_price: Optional[float] = None
|
|
ip_product_id: Optional[int] = None
|
|
ip_unit_price: Optional[float] = None
|
|
notes: Optional[str] = None
|
|
range_id: Optional[int] = None
|
|
ip_address_id: Optional[int] = None
|
|
mark_gateway: bool = False
|
|
|
|
|
|
def _load_subscription(subscription_id: int) -> Dict[str, Any]:
|
|
subscription = execute_query_single(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
s.subscription_number,
|
|
s.sag_id,
|
|
s.customer_id,
|
|
c.name AS customer_name,
|
|
s.product_name,
|
|
s.billing_interval,
|
|
s.price,
|
|
s.status,
|
|
s.start_date
|
|
FROM sag_subscriptions s
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
WHERE s.id = %s
|
|
""",
|
|
(subscription_id,),
|
|
)
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
|
return dict(subscription)
|
|
|
|
|
|
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
i.id,
|
|
i.line_no,
|
|
i.product_id,
|
|
p.name AS product_name,
|
|
p.type AS product_type,
|
|
p.attributes_json,
|
|
i.description,
|
|
i.quantity,
|
|
i.unit_price,
|
|
i.line_total
|
|
FROM sag_subscription_items i
|
|
LEFT JOIN products p ON p.id = i.product_id
|
|
WHERE i.subscription_id = %s
|
|
ORDER BY i.line_no ASC, i.id ASC
|
|
""",
|
|
(subscription_id,),
|
|
) or []
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def _load_subscription_connection(subscription_id: int) -> Optional[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
_connection_select_sql(" AND ic.subscription_id = %s ") + " ORDER BY ic.id ASC LIMIT 1",
|
|
(subscription_id,),
|
|
) or []
|
|
return _decorate_connection_row(dict(rows[0])) if rows else None
|
|
|
|
|
|
def _resolve_group_id_by_name_tokens(tokens: List[str]) -> Optional[int]:
|
|
lowered = [str(token or "").strip().lower() for token in tokens if str(token or "").strip()]
|
|
if not lowered:
|
|
return None
|
|
clauses = " OR ".join(["LOWER(name) LIKE %s" for _ in lowered])
|
|
params = tuple(f"%{token}%" for token in lowered)
|
|
row = execute_query_single(
|
|
f"""
|
|
SELECT id
|
|
FROM groups
|
|
WHERE {clauses}
|
|
ORDER BY id
|
|
LIMIT 1
|
|
""",
|
|
params,
|
|
)
|
|
return int(row["id"]) if row and row.get("id") else None
|
|
|
|
|
|
def _load_connection_ranges(connection_id: int) -> List[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
ir.*,
|
|
c.name AS customer_name,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
|
|
COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses
|
|
FROM internet_connections_ip_ranges ir
|
|
LEFT JOIN customers c ON c.id = ir.customer_id
|
|
LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
|
|
WHERE ir.connection_id = %s
|
|
AND ir.deleted_at IS NULL
|
|
GROUP BY ir.id, c.name
|
|
ORDER BY ir.id ASC
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
payload: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
item = _build_ip_range_payload(dict(row))
|
|
item["is_fully_available"] = bool(
|
|
item.get("total_addresses", 0) > 0 and item.get("available_addresses", 0) == item.get("total_addresses", 0)
|
|
)
|
|
payload.append(item)
|
|
return payload
|
|
|
|
|
|
def _build_provisioning_candidates(range_item: Dict[str, Any], required_prefixes: List[int]) -> List[Dict[str, Any]]:
|
|
if not required_prefixes:
|
|
return []
|
|
if range_item.get("customer_id") is not None:
|
|
return []
|
|
if not range_item.get("is_fully_available"):
|
|
return []
|
|
|
|
cidr = str(range_item.get("cidr") or "").strip()
|
|
if not cidr:
|
|
return []
|
|
|
|
try:
|
|
network = ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError:
|
|
return []
|
|
|
|
candidates: List[Dict[str, Any]] = []
|
|
seen: set[tuple[int, str]] = set()
|
|
for requested_prefix in required_prefixes:
|
|
try:
|
|
prefix_value = int(requested_prefix)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if prefix_value < network.prefixlen:
|
|
continue
|
|
if prefix_value == network.prefixlen:
|
|
candidate = dict(range_item)
|
|
candidate["range_id"] = int(range_item["id"])
|
|
candidate["source_range_id"] = int(range_item["id"])
|
|
candidate["source_cidr"] = cidr
|
|
candidate["requested_cidr"] = cidr
|
|
candidate["is_derived_candidate"] = False
|
|
key = (candidate["source_range_id"], candidate["requested_cidr"])
|
|
if key not in seen:
|
|
candidates.append(candidate)
|
|
seen.add(key)
|
|
continue
|
|
|
|
for subnet in network.subnets(new_prefix=prefix_value):
|
|
requested_cidr = str(subnet)
|
|
key = (int(range_item["id"]), requested_cidr)
|
|
if key in seen:
|
|
continue
|
|
usable_hosts = max(subnet.num_addresses - 2, 0) if subnet.num_addresses > 1 else int(subnet.num_addresses)
|
|
candidates.append(
|
|
{
|
|
"id": f"{range_item['id']}:{requested_cidr}",
|
|
"range_id": int(range_item["id"]),
|
|
"source_range_id": int(range_item["id"]),
|
|
"source_cidr": cidr,
|
|
"requested_cidr": requested_cidr,
|
|
"cidr": requested_cidr,
|
|
"name": f"{range_item.get('name') or 'Range'} -> {requested_cidr}",
|
|
"description": range_item.get("description"),
|
|
"provider_reference": range_item.get("provider_reference"),
|
|
"contract_number": range_item.get("contract_number"),
|
|
"customer_id": None,
|
|
"customer_name": None,
|
|
"service_address": range_item.get("service_address"),
|
|
"monthly_cost": 0,
|
|
"sales_price": 0,
|
|
"network_address": str(subnet.network_address),
|
|
"broadcast_address": str(subnet.broadcast_address),
|
|
"prefix_length": int(subnet.prefixlen),
|
|
"total_hosts": int(subnet.num_addresses),
|
|
"usable_hosts": usable_hosts,
|
|
"total_addresses": usable_hosts,
|
|
"available_addresses": usable_hosts,
|
|
"reserved_addresses": 0,
|
|
"in_use_addresses": 0,
|
|
"used_addresses": 0,
|
|
"is_fully_available": True,
|
|
"is_derived_candidate": True,
|
|
}
|
|
)
|
|
seen.add(key)
|
|
return candidates
|
|
|
|
|
|
def _load_shared_heads_for_provisioning(required_prefixes: List[int]) -> List[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
_connection_select_sql(
|
|
"""
|
|
AND ic.allocation_model = 'shared'
|
|
AND ic.parent_id IS NULL
|
|
"""
|
|
)
|
|
+ " ORDER BY ic.address ASC NULLS LAST, ic.name ASC",
|
|
) or []
|
|
heads: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
head = _decorate_connection_row(dict(row))
|
|
ranges = _load_connection_ranges(int(head["id"]))
|
|
matching_ranges = []
|
|
for range_item in ranges:
|
|
matching_ranges.extend(_build_provisioning_candidates(range_item, required_prefixes))
|
|
head["available_matching_ranges"] = matching_ranges
|
|
head["available_matching_range_count"] = len(matching_ranges)
|
|
heads.append(head)
|
|
return heads
|
|
|
|
|
|
@router.get("/internet-connections/health")
|
|
async def internet_connections_health():
|
|
return {"status": "healthy", "service": "internet-connections-module"}
|
|
|
|
|
|
@router.get("/internet-connections", response_model=List[dict])
|
|
async def list_connections(
|
|
q: Optional[str] = Query(None),
|
|
customer_id: Optional[int] = Query(None),
|
|
provider: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
allocation_model: Optional[str] = Query(None),
|
|
value_type: Optional[str] = Query(None),
|
|
shared_only: bool = Query(False),
|
|
bmcnet_only: bool = Query(False),
|
|
):
|
|
query = _connection_select_sql()
|
|
params: list[object] = []
|
|
if q:
|
|
query += " AND (ic.name ILIKE %s OR ic.provider ILIKE %s OR ic.address ILIKE %s OR c.name ILIKE %s OR ic.circuit_number ILIKE %s OR sub.subscription_number ILIKE %s OR sub.product_name ILIKE %s)"
|
|
term = f"%{q}%"
|
|
params.extend([term, term, term, term, term, term, term])
|
|
if customer_id:
|
|
query += " AND ic.customer_id = %s"
|
|
params.append(customer_id)
|
|
if provider:
|
|
query += " AND ic.provider ILIKE %s"
|
|
params.append(f"%{provider}%")
|
|
if status:
|
|
query += " AND ic.status = %s"
|
|
params.append(status)
|
|
if allocation_model:
|
|
query += " AND ic.allocation_model = %s"
|
|
params.append(allocation_model)
|
|
if value_type:
|
|
query += " AND ic.value_type = %s"
|
|
params.append(value_type)
|
|
if shared_only:
|
|
query += " AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL"
|
|
if bmcnet_only:
|
|
query += """
|
|
AND ic.parent_id IS NOT NULL
|
|
AND parent.allocation_model = 'shared'
|
|
AND (
|
|
ic.value_type = 'subscription'
|
|
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
|
|
)
|
|
"""
|
|
query += " ORDER BY c.name ASC NULLS LAST, ic.name ASC"
|
|
try:
|
|
rows = execute_query(query, tuple(params) if params else ()) or []
|
|
except Exception as exc:
|
|
logger.warning("Failed to load internet connections: %s", exc)
|
|
return []
|
|
return [_decorate_connection_row(dict(row)) for row in rows]
|
|
|
|
|
|
@router.post("/internet-connections", response_model=dict)
|
|
async def create_connection(payload: ConnectionCreatePayload):
|
|
normalized = _normalize_connection_payload(payload.model_dump())
|
|
try:
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_connections (
|
|
parent_id, name, provider, customer_id, address, status, monthly_cost, sales_price, technology,
|
|
connection_type, circuit_number, speed_mbps, upload_mbps, download_mbps, monitoring_url,
|
|
contract_start, contract_end, notes, allocation_model, value_type, value_label, subscription_id
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
normalized.get("parent_id"),
|
|
normalized.get("name"),
|
|
normalized.get("provider"),
|
|
normalized.get("customer_id"),
|
|
normalized.get("address"),
|
|
normalized.get("status"),
|
|
normalized.get("monthly_cost"),
|
|
normalized.get("sales_price"),
|
|
normalized.get("technology"),
|
|
normalized.get("connection_type"),
|
|
normalized.get("circuit_number"),
|
|
normalized.get("speed_mbps"),
|
|
normalized.get("upload_mbps"),
|
|
normalized.get("download_mbps"),
|
|
normalized.get("monitoring_url"),
|
|
normalized.get("contract_start"),
|
|
normalized.get("contract_end"),
|
|
normalized.get("notes"),
|
|
normalized.get("allocation_model"),
|
|
normalized.get("value_type"),
|
|
normalized.get("value_label"),
|
|
normalized.get("subscription_id"),
|
|
),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Failed to create internet connection")
|
|
raise HTTPException(status_code=500, detail=f"Kunne ikke oprette forbindelse: {exc}") from exc
|
|
if not rows:
|
|
raise HTTPException(status_code=500, detail="Failed to create connection")
|
|
connection = dict(rows[0])
|
|
_create_history_entry(
|
|
int(connection["id"]),
|
|
"connection_created",
|
|
f"Oprettede forbindelse {payload.name}",
|
|
{
|
|
"provider": normalized.get("provider"),
|
|
"customer_id": normalized.get("customer_id"),
|
|
"parent_id": normalized.get("parent_id"),
|
|
"status": normalized.get("status"),
|
|
"allocation_model": normalized.get("allocation_model"),
|
|
"value_type": normalized.get("value_type"),
|
|
"subscription_id": normalized.get("subscription_id"),
|
|
},
|
|
)
|
|
return connection
|
|
|
|
|
|
@router.put("/internet-connections/{connection_id:int}", response_model=dict)
|
|
async def update_connection(connection_id: int, payload: ConnectionUpdatePayload):
|
|
existing_rows = execute_query(
|
|
_connection_select_sql(" AND ic.id = %s "),
|
|
(connection_id,),
|
|
) or []
|
|
if not existing_rows:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
|
|
existing = dict(existing_rows[0])
|
|
update_values = payload.model_dump(exclude_unset=True)
|
|
if not update_values:
|
|
return _decorate_connection_row(existing)
|
|
|
|
merged_values = dict(existing)
|
|
merged_values.update(update_values)
|
|
if (
|
|
str(merged_values.get("value_type") or "").strip().lower() == "other"
|
|
and not str(merged_values.get("value_label") or "").strip()
|
|
):
|
|
merged_values["value_label"] = "Mangler klassifikation"
|
|
normalized = _normalize_connection_payload(merged_values)
|
|
|
|
set_parts = []
|
|
params: list[object] = []
|
|
changed: dict[str, object] = {}
|
|
allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id"}
|
|
for field in allowed_fields:
|
|
value = normalized.get(field)
|
|
set_parts.append(f"{field} = %s")
|
|
params.append(value)
|
|
if existing.get(field) != value:
|
|
changed[field] = value
|
|
|
|
params.append(connection_id)
|
|
try:
|
|
rows = execute_query(
|
|
f"""
|
|
UPDATE internet_connections_connections
|
|
SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
RETURNING *
|
|
""",
|
|
tuple(params),
|
|
) or []
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Failed to update internet connection %s", connection_id)
|
|
raise HTTPException(status_code=500, detail=f"Kunne ikke gemme forbindelse: {exc}") from exc
|
|
if not rows:
|
|
raise HTTPException(status_code=500, detail="Failed to update connection")
|
|
|
|
if changed:
|
|
_create_history_entry(
|
|
connection_id,
|
|
"connection_updated",
|
|
f"Opdaterede forbindelse {rows[0].get('name')}",
|
|
changed,
|
|
)
|
|
return dict(rows[0])
|
|
|
|
|
|
@router.get("/internet-connections/contracts")
|
|
async def list_contracts(status: Optional[str] = Query(None), provider: Optional[str] = Query(None)):
|
|
query = """
|
|
SELECT
|
|
id,
|
|
name,
|
|
provider,
|
|
contract_start,
|
|
contract_end,
|
|
status,
|
|
sales_price,
|
|
allocation_model,
|
|
value_type,
|
|
subscription_id
|
|
FROM internet_connections_connections
|
|
WHERE deleted_at IS NULL
|
|
"""
|
|
params: list[object] = []
|
|
|
|
if status:
|
|
query += " AND status = %s"
|
|
params.append(status)
|
|
if provider:
|
|
query += " AND provider ILIKE %s"
|
|
params.append(f"%{provider}%")
|
|
|
|
query += " ORDER BY contract_end IS NULL, contract_end ASC, name ASC"
|
|
try:
|
|
rows = execute_query(query, tuple(params) if params else ()) or []
|
|
except Exception as exc:
|
|
logger.warning("Failed to load contract overview: %s", exc)
|
|
return []
|
|
|
|
payload = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["contract_status"] = _get_contract_status(item.get("contract_end"))
|
|
item["value_type_label"] = _value_type_label(item.get("value_type"))
|
|
item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
|
|
payload.append(item)
|
|
return payload
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id:int}", response_model=dict)
|
|
async def get_connection(connection_id: int):
|
|
rows = execute_query(
|
|
_connection_select_sql(" AND ic.id = %s "),
|
|
(connection_id,),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
connection = _decorate_connection_row(dict(rows[0]))
|
|
if connection.get("is_shared_head"):
|
|
children = _load_bmcnet_children(connection_id)
|
|
connection["bmcnet_summary"] = _build_bmcnet_summary(children)
|
|
else:
|
|
connection["bmcnet_summary"] = _build_bmcnet_summary([])
|
|
return connection
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
|
|
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
|
|
where_parts = [" AND ic.parent_id = %s "]
|
|
if bmcnet_only:
|
|
where_parts.append(
|
|
"""
|
|
AND parent.allocation_model = 'shared'
|
|
AND (
|
|
ic.value_type = 'subscription'
|
|
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
|
|
)
|
|
"""
|
|
)
|
|
rows = execute_query(
|
|
_connection_select_sql("".join(where_parts)) + " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
|
|
(connection_id,),
|
|
) or []
|
|
return [_decorate_connection_row(dict(row)) for row in rows]
|
|
|
|
|
|
@router.get("/internet-connections/pricing/summary")
|
|
async def pricing_summary():
|
|
try:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS total_connections,
|
|
COUNT(*) FILTER (WHERE status = 'active') AS active_connections,
|
|
COUNT(*) FILTER (WHERE allocation_model = 'shared' AND parent_id IS NULL) AS shared_head_connections,
|
|
COALESCE(SUM(monthly_cost), 0) AS total_purchase_cost,
|
|
COALESCE(SUM(sales_price), 0) AS total_sales_price,
|
|
COALESCE(SUM(COALESCE(sales_price, 0) - COALESCE(monthly_cost, 0)), 0) AS total_margin
|
|
FROM internet_connections_connections
|
|
WHERE deleted_at IS NULL
|
|
""",
|
|
) or []
|
|
except Exception as exc:
|
|
logger.warning("Failed to load pricing summary: %s", exc)
|
|
return {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
|
|
return dict(rows[0]) if rows else {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
|
|
|
|
|
|
@router.get("/internet-connections/subscription-options", response_model=List[dict])
|
|
async def subscription_options(q: Optional[str] = Query(None), status: str = Query("active")):
|
|
params: List[Any] = []
|
|
where = ["1=1"]
|
|
if status and status != "all":
|
|
where.append("s.status = %s")
|
|
params.append(status)
|
|
if q:
|
|
term = f"%{q}%"
|
|
where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)")
|
|
params.extend([term, term, term])
|
|
rows = execute_query(
|
|
f"""
|
|
SELECT
|
|
s.id,
|
|
s.subscription_number,
|
|
s.product_name,
|
|
s.customer_id,
|
|
c.name AS customer_name,
|
|
s.status
|
|
FROM sag_subscriptions s
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
WHERE {" AND ".join(where)}
|
|
ORDER BY s.start_date DESC NULLS LAST, s.id DESC
|
|
LIMIT 50
|
|
""",
|
|
tuple(params) if params else (),
|
|
) or []
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
@router.get("/internet-connections/subscriptions/{subscription_id}/provisioning")
|
|
async def get_subscription_provisioning(subscription_id: int):
|
|
subscription = _load_subscription(subscription_id)
|
|
line_items = _load_subscription_line_items(subscription_id)
|
|
requirements = summarize_subscription_network_requirements(line_items)
|
|
existing_connection = _load_subscription_connection(subscription_id)
|
|
|
|
return {
|
|
"subscription": subscription,
|
|
"line_items": line_items,
|
|
"network_provisioning": {
|
|
**requirements,
|
|
"existing_connection_id": existing_connection.get("id") if existing_connection else None,
|
|
"is_provisioned": bool(existing_connection),
|
|
},
|
|
"existing_connection": existing_connection,
|
|
"current_allocated_ranges": _load_connection_ranges(int(existing_connection["id"])) if existing_connection else [],
|
|
"shared_heads": _load_shared_heads_for_provisioning(requirements.get("required_ip_prefixes") or []),
|
|
}
|
|
|
|
|
|
@router.post("/internet-connections/subscriptions/{subscription_id}/provision")
|
|
async def provision_subscription_connection(subscription_id: int, payload: SubscriptionProvisionPayload):
|
|
subscription = _load_subscription(subscription_id)
|
|
line_items = _load_subscription_line_items(subscription_id)
|
|
requirements = summarize_subscription_network_requirements(line_items)
|
|
if not requirements["requires_provisioning"]:
|
|
raise HTTPException(status_code=400, detail="Subscription does not require network provisioning")
|
|
|
|
line_item_map = {int(item["id"]): item for item in line_items if item.get("id") is not None}
|
|
|
|
internet_item = None
|
|
if payload.internet_item_id is not None:
|
|
internet_item = line_item_map.get(int(payload.internet_item_id))
|
|
if not internet_item:
|
|
raise HTTPException(status_code=400, detail="Selected internet product is not part of the subscription")
|
|
internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
|
|
if internet_profile.get("kind") != "internet_access":
|
|
raise HTTPException(status_code=400, detail="Selected internet product is not an internet access line")
|
|
elif requirements.get("primary_internet_item"):
|
|
internet_item = line_item_map.get(int(requirements["primary_internet_item"]["subscription_item_id"]))
|
|
internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
|
|
else:
|
|
internet_profile = {}
|
|
|
|
requested_ip_items = requirements.get("ip_items") or []
|
|
requested_ip_item_ids = {int(item["subscription_item_id"]) for item in requested_ip_items if item.get("subscription_item_id") is not None}
|
|
provided_ip_item_ids = {int(item.subscription_item_id) for item in payload.ip_allocations}
|
|
if requested_ip_item_ids != provided_ip_item_ids:
|
|
raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje")
|
|
|
|
shared_connection = execute_query_single(
|
|
_connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "),
|
|
(payload.shared_connection_id,),
|
|
)
|
|
if not shared_connection:
|
|
raise HTTPException(status_code=404, detail="Shared head connection not found")
|
|
|
|
shared_connection = _decorate_connection_row(dict(shared_connection))
|
|
if not shared_connection.get("address"):
|
|
raise HTTPException(status_code=409, detail="Den valgte hovedforbindelse mangler adresse og kan ikke bruges")
|
|
|
|
existing_connection = _load_subscription_connection(subscription_id)
|
|
|
|
conn = get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_connections
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(payload.shared_connection_id,),
|
|
)
|
|
locked_shared_connection = cursor.fetchone()
|
|
if not locked_shared_connection:
|
|
raise HTTPException(status_code=404, detail="Shared head connection not found")
|
|
|
|
existing_row = None
|
|
existing_connection_id = None
|
|
previous_parent_id = payload.shared_connection_id
|
|
if existing_connection:
|
|
existing_connection_id = int(existing_connection["id"])
|
|
cursor.execute(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_connections
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(existing_connection_id,),
|
|
)
|
|
existing_row = cursor.fetchone()
|
|
previous_parent_id = int(existing_row.get("parent_id") or payload.shared_connection_id)
|
|
|
|
selected_range_ids = [int(item.range_id) for item in payload.ip_allocations]
|
|
allocation_by_item = {
|
|
int(item.subscription_item_id): {
|
|
"range_id": int(item.range_id),
|
|
"requested_cidr": str(item.requested_cidr or "").strip() or None,
|
|
}
|
|
for item in payload.ip_allocations
|
|
}
|
|
|
|
for ip_item in requested_ip_items:
|
|
item_id = int(ip_item["subscription_item_id"])
|
|
allocation = allocation_by_item[item_id]
|
|
range_id = int(allocation["range_id"])
|
|
cursor.execute(
|
|
"""
|
|
SELECT
|
|
ir.*
|
|
FROM internet_connections_ip_ranges ir
|
|
WHERE ir.id = %s
|
|
AND ir.connection_id = %s
|
|
AND ir.deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(range_id, payload.shared_connection_id),
|
|
)
|
|
range_row = cursor.fetchone()
|
|
if not range_row:
|
|
raise HTTPException(status_code=409, detail=f"IP-range #{range_id} er ikke ledigt paa den valgte hovedforbindelse")
|
|
cursor.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total_addresses,
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'available') AS available_addresses
|
|
FROM internet_connections_ip_addresses
|
|
WHERE range_id = %s
|
|
""",
|
|
(range_id,),
|
|
)
|
|
range_stats = cursor.fetchone() or {}
|
|
prefix_length = _build_ip_range_payload(dict(range_row)).get("prefix_length")
|
|
expected_prefix = ip_item.get("ip_prefix_length")
|
|
requested_cidr = allocation.get("requested_cidr") or str(range_row.get("cidr") or "")
|
|
requested_network = _validate_cidr(requested_cidr)
|
|
source_network = _validate_cidr(str(range_row.get("cidr") or ""))
|
|
total_addresses = int(range_stats.get("total_addresses") or 0)
|
|
available_addresses = int(range_stats.get("available_addresses") or 0)
|
|
if total_addresses <= 0:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} har ingen adresser")
|
|
if not requested_network.subnet_of(source_network):
|
|
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} ligger ikke i {range_row.get('cidr')}")
|
|
if expected_prefix is not None and int(requested_network.prefixlen) != int(expected_prefix):
|
|
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} matcher ikke /{expected_prefix}")
|
|
if prefix_length is not None and int(requested_network.prefixlen) < int(prefix_length):
|
|
raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} er stoerre end kildeblokken {range_row.get('cidr')}")
|
|
if requested_cidr == str(range_row.get("cidr") or ""):
|
|
if available_addresses != total_addresses:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er ikke laengere ledigt")
|
|
if range_row.get("customer_id") is not None:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er allerede bundet til en anden kunde")
|
|
|
|
current_range_rows: List[Dict[str, Any]] = []
|
|
if existing_connection_id:
|
|
cursor.execute(
|
|
"""
|
|
SELECT id
|
|
FROM internet_connections_ip_ranges
|
|
WHERE connection_id = %s
|
|
AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(existing_connection_id,),
|
|
)
|
|
current_range_rows = [dict(row) for row in cursor.fetchall() or []]
|
|
|
|
connection_name = internet_item.get("description") if internet_item else subscription.get("product_name")
|
|
connection_name = str(connection_name or subscription.get("subscription_number") or "Internetforbindelse").strip()
|
|
sales_price = 0.0
|
|
if internet_item:
|
|
sales_price += float(internet_item.get("line_total") or 0)
|
|
for ip_item in requested_ip_items:
|
|
sales_price += float(ip_item.get("line_total") or 0)
|
|
|
|
connection_fields = (
|
|
connection_name,
|
|
locked_shared_connection.get("provider"),
|
|
subscription.get("customer_id"),
|
|
payload.shared_connection_id,
|
|
locked_shared_connection.get("address"),
|
|
"active",
|
|
sales_price,
|
|
locked_shared_connection.get("technology"),
|
|
internet_profile.get("connection_type") or locked_shared_connection.get("connection_type"),
|
|
internet_profile.get("speed_mbps"),
|
|
internet_profile.get("upload_mbps"),
|
|
internet_profile.get("download_mbps"),
|
|
"dedicated",
|
|
"subscription",
|
|
None,
|
|
subscription_id,
|
|
)
|
|
|
|
if existing_row:
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_connections
|
|
SET name = %s,
|
|
provider = %s,
|
|
customer_id = %s,
|
|
parent_id = %s,
|
|
address = %s,
|
|
status = %s,
|
|
sales_price = %s,
|
|
technology = %s,
|
|
connection_type = %s,
|
|
speed_mbps = %s,
|
|
upload_mbps = %s,
|
|
download_mbps = %s,
|
|
allocation_model = %s,
|
|
value_type = %s,
|
|
value_label = %s,
|
|
subscription_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
connection_fields + (existing_connection_id,),
|
|
)
|
|
connection_row = cursor.fetchone()
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO internet_connections_connections (
|
|
name, provider, customer_id, parent_id, address, status, sales_price, technology,
|
|
connection_type, speed_mbps, upload_mbps, download_mbps,
|
|
allocation_model, value_type, value_label, subscription_id
|
|
) VALUES (
|
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
)
|
|
RETURNING *
|
|
""",
|
|
connection_fields,
|
|
)
|
|
connection_row = cursor.fetchone()
|
|
existing_connection_id = int(connection_row["id"])
|
|
|
|
released_range_ids: List[int] = []
|
|
for current_range in current_range_rows:
|
|
current_range_id = int(current_range["id"])
|
|
if current_range_id in selected_range_ids:
|
|
continue
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_ip_ranges
|
|
SET connection_id = %s,
|
|
customer_id = NULL,
|
|
service_address = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
""",
|
|
(previous_parent_id, current_range_id),
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_ip_addresses
|
|
SET status = 'available',
|
|
assigned_to = NULL,
|
|
assigned_type = NULL,
|
|
assigned_customer_id = NULL,
|
|
assigned_connection_id = NULL,
|
|
comment = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE range_id = %s
|
|
AND deleted_at IS NULL
|
|
""",
|
|
(current_range_id,),
|
|
)
|
|
released_range_ids.append(current_range_id)
|
|
|
|
allocated_ranges: List[Dict[str, Any]] = []
|
|
for ip_item in requested_ip_items:
|
|
item_id = int(ip_item["subscription_item_id"])
|
|
allocation = allocation_by_item[item_id]
|
|
range_id = int(allocation["range_id"])
|
|
requested_cidr = allocation.get("requested_cidr")
|
|
cursor.execute(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_ip_ranges
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(range_id,),
|
|
)
|
|
source_range = cursor.fetchone()
|
|
if not source_range:
|
|
raise HTTPException(status_code=404, detail=f"IP-range #{range_id} blev ikke fundet")
|
|
|
|
source_cidr = str(source_range.get("cidr") or "")
|
|
effective_cidr = requested_cidr or source_cidr
|
|
if effective_cidr == source_cidr:
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_ip_ranges
|
|
SET connection_id = %s,
|
|
customer_id = %s,
|
|
service_address = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(existing_connection_id, subscription.get("customer_id"), locked_shared_connection.get("address"), range_id),
|
|
)
|
|
allocated_range = cursor.fetchone()
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_ip_addresses
|
|
SET status = 'reserved',
|
|
assigned_to = %s,
|
|
assigned_type = 'subscription',
|
|
assigned_customer_id = %s,
|
|
assigned_connection_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE range_id = %s
|
|
AND deleted_at IS NULL
|
|
""",
|
|
(subscription.get("subscription_number"), subscription.get("customer_id"), existing_connection_id, range_id),
|
|
)
|
|
else:
|
|
requested_network = _validate_cidr(effective_cidr)
|
|
host_addresses = [str(host) for host in requested_network.hosts()]
|
|
if not host_addresses:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} har ingen brugbare adresser")
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, ip_address, status
|
|
FROM internet_connections_ip_addresses
|
|
WHERE range_id = %s
|
|
AND deleted_at IS NULL
|
|
FOR UPDATE
|
|
""",
|
|
(range_id,),
|
|
)
|
|
source_addresses = cursor.fetchall() or []
|
|
source_address_map = {str(row.get("ip_address")): dict(row) for row in source_addresses}
|
|
missing_addresses = [ip for ip in host_addresses if ip not in source_address_map]
|
|
if missing_addresses:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} mangler adresser i lageret")
|
|
busy_addresses = [
|
|
ip for ip in host_addresses
|
|
if str(source_address_map[ip].get("status") or "available").lower() != "available"
|
|
]
|
|
if busy_addresses:
|
|
raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} er ikke laengere ledigt")
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO internet_connections_ip_ranges (
|
|
connection_id, name, cidr, description, provider_reference,
|
|
contract_number, customer_id, service_address, monthly_cost, sales_price
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 0, 0)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
existing_connection_id,
|
|
f"{source_range.get('name') or 'IP-range'} -> {effective_cidr}",
|
|
effective_cidr,
|
|
source_range.get("description"),
|
|
source_range.get("provider_reference"),
|
|
source_range.get("contract_number"),
|
|
subscription.get("customer_id"),
|
|
locked_shared_connection.get("address"),
|
|
),
|
|
)
|
|
allocated_range = cursor.fetchone()
|
|
allocated_range_id = int(allocated_range["id"])
|
|
cursor.execute(
|
|
"""
|
|
UPDATE internet_connections_ip_addresses
|
|
SET range_id = %s,
|
|
status = 'reserved',
|
|
assigned_to = %s,
|
|
assigned_type = 'subscription',
|
|
assigned_customer_id = %s,
|
|
assigned_connection_id = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ANY(%s)
|
|
""",
|
|
(
|
|
allocated_range_id,
|
|
subscription.get("subscription_number"),
|
|
subscription.get("customer_id"),
|
|
existing_connection_id,
|
|
[int(source_address_map[ip]["id"]) for ip in host_addresses],
|
|
),
|
|
)
|
|
allocated_ranges.append(_build_ip_range_payload(dict(allocated_range)))
|
|
|
|
conn.commit()
|
|
|
|
_create_history_entry(
|
|
existing_connection_id,
|
|
"subscription_provisioned",
|
|
f"Provisionerede forbindelse fra abonnement {subscription.get('subscription_number')}",
|
|
{
|
|
"subscription_id": subscription_id,
|
|
"shared_connection_id": payload.shared_connection_id,
|
|
"internet_item_id": payload.internet_item_id,
|
|
"allocated_range_ids": selected_range_ids,
|
|
"released_range_ids": released_range_ids,
|
|
},
|
|
)
|
|
_create_history_entry(
|
|
payload.shared_connection_id,
|
|
"shared_capacity_allocated",
|
|
f"Allokerede kapacitet til abonnement {subscription.get('subscription_number')}",
|
|
{
|
|
"subscription_id": subscription_id,
|
|
"connection_id": existing_connection_id,
|
|
"allocated_range_ids": selected_range_ids,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"subscription_id": subscription_id,
|
|
"connection": _load_subscription_connection(subscription_id),
|
|
"allocated_ranges": _load_connection_ranges(existing_connection_id),
|
|
"released_range_ids": released_range_ids,
|
|
"reused_existing_connection": bool(existing_connection),
|
|
}
|
|
finally:
|
|
release_db_connection(conn)
|
|
|
|
|
|
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
|
|
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
|
|
head_row = execute_query_single(
|
|
_connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "),
|
|
(connection_id,),
|
|
)
|
|
if not head_row:
|
|
raise HTTPException(status_code=404, detail="Delt hovedforbindelse blev ikke fundet")
|
|
|
|
head = _decorate_connection_row(dict(head_row))
|
|
customer = execute_query_single(
|
|
"""
|
|
SELECT id, name
|
|
FROM customers
|
|
WHERE id = %s
|
|
LIMIT 1
|
|
""",
|
|
(payload.customer_id,),
|
|
)
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
|
|
|
|
address = str(payload.address or head.get("address") or "").strip()
|
|
if not address:
|
|
raise HTTPException(status_code=400, detail="Adresse er påkrævet for BMCnet-forbindelsen")
|
|
|
|
customer_name = str(customer.get("name") or "").strip() or f"Kunde #{payload.customer_id}"
|
|
service_label = str(payload.service_label or "").strip() or f"BMCnet - {customer_name}"
|
|
wizard_notes = str(payload.notes or "").strip() or None
|
|
|
|
product_ids = [int(payload.internet_product_id)]
|
|
if payload.ip_product_id:
|
|
product_ids.append(int(payload.ip_product_id))
|
|
product_rows = execute_query(
|
|
"""
|
|
SELECT id, name, short_description, sales_price, attributes_json, type
|
|
FROM products
|
|
WHERE id = ANY(%s)
|
|
AND deleted_at IS NULL
|
|
""",
|
|
(product_ids,),
|
|
) or []
|
|
product_map = {int(row["id"]): dict(row) for row in product_rows if row.get("id") is not None}
|
|
|
|
internet_product = product_map.get(int(payload.internet_product_id))
|
|
if not internet_product:
|
|
raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet")
|
|
internet_profile = build_network_product_profile(internet_product, fallback_text=internet_product.get("short_description"))
|
|
if internet_profile.get("kind") != "internet_access":
|
|
raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt")
|
|
|
|
ip_product = None
|
|
ip_profile = {}
|
|
selected_ip_address = None
|
|
if payload.ip_product_id:
|
|
ip_product = product_map.get(int(payload.ip_product_id))
|
|
if not ip_product:
|
|
raise HTTPException(status_code=404, detail="IP-produktet blev ikke fundet")
|
|
ip_profile = build_network_product_profile(ip_product, fallback_text=ip_product.get("short_description"))
|
|
if ip_profile.get("kind") != "ip_allocation":
|
|
raise HTTPException(status_code=400, detail="Det valgte IP-produkt er ikke et IP-allokeringsprodukt")
|
|
is_static_wan_ip = bool(
|
|
int(ip_profile.get("ip_prefix_length") or 0) == 32
|
|
or "statisk wan" in f"{ip_product.get('name') or ''} {ip_product.get('short_description') or ''}".lower()
|
|
)
|
|
if is_static_wan_ip:
|
|
if not payload.ip_address_id:
|
|
raise HTTPException(status_code=400, detail="Vælg en ledig IP-adresse til Statisk WAN IP")
|
|
selected_ip_address = execute_query_single(
|
|
"""
|
|
SELECT
|
|
ipa.id,
|
|
ipa.ip_address,
|
|
ipa.range_id,
|
|
ipa.status,
|
|
ir.connection_id,
|
|
ir.service_address,
|
|
ir.cidr,
|
|
child.name AS connection_name,
|
|
child.parent_id AS connection_parent_id
|
|
FROM internet_connections_ip_addresses ipa
|
|
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
|
|
LEFT JOIN internet_connections_connections child ON child.id = ir.connection_id
|
|
WHERE ipa.id = %s
|
|
AND ipa.deleted_at IS NULL
|
|
AND ir.deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(payload.ip_address_id,),
|
|
)
|
|
if not selected_ip_address:
|
|
raise HTTPException(status_code=404, detail="Den valgte IP-adresse blev ikke fundet")
|
|
if int(selected_ip_address.get("connection_id") or 0) != int(connection_id):
|
|
if int(selected_ip_address.get("connection_parent_id") or 0) == int(connection_id):
|
|
child_name = str(selected_ip_address.get("connection_name") or "").strip()
|
|
child_label = child_name or f"Forbindelse #{selected_ip_address.get('connection_id')}"
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Den valgte IP-adresse er allerede reserveret på {child_label}. Opdater siden før du opretter igen.",
|
|
)
|
|
raise HTTPException(status_code=409, detail="Den valgte IP-adresse ligger ikke på den valgte hovedforbindelse")
|
|
if str(selected_ip_address.get("status") or "").lower() != "available":
|
|
raise HTTPException(status_code=409, detail="Den valgte IP-adresse er ikke længere ledig")
|
|
elif not payload.range_id:
|
|
raise HTTPException(status_code=400, detail="Vælg et IP-range til IP-produktet")
|
|
elif payload.range_id or payload.ip_address_id:
|
|
raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt")
|
|
|
|
case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}"
|
|
description_lines = [
|
|
f"Hovedforbindelse: {head.get('name') or connection_id}",
|
|
f"Service: {service_label}",
|
|
f"Adresse: {address}",
|
|
f"Internetprodukt: {internet_product.get('name') or '-'}",
|
|
]
|
|
if ip_product:
|
|
description_lines.append(f"IP-produkt: {ip_product.get('name') or '-'}")
|
|
if wizard_notes:
|
|
description_lines.append("")
|
|
description_lines.append(wizard_notes)
|
|
|
|
assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])
|
|
created_case = execute_query_single(
|
|
"""
|
|
INSERT INTO sag_sager (
|
|
titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
case_title,
|
|
"\n".join(description_lines),
|
|
"abonnement",
|
|
"åben",
|
|
payload.customer_id,
|
|
assigned_group_id,
|
|
1,
|
|
),
|
|
)
|
|
if not created_case or not created_case.get("id"):
|
|
raise HTTPException(status_code=500, detail="Kunne ikke oprette sag")
|
|
|
|
line_items = [{
|
|
"product_id": int(internet_product["id"]),
|
|
"description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(),
|
|
"quantity": 1,
|
|
"unit_price": float(payload.internet_unit_price if payload.internet_unit_price is not None else (internet_product.get("sales_price") or 0)),
|
|
}]
|
|
if ip_product:
|
|
line_items.append({
|
|
"product_id": int(ip_product["id"]),
|
|
"description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(),
|
|
"quantity": 1,
|
|
"unit_price": float(payload.ip_unit_price if payload.ip_unit_price is not None else (ip_product.get("sales_price") or 0)),
|
|
})
|
|
|
|
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
|
|
|
|
subscription = await create_sag_subscription({
|
|
"sag_id": int(created_case["id"]),
|
|
"billing_interval": payload.billing_interval,
|
|
"billing_day": int(payload.billing_day),
|
|
"start_date": payload.start_date.isoformat(),
|
|
"notes": wizard_notes,
|
|
"line_items": line_items,
|
|
})
|
|
subscription_id = int(subscription["id"])
|
|
line_items_created = _load_subscription_line_items(subscription_id)
|
|
requirements = summarize_subscription_network_requirements(line_items_created)
|
|
internet_item = requirements.get("primary_internet_item")
|
|
if not internet_item:
|
|
raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men internetlinjen kunne ikke findes")
|
|
|
|
ip_allocations: List[SubscriptionIpAllocationPayload] = []
|
|
if ip_product:
|
|
ip_item = next((item for item in requirements.get("ip_items") or [] if int(item.get("product_id") or 0) == int(ip_product["id"])), None)
|
|
if not ip_item:
|
|
raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men IP-linjen kunne ikke findes")
|
|
requested_cidr = None
|
|
allocation_range_id = int(payload.range_id or 0)
|
|
if selected_ip_address:
|
|
allocation_range_id = int(selected_ip_address["range_id"])
|
|
requested_cidr = f"{selected_ip_address['ip_address']}/32"
|
|
elif payload.range_id:
|
|
range_row = execute_query_single(
|
|
"SELECT cidr FROM internet_connections_ip_ranges WHERE id = %s AND deleted_at IS NULL",
|
|
(payload.range_id,),
|
|
)
|
|
if range_row and range_row.get("cidr"):
|
|
requested_cidr = str(range_row.get("cidr"))
|
|
ip_allocations.append(SubscriptionIpAllocationPayload(
|
|
subscription_item_id=int(ip_item["subscription_item_id"]),
|
|
range_id=allocation_range_id,
|
|
requested_cidr=requested_cidr,
|
|
))
|
|
|
|
provisioned = await provision_subscription_connection(
|
|
subscription_id,
|
|
SubscriptionProvisionPayload(
|
|
shared_connection_id=connection_id,
|
|
internet_item_id=int(internet_item["subscription_item_id"]),
|
|
ip_allocations=ip_allocations,
|
|
),
|
|
)
|
|
|
|
connection = provisioned.get("connection") or _load_subscription_connection(subscription_id)
|
|
if connection:
|
|
execute_query(
|
|
"""
|
|
UPDATE internet_connections_connections
|
|
SET name = %s,
|
|
address = %s,
|
|
notes = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
""",
|
|
(
|
|
case_title,
|
|
address,
|
|
f"Service: {service_label}" if not wizard_notes else f"Service: {service_label}\n{wizard_notes}",
|
|
int(connection["id"]),
|
|
),
|
|
)
|
|
if payload.range_id:
|
|
execute_query(
|
|
"""
|
|
UPDATE internet_connections_ip_ranges
|
|
SET service_address = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE connection_id = %s
|
|
AND deleted_at IS NULL
|
|
""",
|
|
(address, int(connection["id"])),
|
|
)
|
|
if selected_ip_address and payload.mark_gateway:
|
|
execute_query(
|
|
"""
|
|
UPDATE internet_connections_ip_addresses ipa
|
|
SET assigned_type = 'bmcnet_gateway',
|
|
comment = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
FROM internet_connections_ip_ranges ir
|
|
WHERE ipa.range_id = ir.id
|
|
AND ir.connection_id = %s
|
|
AND ipa.ip_address = %s
|
|
AND ipa.deleted_at IS NULL
|
|
""",
|
|
("BMCnet kunde gateway", int(connection["id"]), str(selected_ip_address["ip_address"])),
|
|
)
|
|
connection = _load_subscription_connection(subscription_id)
|
|
|
|
_create_history_entry(
|
|
int(connection["id"]),
|
|
"bmcnet_connection_created",
|
|
f"Oprettede BMCnet-forbindelse via wizard fra sag #{created_case['id']}",
|
|
{
|
|
"parent_connection_id": connection_id,
|
|
"customer_id": payload.customer_id,
|
|
"service_label": service_label,
|
|
"subscription_id": subscription_id,
|
|
"sag_id": int(created_case["id"]),
|
|
},
|
|
)
|
|
return {
|
|
"sag": created_case,
|
|
"subscription": subscription,
|
|
"connection": connection,
|
|
"allocated_range_id": int(payload.range_id) if payload.range_id else None,
|
|
"allocated_ip_address_id": int(selected_ip_address["id"]) if selected_ip_address else None,
|
|
}
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/ip-ranges")
|
|
async def list_ip_ranges(connection_id: int):
|
|
try:
|
|
connection_rows = execute_query(
|
|
_connection_select_sql(" AND ic.id = %s "),
|
|
(connection_id,),
|
|
) or []
|
|
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
|
|
range_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
ir.*,
|
|
c.name AS customer_name
|
|
FROM internet_connections_ip_ranges ir
|
|
LEFT JOIN customers c ON c.id = ir.customer_id
|
|
WHERE ir.connection_id = %s AND ir.deleted_at IS NULL
|
|
ORDER BY id ASC
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
address_rows = execute_query(
|
|
"""
|
|
SELECT ipa.id, ipa.range_id, ipa.ip_address, ipa.status
|
|
FROM internet_connections_ip_addresses ipa
|
|
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
|
|
WHERE ir.connection_id = %s AND ipa.deleted_at IS NULL
|
|
ORDER BY ipa.ip_address ASC
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
except Exception as exc:
|
|
logger.warning("Failed to load IP ranges: %s", exc)
|
|
return []
|
|
|
|
address_map: dict[int, list[dict]] = {}
|
|
for address in address_rows:
|
|
address_map.setdefault(int(address.get("range_id")), []).append(address)
|
|
|
|
payload = []
|
|
for row in range_rows:
|
|
item = _build_ip_range_payload(dict(row), address_map.get(int(row.get("id")), []))
|
|
if connection:
|
|
item.update(_range_alignment_for_connection(connection, item))
|
|
payload.append(item)
|
|
return payload
|
|
|
|
|
|
@router.post("/internet-connections/{connection_id}/ip-ranges")
|
|
async def create_ip_range(connection_id: int, payload: IpRangeCreatePayload):
|
|
cidr = payload.cidr
|
|
if not cidr:
|
|
raise HTTPException(status_code=400, detail="CIDR is required")
|
|
network = _validate_cidr(str(cidr))
|
|
|
|
duplicate_range = execute_query(
|
|
"""
|
|
SELECT ir.id, ir.connection_id, ir.cidr, ic.name AS connection_name
|
|
FROM internet_connections_ip_ranges ir
|
|
LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
|
|
WHERE ir.cidr = %s
|
|
AND ir.deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(str(network),),
|
|
) or []
|
|
if duplicate_range:
|
|
row = duplicate_range[0]
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"IP-range {row.get('cidr')} findes allerede på forbindelse {row.get('connection_name') or row.get('connection_id')}",
|
|
)
|
|
|
|
host_ips = [str(host) for host in network.hosts()]
|
|
if host_ips:
|
|
conflicting_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
ipa.ip_address,
|
|
ir.cidr,
|
|
ic.id AS connection_id,
|
|
ic.name AS connection_name
|
|
FROM internet_connections_ip_addresses ipa
|
|
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
|
|
LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
|
|
WHERE ipa.deleted_at IS NULL
|
|
AND ipa.ip_address = ANY(%s)
|
|
ORDER BY ipa.ip_address ASC
|
|
LIMIT 5
|
|
""",
|
|
(host_ips,),
|
|
) or []
|
|
if conflicting_rows:
|
|
examples = ", ".join(
|
|
f"{row.get('ip_address')} ({row.get('connection_name') or row.get('connection_id')} / {row.get('cidr')})"
|
|
for row in conflicting_rows
|
|
)
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"IP-range {network} overlapper eksisterende IP-adresser: {examples}",
|
|
)
|
|
|
|
try:
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_ip_ranges (
|
|
connection_id, name, cidr, description, provider_reference,
|
|
contract_number, customer_id, service_address, monthly_cost, sales_price
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
connection_id,
|
|
payload.name,
|
|
str(network),
|
|
payload.description,
|
|
payload.provider_reference,
|
|
payload.contract_number,
|
|
payload.customer_id,
|
|
payload.service_address,
|
|
payload.monthly_cost,
|
|
payload.sales_price,
|
|
),
|
|
) or []
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Failed to create IP range on connection %s", connection_id)
|
|
raise HTTPException(status_code=500, detail=f"Kunne ikke oprette IP-range: {exc}") from exc
|
|
if not rows:
|
|
raise HTTPException(status_code=500, detail="Failed to create IP range")
|
|
|
|
range_row = dict(rows[0])
|
|
try:
|
|
_create_ip_addresses_for_range(int(range_row["id"]), str(network))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Failed to auto-create IP addresses for range %s", range_row.get("id"))
|
|
execute_query(
|
|
"""
|
|
UPDATE internet_connections_ip_ranges
|
|
SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
""",
|
|
(range_row["id"],),
|
|
)
|
|
raise HTTPException(status_code=500, detail=f"IP-range blev oprettet men IP-adresser kunne ikke genereres: {exc}") from exc
|
|
|
|
_create_history_entry(
|
|
connection_id,
|
|
"ip_range_created",
|
|
f"Oprettede IP-range {payload.name}",
|
|
{
|
|
"range_name": payload.name,
|
|
"cidr": payload.cidr,
|
|
"provider_reference": payload.provider_reference,
|
|
"contract_number": payload.contract_number,
|
|
"customer_id": payload.customer_id,
|
|
},
|
|
)
|
|
return range_row
|
|
|
|
|
|
@router.put("/internet-connections/{connection_id}/ip-ranges/{range_id}")
|
|
async def update_ip_range(connection_id: int, range_id: int, payload: IpRangeUpdatePayload):
|
|
existing_rows = execute_query(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_ip_ranges
|
|
WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(range_id, connection_id),
|
|
) or []
|
|
if not existing_rows:
|
|
raise HTTPException(status_code=404, detail="IP-range not found")
|
|
|
|
existing = dict(existing_rows[0])
|
|
update_values = payload.model_dump(exclude_unset=True)
|
|
if not update_values:
|
|
return _build_ip_range_payload(existing)
|
|
|
|
merged = dict(existing)
|
|
merged.update(update_values)
|
|
merged["name"] = str(merged.get("name") or "").strip() or str(existing.get("cidr") or "")
|
|
merged["description"] = str(merged.get("description") or "").strip() or None
|
|
merged["provider_reference"] = str(merged.get("provider_reference") or "").strip() or None
|
|
merged["contract_number"] = str(merged.get("contract_number") or "").strip() or None
|
|
merged["service_address"] = str(merged.get("service_address") or "").strip() or None
|
|
|
|
if not merged["name"]:
|
|
raise HTTPException(status_code=400, detail="Navn er påkrævet")
|
|
|
|
changed = {}
|
|
fields = [
|
|
"name",
|
|
"description",
|
|
"provider_reference",
|
|
"contract_number",
|
|
"customer_id",
|
|
"service_address",
|
|
"monthly_cost",
|
|
"sales_price",
|
|
]
|
|
params: list[object] = []
|
|
set_parts: list[str] = []
|
|
for field in fields:
|
|
value = merged.get(field)
|
|
set_parts.append(f"{field} = %s")
|
|
params.append(value)
|
|
if existing.get(field) != value:
|
|
changed[field] = value
|
|
|
|
try:
|
|
rows = execute_query(
|
|
f"""
|
|
UPDATE internet_connections_ip_ranges
|
|
SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
|
|
RETURNING *
|
|
""",
|
|
tuple(params + [range_id, connection_id]),
|
|
) or []
|
|
except Exception as exc:
|
|
logger.exception("Failed to update IP range %s on connection %s", range_id, connection_id)
|
|
raise HTTPException(status_code=500, detail=f"Kunne ikke gemme IP-range: {exc}") from exc
|
|
|
|
if not rows:
|
|
raise HTTPException(status_code=500, detail="Failed to update IP range")
|
|
|
|
if changed:
|
|
_create_history_entry(
|
|
connection_id,
|
|
"ip_range_updated",
|
|
f"Opdaterede IP-range {rows[0].get('name') or rows[0].get('cidr')}",
|
|
{
|
|
"range_id": range_id,
|
|
"cidr": rows[0].get("cidr"),
|
|
"changes": changed,
|
|
},
|
|
)
|
|
|
|
address_rows = execute_query(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_ip_addresses
|
|
WHERE range_id = %s AND deleted_at IS NULL
|
|
ORDER BY ip_address ASC
|
|
""",
|
|
(range_id,),
|
|
) or []
|
|
return _build_ip_range_payload(dict(rows[0]), [dict(row) for row in address_rows])
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/ip-addresses")
|
|
async def list_ip_addresses(connection_id: int):
|
|
connection_rows = execute_query(
|
|
_connection_select_sql(" AND ic.id = %s "),
|
|
(connection_id,),
|
|
) or []
|
|
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
|
|
|
|
where_sql = "ir.connection_id = %s"
|
|
params: tuple[object, ...]
|
|
if connection and connection.get("is_shared_head"):
|
|
where_sql = """
|
|
(
|
|
ir.connection_id = %s
|
|
OR ir.connection_id IN (
|
|
SELECT child.id
|
|
FROM internet_connections_connections child
|
|
WHERE child.parent_id = %s
|
|
AND child.deleted_at IS NULL
|
|
)
|
|
)
|
|
"""
|
|
params = (connection_id, connection_id)
|
|
else:
|
|
params = (connection_id,)
|
|
|
|
rows = execute_query(
|
|
f"""
|
|
SELECT
|
|
ipa.*,
|
|
ir.name AS range_name,
|
|
ir.cidr AS range_cidr,
|
|
ir.connection_id AS range_connection_id,
|
|
ir.service_address AS range_service_address,
|
|
ir.customer_id AS range_customer_id,
|
|
rc.name AS range_customer_name,
|
|
c.name AS assigned_customer_name,
|
|
ic.name AS assigned_connection_name,
|
|
owner.name AS range_connection_name,
|
|
sub.id AS assigned_subscription_id,
|
|
sub.subscription_number AS assigned_subscription_number,
|
|
sub.sag_id AS assigned_subscription_sag_id
|
|
FROM internet_connections_ip_addresses ipa
|
|
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
|
|
LEFT JOIN customers rc ON rc.id = ir.customer_id
|
|
LEFT JOIN customers c ON c.id = ipa.assigned_customer_id
|
|
LEFT JOIN internet_connections_connections ic ON ic.id = ipa.assigned_connection_id
|
|
LEFT JOIN internet_connections_connections owner ON owner.id = ir.connection_id
|
|
LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
|
|
WHERE {where_sql} AND ipa.deleted_at IS NULL
|
|
ORDER BY ipa.ip_address ASC
|
|
""",
|
|
params,
|
|
) or []
|
|
payload = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
if connection:
|
|
item.update(
|
|
_range_alignment_for_connection(
|
|
connection,
|
|
{
|
|
"service_address": item.get("range_service_address"),
|
|
"customer_id": item.get("range_customer_id"),
|
|
"customer_name": item.get("range_customer_name"),
|
|
},
|
|
)
|
|
)
|
|
status = str(item.get("status", "available")).lower()
|
|
if status == "in_use":
|
|
item["status_label"] = "I brug"
|
|
item["badge_class"] = "bg-primary"
|
|
elif status == "reserved":
|
|
item["status_label"] = "Reserveret"
|
|
item["badge_class"] = "bg-warning"
|
|
else:
|
|
item["status_label"] = "Tilgængelig"
|
|
item["badge_class"] = "bg-success"
|
|
payload.append(item)
|
|
return payload
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/ip-addresses/summary")
|
|
async def summarize_ip_addresses(connection_id: int):
|
|
connection_rows = execute_query(
|
|
_connection_select_sql(" AND ic.id = %s "),
|
|
(connection_id,),
|
|
) or []
|
|
connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
|
|
|
|
where_sql = "ir.connection_id = %s"
|
|
params: tuple[object, ...]
|
|
if connection and connection.get("is_shared_head"):
|
|
where_sql = """
|
|
(
|
|
ir.connection_id = %s
|
|
OR ir.connection_id IN (
|
|
SELECT child.id
|
|
FROM internet_connections_connections child
|
|
WHERE child.parent_id = %s
|
|
AND child.deleted_at IS NULL
|
|
)
|
|
)
|
|
"""
|
|
params = (connection_id, connection_id)
|
|
else:
|
|
params = (connection_id,)
|
|
|
|
rows = execute_query(
|
|
f"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'available') AS available,
|
|
COUNT(*) FILTER (WHERE status = 'in_use') AS in_use,
|
|
COUNT(*) FILTER (WHERE status = 'reserved') AS reserved
|
|
FROM internet_connections_ip_addresses ipa
|
|
JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
|
|
WHERE {where_sql} AND ipa.deleted_at IS NULL
|
|
""",
|
|
params,
|
|
) or []
|
|
if not rows:
|
|
return {"available": 0, "in_use": 0, "reserved": 0}
|
|
return dict(rows[0])
|
|
|
|
|
|
@router.post("/internet-connections/{connection_id}/ip-addresses")
|
|
async def create_ip_address(connection_id: int, payload: IpAddressCreatePayload):
|
|
normalized_ip = _normalize_ip_address(payload.ip_address)
|
|
duplicate = execute_query(
|
|
"""
|
|
SELECT id, range_id
|
|
FROM internet_connections_ip_addresses
|
|
WHERE ip_address = %s
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(normalized_ip,),
|
|
) or []
|
|
if duplicate:
|
|
raise HTTPException(status_code=409, detail=f"IP-adressen {normalized_ip} findes allerede")
|
|
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_ip_addresses (
|
|
range_id, ip_address, status, assigned_to, assigned_type,
|
|
assigned_customer_id, assigned_connection_id, comment
|
|
)
|
|
SELECT ir.id, %s, %s, %s, %s, %s, %s, %s
|
|
FROM internet_connections_ip_ranges ir
|
|
WHERE ir.connection_id = %s AND ir.id = %s AND ir.deleted_at IS NULL
|
|
RETURNING *
|
|
""",
|
|
(
|
|
normalized_ip,
|
|
payload.status,
|
|
payload.assigned_to,
|
|
payload.assigned_type,
|
|
payload.assigned_customer_id,
|
|
payload.assigned_connection_id,
|
|
payload.comment,
|
|
connection_id,
|
|
payload.range_id,
|
|
),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="No IP range available for this connection")
|
|
|
|
_create_history_entry(
|
|
connection_id,
|
|
"ip_address_created",
|
|
f"Oprettede IP-adresse {normalized_ip}",
|
|
{
|
|
"ip_address": normalized_ip,
|
|
"status": payload.status,
|
|
"assigned_customer_id": payload.assigned_customer_id,
|
|
"assigned_connection_id": payload.assigned_connection_id,
|
|
},
|
|
)
|
|
return dict(rows[0])
|
|
|
|
|
|
@router.put("/internet-connections/{connection_id}/ip-addresses/{address_id}")
|
|
async def update_ip_address(connection_id: int, address_id: int, payload: IpAddressUpdatePayload):
|
|
rows = execute_query(
|
|
"""
|
|
UPDATE internet_connections_ip_addresses ipa
|
|
SET status = %s,
|
|
assigned_to = %s,
|
|
assigned_type = %s,
|
|
assigned_customer_id = %s,
|
|
assigned_connection_id = %s,
|
|
comment = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
FROM internet_connections_ip_ranges ir
|
|
WHERE ipa.range_id = ir.id
|
|
AND ir.connection_id = %s
|
|
AND ipa.id = %s
|
|
AND ipa.deleted_at IS NULL
|
|
RETURNING ipa.*
|
|
""",
|
|
(
|
|
payload.status,
|
|
payload.assigned_to,
|
|
payload.assigned_type,
|
|
payload.assigned_customer_id,
|
|
payload.assigned_connection_id,
|
|
payload.comment,
|
|
connection_id,
|
|
address_id,
|
|
),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="IP address not found")
|
|
|
|
item = dict(rows[0])
|
|
_create_history_entry(
|
|
connection_id,
|
|
"ip_address_updated",
|
|
f"Opdaterede IP-adresse {item.get('ip_address')}",
|
|
{
|
|
"ip_address": item.get("ip_address"),
|
|
"status": item.get("status"),
|
|
"assigned_customer_id": item.get("assigned_customer_id"),
|
|
"assigned_connection_id": item.get("assigned_connection_id"),
|
|
},
|
|
)
|
|
return item
|
|
|
|
|
|
@router.post("/internet-connections/{connection_id}/pricing", response_model=dict)
|
|
async def create_connection_pricing(connection_id: int, payload: PricingCreatePayload):
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO internet_connections_pricing (
|
|
connection_id, effective_from, purchase_price, sales_price, notes
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(connection_id, payload.effective_from, payload.purchase_price, payload.sales_price, payload.notes),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=500, detail="Failed to create pricing entry")
|
|
|
|
execute_query(
|
|
"""
|
|
UPDATE internet_connections_connections
|
|
SET monthly_cost = %s, sales_price = %s, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
""",
|
|
(payload.purchase_price, payload.sales_price, connection_id),
|
|
)
|
|
|
|
_create_history_entry(
|
|
connection_id,
|
|
"pricing_updated",
|
|
f"Opdaterede pris til {payload.sales_price} kr.",
|
|
{"effective_from": payload.effective_from.isoformat(), "purchase_price": payload.purchase_price, "sales_price": payload.sales_price},
|
|
)
|
|
return dict(rows[0])
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/pricing")
|
|
async def get_connection_pricing(connection_id: int):
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
monthly_cost,
|
|
sales_price,
|
|
connection_type,
|
|
speed_mbps,
|
|
upload_mbps,
|
|
download_mbps,
|
|
contract_start,
|
|
contract_end,
|
|
status
|
|
FROM internet_connections_connections
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
|
|
payload = dict(rows[0])
|
|
payload["contract_status"] = _get_contract_status(payload.get("contract_end"))
|
|
return payload
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/pricing/history")
|
|
async def list_pricing_history(connection_id: int):
|
|
rows = execute_query(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_pricing
|
|
WHERE connection_id = %s
|
|
ORDER BY effective_from DESC, created_at DESC
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
@router.get("/internet-connections/{connection_id}/history")
|
|
async def list_history(connection_id: int):
|
|
rows = execute_query(
|
|
"""
|
|
SELECT *
|
|
FROM internet_connections_history
|
|
WHERE connection_id = %s
|
|
ORDER BY created_at DESC
|
|
""",
|
|
(connection_id,),
|
|
) or []
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
@router.get("/internet-connections/customer-documents")
|
|
async def list_customer_documents(customer_id: Optional[int] = Query(None, ge=1)):
|
|
if customer_id:
|
|
customer = execute_query_single(
|
|
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
|
|
(customer_id,),
|
|
)
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
payload = await _build_customer_document_hits(int(customer["id"]), str(customer["name"] or ""), None)
|
|
return {
|
|
"customer": {"id": int(customer["id"]), "name": customer["name"]},
|
|
"documents": payload["documents"],
|
|
"segments": payload["segments"],
|
|
}
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
|
|
FROM internet_connections_customer_documents
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 100
|
|
"""
|
|
) or []
|
|
documents = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
|
|
item.pop("extracted_text", None)
|
|
documents.append(item)
|
|
return {"documents": documents, "customer": None}
|
|
|
|
|
|
@router.post("/internet-connections/customer-documents/upload")
|
|
async def upload_customer_document(
|
|
customer_id: Optional[int] = Form(None),
|
|
connection_id: Optional[int] = Form(None),
|
|
notes: Optional[str] = Form(None),
|
|
file: UploadFile = File(...),
|
|
):
|
|
customer = None
|
|
if customer_id:
|
|
customer = execute_query_single(
|
|
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
|
|
(customer_id,),
|
|
)
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
if connection_id:
|
|
connection = execute_query_single(
|
|
"""
|
|
SELECT id, customer_id
|
|
FROM internet_connections_connections
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
""",
|
|
(connection_id,),
|
|
)
|
|
if not connection:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
if customer_id and connection.get("customer_id") not in (None, customer_id):
|
|
raise HTTPException(status_code=400, detail="Connection belongs to a different customer")
|
|
|
|
original_filename = str(file.filename or "").strip()
|
|
suffix = Path(original_filename).suffix.lower()
|
|
if suffix not in INTERNET_CUSTOMER_DOC_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Kun tekstfiler er tilladt ({', '.join(sorted(INTERNET_CUSTOMER_DOC_EXTENSIONS))})",
|
|
)
|
|
|
|
upload_dir = _internet_customer_doc_dir()
|
|
safe_name = _sanitize_upload_name(original_filename)
|
|
customer_prefix = str(customer_id) if customer_id else "shared"
|
|
stored_name = f"{customer_prefix}_{int(date.today().strftime('%Y%m%d'))}_{safe_name}"
|
|
target = upload_dir / stored_name
|
|
|
|
total_size = 0
|
|
max_size = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
try:
|
|
with open(target, "wb") as handle:
|
|
while chunk := await file.read(8192):
|
|
total_size += len(chunk)
|
|
if total_size > max_size:
|
|
target.unlink(missing_ok=True)
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Fil for stor (max {settings.EMAIL_MAX_UPLOAD_SIZE_MB} MB)",
|
|
)
|
|
handle.write(chunk)
|
|
|
|
checksum = ollama_service.calculate_file_checksum(target)
|
|
existing = execute_query_single(
|
|
"""
|
|
SELECT id
|
|
FROM internet_connections_customer_documents
|
|
WHERE customer_id = %s
|
|
AND checksum = %s
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(customer_id, checksum),
|
|
)
|
|
if existing and customer_id:
|
|
target.unlink(missing_ok=True)
|
|
return {
|
|
"status": "duplicate",
|
|
"document_id": int(existing["id"]),
|
|
"message": "Filen er allerede uploadet paa denne kunde",
|
|
}
|
|
if not customer_id:
|
|
existing_shared = execute_query_single(
|
|
"""
|
|
SELECT id
|
|
FROM internet_connections_customer_documents
|
|
WHERE customer_id IS NULL
|
|
AND checksum = %s
|
|
AND deleted_at IS NULL
|
|
LIMIT 1
|
|
""",
|
|
(checksum,),
|
|
)
|
|
if existing_shared:
|
|
target.unlink(missing_ok=True)
|
|
return {
|
|
"status": "duplicate",
|
|
"document_id": int(existing_shared["id"]),
|
|
"message": "Filen er allerede uploadet i det delte arkiv",
|
|
}
|
|
|
|
extracted_text = await ollama_service._extract_text_from_file(target)
|
|
mime_type = ollama_service._get_mime_type(target)
|
|
document_id = execute_insert(
|
|
"""
|
|
INSERT INTO internet_connections_customer_documents (
|
|
customer_id,
|
|
connection_id,
|
|
filename,
|
|
original_filename,
|
|
file_path,
|
|
file_size,
|
|
mime_type,
|
|
checksum,
|
|
extracted_text,
|
|
notes
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
customer_id,
|
|
connection_id,
|
|
stored_name,
|
|
original_filename,
|
|
str(target),
|
|
total_size,
|
|
mime_type,
|
|
checksum,
|
|
extracted_text,
|
|
str(notes or "").strip() or None,
|
|
),
|
|
)
|
|
segment_count = _index_customer_document_segments(int(document_id), extracted_text)
|
|
return {
|
|
"status": "uploaded",
|
|
"document_id": int(document_id),
|
|
"filename": original_filename,
|
|
"segment_count": segment_count,
|
|
"customer": {"id": int(customer["id"]), "name": customer["name"]} if customer else None,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
target.unlink(missing_ok=True)
|
|
logger.exception("Customer document upload failed")
|
|
raise HTTPException(status_code=500, detail=f"Upload failed: {exc}") from exc
|
|
|
|
|
|
@router.get("/internet-connections/migration-wizard-v2/context")
|
|
async def get_migration_wizard_v2_context(
|
|
customer_id: Optional[int] = Query(None, ge=1),
|
|
query: Optional[str] = Query(None),
|
|
):
|
|
customer = None
|
|
customer_name = ""
|
|
if customer_id:
|
|
customer = execute_query_single(
|
|
"""
|
|
SELECT id, name
|
|
FROM customers
|
|
WHERE id = %s
|
|
AND is_active = true
|
|
""",
|
|
(customer_id,),
|
|
)
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
customer_name = str(customer.get("name") or "")
|
|
|
|
if customer_id:
|
|
document_payload = await _build_customer_document_hits(customer_id, customer_name, query)
|
|
invoice_hits = await _build_invoice_hits(customer_id, customer_name, query)
|
|
else:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
|
|
FROM internet_connections_customer_documents
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 50
|
|
"""
|
|
) or []
|
|
documents = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
|
|
item.pop("extracted_text", None)
|
|
documents.append(item)
|
|
segment_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
seg.id AS segment_id,
|
|
seg.document_id,
|
|
seg.block_index,
|
|
seg.block_title AS title,
|
|
seg.content,
|
|
seg.ip_addresses,
|
|
seg.cidr_blocks,
|
|
seg.references_json AS references,
|
|
seg.socket_numbers
|
|
FROM internet_connections_customer_document_segments seg
|
|
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
|
|
WHERE doc.deleted_at IS NULL
|
|
ORDER BY doc.created_at DESC, seg.block_index ASC
|
|
LIMIT 30
|
|
"""
|
|
) or []
|
|
segment_hits = []
|
|
for row in segment_rows:
|
|
snippet = re.sub(r"\s+", " ", str(row.get("content") or "")[:240]).strip()
|
|
segment_hits.append(
|
|
{
|
|
"segment_id": int(row["segment_id"]),
|
|
"document_id": int(row["document_id"]),
|
|
"block_index": int(row.get("block_index") or 0),
|
|
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
|
|
"score": 0,
|
|
"snippet": snippet,
|
|
"ip_addresses": [str(item) for item in (row.get("ip_addresses") or [])],
|
|
"cidr_blocks": [str(item) for item in (row.get("cidr_blocks") or [])],
|
|
"references": [str(item) for item in (row.get("references") or [])],
|
|
"socket_numbers": [str(item) for item in (row.get("socket_numbers") or [])],
|
|
}
|
|
)
|
|
document_payload = {"documents": documents, "snippets": [], "segments": segment_hits}
|
|
invoice_hits = []
|
|
|
|
summary_source_parts = []
|
|
if document_payload["snippets"]:
|
|
summary_source_parts.append("Kundefiler:\n" + "\n".join(document_payload["snippets"][:8]))
|
|
if invoice_hits:
|
|
invoice_lines = []
|
|
for item in invoice_hits[:5]:
|
|
base = f"{item['invoice_date']} {item['vendor_name']} {item['invoice_number']}"
|
|
if item["snippets"]:
|
|
invoice_lines.append(base + ": " + " | ".join(item["snippets"]))
|
|
else:
|
|
invoice_lines.append(base)
|
|
summary_source_parts.append("Fakturaer:\n" + "\n".join(invoice_lines))
|
|
|
|
ai_summary = ""
|
|
if summary_source_parts:
|
|
summary_input = (
|
|
f"Kunde: {customer_name}\n"
|
|
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n"
|
|
+ "\n\n".join(summary_source_parts)
|
|
)
|
|
ai_summary = await ollama_service.generate_summary(summary_input)
|
|
|
|
return {
|
|
"customer": {"id": int(customer["id"]), "name": customer_name} if customer else None,
|
|
"query": query,
|
|
"documents": document_payload["documents"],
|
|
"document_snippets": document_payload["snippets"],
|
|
"segment_hits": document_payload["segments"],
|
|
"invoice_hits": invoice_hits,
|
|
"ai_summary": ai_summary,
|
|
}
|