feat: support partial-width rack devices

This commit is contained in:
2026-08-12 10:38:54 +02:00
parent 267a78d58d
commit a504341442
11 changed files with 740 additions and 7 deletions
+1
View File
@@ -4,4 +4,5 @@ __pycache__/
.ruff_cache/
build/
dist/
.testvenv/
*.egg-info/
+35 -2
View File
@@ -1,12 +1,13 @@
# NetBox Utilities
Plugin für **NetBox 4.6.5** mit acht Funktionen:
Plugin für **NetBox 4.6.5** mit neun Funktionen:
- Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden.
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext.
- Automatische 1:1-Verknüpfung von Front- und Rearports auf Geräten mit der Rolle `Patchpanel`.
- Geräte mit optionaler Teilbreite können sich dieselbe Höheneinheit teilen.
- Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
@@ -35,7 +36,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.7.4"
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.8.0"
```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -132,6 +133,38 @@ empfohlen.
## Verwendung
### Mehrere Geräte nebeneinander in derselben HE
Im normalen NetBox-Geräteformular stehen direkt nach **Rackseite** zwei neue
optionale Felder zur Verfügung:
- **Rackbreite**: volle, halbe, Drittel- oder Viertelbreite;
- **Breitenposition**: Position 1 bis 4, von links gezählt.
Für eine Fritzbox und ein zweites Gerät in derselben HE wird bei beiden
Geräten beispielsweise **1/2 Rackbreite** gewählt. Die Fritzbox erhält
**Position 1 (links)**, das andere Gerät **Position 2**. Rack, HE und Rackseite
dürfen anschließend identisch sein. Die Kollisionsprüfung berücksichtigt
sowohl die Gerätehöhe als auch die Breite und verhindert horizontale oder
vertikale Überschneidungen. Mehrere HE hohe Geräte werden ebenfalls
unterstützt.
Ohne Breitenangabe belegt ein Gerät wie bisher die volle Rackbreite. Das gilt
automatisch für sämtliche vorhandenen Geräte; es findet keine Änderung oder
Migration bestehender Platzierungen statt. Teilbreite und Position werden auf
der Geräteseite angezeigt und in der Rackgrafik nebeneinander dargestellt.
NetBox besitzt standardmäßig eine Datenbank-Eindeutigkeit für Rack, HE und
Rackseite. Die Plugin-Migration `0007` entfernt ausschließlich diese
Core-Eindeutigkeit, damit mehrere Geräte dieselbe HE verwenden können. Das
Plugin übernimmt dafür die breitenabhängige Prüfung beim Speichern. Vor einem
späteren Entfernen des Plugins müssen geteilte Höheneinheiten wieder aufgelöst
werden; die Core-Eindeutigkeit wird bei einer Deinstallation nicht automatisch
wiederhergestellt.
Die Zusatzfelder werden derzeit im NetBox-Webformular gepflegt. REST- oder
CSV-Vorgänge ohne diese Felder behandeln neue Geräte als volle Rackbreite.
### Front- und Rearports von Patchpaneln automatisch verknüpfen
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
+4 -2
View File
@@ -1,12 +1,12 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.7.4"
__version__ = "0.8.0"
class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities"
verbose_name = "NetBox Utilities"
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering"
version = __version__
author = "LKE"
base_url = "utilities"
@@ -25,12 +25,14 @@ class NetBoxUtilitiesConfig(PluginConfig):
def ready(self):
super().ready()
from .patchpanel import install_patchpanel_automation
from .rack_width import install_rack_width_support
from .tenant_scope import install_search_filter
from .tenant_validation import install_tenant_validation
install_search_filter()
install_tenant_validation()
install_patchpanel_automation()
install_rack_width_support()
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
from .reorder_rack import install_reorder_rack_bulk_save
@@ -0,0 +1,65 @@
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
def drop_core_rack_position_constraint(apps, schema_editor):
if schema_editor.connection.vendor == "postgresql":
schema_editor.execute(
'ALTER TABLE "dcim_device" DROP CONSTRAINT IF EXISTS "dcim_device_unique_rack_position_face"'
)
class Migration(migrations.Migration):
dependencies = [
("dcim", "0237_module_remove_local_context_data"),
("netbox_utilities", "0006_retrace_existing_patchpanel_paths"),
]
operations = [
migrations.CreateModel(
name="DeviceRackPlacement",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"width",
models.PositiveSmallIntegerField(
choices=[(2, "1/2 Rackbreite"), (3, "1/3 Rackbreite"), (4, "1/4 Rackbreite")]
),
),
(
"horizontal_position",
models.PositiveSmallIntegerField(
help_text="Position von links innerhalb der Rackbreite.",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(4),
],
),
),
("updated", models.DateTimeField(auto_now=True)),
(
"device",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="netbox_utilities_rack_placement",
to="dcim.device",
),
),
],
options={
"verbose_name": "Gerätebreite im Rack",
"verbose_name_plural": "Gerätebreiten im Rack",
"constraints": [
models.CheckConstraint(
condition=models.Q(width__gte=models.F("horizontal_position")),
name="netbox_utilities_placement_position_within_width",
)
],
},
),
migrations.RunPython(drop_core_rack_position_constraint, migrations.RunPython.noop),
]
+33
View File
@@ -46,3 +46,36 @@ class UtilitiesSettings(models.Model):
def __str__(self):
return "NetBox Utilities settings"
class DeviceRackPlacement(models.Model):
WIDTH_CHOICES = (
(2, "1/2 Rackbreite"),
(3, "1/3 Rackbreite"),
(4, "1/4 Rackbreite"),
)
device = models.OneToOneField(
"dcim.Device",
on_delete=models.CASCADE,
related_name="netbox_utilities_rack_placement",
)
width = models.PositiveSmallIntegerField(choices=WIDTH_CHOICES)
horizontal_position = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(4)],
help_text="Position von links innerhalb der Rackbreite.",
)
updated = models.DateTimeField(auto_now=True)
class Meta:
constraints = (
models.CheckConstraint(
condition=models.Q(width__gte=models.F("horizontal_position")),
name="netbox_utilities_placement_position_within_width",
),
)
verbose_name = "Gerätebreite im Rack"
verbose_name_plural = "Gerätebreiten im Rack"
def __str__(self):
return f"{self.device}: 1/{self.width}, Position {self.horizontal_position}"
+462
View File
@@ -0,0 +1,462 @@
import decimal
from contextvars import ContextVar
from fractions import Fraction
from functools import wraps
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models.signals import post_save, pre_save
from .models import DeviceRackPlacement
FULL_WIDTH = 1
WIDTH_CHOICES = (
(FULL_WIDTH, "Volle Rackbreite"),
(2, "1/2 Rackbreite"),
(3, "1/3 Rackbreite"),
(4, "1/4 Rackbreite"),
)
POSITION_CHOICES = (
(1, "Position 1 (links)"),
(2, "Position 2"),
(3, "Position 3"),
(4, "Position 4 (rechts)"),
)
CORE_RACK_POSITION_CONSTRAINT = "dcim_device_unique_rack_position_face"
_validated_device = ContextVar("netbox_utilities_width_validated_device", default=None)
def normalize_width_position(width, horizontal_position):
try:
width = int(width or FULL_WIDTH)
horizontal_position = int(horizontal_position or 1)
except (TypeError, ValueError):
raise ValidationError("Rackbreite und Breitenposition sind ungültig.") from None
if width not in {1, 2, 3, 4}:
raise ValidationError("Die Rackbreite muss voll, 1/2, 1/3 oder 1/4 sein.")
if width == FULL_WIDTH:
return FULL_WIDTH, 1
if not 1 <= horizontal_position <= width:
raise ValidationError(f"Für 1/{width} Rackbreite sind nur die Positionen 1 bis {width} möglich.")
return width, horizontal_position
def horizontal_interval(width, horizontal_position):
width, horizontal_position = normalize_width_position(width, horizontal_position)
return Fraction(horizontal_position - 1, width), Fraction(horizontal_position, width)
def intervals_overlap(first, second):
return max(first[0], second[0]) < min(first[1], second[1])
def intervals_cover_full_width(intervals):
if not intervals:
return False
merged_end = Fraction(0, 1)
for start, end in sorted(intervals):
if start > merged_end:
return False
merged_end = max(merged_end, end)
return merged_end >= 1
def placement_rectangles_overlap(
first_position,
first_height,
first_width,
first_horizontal_position,
second_position,
second_height,
second_width,
second_horizontal_position,
):
first_vertical = (
decimal.Decimal(str(first_position)),
decimal.Decimal(str(first_position)) + decimal.Decimal(str(first_height)),
)
second_vertical = (
decimal.Decimal(str(second_position)),
decimal.Decimal(str(second_position)) + decimal.Decimal(str(second_height)),
)
return intervals_overlap(first_vertical, second_vertical) and intervals_overlap(
horizontal_interval(first_width, first_horizontal_position),
horizontal_interval(second_width, second_horizontal_position),
)
def _stored_width_position(device):
if not getattr(device, "pk", None):
return FULL_WIDTH, 1
try:
placement = device.netbox_utilities_rack_placement
except (AttributeError, ObjectDoesNotExist):
return FULL_WIDTH, 1
return placement.width, placement.horizontal_position
def get_width_position(device, placements=None):
staged_width = getattr(device, "_netbox_utilities_rack_width", None)
staged_position = getattr(device, "_netbox_utilities_horizontal_position", None)
if staged_width is not None:
return normalize_width_position(staged_width, staged_position)
if placements is not None and device.pk in placements:
placement = placements[device.pk]
return placement.width, placement.horizontal_position
return _stored_width_position(device)
def stage_width_position(device, width, horizontal_position):
width, horizontal_position = normalize_width_position(width, horizontal_position)
device._netbox_utilities_rack_width = width
device._netbox_utilities_horizontal_position = horizontal_position
return width, horizontal_position
def _vertical_interval(device, position=None):
start = decimal.Decimal(position if position is not None else device.position)
height = decimal.Decimal(str(device.device_type.u_height))
return start, start + height
def _faces_overlap(first, second):
return bool(first.device_type.is_full_depth or second.device_type.is_full_depth or first.face == second.face)
def _placement_map(device_ids):
return {
placement.device_id: placement for placement in DeviceRackPlacement.objects.filter(device_id__in=device_ids)
}
def find_placement_conflict(device, *, position=None, exclude=None, ignore_excluded_devices=False):
if not getattr(device, "rack_id", None) or not (position if position is not None else device.position):
return None
try:
candidate_vertical = _vertical_interval(device, position)
except (AttributeError, ObjectDoesNotExist, TypeError, ValueError):
return None
candidate_horizontal = horizontal_interval(*get_width_position(device))
from dcim.models import Device
devices = Device.objects.filter(rack_id=device.rack_id, position__gte=1).select_related("device_type")
excluded_ids = set(exclude or ())
if device.pk:
excluded_ids.add(device.pk)
if excluded_ids:
devices = devices.exclude(pk__in=excluded_ids)
if ignore_excluded_devices:
devices = devices.exclude(device_type__exclude_from_utilization=True)
devices = list(devices)
placements = _placement_map([other.pk for other in devices])
for other in devices:
if not _faces_overlap(device, other):
continue
other_horizontal = horizontal_interval(*get_width_position(other, placements))
if intervals_overlap(candidate_vertical, _vertical_interval(other)) and intervals_overlap(
candidate_horizontal,
other_horizontal,
):
return other
return None
def validate_device_placement(device):
if not getattr(device, "rack_id", None) or not getattr(device, "position", None):
return
width, horizontal_position = get_width_position(device)
normalize_width_position(width, horizontal_position)
if conflict := find_placement_conflict(device):
raise ValidationError(
{
"position": (
f"U{device.position} überschneidet sich in der Rackbreite mit dem Gerät {conflict}. "
"Wählen Sie eine freie Breitenposition."
)
}
)
def available_units_for_device(rack, device, *, exclude=None, ignore_excluded_devices=False):
try:
height = decimal.Decimal(str(device.device_type.u_height))
except (AttributeError, ObjectDoesNotExist):
return []
if not device.position:
return []
units = list(rack.units)
required = set(_decimal_range(device.position, decimal.Decimal(device.position) + height))
if not required.issubset(units):
return []
conflict = find_placement_conflict(
device,
exclude=exclude,
ignore_excluded_devices=ignore_excluded_devices,
)
return [device.position] if conflict is None else []
def _decimal_range(start, stop):
current = decimal.Decimal(start)
stop = decimal.Decimal(stop)
while current < stop:
yield current
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(
rack,
u_height=1.0,
rack_face=None,
exclude=None,
ignore_excluded_devices=False,
):
device = _validated_device.get()
if device is not None and getattr(device, "rack_id", None) == rack.pk:
return available_units_for_device(
rack,
device,
exclude=exclude,
ignore_excluded_devices=ignore_excluded_devices,
)
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
def _install_device_validation():
from dcim.models import Device
if getattr(Device, "_netbox_utilities_rack_width_installed", False):
return
original_clean = Device.clean
@wraps(original_clean)
def width_aware_clean(device, *args, **kwargs):
token = _validated_device.set(device)
try:
result = original_clean(device, *args, **kwargs)
finally:
_validated_device.reset(token)
validate_device_placement(device)
return result
Device.clean = width_aware_clean
Device._meta.constraints = tuple(
constraint
for constraint in Device._meta.constraints
if not (
constraint.name == CORE_RACK_POSITION_CONSTRAINT
or tuple(getattr(constraint, "fields", ())) == ("rack", "position", "face")
)
)
Device._meta.__dict__.pop("total_unique_constraints", None)
Device._netbox_utilities_rack_width_installed = True
def _install_device_form():
from dcim.forms import DeviceForm
if getattr(DeviceForm, "_netbox_utilities_rack_width_installed", False):
return
original_init = DeviceForm.__init__
original_clean = DeviceForm.clean
original_save = DeviceForm.save
@wraps(original_init)
def width_aware_init(form, *args, **kwargs):
original_init(form, *args, **kwargs)
width, horizontal_position = _stored_width_position(form.instance)
form.fields["utilities_rack_width"] = forms.ChoiceField(
label="Rackbreite",
choices=WIDTH_CHOICES,
required=False,
initial=width,
help_text="Optional: Geräte können sich eine HE nebeneinander teilen.",
)
form.fields["utilities_horizontal_position"] = forms.ChoiceField(
label="Breitenposition",
choices=POSITION_CHOICES,
required=False,
initial=horizontal_position,
help_text="Position von links; bei halber Breite sind Position 1 und 2 möglich.",
)
if not form.is_bound:
form.initial["utilities_rack_width"] = width
form.initial["utilities_horizontal_position"] = horizontal_position
reordered = {}
for name, field in form.fields.items():
if name in {"utilities_rack_width", "utilities_horizontal_position"}:
continue
reordered[name] = field
if name == "face":
reordered["utilities_rack_width"] = form.fields["utilities_rack_width"]
reordered["utilities_horizontal_position"] = form.fields["utilities_horizontal_position"]
form.fields = reordered
@wraps(original_clean)
def width_aware_form_clean(form):
cleaned_data = original_clean(form)
try:
width, horizontal_position = normalize_width_position(
cleaned_data.get("utilities_rack_width"),
cleaned_data.get("utilities_horizontal_position"),
)
except ValidationError as error:
form.add_error("utilities_horizontal_position", error)
return cleaned_data
if not cleaned_data.get("rack") or not cleaned_data.get("position"):
width, horizontal_position = FULL_WIDTH, 1
cleaned_data["utilities_rack_width"] = width
cleaned_data["utilities_horizontal_position"] = horizontal_position
stage_width_position(form.instance, width, horizontal_position)
return cleaned_data
@wraps(original_save)
def width_aware_form_save(form, commit=True):
device = original_save(form, commit=commit)
if not commit:
return device
width, horizontal_position = get_width_position(device)
if width == FULL_WIDTH or not device.rack_id or not device.position:
DeviceRackPlacement.objects.filter(device=device).delete()
else:
DeviceRackPlacement.objects.update_or_create(
device=device,
defaults={"width": width, "horizontal_position": horizontal_position},
)
return device
DeviceForm.__init__ = width_aware_init
DeviceForm.clean = width_aware_form_clean
DeviceForm.save = width_aware_form_save
DeviceForm._netbox_utilities_rack_width_installed = True
def _install_rack_svg():
from dcim.svg.racks import RackElevationSVG
if getattr(RackElevationSVG, "_netbox_utilities_rack_width_installed", False):
return
original_draw_face = RackElevationSVG.draw_face
@wraps(original_draw_face)
def width_aware_draw_face(elevation, face, opposite=False):
original_draw_face(elevation, face, opposite)
placements = DeviceRackPlacement.objects.filter(
device__rack=elevation.rack,
device__position__isnull=False,
).select_related("device", "device__device_type", "device__role")
for placement in placements:
device = placement.device
if not (device.face == face or device.device_type.is_full_depth):
continue
height = decimal.Decimal(str(device.device_type.u_height))
coords = elevation._get_device_coords(device.position, height)
width = elevation.unit_width / placement.width
coords = (coords[0] + width * (placement.horizontal_position - 1), coords[1])
size = (width, int(elevation.unit_height * height))
if 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)
else:
from svgwrite.shapes import Rect
elevation.drawing.add(Rect(coords, size, class_="blocked"))
RackElevationSVG.draw_face = width_aware_draw_face
RackElevationSVG._netbox_utilities_rack_width_installed = True
def _validate_before_save(sender, instance, raw=False, **kwargs):
if not raw:
using = kwargs.get("using")
if instance.rack_id:
from dcim.models import Rack
from django.db import transaction
connection = transaction.get_connection(using)
if connection.in_atomic_block:
Rack.objects.using(using).select_for_update().filter(pk=instance.rack_id).exists()
validate_device_placement(instance)
def _cleanup_unracked_placement(sender, instance, raw=False, using=None, **kwargs):
if not raw and (not instance.rack_id or not instance.position):
DeviceRackPlacement.objects.using(using).filter(device_id=instance.pk).delete()
def install_rack_width_support():
from dcim.models import Device
_install_rack_methods()
_install_device_validation()
_install_device_form()
_install_rack_svg()
pre_save.connect(
_validate_before_save,
sender=Device,
dispatch_uid="netbox_utilities.validate_device_rack_width",
weak=False,
)
post_save.connect(
_cleanup_unracked_placement,
sender=Device,
dispatch_uid="netbox_utilities.cleanup_device_rack_width",
weak=False,
)
@@ -91,10 +91,34 @@
});
}
function configureRackWidth(root = document) {
const widthField = root.querySelector('[name="utilities_rack_width"]');
const positionField = root.querySelector('[name="utilities_horizontal_position"]');
if (!widthField || !positionField || widthField.dataset.netboxUtilitiesWidth === 'true') return;
widthField.dataset.netboxUtilitiesWidth = 'true';
const updatePositions = () => {
const width = Number.parseInt(widthField.value || '1', 10);
Array.from(positionField.options).forEach((option) => {
const position = Number.parseInt(option.value || '1', 10);
option.disabled = position > width;
});
if (width === 1 || Number.parseInt(positionField.value || '1', 10) > width) {
positionField.value = '1';
positionField.dispatchEvent(new Event('change', {bubbles: true}));
}
positionField.disabled = width === 1;
};
widthField.addEventListener('change', updatePositions);
updatePositions();
}
function initialize(root = document) {
rewriteImageUploadLinks(root);
configureTenantConfirmation(root);
configureImagePreview(root);
configureRackWidth(root);
}
if (document.readyState === 'loading') {
+13 -1
View File
@@ -5,7 +5,7 @@ from netbox.plugins import PluginTemplateExtension
from tenancy.models import Tenant, TenantGroup
from . import __version__
from .models import NavigationPreference
from .models import DeviceRackPlacement, NavigationPreference
from .navigation_helpers import (
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
@@ -99,5 +99,17 @@ class DeviceUtilitiesContent(PluginTemplateExtension):
return ""
return self.render("netbox_utilities/device_bulk_module_button.html")
def right_page(self):
placement = DeviceRackPlacement.objects.filter(device=self.context["object"]).first()
if placement is None:
return ""
return self.render(
"netbox_utilities/device_rack_width_panel.html",
{
"rack_width": f"1/{placement.width}",
"horizontal_position": placement.horizontal_position,
},
)
template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent]
@@ -0,0 +1,13 @@
<div class="card">
<h2 class="card-header">Rackbreite</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">Breite</th>
<td>{{ rack_width }} der Rackbreite</td>
</tr>
<tr>
<th scope="row">Position von links</th>
<td>{{ horizontal_position }}</td>
</tr>
</table>
</div>
+88
View File
@@ -0,0 +1,88 @@
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from netbox_utilities.rack_width import (
_cleanup_unracked_placement,
available_units_for_device,
horizontal_interval,
intervals_cover_full_width,
intervals_overlap,
normalize_width_position,
placement_rectangles_overlap,
stage_width_position,
)
class RackWidthTest(SimpleTestCase):
def test_normalizes_full_width_to_first_position(self):
self.assertEqual(normalize_width_position(1, 4), (1, 1))
def test_rejects_position_outside_selected_width(self):
with self.assertRaises(ValidationError):
normalize_width_position(2, 3)
def test_adjacent_half_widths_do_not_overlap(self):
self.assertFalse(intervals_overlap(horizontal_interval(2, 1), horizontal_interval(2, 2)))
def test_half_width_overlaps_full_width(self):
self.assertTrue(intervals_overlap(horizontal_interval(1, 1), horizontal_interval(2, 2)))
def test_two_halves_cover_full_width(self):
self.assertTrue(intervals_cover_full_width([horizontal_interval(2, 1), horizontal_interval(2, 2)]))
def test_single_half_does_not_cover_full_width(self):
self.assertFalse(intervals_cover_full_width([horizontal_interval(2, 1)]))
def test_same_unit_adjacent_devices_do_not_collide(self):
self.assertFalse(placement_rectangles_overlap(10, 1, 2, 1, 10, 1, 2, 2))
def test_multi_unit_device_collides_only_on_same_horizontal_area(self):
self.assertTrue(placement_rectangles_overlap(10, 2, 2, 1, 11, 1, 2, 1))
self.assertFalse(placement_rectangles_overlap(10, 2, 2, 1, 11, 1, 2, 2))
def test_stages_width_without_requiring_an_already_assigned_rack(self):
device = SimpleNamespace()
stage_width_position(device, 2, 2)
self.assertEqual(device._netbox_utilities_rack_width, 2)
self.assertEqual(device._netbox_utilities_horizontal_position, 2)
@patch("netbox_utilities.rack_width.find_placement_conflict", return_value=None)
def test_current_position_is_available_without_conflict(self, _find_conflict):
rack = SimpleNamespace(units=[1, 1.5, 2, 2.5])
device = SimpleNamespace(
position=1,
device_type=SimpleNamespace(u_height=1),
)
self.assertEqual(available_units_for_device(rack, device), [1])
@patch("netbox_utilities.rack_width.DeviceRackPlacement.objects")
def test_unracked_device_removes_stale_width(self, placement_objects):
device = SimpleNamespace(pk=12, rack_id=None, position=None)
_cleanup_unracked_placement(None, device, using="default")
placement_objects.using.assert_called_once_with("default")
placement_objects.using.return_value.filter.assert_called_once_with(device_id=12)
placement_objects.using.return_value.filter.return_value.delete.assert_called_once_with()
class RackWidthMigrationTest(SimpleTestCase):
def test_postgresql_migration_drops_core_uniqueness(self):
migration = import_module("netbox_utilities.migrations.0007_devicerackplacement")
schema_editor = SimpleNamespace(
connection=SimpleNamespace(vendor="postgresql"),
execute=MagicMock(),
)
migration.drop_core_rack_position_constraint(None, schema_editor)
statement = schema_editor.execute.call_args.args[0]
self.assertIn("DROP CONSTRAINT IF EXISTS", statement)
self.assertIn("dcim_device_unique_rack_position_face", statement)
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.7.4"
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering for NetBox 4.6"
version = "0.8.0"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }