feat: add locations outlet and cross-field management

This commit is contained in:
Christian 2026-07-17 10:23:57 +02:00
parent 0655b4c4f8
commit d4d9ac22a5
20 changed files with 976 additions and 47 deletions

View File

@ -370,7 +370,7 @@ async def create_hardware(data: dict):
try: try:
query = """ query = """
INSERT INTO hardware_assets ( INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id, asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life, status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link, anydesk_id, anydesk_link,
@ -378,7 +378,7 @@ async def create_hardware(data: dict):
rental_default_start_price, rental_default_freight_price, rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price rental_default_preparation_price, rental_default_operations_monthly_price
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
@ -392,6 +392,7 @@ async def create_hardware(data: dict):
data.get("model"), data.get("model"),
data.get("serial_number"), data.get("serial_number"),
data.get("customer_asset_id"), data.get("customer_asset_id"),
data.get("current_location_id"),
data.get("internal_asset_id"), data.get("internal_asset_id"),
data.get("notes"), data.get("notes"),
data.get("current_owner_type", "bmc"), data.get("current_owner_type", "bmc"),
@ -1172,4 +1173,3 @@ async def list_eset_incidents(
""" """
result = execute_query(query, (severity_list, limit)) result = execute_query(query, (severity_list, limit))
return result or [] return result or []

View File

@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import (
Service, ServiceCreate, ServiceUpdate, Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate, Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats, BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse LocationWizardCreateRequest, LocationWizardCreateResponse,
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
) )
router = APIRouter() router = APIRouter()
@ -164,15 +165,21 @@ async def create_location(request: Request):
logger.warning("⚠️ Invalid location payload") logger.warning("⚠️ Invalid location payload")
raise HTTPException(status_code=422, detail=e.errors()) raise HTTPException(status_code=422, detail=e.errors())
# Check for duplicate name # Names only need to be unique within the same customer and hierarchy.
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (data.name,)) SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
if existing: if existing:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name: {data.name}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location with name '{data.name}' already exists" detail=f"Location with name '{data.name}' already exists under the same customer/location"
) )
if data.customer_id is not None: if data.customer_id is not None:
@ -201,9 +208,9 @@ async def create_location(request: Request):
INSERT INTO locations_locations ( INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city, name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude, address_postal_code, address_country, latitude, longitude,
phone, email, notes, is_active, created_at, updated_at phone, email, notes, is_active, has_cross_field, created_at, updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING * RETURNING *
""" """
@ -221,7 +228,8 @@ async def create_location(request: Request):
data.phone, data.phone,
data.email, data.email,
data.notes, data.notes,
data.is_active data.is_active,
data.has_cross_field if data.location_type == 'rum' else False
) )
result = execute_query(insert_query, params) result = execute_query(insert_query, params)
@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
def _normalize_name(value: str) -> str: def _normalize_name(value: str) -> str:
return (value or "").strip().lower() return (value or "").strip().lower()
def _name_exists(value: str) -> bool: def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
normalized = _normalize_name(value) normalized = _normalize_name(value)
if normalized in reserved_names: scope_key = (normalized, parent_location_id, customer_id)
if scope_key in reserved_names:
return True return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (value,)) SELECT 1 FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (value, parent_location_id, customer_id))
return bool(existing) return bool(existing)
def _reserve_name(value: str) -> None: def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
normalized = _normalize_name(value) normalized = _normalize_name(value)
if normalized: if normalized:
reserved_names.add(normalized) reserved_names.add((normalized, parent_location_id, customer_id))
def _resolve_unique_name(base_name: str) -> str: def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
if not auto_suffix: if not auto_suffix:
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
base_name = base_name.strip() base_name = base_name.strip()
if not _name_exists(base_name): if not _name_exists(base_name, parent_location_id, customer_id):
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
suffix = 2 suffix = 2
while True: while True:
candidate = f"{base_name} ({suffix})" candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate): if not _name_exists(candidate, parent_location_id, customer_id):
_reserve_name(candidate) _reserve_name(candidate, parent_location_id, customer_id)
return candidate return candidate
suffix += 1 suffix += 1
@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
raise HTTPException(status_code=500, detail="Failed to create location") raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0]) return Location(**result[0])
resolved_root_name = _resolve_unique_name(root.name) resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
root_location = insert_location_record( root_location = insert_location_record(
name=resolved_root_name, name=resolved_root_name,
location_type=root.location_type, location_type=root.location_type,
@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
room_ids: List[int] = [] room_ids: List[int] = []
for floor in data.floors: for floor in data.floors:
resolved_floor_name = _resolve_unique_name(floor.name) resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
floor_location = insert_location_record( floor_location = insert_location_record(
name=resolved_floor_name, name=resolved_floor_name,
location_type=floor.location_type, location_type=floor.location_type,
@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
)) ))
for room in floor.rooms: for room in floor.rooms:
resolved_room_name = _resolve_unique_name(room.name) resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
room_location = insert_location_record( room_location = insert_location_record(
name=resolved_room_name, name=resolved_room_name,
location_type=room.location_type, location_type=room.location_type,
@ -594,6 +609,187 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
# 3. GET /api/v1/locations/{id} - Get single location with all relationships # 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================ # ============================================================================
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
@router.get('/locations/cross-fields', response_model=List[CrossField])
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE'
params: tuple = ()
if location_id:
where += ' AND cf.location_id = %s'
params = (location_id,)
fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.name''', params) or []
for field in fields:
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
return [CrossField(**field) for field in fields]
@router.get('/locations/cross-field-ports')
async def list_cross_field_ports():
return execute_query('''
SELECT p.id, p.port_number, cf.name AS cross_field_name,
l.name AS location_name, l.id AS location_id
FROM locations_cross_field_ports p
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
JOIN locations_locations l ON l.id = cf.location_id
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL
ORDER BY l.name, cf.name, p.port_number
''') or []
@router.post('/locations/cross-fields', response_model=CrossField, status_code=201)
async def create_cross_field(data: CrossFieldCreate):
location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or []
if not location:
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt')
try:
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, notes) VALUES (%s, %s, %s, %s) RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.notes)) or []
if not created:
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
field = created[0]
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(1, %s)''', (field['id'], data.port_count))
except HTTPException:
raise
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
return CrossField(**field)
@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField)
async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate):
field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or []
if not field_rows:
raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet')
field = field_rows[0]
changes = data.model_dump(exclude_unset=True)
requested_ports = changes.pop('port_count', None)
if requested_ports is not None and requested_ports < field['port_count']:
raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt')
if changes:
assignments = ', '.join(f'{column} = %s' for column in changes)
try:
updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or []
field = updated[0]
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
if requested_ports and requested_ports > field['port_count']:
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(%s, %s)''', (cross_field_id, field['port_count'] + 1, requested_ports))
field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0]
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (cross_field_id,)) or []
return CrossField(**field)
def _outlet_location(location_id: int) -> dict:
rows = execute_query(
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
(location_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
location = dict(rows[0])
if location.get('location_type') not in _OUTLET_LOCATION_TYPES:
raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum")
return location
_OUTLET_SELECT = """
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE id = l.id
UNION ALL
SELECT parent.id, parent.name, parent.parent_location_id,
parent.name || ' > ' || ancestors.hierarchy_path
FROM locations_locations parent
JOIN ancestors ON ancestors.parent_location_id = parent.id
)
SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1
) path ON TRUE
"""
@router.get('/locations/outlets', response_model=List[WallOutlet])
async def list_wall_outlets(
q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1),
status: Optional[str] = Query(None), include_inactive: bool = Query(False),
):
where = ['o.deleted_at IS NULL']
params: List[Any] = []
if not include_inactive:
where.append('o.is_active = TRUE')
if location_id:
where.append('o.location_id = %s')
params.append(location_id)
if status:
where.append('o.status = %s')
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f'%{q.strip()}%'] * 7)
rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or []
return [WallOutlet(**row) for row in rows]
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id)
try:
rows = execute_query(
"""INSERT INTO locations_wall_outlets
(location_id, outlet_number, category, patch_panel, patch_port, cross_field_port_id, switch_name, switch_port, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, data.outlet_number.strip(), data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
outlet_id = rows[0]['id']
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
changes = data.model_dump(exclude_unset=True)
if not changes:
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
if 'outlet_number' in changes:
changes['outlet_number'] = changes['outlet_number'].strip()
fields = ', '.join(f'{field} = %s' for field in changes)
try:
rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.delete('/locations/outlets/{outlet_id}')
async def delete_wall_outlet(outlet_id: int):
rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
return {'status': 'deleted', 'id': outlet_id}
@router.get("/locations/{id}", response_model=LocationDetail) @router.get("/locations/{id}", response_model=LocationDetail)
async def get_location(id: int): async def get_location(id: int):
""" """
@ -645,6 +841,12 @@ async def get_location(id: int):
capacity_result = execute_query(capacity_query, (id,)) capacity_result = execute_query(capacity_query, (id,))
capacity = [dict(row) for row in capacity_result] if capacity_result else [] capacity = [dict(row) for row in capacity_result] if capacity_result else []
outlet_result = execute_query(
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
(id,),
)
wall_outlets = [dict(row) for row in (outlet_result or [])]
# Build hierarchy breadcrumb (ancestors from root to parent) # Build hierarchy breadcrumb (ancestors from root to parent)
hierarchy_query = """ hierarchy_query = """
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
@ -693,7 +895,8 @@ async def get_location(id: int):
contacts=contacts, contacts=contacts,
hours=hours, hours=hours,
services=services, services=services,
capacity=capacity capacity=capacity,
wall_outlets=wall_outlets,
) )
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})") logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
@ -740,15 +943,26 @@ async def update_location(id: int, data: LocationUpdate):
old_location = Location(**existing[0]) old_location = Location(**existing[0])
# Check for duplicate name if name is being updated # Check the resulting name/customer/parent scope, including when only
if data.name is not None and data.name != old_location.name: # customer or parent is changed.
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL" if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
dup_check = execute_query(dup_query, (data.name, id)) candidate_name = data.name if data.name is not None else old_location.name
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
dup_query = """
SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND id != %s
AND deleted_at IS NULL
"""
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
if dup_check: if dup_check:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location with name '{data.name}' already exists" detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
) )
# Build UPDATE query with only provided fields # Build UPDATE query with only provided fields
@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate):
'phone': 'phone', 'phone': 'phone',
'email': 'email', 'email': 'email',
'notes': 'notes', 'notes': 'notes',
'is_active': 'is_active' 'is_active': 'is_active',
'has_cross_field': 'has_cross_field'
} }
update_data = {} update_data = {}
@ -833,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400, status_code=400,
detail=f"location_type must be one of: {', '.join(allowed_types)}" detail=f"location_type must be one of: {', '.join(allowed_types)}"
) )
if key == 'has_cross_field' and value:
resulting_type = data.location_type or old_location.location_type
if resulting_type != 'rum':
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
update_parts.append(f"{db_column} = %s") update_parts.append(f"{db_column} = %s")
params.append(value) params.append(value)
update_data[key] = value update_data[key] = value

View File

@ -261,7 +261,7 @@ def list_locations_view(
""" """
query_params.extend([limit, skip]) query_params.extend([limit, skip])
locations = execute_query(query, tuple(query_params)) locations = execute_query(query, tuple(query_params)) or []
def build_tree(items: list) -> list: def build_tree(items: list) -> list:
nodes = {} nodes = {}
@ -365,7 +365,7 @@ def create_location_view(
try: try:
logger.info("🆕 Rendering create location form") logger.info("🆕 Rendering create location form")
parent_locations = get_parent_location_choices() parent_locations = get_parent_location_choices() or []
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None) selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None: if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
@ -389,7 +389,7 @@ def create_location_view(
cancel_url="/app/locations", cancel_url="/app/locations",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, parent_locations=parent_locations,
customers=customers, customers=customers or [],
selected_parent_id=parent_location_id, selected_parent_id=parent_location_id,
selected_customer_id=customer_id, selected_customer_id=customer_id,
selected_parent=selected_parent, selected_parent=selected_parent,
@ -447,7 +447,47 @@ def location_wizard_view():
# ============================================================================ # ============================================================================
# 3. GET /app/locations/{id} - Detail view (HTML) # 3. GET /app/locations/outlets - Wall outlet overview
# ============================================================================
@router.get("/app/locations/outlets", response_class=HTMLResponse)
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
try:
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
params = []
if status:
where.append("o.status = %s")
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f"%{q.strip()}%"] * 7)
outlets = execute_query(f"""
WITH RECURSIVE tree AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
UNION ALL
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
WHERE l.deleted_at IS NULL
)
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN tree ON tree.id = l.id
WHERE {' AND '.join(where)}
ORDER BY tree.hierarchy_path, o.outlet_number
""", tuple(params)) or []
return HTMLResponse(render_template(
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
))
except Exception as exc:
logger.error("Error rendering wall outlets overview: %s", exc)
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
# ============================================================================
# 4. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================ # ============================================================================
@router.get("/app/locations/{id}", response_class=HTMLResponse) @router.get("/app/locations/{id}", response_class=HTMLResponse)
@ -564,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,) (id,)
) )
wall_outlets = execute_query(
"""
SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active
FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY outlet_number
""",
(id,),
)
cross_fields = execute_query(
"""SELECT id, name, port_count, notes, is_active
FROM locations_cross_fields
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
ORDER BY name""",
(id,),
)
for cross_field in cross_fields or []:
cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.is_active,
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
l.name AS outlet_location_name
FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
LEFT JOIN locations_locations l ON l.id = o.location_id
WHERE p.cross_field_id = %s ORDER BY p.port_number""",
(cross_field["id"],),
) or []
audit_log = execute_query( audit_log = execute_query(
""" """
SELECT id, location_id, event_type, user_id, changes, created_at SELECT id, location_id, event_type, user_id, changes, created_at
@ -581,6 +650,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["services"] = services or [] location["services"] = services or []
location["capacity"] = capacity or [] location["capacity"] = capacity or []
location["hardware"] = hardware or [] location["hardware"] = hardware or []
location["wall_outlets"] = wall_outlets or []
location["cross_fields"] = cross_fields or []
location["audit_log"] = audit_log or [] location["audit_log"] = audit_log or []
# Query customers # Query customers
@ -596,6 +667,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts") # contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours") # hours = call_api("GET", f"/api/v1/locations/{id}/hours")
customers = customers or []
# Render template with context # Render template with context
html = render_template( html = render_template(
"modules/locations/templates/detail.html", "modules/locations/templates/detail.html",
@ -646,7 +719,7 @@ def edit_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result location = location[0] # Get first result
parent_locations = get_parent_location_choices(exclude_id=id) parent_locations = get_parent_location_choices(exclude_id=id) or []
selected_parent = next( selected_parent = next(
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")), (row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
None, None,
@ -672,7 +745,7 @@ def edit_location_view(id: int = Path(..., gt=0)):
cancel_url=f"/app/locations/{id}", cancel_url=f"/app/locations/{id}",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, parent_locations=parent_locations,
customers=customers, customers=customers or [],
selected_parent=selected_parent, selected_parent=selected_parent,
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
) )
@ -714,6 +787,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
latitude = %s, latitude = %s,
longitude = %s, longitude = %s,
notes = %s, notes = %s,
has_cross_field = %s,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", ( """, (
@ -731,6 +805,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
float(form.get("latitude")) if form.get("latitude") else None, float(form.get("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None, float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"), form.get("notes"),
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
id id
)) ))

View File

@ -17,7 +17,7 @@ from decimal import Decimal
class LocationBase(BaseModel): class LocationBase(BaseModel):
"""Shared fields for location models""" """Shared fields for location models"""
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)") name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
location_type: str = Field( location_type: str = Field(
..., ...,
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = Field(True, description="Whether location is active") is_active: bool = Field(True, description="Whether location is active")
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
@field_validator('location_type') @field_validator('location_type')
@classmethod @classmethod
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
has_cross_field: Optional[bool] = None
@field_validator('location_type') @field_validator('location_type')
@classmethod @classmethod
@ -101,6 +103,99 @@ class Location(LocationBase):
from_attributes = True from_attributes = True
# ============================================================================
# NETWORK WALL OUTLET MODELS
# ============================================================================
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
class WallOutletBase(BaseModel):
location_id: int = Field(..., ge=1)
outlet_number: str = Field(..., min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: str = Field('unknown')
notes: Optional[str] = None
is_active: bool = True
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutletCreate(WallOutletBase):
pass
class WallOutletUpdate(BaseModel):
outlet_number: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value is not None and value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutlet(WallOutletBase):
id: int
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
location_name: Optional[str] = None
location_type: Optional[str] = None
customer_name: Optional[str] = None
hierarchy_path: Optional[str] = None
class CrossFieldCreate(BaseModel):
location_id: int = Field(..., ge=1)
name: str = Field(..., min_length=1, max_length=100)
port_count: int = Field(..., ge=1, le=999)
notes: Optional[str] = None
class CrossFieldUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
port_count: Optional[int] = Field(None, ge=1, le=999)
notes: Optional[str] = None
class CrossFieldPort(BaseModel):
id: int
port_number: int
is_active: bool
class CrossField(BaseModel):
id: int
location_id: int
name: str
port_count: int
notes: Optional[str] = None
is_active: bool
created_at: datetime
ports: List[CrossFieldPort] = []
# ============================================================================ # ============================================================================
# 2. CONTACT MODELS # 2. CONTACT MODELS
# ============================================================================ # ============================================================================
@ -360,6 +455,7 @@ class LocationDetail(Location):
hours: List[OperatingHours] = Field(default_factory=list) hours: List[OperatingHours] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list) services: List[Service] = Field(default_factory=list)
capacity: List[Capacity] = Field(default_factory=list) capacity: List[Capacity] = Field(default_factory=list)
wall_outlets: List[WallOutlet] = Field(default_factory=list)
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):

View File

@ -158,6 +158,14 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crossFieldOption" hidden>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
</div>
</div>
</fieldset> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -339,6 +347,7 @@ document.addEventListener('DOMContentLoaded', function() {
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null, parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null, customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), address_postal_code: formData.get('address_postal_code'),
@ -375,6 +384,17 @@ document.addEventListener('DOMContentLoaded', function() {
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation'; submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
} }
}); });
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,17 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
.locations-detail-page { .locations-detail-page {
--loc-accent: var(--accent, #0f4c75); --loc-accent: var(--accent, #0f4c75);
} }
@ -247,6 +258,9 @@
<span class="case-type-chip" style="--tcolor: {{ type_color }};"> <span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }} {{ type_label }}
</span> </span>
{% if location.has_cross_field %}
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
{% endif %}
{% if location.is_active %} {% if location.is_active %}
<span class="case-status-chip open"> <span class="case-status-chip open">
<span class="case-status-dot"></span>Aktiv <span class="case-status-dot"></span>Aktiv
@ -328,6 +342,13 @@
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span> <span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button> </button>
</li> </li>
{% if location.location_type == 'rum' and location.has_cross_field %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false"> <button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Relationer <i class="bi bi-diagram-3 me-2"></i>Relationer
@ -769,6 +790,32 @@
</div> </div>
</div> </div>
<div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
<div>
<h5 class="card-title mb-0">Vægstik</h5>
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
{% endif %}
</div>
</div>
<div class="card-body">
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
{% elif location.wall_outlets %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %}
</tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
</div>
</div>
<div class="card border-0 mt-4"> <div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5> <h5 class="card-title mb-0">Hierarki (træ)</h5>
@ -809,6 +856,23 @@
</div> </div>
</div> </div>
{% if location.location_type == 'rum' and location.has_cross_field %}
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
<div class="card border-0">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
</div>
<div class="card-body">
{% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">{{ field.port_count }} porte</span></div><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div>
<div class="patch-panel"><div class="patch-panel-grid">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tab 7: Hardware --> <!-- Tab 7: Hardware -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab"> <div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0"> <div class="card border-0">
@ -982,6 +1046,37 @@
</div> </div>
</div> </div>
<!-- Wall outlet modal -->
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
<input type="hidden" id="crossFieldId">
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 24"></div>
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
</div>
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer *</label><input class="form-control" id="outletNumber" required placeholder="Fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" placeholder="Fx Switch 3"></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><input class="form-control" id="outletSwitchPort" placeholder="Fx Gi1/0/12"></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div>
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
</div></div>
</div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -1298,6 +1393,103 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
}); });
const crossFieldButton = document.getElementById('addCrossFieldBtn');
const crossFieldModalElement = document.getElementById('crossFieldModal');
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const crossFieldId = document.getElementById('crossFieldId').value;
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), notes: document.getElementById('crossFieldNotes').value || null})});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
const outletModalElement = document.getElementById('outletModal');
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
const outletForm = document.getElementById('outletForm');
const outletValue = (id) => document.getElementById(id).value.trim() || null;
async function loadCrossFieldPorts() {
const select = document.getElementById('outletCrossFieldPort');
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
}
async function loadOutletLocations() {
const select = document.getElementById('outletLocationId');
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
select.value = String(locationId);
}
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations()]);
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || '';
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
document.getElementById('outletSwitch').value = outlet?.switchName || '';
document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletNotes').value = outlet?.notes || '';
if (selectedPort) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(selectedPort.id);
if (portSelect.value !== String(selectedPort.id)) {
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
}
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
}
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
outletModal.show();
}
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
id: btn.dataset.id, number: btn.dataset.number, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
status: btn.dataset.status, notes: btn.dataset.notes
})));
outletForm?.addEventListener('submit', async (event) => {
event.preventDefault();
const outletId = document.getElementById('outletId').value;
const payload = {
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), category: outletValue('outletCategory'),
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme vægstik');
return;
}
location.reload();
});
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
const outletId = document.getElementById('outletId').value;
if (!outletId || !confirm('Slet dette vægstik?')) return;
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
if (response.ok) location.reload();
else alert('Kunne ikke slette vægstik');
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -159,6 +159,13 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
</div>
</div>
</fieldset> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -197,7 +204,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk"> <input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
</div> </div>
</fieldset> </fieldset>
@ -227,7 +234,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="notes" class="form-label">Noter og kommentarer</label> <label for="notes" class="form-label">Noter og kommentarer</label>
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea> <textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small> <small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
</div> </div>
</fieldset> </fieldset>
@ -358,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null, parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null, customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), address_postal_code: formData.get('address_postal_code'),
@ -394,6 +402,17 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
// Delete location // Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() { document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
fetch(`/api/v1/locations/${locationId}`, { fetch(`/api/v1/locations/${locationId}`, {

View File

@ -303,6 +303,9 @@
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm"> <a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard <i class="bi bi-diagram-3 me-2"></i>Wizard
</a> </a>
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-ethernet me-2"></i>Vægstik
</a>
<span class="shortcut-hint">Tip: Tryk / for søgning</span> <span class="shortcut-hint">Tip: Tryk / for søgning</span>
</div> </div>
</div> </div>

View File

@ -0,0 +1,22 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vægstik - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div><div class="small text-uppercase text-muted fw-semibold mb-1">Lokaliteter</div><h1 class="h3 mb-1">Vægstik</h1><p class="text-muted mb-0">Søg og find netværksstik på tværs af bygninger, etager og rum.</p></div>
<a href="/app/locations" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Lokaliteter</a>
</div>
<div class="card border-0 shadow-sm mb-4"><div class="card-body">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-7"><label class="form-label">Søg</label><input class="form-control" name="q" value="{{ query }}" placeholder="Stiknummer, lokation, patchpanel eller switch-port"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="">Alle</option>{% for value, label in [('available', 'Ledig'), ('active', 'Aktiv'), ('reserved', 'Reserveret'), ('faulty', 'Defekt'), ('unknown', 'Ukendt')] %}<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-2 d-grid"><button class="btn btn-primary"><i class="bi bi-search me-1"></i>Søg</button></div>
</form>
</div></div>
<div class="card border-0 shadow-sm"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Stik</th><th>Lokation</th><th>Patchpanel</th><th>Switch</th><th>Status</th></tr></thead><tbody>
{% for outlet in outlets %}<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><a href="/app/locations/{{ outlet.location_id }}" class="text-decoration-none">{{ outlet.hierarchy_path or outlet.location_name }}</a>{% if outlet.customer_name %}<div class="small text-muted">{{ outlet.customer_name }}</div>{% endif %}</td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td></tr>{% else %}<tr><td colspan="5" class="text-center text-muted py-5">Ingen vægstik matcher søgningen.</td></tr>{% endfor %}
</tbody></table></div></div>
</div>
{% endblock %}

View File

@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
return execute_migration(model) return execute_migration(model)
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
async def execute_missing_migrations_api():
"""Run schema-detected missing migrations via the API namespace."""
from app.settings.backend.views import execute_missing_migrations
return execute_missing_migrations()
@router.post("/settings/sync-from-env", tags=["Settings"]) @router.post("/settings/sync-from-env", tags=["Settings"])
async def sync_settings_from_env(): async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)""" """Sync settings from .env file into database (only updates empty values)"""

View File

@ -3,6 +3,7 @@ Settings Frontend Views
""" """
from datetime import datetime from datetime import datetime
import time
from pathlib import Path from pathlib import Path
import re import re
from fastapi import APIRouter, Request, HTTPException, Depends from fastapi import APIRouter, Request, HTTPException, Depends
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
release_db_connection(conn) release_db_connection(conn)
return {"message": "Migration executed successfully"} return {"message": "Migration executed successfully"}
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
def execute_missing_migrations():
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
conn = get_db_connection()
logs = []
try:
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
candidates = []
for migration_file in files:
sql = migration_file.read_text(encoding="utf-8")
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
if status["status"] == "red":
candidates.append((migration_file, sql, status))
for migration_file, sql, status in candidates:
started = time.monotonic()
try:
with conn.cursor() as cursor:
cursor.execute(sql)
conn.commit()
logs.append({
"file_name": migration_file.name,
"status": "success",
"summary": status["summary"],
"duration_ms": round((time.monotonic() - started) * 1000),
})
except Exception as exc:
conn.rollback()
logs.append({
"file_name": migration_file.name,
"status": "failed",
"summary": str(exc).splitlines()[0],
"duration_ms": round((time.monotonic() - started) * 1000),
})
return {
"message": "Manglende migrationer behandlet",
"checked": len(files),
"candidates": len(candidates),
"executed": sum(item["status"] == "success" for item in logs),
"failed": sum(item["status"] == "failed" for item in logs),
"logs": logs,
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
finally:
release_db_connection(conn)

View File

@ -69,6 +69,9 @@
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()"> <button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
<i class="bi bi-check2-circle me-1"></i>Tjek status <i class="bi bi-check2-circle me-1"></i>Tjek status
</button> </button>
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
<i class="bi bi-play-fill me-1"></i>Kør manglende
</button>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@ -356,5 +359,35 @@
button.disabled = false; button.disabled = false;
} }
} }
async function runMissingMigrations() {
const button = document.getElementById('runMissingMigrationsBtn');
const feedback = document.getElementById('migrationFeedback');
button.disabled = true;
feedback.className = 'alert alert-info mt-3';
feedback.textContent = 'Tjekker og kører manglende migrationer...';
feedback.classList.remove('d-none');
try {
const urls = buildMigrationActionUrls('execute-missing');
let data = null;
let lastError = null;
for (const url of urls) {
const response = await fetch(url, {method: 'POST', credentials: 'include'});
const payload = await response.json().catch(() => ({}));
if (response.ok) { data = payload; break; }
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
lastError = payload.detail || `HTTP ${response.status}`;
}
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
} catch (error) {
feedback.className = 'alert alert-danger mt-3';
feedback.textContent = `Fejl: ${error.message}`;
} finally {
button.disabled = false;
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -9,7 +9,7 @@ E-CONOMIC owns and syncs:
- economic_customer_number (primary key from e-conomic) - economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address) - address, city, postal_code, country (physical address)
- email_domain, website (contact information) - email_domain, website (contact information)
- cvr_number (used for matching only, not overwritten if already set) - cvr_number (company metadata; may be shared by several customers)
vTIGER owns and syncs: vTIGER owns and syncs:
- vtiger_id (primary key from vTiger) - vtiger_id (primary key from vTiger)
@ -18,7 +18,7 @@ vTIGER owns and syncs:
HUB owns (manual or first-sync only): HUB owns (manual or first-sync only):
- name (can be synced initially but not overwritten) - name (can be synced initially but not overwritten)
- cvr_number (used for matching, set once) - cvr_number (informational; can be refreshed from e-conomic)
- Tags, notes, custom fields - Tags, notes, custom fields
SYNC RULES: SYNC RULES:
@ -192,7 +192,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
# Strict matching: ONLY match by economic_customer_number # Strict matching: ONLY match by economic_customer_number
existing = execute_query( existing = execute_query(
""" """
SELECT id, name, email_domain, address, city, postal_code, country, website SELECT id, name, cvr_number, email_domain, address, city, postal_code, country, website
FROM customers FROM customers
WHERE economic_customer_number = %s WHERE economic_customer_number = %s
ORDER BY id ORDER BY id
@ -221,6 +221,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing: if existing:
target_customer_id = existing[0]['id'] target_customer_id = existing[0]['id']
current_values = { current_values = {
"cvr_number": existing[0].get("cvr_number"),
"email_domain": existing[0].get("email_domain"), "email_domain": existing[0].get("email_domain"),
"address": existing[0].get("address"), "address": existing[0].get("address"),
"city": existing[0].get("city"), "city": existing[0].get("city"),
@ -229,6 +230,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
"website": existing[0].get("website"), "website": existing[0].get("website"),
} }
proposed_values = { proposed_values = {
"cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
"address": address, "address": address,
"city": city, "city": city,
@ -252,6 +254,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
update_query = """ update_query = """
UPDATE customers SET UPDATE customers SET
economic_customer_number = %s, economic_customer_number = %s,
cvr_number = %s,
email_domain = %s, email_domain = %s,
address = %s, address = %s,
city = %s, city = %s,
@ -262,7 +265,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
WHERE id = %s WHERE id = %s
""" """
execute_query(update_query, ( execute_query(update_query, (
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id customer_number, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
)) ))
logger.info( logger.info(
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", "✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",

View File

@ -0,0 +1,13 @@
-- CVR is company metadata, not an external customer identity. Multiple
-- e-conomic customer records may legitimately share the same CVR number.
ALTER TABLE customers
DROP CONSTRAINT IF EXISTS customers_cvr_number_key;
DROP INDEX IF EXISTS customers_cvr_number_unique_idx;
CREATE INDEX IF NOT EXISTS idx_customers_cvr
ON customers(cvr_number)
WHERE cvr_number IS NOT NULL AND cvr_number <> '';
COMMENT ON COLUMN customers.cvr_number IS
'Danish CVR number. Informational/searchable; duplicates are allowed.';

View File

@ -0,0 +1,38 @@
-- Network wall outlets attached to buildings, floors, or rooms.
CREATE TABLE IF NOT EXISTS locations_wall_outlets (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
outlet_number VARCHAR(100) NOT NULL,
category VARCHAR(50),
patch_panel VARCHAR(255),
patch_port VARCHAR(100),
switch_name VARCHAR(255),
switch_port VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'unknown'
CHECK (status IN ('available', 'active', 'reserved', 'faulty', 'unknown')),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_wall_outlets_unique_location_number
ON locations_wall_outlets(location_id, lower(outlet_number))
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_location ON locations_wall_outlets(location_id);
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_status ON locations_wall_outlets(status) WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_locations_wall_outlets_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_locations_wall_outlets_updated_at ON locations_wall_outlets;
CREATE TRIGGER trg_locations_wall_outlets_updated_at
BEFORE UPDATE ON locations_wall_outlets
FOR EACH ROW EXECUTE FUNCTION update_locations_wall_outlets_updated_at();

View File

@ -0,0 +1,17 @@
-- Location names are meaningful within a customer and hierarchy, not globally.
-- Example: every building may have an "1 Sal".
BEGIN;
ALTER TABLE locations_locations
DROP CONSTRAINT IF EXISTS locations_locations_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_name_scope_unique
ON locations_locations (
COALESCE(parent_location_id, 0),
COALESCE(customer_id, 0),
lower(name)
)
WHERE deleted_at IS NULL;
COMMIT;

View File

@ -0,0 +1,8 @@
-- A technical room can contain a network cross-connect / patch field without
-- becoming a separate location type.
ALTER TABLE locations_locations
ADD COLUMN IF NOT EXISTS has_cross_field BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_locations_has_cross_field
ON locations_locations(has_cross_field)
WHERE has_cross_field = TRUE AND deleted_at IS NULL;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS locations_cross_fields (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
port_count INTEGER NOT NULL CHECK (port_count BETWEEN 1 AND 999),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_fields_location_name_active
ON locations_cross_fields(location_id, lower(name)) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS locations_cross_field_ports (
id SERIAL PRIMARY KEY,
cross_field_id INTEGER NOT NULL REFERENCES locations_cross_fields(id) ON DELETE CASCADE,
port_number INTEGER NOT NULL CHECK (port_number > 0),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(cross_field_id, port_number)
);
CREATE INDEX IF NOT EXISTS idx_cross_field_ports_field ON locations_cross_field_ports(cross_field_id);

View File

@ -0,0 +1,7 @@
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS cross_field_port_id INTEGER
REFERENCES locations_cross_field_ports(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wall_outlet_cross_field_port_active
ON locations_wall_outlets(cross_field_port_id)
WHERE cross_field_port_id IS NOT NULL AND deleted_at IS NULL;

View File

@ -0,0 +1,78 @@
import asyncio
import importlib
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).parent.parent))
from main import app # noqa: F401 - initializes the project import path used by module tests
locations_router = importlib.import_module("app.modules.locations.backend.router")
from app.modules.locations.models.schemas import WallOutletCreate
def test_wall_outlet_requires_supported_location_type(monkeypatch):
monkeypatch.setattr(
locations_router,
"execute_query",
lambda query, params=None: [{"id": 1, "name": "HQ", "location_type": "kompleks"}],
)
with pytest.raises(HTTPException) as exc:
asyncio.run(locations_router.create_wall_outlet(WallOutletCreate(location_id=1, outlet_number="A-01")))
assert exc.value.status_code == 400
def test_wall_outlet_create_returns_location_context(monkeypatch):
calls = []
def fake_execute_query(query, params=None):
calls.append((query, params))
if "SELECT id, name, location_type FROM locations_locations" in query:
return [{"id": 2, "name": "1. sal", "location_type": "etage"}]
if "INSERT INTO locations_wall_outlets" in query:
return [{"id": 33}]
return [{
"id": 33, "location_id": 2, "outlet_number": "A-12", "category": "Cat6a",
"patch_panel": "PP-A", "patch_port": "12", "switch_name": "SW-1",
"switch_port": "Gi1/0/12", "status": "active", "notes": None,
"is_active": True, "created_at": "2026-07-17T12:00:00",
"updated_at": "2026-07-17T12:00:00", "deleted_at": None,
"location_name": "1. sal", "location_type": "etage", "customer_name": "BMC",
"hierarchy_path": "HQ > 1. sal",
}]
monkeypatch.setattr(locations_router, "execute_query", fake_execute_query)
result = asyncio.run(locations_router.create_wall_outlet(
WallOutletCreate(location_id=2, outlet_number="A-12", category="Cat6a", status="active")
))
assert result.id == 33
assert result.hierarchy_path == "HQ > 1. sal"
assert any("INSERT INTO locations_wall_outlets" in query for query, _ in calls)
def test_wall_outlet_allows_customer_site(monkeypatch):
def fake_execute_query(query, params=None):
if "SELECT id, name, location_type FROM locations_locations" in query:
return [{"id": 2, "name": "Kundesite", "location_type": "customer_site"}]
if "INSERT INTO locations_wall_outlets" in query:
return [{"id": 34}]
return [{
"id": 34, "location_id": 2, "outlet_number": "A-01", "category": None,
"patch_panel": None, "patch_port": None, "switch_name": None, "switch_port": None,
"status": "unknown", "notes": None, "is_active": True,
"created_at": "2026-07-17T12:00:00", "updated_at": "2026-07-17T12:00:00",
"deleted_at": None, "location_name": "Kundesite", "location_type": "customer_site",
"customer_name": "BMC", "hierarchy_path": "Kundesite",
}]
monkeypatch.setattr(locations_router, "execute_query", fake_execute_query)
result = asyncio.run(locations_router.create_wall_outlet(
WallOutletCreate(location_id=2, outlet_number="A-01")
))
assert result.location_type == "customer_site"