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:
query = """
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,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
@ -378,7 +378,7 @@ async def create_hardware(data: dict):
rental_default_start_price, rental_default_freight_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 *
"""
@ -392,6 +392,7 @@ async def create_hardware(data: dict):
data.get("model"),
data.get("serial_number"),
data.get("customer_asset_id"),
data.get("current_location_id"),
data.get("internal_asset_id"),
data.get("notes"),
data.get("current_owner_type", "bmc"),
@ -1172,4 +1173,3 @@ async def list_eset_incidents(
"""
result = execute_query(query, (severity_list, limit))
return result or []

View File

@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import (
Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse
LocationWizardCreateRequest, LocationWizardCreateResponse,
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
)
router = APIRouter()
@ -164,15 +165,21 @@ async def create_location(request: Request):
logger.warning("⚠️ Invalid location payload")
raise HTTPException(status_code=422, detail=e.errors())
# Check for duplicate name
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (data.name,))
# Names only need to be unique within the same customer and hierarchy.
check_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 deleted_at IS NULL
"""
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
if existing:
logger.warning(f"⚠️ Duplicate location name: {data.name}")
raise HTTPException(
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:
@ -201,9 +208,9 @@ async def create_location(request: Request):
INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city,
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 *
"""
@ -221,7 +228,8 @@ async def create_location(request: Request):
data.phone,
data.email,
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)
@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
def _normalize_name(value: str) -> str:
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)
if normalized in reserved_names:
scope_key = (normalized, parent_location_id, customer_id)
if scope_key in reserved_names:
return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (value,))
check_query = """
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)
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)
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:
_reserve_name(base_name)
_reserve_name(base_name, parent_location_id, customer_id)
return base_name
base_name = base_name.strip()
if not _name_exists(base_name):
_reserve_name(base_name)
if not _name_exists(base_name, parent_location_id, customer_id):
_reserve_name(base_name, parent_location_id, customer_id)
return base_name
suffix = 2
while True:
candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate):
_reserve_name(candidate)
if not _name_exists(candidate, parent_location_id, customer_id):
_reserve_name(candidate, parent_location_id, customer_id)
return candidate
suffix += 1
@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
raise HTTPException(status_code=500, detail="Failed to create location")
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(
name=resolved_root_name,
location_type=root.location_type,
@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
room_ids: List[int] = []
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(
name=resolved_floor_name,
location_type=floor.location_type,
@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
))
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(
name=resolved_room_name,
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
# ============================================================================
_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)
async def get_location(id: int):
"""
@ -645,6 +841,12 @@ async def get_location(id: int):
capacity_result = execute_query(capacity_query, (id,))
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)
hierarchy_query = """
WITH RECURSIVE ancestors AS (
@ -693,7 +895,8 @@ async def get_location(id: int):
contacts=contacts,
hours=hours,
services=services,
capacity=capacity
capacity=capacity,
wall_outlets=wall_outlets,
)
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])
# Check for duplicate name if name is being updated
if data.name is not None and data.name != old_location.name:
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL"
dup_check = execute_query(dup_query, (data.name, id))
# Check the resulting name/customer/parent scope, including when only
# customer or parent is changed.
if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
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:
logger.warning(f"⚠️ Duplicate location name: {data.name}")
logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
raise HTTPException(
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
@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate):
'phone': 'phone',
'email': 'email',
'notes': 'notes',
'is_active': 'is_active'
'is_active': 'is_active',
'has_cross_field': 'has_cross_field'
}
update_data = {}
@ -833,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400,
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")
params.append(value)
update_data[key] = value

View File

@ -261,7 +261,7 @@ def list_locations_view(
"""
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:
nodes = {}
@ -365,7 +365,7 @@ def create_location_view(
try:
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)
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",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
customers=customers or [],
selected_parent_id=parent_location_id,
selected_customer_id=customer_id,
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)
@ -564,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)):
(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(
"""
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["capacity"] = capacity or []
location["hardware"] = hardware or []
location["wall_outlets"] = wall_outlets or []
location["cross_fields"] = cross_fields or []
location["audit_log"] = audit_log or []
# 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")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
customers = customers or []
# Render template with context
html = render_template(
"modules/locations/templates/detail.html",
@ -646,7 +719,7 @@ def edit_location_view(id: int = Path(..., gt=0)):
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(
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
None,
@ -672,7 +745,7 @@ def edit_location_view(id: int = Path(..., gt=0)):
cancel_url=f"/app/locations/{id}",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
customers=customers or [],
selected_parent=selected_parent,
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,
longitude = %s,
notes = %s,
has_cross_field = %s,
updated_at = CURRENT_TIMESTAMP
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("longitude")) if form.get("longitude") else None,
form.get("notes"),
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
id
))

View File

@ -17,7 +17,7 @@ from decimal import Decimal
class LocationBase(BaseModel):
"""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(
...,
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
email: Optional[str] = None
notes: Optional[str] = None
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')
@classmethod
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
email: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
has_cross_field: Optional[bool] = None
@field_validator('location_type')
@classmethod
@ -101,6 +103,99 @@ class Location(LocationBase):
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
# ============================================================================
@ -360,6 +455,7 @@ class LocationDetail(Location):
hours: List[OperatingHours] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list)
capacity: List[Capacity] = Field(default_factory=list)
wall_outlets: List[WallOutlet] = Field(default_factory=list)
class AuditLogEntry(BaseModel):

View File

@ -158,6 +158,14 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
</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>
<!-- 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,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'),
address_city: formData.get('address_city'),
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';
}
});
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>
{% endblock %}

View File

@ -4,6 +4,17 @@
{% block extra_css %}
<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 {
--loc-accent: var(--accent, #0f4c75);
}
@ -247,6 +258,9 @@
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }}
</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 %}
<span class="case-status-chip open">
<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>
</button>
</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">
<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
@ -769,6 +790,32 @@
</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-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5>
@ -809,6 +856,23 @@
</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 -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0">
@ -982,6 +1046,37 @@
</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 -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<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>
{% endblock %}

View File

@ -159,6 +159,13 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
</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>
<!-- Section 2: Address -->
@ -197,7 +204,7 @@
<div class="mb-3">
<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>
</fieldset>
@ -227,7 +234,7 @@
<div class="mb-3">
<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>
<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>
</fieldset>
@ -358,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
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,
is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'),
address_city: formData.get('address_city'),
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
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
fetch(`/api/v1/locations/${locationId}`, {

View File

@ -303,6 +303,9 @@
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard
</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>
</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)
@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"])
async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)"""

View File

@ -3,6 +3,7 @@ Settings Frontend Views
"""
from datetime import datetime
import time
from pathlib import Path
import re
from fastapi import APIRouter, Request, HTTPException, Depends
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
release_db_connection(conn)
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()">
<i class="bi bi-check2-circle me-1"></i>Tjek status
</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 class="card-body">
@ -356,5 +359,35 @@
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>
{% endblock %}

View File

@ -9,7 +9,7 @@ E-CONOMIC owns and syncs:
- economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address)
- 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_id (primary key from vTiger)
@ -18,7 +18,7 @@ vTIGER owns and syncs:
HUB owns (manual or first-sync only):
- 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
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
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
WHERE economic_customer_number = %s
ORDER BY id
@ -221,6 +221,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing:
target_customer_id = existing[0]['id']
current_values = {
"cvr_number": existing[0].get("cvr_number"),
"email_domain": existing[0].get("email_domain"),
"address": existing[0].get("address"),
"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"),
}
proposed_values = {
"cvr_number": cvr,
"email_domain": email_domain,
"address": address,
"city": city,
@ -252,6 +254,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
update_query = """
UPDATE customers SET
economic_customer_number = %s,
cvr_number = %s,
email_domain = %s,
address = %s,
city = %s,
@ -262,7 +265,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
WHERE id = %s
"""
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(
"✏️ 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"