feat: support partial-width rack devices
This commit is contained in:
@@ -4,4 +4,5 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
.testvenv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# NetBox Utilities
|
# 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.
|
- 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.
|
- 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.
|
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
|
||||||
- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext.
|
- 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`.
|
- 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 Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen.
|
||||||
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
- Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
|
||||||
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
|
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
|
||||||
@@ -35,7 +36,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/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.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -132,6 +133,38 @@ empfohlen.
|
|||||||
|
|
||||||
## Verwendung
|
## 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
|
### Front- und Rearports von Patchpaneln automatisch verknüpfen
|
||||||
|
|
||||||
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
|
Geräte, deren NetBox-Geräterolle `Patchpanel` heißt, werden automatisch als
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.7.4"
|
__version__ = "0.8.0"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_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__
|
version = __version__
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "utilities"
|
base_url = "utilities"
|
||||||
@@ -25,12 +25,14 @@ class NetBoxUtilitiesConfig(PluginConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
super().ready()
|
super().ready()
|
||||||
from .patchpanel import install_patchpanel_automation
|
from .patchpanel import install_patchpanel_automation
|
||||||
|
from .rack_width import install_rack_width_support
|
||||||
from .tenant_scope import install_search_filter
|
from .tenant_scope import install_search_filter
|
||||||
from .tenant_validation import install_tenant_validation
|
from .tenant_validation import install_tenant_validation
|
||||||
|
|
||||||
install_search_filter()
|
install_search_filter()
|
||||||
install_tenant_validation()
|
install_tenant_validation()
|
||||||
install_patchpanel_automation()
|
install_patchpanel_automation()
|
||||||
|
install_rack_width_support()
|
||||||
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
|
if get_plugin_config("netbox_utilities", "reorder_rack_bulk_save_enabled"):
|
||||||
from .reorder_rack import install_reorder_rack_bulk_save
|
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),
|
||||||
|
]
|
||||||
@@ -46,3 +46,36 @@ class UtilitiesSettings(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "NetBox Utilities settings"
|
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}"
|
||||||
|
|||||||
@@ -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) {
|
function initialize(root = document) {
|
||||||
rewriteImageUploadLinks(root);
|
rewriteImageUploadLinks(root);
|
||||||
configureTenantConfirmation(root);
|
configureTenantConfirmation(root);
|
||||||
configureImagePreview(root);
|
configureImagePreview(root);
|
||||||
|
configureRackWidth(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from netbox.plugins import PluginTemplateExtension
|
|||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .models import NavigationPreference
|
from .models import DeviceRackPlacement, NavigationPreference
|
||||||
from .navigation_helpers import (
|
from .navigation_helpers import (
|
||||||
SIDEBAR_WIDTH_DEFAULT,
|
SIDEBAR_WIDTH_DEFAULT,
|
||||||
SIDEBAR_WIDTH_MAX,
|
SIDEBAR_WIDTH_MAX,
|
||||||
@@ -99,5 +99,17 @@ class DeviceUtilitiesContent(PluginTemplateExtension):
|
|||||||
return ""
|
return ""
|
||||||
return self.render("netbox_utilities/device_bulk_module_button.html")
|
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]
|
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>
|
||||||
@@ -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
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.7.4"
|
version = "0.8.0"
|
||||||
description = "Navigation, tenant utilities, patchpanel mapping, bulk uploads, and atomic rack reordering for NetBox 4.6"
|
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user