feat: add atomic multi-device rack reordering
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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 ValidationError
|
||||
from django.db import transaction
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_REORDER_RACK_VERSIONS = {"1.1.4"}
|
||||
PATCH_MARKER = "_netbox_utilities_bulk_save"
|
||||
|
||||
|
||||
class RackLayoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DevicePlacement:
|
||||
device_id: int
|
||||
position: Decimal | None
|
||||
face: str
|
||||
|
||||
|
||||
def parse_device_placements(layout):
|
||||
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
|
||||
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.")
|
||||
|
||||
placements.append(DevicePlacement(device_id=device_id, position=position, face=face))
|
||||
|
||||
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}.")
|
||||
|
||||
changed = [
|
||||
(devices[placement.device_id], placement)
|
||||
for placement in placements
|
||||
if (
|
||||
devices[placement.device_id].position != placement.position
|
||||
or devices[placement.device_id].face != placement.face
|
||||
)
|
||||
]
|
||||
if not changed:
|
||||
return []
|
||||
|
||||
permission = get_permission_for_model(Device, "change")
|
||||
for device, _placement in changed:
|
||||
if not user.has_perm(permission, obj=device):
|
||||
raise PermissionDenied(f"Keine Berechtigung zum Verschieben von {device}.")
|
||||
|
||||
for device, _placement in changed:
|
||||
device.snapshot()
|
||||
|
||||
changed_ids = [device.pk for device, _placement in changed]
|
||||
Device.objects.filter(pk__in=changed_ids).update(position=None, face="")
|
||||
|
||||
for device, placement in changed:
|
||||
device.position = placement.position
|
||||
device.face = placement.face
|
||||
device.full_clean()
|
||||
device.save()
|
||||
|
||||
return [device for device, _placement 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 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.")
|
||||
|
||||
placements = parse_device_placements(layout)
|
||||
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 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
|
||||
|
||||
views = import_module("netbox_reorder_rack.api.views")
|
||||
save_viewset = views.SaveViewSet
|
||||
if getattr(save_viewset, PATCH_MARKER, False):
|
||||
return True
|
||||
|
||||
save_viewset._netbox_utilities_original_update = save_viewset.update
|
||||
save_viewset.update = bulk_reorder_update
|
||||
setattr(save_viewset, PATCH_MARKER, True)
|
||||
logger.info("Enabled atomic multi-device saving for netbox-reorder-rack %s", plugin_version)
|
||||
return True
|
||||
Reference in New Issue
Block a user