489 lines
18 KiB
Python
489 lines
18 KiB
Python
import logging
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal, InvalidOperation
|
|
from importlib import import_module
|
|
|
|
from dcim.models import Device, Rack
|
|
from django.apps import apps
|
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
from django.db import OperationalError, ProgrammingError, transaction
|
|
from django.http import Http404
|
|
from django.shortcuts import get_object_or_404
|
|
from netbox.plugins import get_plugin_config
|
|
from rest_framework import status
|
|
from rest_framework.exceptions import PermissionDenied
|
|
from rest_framework.response import Response
|
|
from utilities.permissions import get_permission_for_model
|
|
|
|
from . import __version__
|
|
from .models import DeviceRackPlacement
|
|
from .rack_width import FULL_WIDTH, effective_width_positions, normalize_width_position, stage_width_position
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
|
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
|
VIEW_PATCH_MARKER = "_netbox_utilities_width_aware_view"
|
|
GRID_COLUMNS = 12
|
|
REORDER_RACK_VIEW = "dcim:rack_reorder"
|
|
|
|
|
|
class RackLayoutError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DevicePlacement:
|
|
device_id: int
|
|
position: Decimal | None
|
|
face: str
|
|
grid_x: int | None
|
|
|
|
|
|
def grid_dimensions(width, horizontal_position):
|
|
try:
|
|
width, horizontal_position = normalize_width_position(width, horizontal_position)
|
|
except ValidationError as error:
|
|
raise RackLayoutError(str(error)) from error
|
|
grid_width = GRID_COLUMNS // width
|
|
return grid_width, (horizontal_position - 1) * grid_width
|
|
|
|
|
|
def horizontal_position_from_grid_x(width, grid_x):
|
|
if isinstance(grid_x, bool) or not isinstance(grid_x, int):
|
|
raise RackLayoutError("Die horizontale Rasterposition muss eine ganze Zahl sein.")
|
|
grid_width, _ = grid_dimensions(width, 1)
|
|
if grid_x < 0 or grid_x > GRID_COLUMNS - grid_width or grid_x % grid_width:
|
|
raise RackLayoutError(f"Rasterposition {grid_x} ist für 1/{width} Rackbreite nicht zulässig.")
|
|
return grid_x // grid_width + 1
|
|
|
|
|
|
def parse_device_placements(layout, *, grid_columns=None):
|
|
if grid_columns not in {None, GRID_COLUMNS}:
|
|
raise RackLayoutError(f"Das Rackraster muss {GRID_COLUMNS} Spalten verwenden.")
|
|
|
|
placements = []
|
|
seen_device_ids = set()
|
|
|
|
for section, face in (("front", "front"), ("rear", "rear"), ("other", "")):
|
|
for item in layout[section]:
|
|
if not isinstance(item, dict):
|
|
raise RackLayoutError(f"Ungültiger Eintrag im Bereich {section}.")
|
|
|
|
try:
|
|
device_id = int(item["id"])
|
|
except (KeyError, TypeError, ValueError) as error:
|
|
raise RackLayoutError(f"Ein Gerät im Bereich {section} besitzt keine gültige ID.") from error
|
|
|
|
if isinstance(item.get("id"), bool) or device_id < 1:
|
|
raise RackLayoutError(f"Ein Gerät im Bereich {section} besitzt keine gültige ID.")
|
|
if device_id in seen_device_ids:
|
|
raise RackLayoutError(f"Gerät {device_id} kommt im Rack-Layout mehrfach vor.")
|
|
seen_device_ids.add(device_id)
|
|
|
|
if section == "other":
|
|
position = None
|
|
grid_x = None
|
|
else:
|
|
try:
|
|
position = Decimal(str(item["y"]))
|
|
except (KeyError, InvalidOperation, TypeError, ValueError) as error:
|
|
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.") from error
|
|
if not position.is_finite():
|
|
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige Rack-Position.")
|
|
|
|
grid_x = None
|
|
if grid_columns == GRID_COLUMNS:
|
|
grid_x = item.get("x")
|
|
if isinstance(grid_x, bool) or not isinstance(grid_x, int) or not 0 <= grid_x < GRID_COLUMNS:
|
|
raise RackLayoutError(f"Gerät {device_id} besitzt keine gültige horizontale Rasterposition.")
|
|
|
|
placements.append(DevicePlacement(device_id=device_id, position=position, face=face, grid_x=grid_x))
|
|
|
|
return placements
|
|
|
|
|
|
def apply_rack_layout(*, rack, placements, user):
|
|
placement_by_id = {placement.device_id: placement for placement in placements}
|
|
devices = {
|
|
device.pk: device for device in Device.objects.select_for_update().filter(rack=rack, pk__in=placement_by_id)
|
|
}
|
|
|
|
missing_ids = set(placement_by_id) - set(devices)
|
|
if missing_ids:
|
|
missing = ", ".join(str(device_id) for device_id in sorted(missing_ids))
|
|
raise RackLayoutError(f"Diese Geräte gehören nicht zum gewählten Rack: {missing}.")
|
|
|
|
stored_placements = {
|
|
placement.device_id: placement
|
|
for placement in DeviceRackPlacement.objects.select_for_update().filter(device_id__in=placement_by_id)
|
|
}
|
|
mounted_devices = [device for device in devices.values() if device.position is not None and device.position > 0]
|
|
effective_placements = effective_width_positions(mounted_devices, stored_placements)
|
|
target_placements = []
|
|
for placement in placements:
|
|
current_width, current_horizontal_position, _source = effective_placements.get(
|
|
placement.device_id,
|
|
(FULL_WIDTH, 1, "default"),
|
|
)
|
|
if placement.position is None:
|
|
target_width, target_horizontal_position = FULL_WIDTH, 1
|
|
elif placement.grid_x is None:
|
|
target_width = current_width
|
|
target_horizontal_position = current_horizontal_position
|
|
else:
|
|
target_width = current_width
|
|
target_horizontal_position = horizontal_position_from_grid_x(target_width, placement.grid_x)
|
|
target_placements.append(
|
|
(
|
|
devices[placement.device_id],
|
|
placement,
|
|
target_width,
|
|
target_horizontal_position,
|
|
current_width,
|
|
current_horizontal_position,
|
|
)
|
|
)
|
|
|
|
changed = [
|
|
(device, placement, target_width, target_horizontal_position)
|
|
for (
|
|
device,
|
|
placement,
|
|
target_width,
|
|
target_horizontal_position,
|
|
current_width,
|
|
current_horizontal_position,
|
|
) in target_placements
|
|
if (
|
|
device.position != placement.position
|
|
or device.face != placement.face
|
|
or current_width != target_width
|
|
or current_horizontal_position != target_horizontal_position
|
|
)
|
|
]
|
|
if not changed:
|
|
return []
|
|
|
|
permission = get_permission_for_model(Device, "change")
|
|
for device, _placement, _width, _horizontal_position in changed:
|
|
if not user.has_perm(permission, obj=device):
|
|
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
|
|
|
|
for device, _placement, _width, _horizontal_position in changed:
|
|
device.snapshot()
|
|
|
|
changed_ids = [device.pk for device, _placement, _width, _horizontal_position in changed]
|
|
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
|
|
|
|
for device, placement, width, horizontal_position in changed:
|
|
device.position = placement.position
|
|
device.face = placement.face
|
|
stage_width_position(device, width, horizontal_position)
|
|
device.full_clean()
|
|
device.save()
|
|
|
|
if width == FULL_WIDTH or placement.position is None:
|
|
DeviceRackPlacement.objects.filter(device=device).delete()
|
|
else:
|
|
DeviceRackPlacement.objects.update_or_create(
|
|
device=device,
|
|
defaults={"width": width, "horizontal_position": horizontal_position},
|
|
)
|
|
|
|
return [device for device, _placement, _width, _horizontal_position in changed]
|
|
|
|
|
|
def _validation_error_message(error):
|
|
if hasattr(error, "message_dict"):
|
|
return "; ".join(
|
|
f"{field}: {', '.join(str(message) for message in messages)}"
|
|
for field, messages in error.message_dict.items()
|
|
)
|
|
return "; ".join(str(message) for message in error.messages)
|
|
|
|
|
|
def is_reorder_rack_request(request):
|
|
resolver_match = getattr(request, "resolver_match", None)
|
|
return getattr(resolver_match, "view_name", None) == REORDER_RACK_VIEW
|
|
|
|
|
|
def reorder_rack_width_enabled(request):
|
|
if not (
|
|
is_reorder_rack_request(request)
|
|
and get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled")
|
|
and apps.is_installed("netbox_reorder_rack")
|
|
):
|
|
return False
|
|
plugin_config = apps.get_app_config("netbox_reorder_rack")
|
|
return getattr(plugin_config, "version", None) in SUPPORTED_REORDER_RACK_VERSIONS
|
|
|
|
|
|
def reorder_grid_y(rack, position, height):
|
|
rack_height = int(Decimal(str(rack.u_height)) * 2)
|
|
grid_height = int(Decimal(str(height)) * 2)
|
|
unit_id = int(Decimal(str(position)) * 2)
|
|
if rack.desc_units:
|
|
return unit_id - 2
|
|
if grid_height > 1:
|
|
return rack_height - unit_id - grid_height + 2
|
|
return rack_height - unit_id
|
|
|
|
|
|
def _image_url(image):
|
|
if not image:
|
|
return ""
|
|
try:
|
|
return image.url
|
|
except (AttributeError, ValueError):
|
|
return ""
|
|
|
|
|
|
def _device_label(device):
|
|
return device.label or str(device.device_type)
|
|
|
|
|
|
def _empty_reorder_rack_width_data(request, *, status="ready"):
|
|
selected_view = request.GET.get("view", "images-and-labels")
|
|
return {
|
|
"columns": GRID_COLUMNS,
|
|
"unit_width": 220,
|
|
"images": selected_view != "labels-only",
|
|
"labels": selected_view != "images-only",
|
|
"devices": [],
|
|
"status": status,
|
|
"schema_version": 3,
|
|
"complete": False,
|
|
}
|
|
|
|
|
|
def get_reorder_rack_width_data(request, *, rack=None, devices=None):
|
|
"""Describe every mounted widget for netbox-reorder-rack's GridStack UI."""
|
|
# The patched Reorder view passes its already-authorized rack and device
|
|
# queryset explicitly. The global helper keeps the permission-scoped
|
|
# lookup for callers which do not own the rendered view.
|
|
view_owned_data = rack is not None
|
|
if not view_owned_data and not reorder_rack_width_enabled(request):
|
|
return None
|
|
|
|
data = _empty_reorder_rack_width_data(request)
|
|
rack_id = rack.pk if rack is not None else getattr(request.resolver_match, "kwargs", {}).get("pk")
|
|
if not rack_id:
|
|
data["status"] = "missing-rack"
|
|
return data
|
|
|
|
from netbox.config import get_config
|
|
from utilities.html import foreground_color
|
|
|
|
try:
|
|
if rack is None:
|
|
rack = Rack.objects.restrict(request.user, "view").filter(pk=rack_id).first()
|
|
if rack is None:
|
|
return None
|
|
if devices is None:
|
|
devices = (
|
|
Device.objects.restrict(request.user, "view")
|
|
.filter(
|
|
rack=rack,
|
|
position__gt=0,
|
|
device_type__u_height__gt=0,
|
|
)
|
|
.select_related(
|
|
"device_type",
|
|
"device_type__manufacturer",
|
|
"role",
|
|
"virtual_chassis",
|
|
"netbox_utilities_rack_placement",
|
|
)
|
|
.order_by("position", "pk")
|
|
)
|
|
devices = list(devices)
|
|
widths = effective_width_positions(devices)
|
|
permission = get_permission_for_model(Device, "change")
|
|
for device in devices:
|
|
width, horizontal_position, width_source = widths[device.pk]
|
|
grid_width, grid_x = grid_dimensions(width, horizontal_position)
|
|
role_color = device.role.color or "1685fc"
|
|
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
|
|
data["devices"].append(
|
|
{
|
|
"id": device.pk,
|
|
"label": _device_label(device),
|
|
"face": device.face,
|
|
"full_depth": device.device_type.is_full_depth,
|
|
"grid_x": grid_x,
|
|
"grid_y": reorder_grid_y(rack, device.position, device.device_type.u_height),
|
|
"grid_width": grid_width,
|
|
"grid_height": grid_height,
|
|
"width": width,
|
|
"horizontal_position": horizontal_position,
|
|
"width_source": width_source,
|
|
"color": role_color,
|
|
"text_color": foreground_color(role_color),
|
|
"front_image": _image_url(device.device_type.front_image),
|
|
"rear_image": _image_url(device.device_type.rear_image),
|
|
"locked": not request.user.has_perm(permission, obj=device),
|
|
}
|
|
)
|
|
data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
|
data["complete"] = True
|
|
return data
|
|
except (
|
|
AttributeError,
|
|
ObjectDoesNotExist,
|
|
OperationalError,
|
|
ProgrammingError,
|
|
RackLayoutError,
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
logger.warning(
|
|
"Could not load partial rack widths for netbox-reorder-rack rack %s; using the native rack layout",
|
|
rack_id,
|
|
exc_info=True,
|
|
)
|
|
data["status"] = "native-fallback"
|
|
return data
|
|
|
|
|
|
def bulk_reorder_update(self, request, pk=None):
|
|
serializer = self.serializer_class(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
layout = serializer.validated_data
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
rack = get_object_or_404(Rack.objects.select_for_update(), pk=pk)
|
|
if layout["rack_id"] != rack.pk:
|
|
raise RackLayoutError("Die Rack-ID der Anfrage stimmt nicht mit dem Ziel-Rack überein.")
|
|
|
|
grid_columns_header = request.headers.get("X-NetBox-Utilities-Rack-Grid-Columns")
|
|
try:
|
|
grid_columns = int(grid_columns_header) if grid_columns_header is not None else None
|
|
except (TypeError, ValueError):
|
|
raise RackLayoutError("Das angegebene Rackraster ist ungültig.") from None
|
|
placements = parse_device_placements(layout, grid_columns=grid_columns)
|
|
changed_devices = apply_rack_layout(rack=rack, placements=placements, user=request.user)
|
|
if not changed_devices:
|
|
return Response(
|
|
{"message": "No changes detected."},
|
|
status=status.HTTP_304_NOT_MODIFIED,
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"message": f"{len(changed_devices)} devices reordered successfully",
|
|
"data": serializer.data,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
except RackLayoutError as error:
|
|
return Response(
|
|
{"message": "Invalid rack layout", "error": str(error)},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except ValidationError as error:
|
|
return Response(
|
|
{"message": "Invalid device position", "error": _validation_error_message(error)},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except PermissionDenied as error:
|
|
return Response(
|
|
{"message": "Permission denied", "error": str(error)},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
except Http404:
|
|
raise
|
|
except Exception as error:
|
|
logger.exception("Failed to save a reordered rack layout")
|
|
return Response(
|
|
{"message": "Error saving data", "error": str(error)},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
|
|
def width_aware_reorder_get(self, request, pk):
|
|
"""Render every rack device server-side on the native Reorder page."""
|
|
from dcim.choices import SubdeviceRoleChoices
|
|
from django.conf import settings
|
|
from django.shortcuts import render
|
|
|
|
rack = get_object_or_404(Rack, pk=pk)
|
|
selected_view = request.GET.get("view", "images-and-labels")
|
|
images = selected_view != "labels-only"
|
|
labels = selected_view != "images-only"
|
|
non_racked = (
|
|
Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)
|
|
.exclude(device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD)
|
|
.select_related("device_type", "role")
|
|
)
|
|
mounted_devices = (
|
|
Device.objects.filter(
|
|
rack=rack,
|
|
position__gt=0,
|
|
device_type__u_height__gt=0,
|
|
)
|
|
.select_related(
|
|
"device_type",
|
|
"device_type__manufacturer",
|
|
"role",
|
|
"virtual_chassis",
|
|
"netbox_utilities_rack_placement",
|
|
)
|
|
.order_by("position", "pk")
|
|
)
|
|
width_data = get_reorder_rack_width_data(request, rack=rack, devices=mounted_devices)
|
|
if not width_data or not width_data.get("complete"):
|
|
original_get = getattr(type(self), "_netbox_utilities_original_get", None)
|
|
if original_get is not None:
|
|
return original_get(self, request, pk)
|
|
raise Http404
|
|
|
|
return render(
|
|
request,
|
|
"netbox_utilities/reorder_rack.html",
|
|
{
|
|
"object": rack,
|
|
"images": images,
|
|
"labels": labels,
|
|
"unit_width": width_data["unit_width"],
|
|
"reorder_devices": width_data["devices"],
|
|
"reorder_rack_width_data": width_data,
|
|
"non_racked": non_racked,
|
|
"basepath": settings.BASE_PATH,
|
|
"asset_version": __version__,
|
|
},
|
|
)
|
|
|
|
|
|
def install_reorder_rack_bulk_save():
|
|
if not apps.is_installed("netbox_reorder_rack"):
|
|
return False
|
|
|
|
plugin_config = apps.get_app_config("netbox_reorder_rack")
|
|
plugin_version = getattr(plugin_config, "version", None)
|
|
if plugin_version not in SUPPORTED_REORDER_RACK_VERSIONS:
|
|
logger.warning(
|
|
"NetBox Utilities did not patch netbox-reorder-rack version %s; supported versions: %s",
|
|
plugin_version,
|
|
", ".join(sorted(SUPPORTED_REORDER_RACK_VERSIONS)),
|
|
)
|
|
return False
|
|
|
|
api_views = import_module("netbox_reorder_rack.api.views")
|
|
save_viewset = api_views.SaveViewSet
|
|
if not getattr(save_viewset, PATCH_MARKER, False):
|
|
save_viewset._netbox_utilities_original_update = save_viewset.update
|
|
save_viewset.update = bulk_reorder_update
|
|
setattr(save_viewset, PATCH_MARKER, True)
|
|
|
|
page_views = import_module("netbox_reorder_rack.views")
|
|
reorder_view = page_views.ReorderView
|
|
if not getattr(reorder_view, VIEW_PATCH_MARKER, False):
|
|
reorder_view._netbox_utilities_original_get = reorder_view.get
|
|
reorder_view.get = width_aware_reorder_get
|
|
setattr(reorder_view, VIEW_PATCH_MARKER, True)
|
|
|
|
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
|
|
return True
|