diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py index c23207e..78fc879 100644 --- a/app/modules/hardware/backend/router.py +++ b/app/modules/hardware/backend/router.py @@ -370,7 +370,7 @@ async def create_hardware(data: dict): try: query = """ INSERT INTO hardware_assets ( - asset_type, brand, model, serial_number, customer_asset_id, + asset_type, brand, model, serial_number, customer_asset_id, current_location_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id, status, status_reason, warranty_until, end_of_life, anydesk_id, anydesk_link, @@ -378,7 +378,7 @@ async def create_hardware(data: dict): rental_default_start_price, rental_default_freight_price, rental_default_preparation_price, rental_default_operations_monthly_price ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ @@ -392,6 +392,7 @@ async def create_hardware(data: dict): data.get("model"), data.get("serial_number"), data.get("customer_asset_id"), + data.get("current_location_id"), data.get("internal_asset_id"), data.get("notes"), data.get("current_owner_type", "bmc"), @@ -1172,4 +1173,3 @@ async def list_eset_incidents( """ result = execute_query(query, (severity_list, limit)) return result or [] - diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index 52a7991..89a1413 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import ( Service, ServiceCreate, ServiceUpdate, Capacity, CapacityCreate, CapacityUpdate, BulkUpdateRequest, BulkDeleteRequest, LocationStats, - LocationWizardCreateRequest, LocationWizardCreateResponse + LocationWizardCreateRequest, LocationWizardCreateResponse, + WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate ) router = APIRouter() @@ -164,15 +165,21 @@ async def create_location(request: Request): logger.warning("⚠️ Invalid location payload") raise HTTPException(status_code=422, detail=e.errors()) - # Check for duplicate name - check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL" - existing = execute_query(check_query, (data.name,)) + # Names only need to be unique within the same customer and hierarchy. + check_query = """ + SELECT id FROM locations_locations + WHERE lower(name) = lower(%s) + AND parent_location_id IS NOT DISTINCT FROM %s + AND customer_id IS NOT DISTINCT FROM %s + AND deleted_at IS NULL + """ + existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id)) if existing: logger.warning(f"⚠️ Duplicate location name: {data.name}") raise HTTPException( status_code=400, - detail=f"Location with name '{data.name}' already exists" + detail=f"Location with name '{data.name}' already exists under the same customer/location" ) if data.customer_id is not None: @@ -201,9 +208,9 @@ async def create_location(request: Request): INSERT INTO locations_locations ( name, location_type, parent_location_id, customer_id, address_street, address_city, address_postal_code, address_country, latitude, longitude, - phone, email, notes, is_active, created_at, updated_at + phone, email, notes, is_active, has_cross_field, created_at, updated_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) RETURNING * """ @@ -221,7 +228,8 @@ async def create_location(request: Request): data.phone, data.email, data.notes, - data.is_active + data.is_active, + data.has_cross_field if data.location_type == 'rum' else False ) result = execute_query(insert_query, params) @@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): def _normalize_name(value: str) -> str: return (value or "").strip().lower() - def _name_exists(value: str) -> bool: + def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool: normalized = _normalize_name(value) - if normalized in reserved_names: + scope_key = (normalized, parent_location_id, customer_id) + if scope_key in reserved_names: return True - check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL" - existing = execute_query(check_query, (value,)) + check_query = """ + SELECT 1 FROM locations_locations + WHERE lower(name) = lower(%s) + AND parent_location_id IS NOT DISTINCT FROM %s + AND customer_id IS NOT DISTINCT FROM %s + AND deleted_at IS NULL + """ + existing = execute_query(check_query, (value, parent_location_id, customer_id)) return bool(existing) - def _reserve_name(value: str) -> None: + def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None: normalized = _normalize_name(value) if normalized: - reserved_names.add(normalized) + reserved_names.add((normalized, parent_location_id, customer_id)) - def _resolve_unique_name(base_name: str) -> str: + def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str: if not auto_suffix: - _reserve_name(base_name) + _reserve_name(base_name, parent_location_id, customer_id) return base_name base_name = base_name.strip() - if not _name_exists(base_name): - _reserve_name(base_name) + if not _name_exists(base_name, parent_location_id, customer_id): + _reserve_name(base_name, parent_location_id, customer_id) return base_name suffix = 2 while True: candidate = f"{base_name} ({suffix})" - if not _name_exists(candidate): - _reserve_name(candidate) + if not _name_exists(candidate, parent_location_id, customer_id): + _reserve_name(candidate, parent_location_id, customer_id) return candidate suffix += 1 @@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): raise HTTPException(status_code=500, detail="Failed to create location") return Location(**result[0]) - resolved_root_name = _resolve_unique_name(root.name) + resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id) root_location = insert_location_record( name=resolved_root_name, location_type=root.location_type, @@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): room_ids: List[int] = [] for floor in data.floors: - resolved_floor_name = _resolve_unique_name(floor.name) + resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id) floor_location = insert_location_record( name=resolved_floor_name, location_type=floor.location_type, @@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): )) for room in floor.rooms: - resolved_room_name = _resolve_unique_name(room.name) + resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id) room_location = insert_location_record( name=resolved_room_name, location_type=room.location_type, @@ -594,6 +609,187 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest): # 3. GET /api/v1/locations/{id} - Get single location with all relationships # ============================================================================ +_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site') + + +@router.get('/locations/cross-fields', response_model=List[CrossField]) +async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)): + where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE' + params: tuple = () + if location_id: + where += ' AND cf.location_id = %s' + params = (location_id,) + fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.name''', params) or [] + for field in fields: + field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or [] + return [CrossField(**field) for field in fields] + + +@router.get('/locations/cross-field-ports') +async def list_cross_field_ports(): + return execute_query(''' + SELECT p.id, p.port_number, cf.name AS cross_field_name, + l.name AS location_name, l.id AS location_id + FROM locations_cross_field_ports p + JOIN locations_cross_fields cf ON cf.id = p.cross_field_id + JOIN locations_locations l ON l.id = cf.location_id + LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL + WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL + ORDER BY l.name, cf.name, p.port_number + ''') or [] + + +@router.post('/locations/cross-fields', response_model=CrossField, status_code=201) +async def create_cross_field(data: CrossFieldCreate): + location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or [] + if not location: + raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet') + if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'): + raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt') + try: + created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, notes) VALUES (%s, %s, %s, %s) RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.notes)) or [] + if not created: + raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes') + field = created[0] + execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(1, %s)''', (field['id'], data.port_count)) + except HTTPException: + raise + except Exception as exc: + if 'unique' in str(exc).lower(): + raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc + raise + field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or [] + return CrossField(**field) + + +@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField) +async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate): + field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or [] + if not field_rows: + raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet') + field = field_rows[0] + changes = data.model_dump(exclude_unset=True) + requested_ports = changes.pop('port_count', None) + if requested_ports is not None and requested_ports < field['port_count']: + raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt') + if changes: + assignments = ', '.join(f'{column} = %s' for column in changes) + try: + updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or [] + field = updated[0] + except Exception as exc: + if 'unique' in str(exc).lower(): + raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc + raise + if requested_ports and requested_ports > field['port_count']: + execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(%s, %s)''', (cross_field_id, field['port_count'] + 1, requested_ports)) + field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0] + field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (cross_field_id,)) or [] + return CrossField(**field) + + +def _outlet_location(location_id: int) -> dict: + rows = execute_query( + "SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL", + (location_id,), + ) or [] + if not rows: + raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet") + location = dict(rows[0]) + if location.get('location_type') not in _OUTLET_LOCATION_TYPES: + raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum") + return location + + +_OUTLET_SELECT = """ + SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, + COALESCE(path.hierarchy_path, l.name) AS hierarchy_path + FROM locations_wall_outlets o + JOIN locations_locations l ON l.id = o.location_id + LEFT JOIN customers c ON c.id = l.customer_id + LEFT JOIN LATERAL ( + WITH RECURSIVE ancestors AS ( + SELECT id, name, parent_location_id, name::text AS hierarchy_path + FROM locations_locations WHERE id = l.id + UNION ALL + SELECT parent.id, parent.name, parent.parent_location_id, + parent.name || ' > ' || ancestors.hierarchy_path + FROM locations_locations parent + JOIN ancestors ON ancestors.parent_location_id = parent.id + ) + SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1 + ) path ON TRUE +""" + + +@router.get('/locations/outlets', response_model=List[WallOutlet]) +async def list_wall_outlets( + q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1), + status: Optional[str] = Query(None), include_inactive: bool = Query(False), +): + where = ['o.deleted_at IS NULL'] + params: List[Any] = [] + if not include_inactive: + where.append('o.is_active = TRUE') + if location_id: + where.append('o.location_id = %s') + params.append(location_id) + if status: + where.append('o.status = %s') + params.append(status) + if q: + where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)") + params.extend([f'%{q.strip()}%'] * 7) + rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or [] + return [WallOutlet(**row) for row in rows] + + +@router.post('/locations/outlets', response_model=WallOutlet, status_code=201) +async def create_wall_outlet(data: WallOutletCreate): + _outlet_location(data.location_id) + try: + rows = execute_query( + """INSERT INTO locations_wall_outlets + (location_id, outlet_number, category, patch_panel, patch_port, cross_field_port_id, switch_name, switch_port, status, notes, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", + (data.location_id, data.outlet_number.strip(), data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active), + ) or [] + except Exception as exc: + if 'unique' in str(exc).lower(): + raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc + raise + outlet_id = rows[0]['id'] + result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or [] + return WallOutlet(**result[0]) + + +@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet) +async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate): + changes = data.model_dump(exclude_unset=True) + if not changes: + raise HTTPException(status_code=400, detail='Ingen ændringer sendt') + if 'outlet_number' in changes: + changes['outlet_number'] = changes['outlet_number'].strip() + fields = ', '.join(f'{field} = %s' for field in changes) + try: + rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or [] + except Exception as exc: + if 'unique' in str(exc).lower(): + raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc + raise + if not rows: + raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet') + result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or [] + return WallOutlet(**result[0]) + + +@router.delete('/locations/outlets/{outlet_id}') +async def delete_wall_outlet(outlet_id: int): + rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or [] + if not rows: + raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet') + return {'status': 'deleted', 'id': outlet_id} + @router.get("/locations/{id}", response_model=LocationDetail) async def get_location(id: int): """ @@ -645,6 +841,12 @@ async def get_location(id: int): capacity_result = execute_query(capacity_query, (id,)) capacity = [dict(row) for row in capacity_result] if capacity_result else [] + outlet_result = execute_query( + _OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number", + (id,), + ) + wall_outlets = [dict(row) for row in (outlet_result or [])] + # Build hierarchy breadcrumb (ancestors from root to parent) hierarchy_query = """ WITH RECURSIVE ancestors AS ( @@ -693,7 +895,8 @@ async def get_location(id: int): contacts=contacts, hours=hours, services=services, - capacity=capacity + capacity=capacity, + wall_outlets=wall_outlets, ) logger.info(f"📍 Location retrieved: {location.name} (ID: {id})") @@ -740,15 +943,26 @@ async def update_location(id: int, data: LocationUpdate): old_location = Location(**existing[0]) - # Check for duplicate name if name is being updated - if data.name is not None and data.name != old_location.name: - dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL" - dup_check = execute_query(dup_query, (data.name, id)) + # Check the resulting name/customer/parent scope, including when only + # customer or parent is changed. + if data.name is not None or data.parent_location_id is not None or data.customer_id is not None: + candidate_name = data.name if data.name is not None else old_location.name + candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id + candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id + dup_query = """ + SELECT id FROM locations_locations + WHERE lower(name) = lower(%s) + AND parent_location_id IS NOT DISTINCT FROM %s + AND customer_id IS NOT DISTINCT FROM %s + AND id != %s + AND deleted_at IS NULL + """ + dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id)) if dup_check: - logger.warning(f"⚠️ Duplicate location name: {data.name}") + logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}") raise HTTPException( status_code=400, - detail=f"Location with name '{data.name}' already exists" + detail=f"Location with name '{candidate_name}' already exists under the same customer/location" ) # Build UPDATE query with only provided fields @@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate): 'phone': 'phone', 'email': 'email', 'notes': 'notes', - 'is_active': 'is_active' + 'is_active': 'is_active', + 'has_cross_field': 'has_cross_field' } update_data = {} @@ -833,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate): status_code=400, detail=f"location_type must be one of: {', '.join(allowed_types)}" ) + if key == 'has_cross_field' and value: + resulting_type = data.location_type or old_location.location_type + if resulting_type != 'rum': + raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt") update_parts.append(f"{db_column} = %s") params.append(value) update_data[key] = value diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 553fc5c..10bf225 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -261,7 +261,7 @@ def list_locations_view( """ query_params.extend([limit, skip]) - locations = execute_query(query, tuple(query_params)) + locations = execute_query(query, tuple(query_params)) or [] def build_tree(items: list) -> list: nodes = {} @@ -365,7 +365,7 @@ def create_location_view( try: logger.info("🆕 Rendering create location form") - parent_locations = get_parent_location_choices() + parent_locations = get_parent_location_choices() or [] selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None) if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None: @@ -389,7 +389,7 @@ def create_location_view( cancel_url="/app/locations", location_types=LOCATION_TYPES, parent_locations=parent_locations, - customers=customers, + customers=customers or [], selected_parent_id=parent_location_id, selected_customer_id=customer_id, selected_parent=selected_parent, @@ -447,7 +447,47 @@ def location_wizard_view(): # ============================================================================ -# 3. GET /app/locations/{id} - Detail view (HTML) +# 3. GET /app/locations/outlets - Wall outlet overview +# ============================================================================ + +@router.get("/app/locations/outlets", response_class=HTMLResponse) +def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)): + try: + where = ["o.deleted_at IS NULL", "o.is_active = TRUE"] + params = [] + if status: + where.append("o.status = %s") + params.append(status) + if q: + where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)") + params.extend([f"%{q.strip()}%"] * 7) + outlets = execute_query(f""" + WITH RECURSIVE tree AS ( + SELECT id, name, parent_location_id, name::text AS hierarchy_path + FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL + UNION ALL + SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name + FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id + WHERE l.deleted_at IS NULL + ) + SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path + FROM locations_wall_outlets o + JOIN locations_locations l ON l.id = o.location_id + LEFT JOIN customers c ON c.id = l.customer_id + LEFT JOIN tree ON tree.id = l.id + WHERE {' AND '.join(where)} + ORDER BY tree.hierarchy_path, o.outlet_number + """, tuple(params)) or [] + return HTMLResponse(render_template( + "modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or '' + )) + except Exception as exc: + logger.error("Error rendering wall outlets overview: %s", exc) + raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik") + + +# ============================================================================ +# 4. GET /app/locations/{id} - Detail view (HTML) # ============================================================================ @router.get("/app/locations/{id}", response_class=HTMLResponse) @@ -564,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)): (id,) ) + wall_outlets = execute_query( + """ + SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active + FROM locations_wall_outlets + WHERE location_id = %s AND deleted_at IS NULL + ORDER BY outlet_number + """, + (id,), + ) + + cross_fields = execute_query( + """SELECT id, name, port_count, notes, is_active + FROM locations_cross_fields + WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE + ORDER BY name""", + (id,), + ) + for cross_field in cross_fields or []: + cross_field["ports"] = execute_query( + """SELECT p.id, p.port_number, p.is_active, + o.id AS outlet_id, o.outlet_number, o.status AS outlet_status, + l.name AS outlet_location_name + FROM locations_cross_field_ports p + LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL + LEFT JOIN locations_locations l ON l.id = o.location_id + WHERE p.cross_field_id = %s ORDER BY p.port_number""", + (cross_field["id"],), + ) or [] + audit_log = execute_query( """ SELECT id, location_id, event_type, user_id, changes, created_at @@ -581,6 +650,8 @@ def detail_location_view(id: int = Path(..., gt=0)): location["services"] = services or [] location["capacity"] = capacity or [] location["hardware"] = hardware or [] + location["wall_outlets"] = wall_outlets or [] + location["cross_fields"] = cross_fields or [] location["audit_log"] = audit_log or [] # Query customers @@ -596,6 +667,8 @@ def detail_location_view(id: int = Path(..., gt=0)): # contacts = call_api("GET", f"/api/v1/locations/{id}/contacts") # hours = call_api("GET", f"/api/v1/locations/{id}/hours") + customers = customers or [] + # Render template with context html = render_template( "modules/locations/templates/detail.html", @@ -646,7 +719,7 @@ def edit_location_view(id: int = Path(..., gt=0)): location = location[0] # Get first result - parent_locations = get_parent_location_choices(exclude_id=id) + parent_locations = get_parent_location_choices(exclude_id=id) or [] selected_parent = next( (row for row in parent_locations if row.get("id") == location.get("parent_location_id")), None, @@ -672,7 +745,7 @@ def edit_location_view(id: int = Path(..., gt=0)): cancel_url=f"/app/locations/{id}", location_types=LOCATION_TYPES, parent_locations=parent_locations, - customers=customers, + customers=customers or [], selected_parent=selected_parent, http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field ) @@ -714,6 +787,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)): latitude = %s, longitude = %s, notes = %s, + has_cross_field = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, ( @@ -731,6 +805,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)): float(form.get("latitude")) if form.get("latitude") else None, float(form.get("longitude")) if form.get("longitude") else None, form.get("notes"), + form.get("has_cross_field") == "on" and form.get("location_type") == "rum", id )) diff --git a/app/modules/locations/models/schemas.py b/app/modules/locations/models/schemas.py index b4f0e8b..96c9142 100644 --- a/app/modules/locations/models/schemas.py +++ b/app/modules/locations/models/schemas.py @@ -17,7 +17,7 @@ from decimal import Decimal class LocationBase(BaseModel): """Shared fields for location models""" - name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)") + name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)") location_type: str = Field( ..., description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" @@ -40,6 +40,7 @@ class LocationBase(BaseModel): email: Optional[str] = None notes: Optional[str] = None is_active: bool = Field(True, description="Whether location is active") + has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field") @field_validator('location_type') @classmethod @@ -75,6 +76,7 @@ class LocationUpdate(BaseModel): email: Optional[str] = None notes: Optional[str] = None is_active: Optional[bool] = None + has_cross_field: Optional[bool] = None @field_validator('location_type') @classmethod @@ -101,6 +103,99 @@ class Location(LocationBase): from_attributes = True +# ============================================================================ +# NETWORK WALL OUTLET MODELS +# ============================================================================ + +OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'} + + +class WallOutletBase(BaseModel): + location_id: int = Field(..., ge=1) + outlet_number: str = Field(..., min_length=1, max_length=100) + category: Optional[str] = Field(None, max_length=50) + patch_panel: Optional[str] = Field(None, max_length=255) + patch_port: Optional[str] = Field(None, max_length=100) + cross_field_port_id: Optional[int] = Field(None, ge=1) + switch_name: Optional[str] = Field(None, max_length=255) + switch_port: Optional[str] = Field(None, max_length=100) + status: str = Field('unknown') + notes: Optional[str] = None + is_active: bool = True + + @field_validator('status') + @classmethod + def validate_outlet_status(cls, value): + if value not in OUTLET_STATUSES: + raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}') + return value + + +class WallOutletCreate(WallOutletBase): + pass + + +class WallOutletUpdate(BaseModel): + outlet_number: Optional[str] = Field(None, min_length=1, max_length=100) + category: Optional[str] = Field(None, max_length=50) + patch_panel: Optional[str] = Field(None, max_length=255) + patch_port: Optional[str] = Field(None, max_length=100) + cross_field_port_id: Optional[int] = Field(None, ge=1) + switch_name: Optional[str] = Field(None, max_length=255) + switch_port: Optional[str] = Field(None, max_length=100) + status: Optional[str] = None + notes: Optional[str] = None + is_active: Optional[bool] = None + + @field_validator('status') + @classmethod + def validate_outlet_status(cls, value): + if value is not None and value not in OUTLET_STATUSES: + raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}') + return value + + +class WallOutlet(WallOutletBase): + id: int + created_at: datetime + updated_at: datetime + deleted_at: Optional[datetime] = None + location_name: Optional[str] = None + location_type: Optional[str] = None + customer_name: Optional[str] = None + hierarchy_path: Optional[str] = None + + +class CrossFieldCreate(BaseModel): + location_id: int = Field(..., ge=1) + name: str = Field(..., min_length=1, max_length=100) + port_count: int = Field(..., ge=1, le=999) + notes: Optional[str] = None + + +class CrossFieldUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + port_count: Optional[int] = Field(None, ge=1, le=999) + notes: Optional[str] = None + + +class CrossFieldPort(BaseModel): + id: int + port_number: int + is_active: bool + + +class CrossField(BaseModel): + id: int + location_id: int + name: str + port_count: int + notes: Optional[str] = None + is_active: bool + created_at: datetime + ports: List[CrossFieldPort] = [] + + # ============================================================================ # 2. CONTACT MODELS # ============================================================================ @@ -360,6 +455,7 @@ class LocationDetail(Location): hours: List[OperatingHours] = Field(default_factory=list) services: List[Service] = Field(default_factory=list) capacity: List[Capacity] = Field(default_factory=list) + wall_outlets: List[WallOutlet] = Field(default_factory=list) class AuditLogEntry(BaseModel): diff --git a/app/modules/locations/templates/create.html b/app/modules/locations/templates/create.html index 8bcd221..53834b3 100644 --- a/app/modules/locations/templates/create.html +++ b/app/modules/locations/templates/create.html @@ -158,6 +158,14 @@ + +