2026-07-09 23:44:30 +02:00
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 :
2026-07-17 01:58:02 +02:00
# A block is only relevant when it contains every word from the user's
# search. A query such as "sales management" must not return a block
# containing just one of the two words.
all_query_terms_match = all (
term in _normalize_text_for_match ( searchable )
for term in terms [ " query_terms " ]
)
if not all_query_terms_match :
2026-07-09 23:44:30 +02:00
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 "
2026-07-17 01:58:02 +02:00
f " Sporgsmaal: { query or ' Vis relevant historik om internetforbindelser, adresser og gamle noter ' } \n "
" VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, "
" eller skriv eksplicit at ingen WAN IP-adresse blev fundet. \n \n "
2026-07-09 23:44:30 +02:00
+ " \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 ,
}
2026-07-17 01:58:02 +02:00
@router.get ( " /internet-connections/customer-documents/segments/ {segment_id} " )
async def get_customer_document_segment ( segment_id : int ) :
""" Return the complete, indexed text block for the migration wizard. """
row = execute_query_single (
"""
SELECT
seg . id AS segment_id ,
seg . document_id ,
seg . block_index ,
seg . block_title AS title ,
seg . content ,
doc . original_filename
FROM internet_connections_customer_document_segments seg
JOIN internet_connections_customer_documents doc ON doc . id = seg . document_id
WHERE seg . id = % s
AND doc . deleted_at IS NULL
""" ,
( segment_id , ) ,
)
if not row :
raise HTTPException ( status_code = 404 , detail = " Text block not found " )
return {
" segment_id " : int ( row [ " segment_id " ] ) ,
" document_id " : int ( row [ " document_id " ] ) ,
" block_index " : int ( row . get ( " block_index " ) or 0 ) ,
" title " : row . get ( " title " ) or f " Blok { int ( row . get ( ' block_index ' ) or 0 ) + 1 } " ,
" original_filename " : row . get ( " original_filename " ) or " Tekstfil " ,
" content " : str ( row . get ( " content " ) or " " ) ,
}