fix: restore rack and reorder width views
This commit is contained in:
@@ -105,6 +105,30 @@ sudo systemctl restart netbox netbox-rq
|
||||
Nach Änderungen an JavaScript oder CSS kann ein Hard-Reload des Browsers mit
|
||||
`Strg+F5` erforderlich sein.
|
||||
|
||||
Ab Version `0.9.1` werden die Teilbreiten-Adapter auf der Rack- und
|
||||
Reorder-Seite auch dann eingebunden, wenn keine Teilbreitengeräte gefunden
|
||||
wurden. Nach diesem Update ist `collectstatic` deshalb zwingend erforderlich;
|
||||
die Dateien `topology-rack-width.js` und `reorder-rack-width.js` müssen unter
|
||||
`/opt/netbox/netbox/static/netbox_utilities/` vorhanden sein.
|
||||
|
||||
Die Installation lässt sich anschließend mit diesen Befehlen kontrollieren:
|
||||
|
||||
```bash
|
||||
/opt/netbox/venv/bin/python -c \
|
||||
"import netbox_utilities; print(netbox_utilities.__version__, netbox_utilities.__file__)"
|
||||
|
||||
test -f /opt/netbox/netbox/static/netbox_utilities/reorder-rack-width.js
|
||||
test -f /opt/netbox/netbox/static/netbox_utilities/topology-rack-width.js
|
||||
|
||||
cd /opt/netbox/netbox
|
||||
/opt/netbox/venv/bin/python manage.py shell -c \
|
||||
"from django.urls import reverse; print(reverse('dcim:rack_reorder', kwargs={'pk': 1})); print(reverse('plugins:netbox_topology_views:rack_elevation'))"
|
||||
```
|
||||
|
||||
Die letzten beiden Routen müssen auflösbar sein. Die Topology-Rack-Route ist
|
||||
im getesteten MrBlake-Develop-Stand `86a8daf45ef380d57ac73b461dbc47a16bb831ba`
|
||||
enthalten, aber nicht im gleich bezeichneten öffentlichen Tag `v4.5.1`.
|
||||
|
||||
## Aktualisierung
|
||||
|
||||
Bei einer Installation aus dem `main`-Branch wird das Paket erneut aus Gitea
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.9.0"
|
||||
__version__ = "0.9.1"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -248,25 +248,12 @@ def _decimal_range(start, stop):
|
||||
current += decimal.Decimal("0.5")
|
||||
|
||||
|
||||
def _partial_intervals_for_unit(unit, face, placements):
|
||||
intervals = []
|
||||
for placement in placements:
|
||||
device = placement.device
|
||||
if not (device.face == face or device.device_type.is_full_depth):
|
||||
continue
|
||||
start, end = _vertical_interval(device)
|
||||
if start <= decimal.Decimal(unit) < end:
|
||||
intervals.append(horizontal_interval(placement.width, placement.horizontal_position))
|
||||
return intervals
|
||||
|
||||
|
||||
def _install_rack_methods():
|
||||
from dcim.models import Rack
|
||||
|
||||
if getattr(Rack, "_netbox_utilities_rack_width_installed", False):
|
||||
return
|
||||
original_available_units = Rack.get_available_units
|
||||
original_rack_units = Rack.get_rack_units
|
||||
|
||||
@wraps(original_available_units)
|
||||
def width_aware_available_units(
|
||||
@@ -286,33 +273,7 @@ def _install_rack_methods():
|
||||
)
|
||||
return original_available_units(rack, u_height, rack_face, exclude, ignore_excluded_devices)
|
||||
|
||||
@wraps(original_rack_units)
|
||||
def width_aware_rack_units(rack, *args, **kwargs):
|
||||
units = original_rack_units(rack, *args, **kwargs)
|
||||
face = kwargs.get("face")
|
||||
if face is None and len(args) > 1:
|
||||
face = args[1]
|
||||
if face is None:
|
||||
from dcim.choices import DeviceFaceChoices
|
||||
|
||||
face = DeviceFaceChoices.FACE_FRONT
|
||||
placements = list(
|
||||
DeviceRackPlacement.objects.filter(
|
||||
device__rack=rack,
|
||||
device__position__isnull=False,
|
||||
).select_related("device", "device__device_type")
|
||||
)
|
||||
for unit in units:
|
||||
intervals = _partial_intervals_for_unit(unit["id"], face, placements)
|
||||
if not intervals:
|
||||
continue
|
||||
unit["device"] = None
|
||||
unit.pop("height", None)
|
||||
unit["occupied"] = intervals_cover_full_width(intervals)
|
||||
return units
|
||||
|
||||
Rack.get_available_units = width_aware_available_units
|
||||
Rack.get_rack_units = width_aware_rack_units
|
||||
Rack._netbox_utilities_rack_width_installed = True
|
||||
|
||||
|
||||
@@ -463,8 +424,31 @@ def _install_rack_svg():
|
||||
|
||||
@wraps(original_draw_face)
|
||||
def width_aware_draw_face(elevation, face, opposite=False):
|
||||
original_draw_face(elevation, face, opposite)
|
||||
for device in _partial_width_elevation_devices(elevation.rack, face):
|
||||
partial_devices = list(_partial_width_elevation_devices(elevation.rack, face))
|
||||
partial_device_ids = {device.pk for device in partial_devices}
|
||||
|
||||
# Draw regular devices with NetBox's native geometry. Partial devices
|
||||
# are skipped here and rendered once with their stored horizontal slot
|
||||
# below. Rack.get_rack_units() deliberately remains untouched so that
|
||||
# other consumers such as netbox-reorder-rack keep receiving devices.
|
||||
for unit in elevation.rack.get_rack_units(face=face, expand_devices=False):
|
||||
device = unit["device"]
|
||||
if device and device.pk in partial_device_ids:
|
||||
continue
|
||||
height = unit.get("height", decimal.Decimal("1.0"))
|
||||
coords = elevation._get_device_coords(unit["id"], height)
|
||||
size = (elevation.unit_width, int(elevation.unit_height * height))
|
||||
if device and device.pk in elevation.permitted_device_ids:
|
||||
if device.face == face and not opposite:
|
||||
elevation.draw_device_front(device, coords, size)
|
||||
else:
|
||||
elevation.draw_device_rear(device, coords, size)
|
||||
elif device:
|
||||
from svgwrite.shapes import Rect
|
||||
|
||||
elevation.drawing.add(Rect(coords, size, class_="blocked"))
|
||||
|
||||
for device in partial_devices:
|
||||
placement = device.netbox_utilities_rack_placement
|
||||
height = decimal.Decimal(str(device.device_type.u_height))
|
||||
coords = elevation._get_device_coords(device.position, height)
|
||||
|
||||
@@ -234,16 +234,33 @@ def _image_url(image):
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def get_reorder_rack_width_data(request):
|
||||
"""Describe partial-width widgets for netbox-reorder-rack's GridStack UI."""
|
||||
if not reorder_rack_width_enabled(request):
|
||||
return None
|
||||
|
||||
data = _empty_reorder_rack_width_data(request)
|
||||
rack_id = getattr(request.resolver_match, "kwargs", {}).get("pk")
|
||||
if not rack_id:
|
||||
return None
|
||||
data["status"] = "missing-rack"
|
||||
return data
|
||||
|
||||
from dcim.svg.racks import get_device_name
|
||||
from netbox.config import get_config
|
||||
from utilities.html import foreground_color
|
||||
|
||||
@@ -262,16 +279,15 @@ def get_reorder_rack_width_data(request):
|
||||
.order_by("pk")
|
||||
)
|
||||
permission = get_permission_for_model(Device, "change")
|
||||
descriptors = []
|
||||
for device in devices:
|
||||
placement = device.netbox_utilities_rack_placement
|
||||
grid_width, grid_x = grid_dimensions(placement.width, placement.horizontal_position)
|
||||
role_color = device.role.color or "1685fc"
|
||||
grid_height = int(Decimal(str(device.device_type.u_height)) * 2)
|
||||
descriptors.append(
|
||||
data["devices"].append(
|
||||
{
|
||||
"id": device.pk,
|
||||
"label": get_device_name(device),
|
||||
"label": _device_label(device),
|
||||
"face": device.face,
|
||||
"full_depth": device.device_type.is_full_depth,
|
||||
"grid_x": grid_x,
|
||||
@@ -287,17 +303,8 @@ def get_reorder_rack_width_data(request):
|
||||
"locked": not request.user.has_perm(permission, obj=device),
|
||||
}
|
||||
)
|
||||
if not descriptors:
|
||||
return None
|
||||
|
||||
selected_view = request.GET.get("view", "images-and-labels")
|
||||
return {
|
||||
"columns": GRID_COLUMNS,
|
||||
"unit_width": get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
|
||||
"images": selected_view != "labels-only",
|
||||
"labels": selected_view != "images-only",
|
||||
"devices": descriptors,
|
||||
}
|
||||
data["unit_width"] = get_config().RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||
return data
|
||||
except (
|
||||
AttributeError,
|
||||
ObjectDoesNotExist,
|
||||
@@ -307,8 +314,13 @@ def get_reorder_rack_width_data(request):
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
logger.warning("Could not load partial rack widths for netbox-reorder-rack", exc_info=True)
|
||||
return None
|
||||
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):
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
console.error('NetBox Utilities could not read the Reorder Rack width data.', error);
|
||||
return;
|
||||
}
|
||||
if (data.status && data.status !== 'ready') {
|
||||
console.warn('NetBox Utilities is leaving Reorder Rack in its native layout.', data.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = Number.parseInt(data.columns, 10) || 12;
|
||||
const gridElements = {
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
const placementSource = document.getElementById('netbox-utilities-topology-rack-width-data');
|
||||
if (!placementSource) return;
|
||||
|
||||
let placementData;
|
||||
let payload;
|
||||
try {
|
||||
placementData = JSON.parse(placementSource.textContent);
|
||||
payload = JSON.parse(placementSource.textContent);
|
||||
} catch (error) {
|
||||
console.error('NetBox Utilities could not read the Topology Views rack widths.', error);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload) && payload?.status && payload.status !== 'ready') {
|
||||
console.warn('NetBox Utilities is leaving Topology Views in its native layout.', payload.status);
|
||||
return;
|
||||
}
|
||||
const placementData = Array.isArray(payload) ? payload : payload?.devices;
|
||||
if (!Array.isArray(placementData)) {
|
||||
console.error('NetBox Utilities received invalid Topology Views rack width data.');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePath = value => {
|
||||
try {
|
||||
@@ -59,6 +68,7 @@
|
||||
&& typeof device.dataset.deviceColor === 'string'
|
||||
));
|
||||
});
|
||||
if (!rackCards.length && !placementData.length) return;
|
||||
if (!liveViewCompatible) {
|
||||
console.warn('NetBox Utilities left Topology Views unchanged because its rack DOM is not compatible.');
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.context_processors import PermWrapper
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from netbox_utilities.template_content import UtilitiesGlobalContent
|
||||
|
||||
|
||||
class OptionalRackIntegrationHeadTest(SimpleTestCase):
|
||||
@staticmethod
|
||||
def _context(path):
|
||||
request = RequestFactory().get(path)
|
||||
request.user = AnonymousUser()
|
||||
return {
|
||||
"request": request,
|
||||
"settings": settings,
|
||||
"csrf_token": "",
|
||||
"perms": PermWrapper(request.user),
|
||||
}
|
||||
|
||||
def test_reorder_adapter_is_emitted_for_an_empty_enabled_payload(self):
|
||||
data = {
|
||||
"columns": 12,
|
||||
"unit_width": 220,
|
||||
"images": True,
|
||||
"labels": True,
|
||||
"devices": [],
|
||||
"status": "ready",
|
||||
}
|
||||
with (
|
||||
patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=data),
|
||||
patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=None),
|
||||
):
|
||||
html = UtilitiesGlobalContent(self._context("/dcim/racks/3/reorder/")).head()
|
||||
|
||||
self.assertIn('id="netbox-utilities-reorder-rack-width-data"', html)
|
||||
self.assertIn("netbox_utilities/reorder-rack-width.js", html)
|
||||
self.assertIn('"status": "ready"', html)
|
||||
|
||||
def test_topology_adapter_is_emitted_for_an_empty_enabled_payload(self):
|
||||
data = {"devices": [], "status": "ready"}
|
||||
with (
|
||||
patch("netbox_utilities.template_content.get_reorder_rack_width_data", return_value=None),
|
||||
patch("netbox_utilities.template_content.get_topology_rack_width_data", return_value=data),
|
||||
):
|
||||
html = UtilitiesGlobalContent(
|
||||
self._context("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
|
||||
).head()
|
||||
|
||||
self.assertIn('id="netbox-utilities-topology-rack-width-data"', html)
|
||||
self.assertIn("netbox_utilities/topology-rack-width.js", html)
|
||||
self.assertIn('"status": "ready"', html)
|
||||
@@ -1,7 +1,7 @@
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.context_processors import PermWrapper
|
||||
@@ -43,6 +43,67 @@ class RackWidthTest(SimpleTestCase):
|
||||
self.assertIn("device_type", devices.query.select_related)
|
||||
self.assertIn("manufacturer", devices.query.select_related["device_type"])
|
||||
|
||||
def test_core_rack_units_remain_available_to_other_plugins(self):
|
||||
from dcim.models import Rack
|
||||
|
||||
self.assertEqual(Rack.get_rack_units.__module__, "dcim.models.racks")
|
||||
self.assertFalse(hasattr(Rack.get_rack_units, "__wrapped__"))
|
||||
|
||||
def test_svg_draws_all_partial_devices_sharing_one_unit_exactly_once(self):
|
||||
from dcim.svg.racks import RackElevationSVG
|
||||
|
||||
regular = SimpleNamespace(pk=1, face="front")
|
||||
partial_right = SimpleNamespace(
|
||||
pk=2,
|
||||
face="front",
|
||||
position=11,
|
||||
device_type=SimpleNamespace(u_height=1),
|
||||
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=2),
|
||||
)
|
||||
partial_left = SimpleNamespace(
|
||||
pk=3,
|
||||
face="front",
|
||||
position=11,
|
||||
device_type=SimpleNamespace(u_height=1),
|
||||
netbox_utilities_rack_placement=SimpleNamespace(width=2, horizontal_position=1),
|
||||
)
|
||||
rack = SimpleNamespace(
|
||||
get_rack_units=MagicMock(
|
||||
return_value=[
|
||||
{"id": 12, "device": regular, "height": 1},
|
||||
# NetBox's native unit list can expose only one of multiple
|
||||
# partial devices occupying the same rack unit.
|
||||
{"id": 11, "device": partial_right, "height": 1},
|
||||
]
|
||||
)
|
||||
)
|
||||
elevation = SimpleNamespace(
|
||||
rack=rack,
|
||||
unit_width=200,
|
||||
unit_height=20,
|
||||
permitted_device_ids={1, 2, 3},
|
||||
drawing=SimpleNamespace(add=MagicMock()),
|
||||
_get_device_coords=MagicMock(return_value=(10, 20)),
|
||||
draw_device_front=MagicMock(),
|
||||
draw_device_rear=MagicMock(),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"netbox_utilities.rack_width._partial_width_elevation_devices",
|
||||
return_value=[partial_left, partial_right],
|
||||
):
|
||||
RackElevationSVG.draw_face(elevation, "front")
|
||||
|
||||
rack.get_rack_units.assert_called_once_with(face="front", expand_devices=False)
|
||||
self.assertEqual(
|
||||
elevation.draw_device_front.call_args_list,
|
||||
[
|
||||
call(regular, (10, 20), (200, 20)),
|
||||
call(partial_left, (10.0, 20), (100.0, 20)),
|
||||
call(partial_right, (110.0, 20), (100.0, 20)),
|
||||
],
|
||||
)
|
||||
|
||||
def test_adds_rack_width_fields_to_existing_location_fieldset(self):
|
||||
from utilities.forms.rendering import FieldSet
|
||||
|
||||
|
||||
@@ -223,7 +223,10 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
rack_model.objects.restrict.return_value.filter.return_value.first.return_value = SimpleNamespace(pk=5)
|
||||
|
||||
with patch("netbox_utilities.reorder_rack.Device.objects.restrict", side_effect=AttributeError("stale")):
|
||||
self.assertIsNone(get_reorder_rack_width_data(request))
|
||||
result = get_reorder_rack_width_data(request)
|
||||
|
||||
self.assertEqual(result["status"], "native-fallback")
|
||||
self.assertEqual(result["devices"], [])
|
||||
|
||||
@patch("netbox_utilities.reorder_rack.reorder_rack_width_enabled", return_value=True)
|
||||
@patch("netbox_utilities.reorder_rack.get_permission_for_model", return_value="dcim.change_device")
|
||||
@@ -246,6 +249,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
device = SimpleNamespace(
|
||||
pk=10,
|
||||
name="Fritzbox",
|
||||
label="Fritzbox",
|
||||
virtual_chassis=None,
|
||||
device_type=SimpleNamespace(
|
||||
u_height=1,
|
||||
@@ -263,7 +267,6 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
device_model.objects.restrict.return_value = device_queryset
|
||||
|
||||
with (
|
||||
patch("dcim.svg.racks.get_device_name", return_value="Fritzbox"),
|
||||
patch(
|
||||
"netbox.config.get_config",
|
||||
return_value=SimpleNamespace(RACK_ELEVATION_DEFAULT_UNIT_WIDTH=220),
|
||||
@@ -273,6 +276,8 @@ class ReorderRackFrontendTest(SimpleTestCase):
|
||||
result = get_reorder_rack_width_data(request)
|
||||
|
||||
self.assertEqual(result["columns"], 12)
|
||||
self.assertEqual(result["status"], "ready")
|
||||
self.assertEqual(result["devices"][0]["label"], "Fritzbox")
|
||||
self.assertFalse(result["images"])
|
||||
self.assertTrue(result["labels"])
|
||||
self.assertEqual(
|
||||
|
||||
@@ -94,8 +94,9 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
position__isnull=False,
|
||||
)
|
||||
placement_objects.filter.assert_called_once_with(device__in="permitted-device-query")
|
||||
self.assertEqual(result[0]["width"], 2)
|
||||
self.assertEqual(result[0]["horizontal_position"], 2)
|
||||
self.assertEqual(result["status"], "ready")
|
||||
self.assertEqual(result["devices"][0]["width"], 2)
|
||||
self.assertEqual(result["devices"][0]["horizontal_position"], 2)
|
||||
|
||||
def test_frontend_supports_live_view_and_all_rack_export_formats(self):
|
||||
script = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "topology-rack-width.js").read_text(
|
||||
@@ -112,6 +113,8 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
self.assertIn("event.stopImmediatePropagation()", script)
|
||||
self.assertIn("rackExportCompatible", script)
|
||||
self.assertIn("liveViewCompatible", script)
|
||||
self.assertIn("payload?.devices", script)
|
||||
self.assertIn("payload.status !== 'ready'", script)
|
||||
self.assertLess(script.index("liveViewCompatible"), script.index("netbox-utilities-partial-width"))
|
||||
self.assertLess(script.index("exportGraphic(graphic"), script.index("event.preventDefault()"))
|
||||
|
||||
@@ -119,7 +122,10 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
|
||||
def test_malformed_partial_placement_fails_open(self, _enabled):
|
||||
self.request.resolver_match = SimpleNamespace(view_name="plugins:netbox_topology_views:rack_elevation")
|
||||
with patch("dcim.models.Rack.objects.restrict", side_effect=AttributeError("stale")):
|
||||
self.assertIsNone(get_topology_rack_width_data(self.request))
|
||||
result = get_topology_rack_width_data(self.request)
|
||||
|
||||
self.assertEqual(result["status"], "native-fallback")
|
||||
self.assertEqual(result["devices"], [])
|
||||
|
||||
def test_partial_width_css_uses_topology_device_slot_geometry(self):
|
||||
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
|
||||
|
||||
@@ -41,9 +41,13 @@ def serialize_topology_placement(placement):
|
||||
|
||||
def get_topology_rack_width_data(request):
|
||||
"""Return permitted partial-width devices shown by Topology Views."""
|
||||
if not topology_rack_width_enabled(request) or not request.GET:
|
||||
if not topology_rack_width_enabled(request):
|
||||
return None
|
||||
|
||||
data = {"devices": [], "status": "ready"}
|
||||
if not request.GET:
|
||||
return data
|
||||
|
||||
from dcim.models import Device, Rack
|
||||
|
||||
try:
|
||||
@@ -66,7 +70,8 @@ def get_topology_rack_width_data(request):
|
||||
placements = (
|
||||
DeviceRackPlacement.objects.filter(device__in=devices).select_related("device").order_by("device_id")
|
||||
)
|
||||
return [serialize_topology_placement(placement) for placement in placements]
|
||||
data["devices"] = [serialize_topology_placement(placement) for placement in placements]
|
||||
return data
|
||||
except (
|
||||
AttributeError,
|
||||
ObjectDoesNotExist,
|
||||
@@ -76,5 +81,10 @@ def get_topology_rack_width_data(request):
|
||||
ValueError,
|
||||
):
|
||||
# Keep NetBox usable while plugin migrations are being installed.
|
||||
logger.warning("Could not load partial rack widths for NetBox Topology Views", exc_info=True)
|
||||
return None
|
||||
logger.warning(
|
||||
"Could not load partial rack widths for NetBox Topology Views path %s; using its native rack layout",
|
||||
request.get_full_path(),
|
||||
exc_info=True,
|
||||
)
|
||||
data["status"] = "native-fallback"
|
||||
return data
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "netbox-utilities"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user