feat: add locations outlet and cross-field management
This commit is contained in:
parent
0655b4c4f8
commit
d4d9ac22a5
@ -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 []
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
22
app/modules/locations/templates/outlets.html
Normal file
22
app/modules/locations/templates/outlets.html
Normal 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 %}
|
||||||
@ -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)"""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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)",
|
||||||
|
|||||||
13
migrations/1000_customers_allow_duplicate_cvr.sql
Normal file
13
migrations/1000_customers_allow_duplicate_cvr.sql
Normal 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.';
|
||||||
38
migrations/215_locations_wall_outlets.sql
Normal file
38
migrations/215_locations_wall_outlets.sql
Normal 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();
|
||||||
17
migrations/216_locations_scoped_name_uniqueness.sql
Normal file
17
migrations/216_locations_scoped_name_uniqueness.sql
Normal 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;
|
||||||
8
migrations/217_locations_cross_field_marker.sql
Normal file
8
migrations/217_locations_cross_field_marker.sql
Normal 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;
|
||||||
25
migrations/218_locations_cross_fields.sql
Normal file
25
migrations/218_locations_cross_fields.sql
Normal 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);
|
||||||
7
migrations/219_wall_outlet_cross_field_port.sql
Normal file
7
migrations/219_wall_outlet_cross_field_port.sql
Normal 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;
|
||||||
78
tests/test_locations_wall_outlets.py
Normal file
78
tests/test_locations_wall_outlets.py
Normal 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"
|
||||||
Loading…
Reference in New Issue
Block a user