Add netbox_rack_concept plugin: rack blueprint planning

Introduces a NetBox 4.6 plugin for planning rack layouts (RackConcept,
ConceptDevice) without creating real dcim.Rack/dcim.Device objects, so
blueprints never appear in rack/device lists, utilization reports, or
IPAM.

Features:
- Copy paths: real rack -> concept, concept -> concept (clone), and
  concept -> one or more real racks (deploy, with name patterning and
  dry-run support)
- Manual concept creation via a dedicated "Rack Concepts" nav menu
- Tenant and tenant group assignment on concepts and planned devices
- Partial-width device placement (1/1-1/4) with slot-based collision
  detection, matching netbox_utilities' partial-width feature
- Custom drag-and-drop elevation (netbox-reorder-rack operates on real
  Device querysets, which concepts don't have) with server-side
  validation of every move
- Full REST API, filtering, CSV import, bulk edit, search integration
- Optional, config-driven interop with netbox_utilities (partial-width
  field mapping) and netbox-reorder-rack, degrading gracefully when
  either plugin is absent

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:00:26 +02:00
co-authored by Claude Sonnet 5
commit 17cf94ac0d
34 changed files with 3821 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
from netbox.plugins import PluginConfig
__version__ = '0.1.0'
class RackConceptConfig(PluginConfig):
name = 'netbox_rack_concept'
verbose_name = 'Rack Concepts'
description = 'Rack blueprints: plan racks and their equipment without creating real racks or devices'
version = __version__
author = 'Louis'
base_url = 'rack-concepts'
min_version = '4.6.0'
max_version = '4.6.99'
menu = 'netbox_rack_concept.navigation.menu'
template_extensions = 'netbox_rack_concept.template_content.template_extensions'
default_settings = {
# Defaults used when a concept is created from scratch.
'default_u_height': 42,
'default_width': 19,
# Show the "Copy to concept" button on the dcim.Rack detail page.
'enable_rack_tab': True,
# Status assigned to the objects created when a concept is deployed.
'deployed_rack_status': 'planned',
'deployed_device_status': 'planned',
# Mapping onto the partial-width implementation of netbox_utilities.
# Adjust these if that plugin renames its fields; set 'enabled' to False
# to never write partial-width data during deployment.
'partial_width': {
'enabled': True,
'model': 'netbox_utilities.RackDevicePosition',
'device_field': 'device',
'fraction_field': 'width_fraction',
'position_field': 'horizontal_position',
},
}
def ready(self):
super().ready()
from . import search # noqa: F401
config = RackConceptConfig
View File
+161
View File
@@ -0,0 +1,161 @@
from rest_framework import serializers
from dcim.api.serializers import (
DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer,
RackSerializer, RackTypeSerializer, SiteSerializer,
)
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
from ..choices import ConceptStatusChoices
from ..models import ConceptDeployment, ConceptDevice, RackConcept
__all__ = (
'ConceptDeploySerializer',
'ConceptDeploymentSerializer',
'ConceptDeviceSerializer',
'RackConceptCloneSerializer',
'RackConceptSerializer',
'RackCopySerializer',
)
class RackConceptSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:netbox_rack_concept-api:rackconcept-detail'
)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
tenant_group = TenantGroupSerializer(nested=True, required=False, allow_null=True)
rack_type = RackTypeSerializer(nested=True, required=False, allow_null=True)
rack_role = RackRoleSerializer(nested=True, required=False, allow_null=True)
default_site = SiteSerializer(nested=True, required=False, allow_null=True)
default_location = LocationSerializer(nested=True, required=False, allow_null=True)
source_rack = RackSerializer(nested=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
utilization = serializers.IntegerField(read_only=True)
class Meta:
model = RackConcept
fields = (
'id', 'url', 'display_url', 'display', 'name', 'status', 'description', 'tenant_group', 'tenant',
'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight',
'default_site', 'default_location', 'rack_role', 'source_rack', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'utilization',
)
brief_fields = ('id', 'url', 'display', 'name', 'status', 'u_height', 'description')
class ConceptDeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:netbox_rack_concept-api:conceptdevice-detail'
)
concept = RackConceptSerializer(nested=True)
device_type = DeviceTypeSerializer(nested=True, required=False, allow_null=True)
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
platform = PlatformSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
effective_u_height = serializers.DecimalField(
max_digits=4, decimal_places=1, read_only=True
)
class Meta:
model = ConceptDevice
fields = (
'id', 'url', 'display_url', 'display', 'concept', 'name', 'label', 'status', 'device_type', 'role',
'platform', 'tenant', 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height',
'is_full_depth', 'effective_u_height', 'serial', 'source_device', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
)
brief_fields = ('id', 'url', 'display', 'name', 'position', 'face', 'device_type')
class ConceptDeploymentSerializer(serializers.ModelSerializer):
rack = RackSerializer(nested=True, read_only=True, allow_null=True)
class Meta:
model = ConceptDeployment
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created')
#
# Action payloads
#
class RackCopySerializer(serializers.Serializer):
"""POST body for copying a real rack into a new concept."""
rack = serializers.PrimaryKeyRelatedField(queryset=None)
name = serializers.CharField(max_length=100)
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
include_devices = serializers.BooleanField(default=True)
include_device_names = serializers.BooleanField(default=True)
def __init__(self, *args, **kwargs):
from dcim.models import Rack
from tenancy.models import Tenant
super().__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.all()
self.fields['tenant'].queryset = Tenant.objects.all()
class RackConceptCloneSerializer(serializers.Serializer):
"""POST body for duplicating a concept."""
name = serializers.CharField(max_length=100)
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
include_devices = serializers.BooleanField(default=True)
def __init__(self, *args, **kwargs):
from tenancy.models import Tenant
super().__init__(*args, **kwargs)
self.fields['tenant'].queryset = Tenant.objects.all()
class ConceptDeploySerializer(serializers.Serializer):
"""POST body for materialising a concept."""
name_pattern = serializers.CharField(max_length=100)
count = serializers.IntegerField(min_value=1, max_value=100, default=1)
start_index = serializers.IntegerField(default=1)
site = serializers.PrimaryKeyRelatedField(queryset=None)
location = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
create_devices = serializers.BooleanField(default=True)
device_name_pattern = serializers.CharField(max_length=100, required=False, allow_blank=True, default='')
skip_unnamed_devices = serializers.BooleanField(default=True)
dry_run = serializers.BooleanField(
default=False,
help_text='Return the planned rack names and warnings without creating anything'
)
def __init__(self, *args, **kwargs):
from dcim.models import Location, Site
from tenancy.models import Tenant
super().__init__(*args, **kwargs)
self.fields['site'].queryset = Site.objects.all()
self.fields['location'].queryset = Location.objects.all()
self.fields['tenant'].queryset = Tenant.objects.all()
def validate(self, attrs):
from dcim.models import Rack
try:
names = [
attrs['name_pattern'].format(n=i)
for i in range(attrs['start_index'], attrs['start_index'] + attrs['count'])
]
except (IndexError, KeyError, ValueError) as exc:
raise serializers.ValidationError({'name_pattern': f'Invalid pattern: {exc}'})
if len(set(names)) != len(names):
raise serializers.ValidationError({
'name_pattern': 'The pattern produces duplicate names. Include {n} when creating more than one rack.'
})
clashes = list(Rack.objects.filter(site=attrs['site'], name__in=names).values_list('name', flat=True))
if clashes:
raise serializers.ValidationError({
'name_pattern': f'These racks already exist at the site: {", ".join(sorted(clashes))}'
})
attrs['rack_names'] = names
return attrs
+11
View File
@@ -0,0 +1,11 @@
from netbox.api.routers import NetBoxRouter
from . import views
app_name = 'netbox_rack_concept-api'
router = NetBoxRouter()
router.register('concepts', views.RackConceptViewSet)
router.register('devices', views.ConceptDeviceViewSet)
urlpatterns = router.urls
+175
View File
@@ -0,0 +1,175 @@
from django.core.exceptions import ValidationError
from django.db.models import Count
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from dcim.models import Rack
from netbox.api.viewsets import NetBoxModelViewSet
from .. import filtersets
from .. import deploy as deploy_utils
from ..elevation import get_elevations
from ..models import ConceptDevice, RackConcept
from .serializers import (
ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer,
RackCopySerializer,
)
__all__ = (
'ConceptDeviceViewSet',
'RackConceptViewSet',
)
class RackConceptViewSet(NetBoxModelViewSet):
queryset = RackConcept.objects.prefetch_related('tags').annotate(
device_count=Count('devices')
).select_related('tenant', 'tenant_group', 'rack_type')
serializer_class = RackConceptSerializer
filterset_class = filtersets.RackConceptFilterSet
@action(detail=False, methods=['post'], url_path='copy-from-rack')
def copy_from_rack(self, request):
"""Create a concept from an existing rack, optionally including its devices."""
serializer = RackCopySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=data['rack'].pk)
tenant = data.get('tenant')
try:
concept = deploy_utils.copy_rack_to_concept(
rack=rack,
name=data['name'],
status=data['status'],
tenant=tenant,
tenant_group=tenant.group if tenant else None,
description=data['description'],
include_devices=data['include_devices'],
include_device_names=data['include_device_names'],
)
except ValidationError as exc:
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
status=status.HTTP_400_BAD_REQUEST)
return Response(
RackConceptSerializer(concept, context={'request': request}).data,
status=status.HTTP_201_CREATED
)
@action(detail=True, methods=['post'])
def clone(self, request, pk=None):
"""Duplicate this concept into a new one."""
concept = self.get_object()
serializer = RackConceptCloneSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
new_concept = deploy_utils.clone_concept(
concept=concept,
name=data['name'],
status=data['status'],
tenant=data.get('tenant'),
include_devices=data['include_devices'],
)
except ValidationError as exc:
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
status=status.HTTP_400_BAD_REQUEST)
return Response(
RackConceptSerializer(new_concept, context={'request': request}).data,
status=status.HTTP_201_CREATED
)
@action(detail=True, methods=['post'])
def deploy(self, request, pk=None):
"""
Materialise this concept as one or more real racks. Pass dry_run=true to get the
rack names and warnings back without writing anything.
"""
concept = self.get_object()
serializer = ConceptDeploySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if not request.user.has_perm('dcim.add_rack'):
return Response({'detail': 'Permission denied: dcim.add_rack'}, status=status.HTTP_403_FORBIDDEN)
planned, warnings = deploy_utils.validate_deployment(
concept, data['create_devices'], data['skip_unnamed_devices']
)
if data['dry_run']:
return Response({
'dry_run': True,
'racks': data['rack_names'],
'devices_per_rack': len(planned),
'total_devices': len(planned) * len(data['rack_names']),
'warnings': warnings,
})
try:
racks, devices, warnings = deploy_utils.deploy_concept(
concept=concept,
rack_names=data['rack_names'],
site=data['site'],
location=data.get('location'),
tenant=data.get('tenant'),
create_devices=data['create_devices'],
device_name_pattern=data['device_name_pattern'],
skip_unnamed_devices=data['skip_unnamed_devices'],
user=request.user,
)
except ValidationError as exc:
return Response(
{
'detail': 'Deployment failed; nothing was created.',
'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages,
},
status=status.HTTP_400_BAD_REQUEST
)
return Response({
'racks': [{'id': r.pk, 'name': r.name, 'url': r.get_absolute_url()} for r in racks],
'device_count': len(devices),
'warnings': warnings,
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def elevation(self, request, pk=None):
"""The computed elevation geometry, for external renderers."""
concept = self.get_object()
front, rear = get_elevations(concept)
return Response({
'u_height': concept.u_height,
'starting_unit': concept.starting_unit,
'desc_units': concept.desc_units,
'faces': {
elevation.face: [
{
'device_id': block.device.pk,
'label': block.label,
'position': block.device.position,
'u_height': block.device.effective_u_height,
'width_fraction': block.device.width_fraction,
'horizontal_position': block.device.horizontal_position,
'mounted_on_opposite_face': block.opposite,
'color': block.color,
}
for block in elevation.blocks
]
for elevation in (front, rear)
},
})
class ConceptDeviceViewSet(NetBoxModelViewSet):
queryset = ConceptDevice.objects.prefetch_related('tags').select_related(
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant'
)
serializer_class = ConceptDeviceSerializer
filterset_class = filtersets.ConceptDeviceFilterSet
+61
View File
@@ -0,0 +1,61 @@
from utilities.choices import ChoiceSet
class ConceptStatusChoices(ChoiceSet):
"""Lifecycle of a rack blueprint."""
key = 'RackConcept.status'
STATUS_DRAFT = 'draft'
STATUS_REVIEW = 'review'
STATUS_APPROVED = 'approved'
STATUS_ARCHIVED = 'archived'
CHOICES = [
(STATUS_DRAFT, 'Draft', 'gray'),
(STATUS_REVIEW, 'In review', 'orange'),
(STATUS_APPROVED, 'Approved', 'green'),
(STATUS_ARCHIVED, 'Archived', 'red'),
]
class ConceptDeviceStatusChoices(ChoiceSet):
"""
What a planned device means. This is deliberately *not* dcim's device status:
a blueprint describes intent, not operational state.
"""
key = 'ConceptDevice.status'
STATUS_PLANNED = 'planned'
STATUS_EXISTING = 'existing'
STATUS_OPTIONAL = 'optional'
STATUS_RESERVED = 'reserved'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'blue'),
(STATUS_EXISTING, 'Existing', 'green'),
(STATUS_OPTIONAL, 'Optional', 'cyan'),
(STATUS_RESERVED, 'Reserved space', 'gray'),
]
class WidthFractionChoices(ChoiceSet):
"""
Horizontal subdivision of a rack unit, mirroring the partial-width feature of
netbox_utilities. The value is the denominator: 1 = full width, 2 = half, ...
"""
FRACTION_FULL = 1
FRACTION_HALF = 2
FRACTION_THIRD = 3
FRACTION_QUARTER = 4
CHOICES = (
(FRACTION_FULL, 'Full width'),
(FRACTION_HALF, '1/2'),
(FRACTION_THIRD, '1/3'),
(FRACTION_QUARTER, '1/4'),
)
# Least common multiple of all supported denominators. The elevation is laid out
# on this many lanes so that every fraction lands on an integer lane boundary.
LANE_COUNT = 12
+251
View File
@@ -0,0 +1,251 @@
"""
The three copy paths: rack -> concept, concept -> concept, concept -> rack(s).
"""
from django.conf import settings
from django.db import transaction
from django.utils.translation import gettext as _
from dcim.models import Device, Rack
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
from .integrations import apply_partial_width, read_partial_width
from .models import ConceptDeployment, ConceptDevice, RackConcept
__all__ = (
'clone_concept',
'copy_rack_to_concept',
'deploy_concept',
'validate_deployment',
)
GEOMETRY_FIELDS = (
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight',
)
def _config():
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
#
# dcim.Rack -> RackConcept
#
@transaction.atomic
def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None,
description='', include_devices=True, include_device_names=True):
"""Snapshot a real rack, and optionally its contents, into a new blueprint."""
concept = RackConcept(
name=name,
status=status,
tenant=tenant,
tenant_group=tenant_group,
description=description,
rack_type=rack.rack_type,
default_site=rack.site,
default_location=rack.location,
rack_role=rack.role,
source_rack=rack,
)
for field in GEOMETRY_FIELDS:
value = getattr(rack, field, None)
# A rack built from a rack type may leave its own dimension fields empty.
if value in (None, '') and rack.rack_type:
value = getattr(rack.rack_type, field, None)
if value not in (None, ''):
setattr(concept, field, value)
concept.full_clean()
concept.save()
if include_devices:
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
'device_type', 'role', 'platform', 'tenant'
)
for device in devices:
fraction, horizontal = read_partial_width(device)
ConceptDevice.objects.create(
concept=concept,
name=(device.name or '') if include_device_names else '',
status=ConceptDeviceStatusChoices.STATUS_EXISTING,
device_type=device.device_type,
role=device.role,
platform=device.platform,
tenant=device.tenant,
position=device.position,
face=device.face,
width_fraction=fraction,
horizontal_position=horizontal,
serial=device.serial if include_device_names else '',
source_device=device,
)
return concept
#
# RackConcept -> RackConcept
#
@transaction.atomic
def clone_concept(concept, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, include_devices=True):
"""Duplicate a blueprint so variants can be built without touching the original."""
clone = RackConcept(
name=name,
status=status,
tenant=tenant or concept.tenant,
tenant_group=concept.tenant_group,
description=concept.description,
comments=concept.comments,
rack_type=concept.rack_type,
default_site=concept.default_site,
default_location=concept.default_location,
rack_role=concept.rack_role,
source_rack=concept.source_rack,
)
for field in GEOMETRY_FIELDS:
setattr(clone, field, getattr(concept, field))
clone.full_clean()
clone.save()
if include_devices:
for device in concept.devices.all():
ConceptDevice.objects.create(
concept=clone,
name=device.name,
label=device.label,
status=device.status,
device_type=device.device_type,
role=device.role,
platform=device.platform,
tenant=device.tenant,
position=device.position,
face=device.face,
width_fraction=device.width_fraction,
horizontal_position=device.horizontal_position,
u_height=device.u_height,
is_full_depth=device.is_full_depth,
serial=device.serial,
description=device.description,
comments=device.comments,
source_device=device.source_device,
)
return clone
#
# RackConcept -> dcim.Rack
#
def validate_deployment(concept, create_devices=True, skip_unnamed_devices=True):
"""
Report what a deployment would and would not be able to create. Returned as
(deployable_devices, warnings) so the confirmation page can show it before anything
is written.
"""
warnings = []
deployable = []
if create_devices:
for device in concept.devices.select_related('device_type', 'role'):
if device.position is None:
warnings.append(_('%(device)s has no position and will not be created.') % {'device': device})
continue
if device.device_type is None:
warnings.append(
_('%(device)s is reserved space without a device type and will not be created.')
% {'device': device}
)
continue
if device.role is None:
warnings.append(
_('%(device)s has no role. NetBox requires one, so it will not be created.')
% {'device': device}
)
continue
if not device.name and skip_unnamed_devices:
warnings.append(_('%(device)s has no name and will be skipped.') % {'device': device})
continue
if device.status == ConceptDeviceStatusChoices.STATUS_OPTIONAL:
warnings.append(
_('%(device)s is marked optional but will be created.') % {'device': device}
)
deployable.append(device)
return deployable, warnings
@transaction.atomic
def deploy_concept(concept, rack_names, site, location=None, tenant=None, create_devices=True,
device_name_pattern='', skip_unnamed_devices=True, user=None):
"""
Materialise the blueprint as one real rack per name in `rack_names`.
Returns (racks, devices, warnings).
"""
config = _config()
rack_status = config.get('deployed_rack_status', 'planned')
device_status = config.get('deployed_device_status', 'planned')
planned, warnings = validate_deployment(concept, create_devices, skip_unnamed_devices)
racks = []
created_devices = []
for rack_name in rack_names:
rack = Rack(
name=rack_name,
site=site,
location=location,
tenant=tenant or concept.tenant,
status=rack_status,
role=concept.rack_role,
description=concept.description,
comments=concept.comments,
)
if concept.rack_type:
# Let NetBox derive the geometry from the rack type rather than fighting it.
rack.rack_type = concept.rack_type
else:
for field in GEOMETRY_FIELDS:
setattr(rack, field, getattr(concept, field))
rack.full_clean()
rack.save()
racks.append(rack)
rack_device_count = 0
for planned_device in planned:
name = planned_device.name
if name and device_name_pattern:
name = device_name_pattern.format(rack=rack_name, name=planned_device.name)
device = Device(
name=name or None,
device_type=planned_device.device_type,
role=planned_device.role,
platform=planned_device.platform,
site=site,
location=location,
rack=rack,
position=planned_device.position,
face=planned_device.face,
status=device_status,
tenant=planned_device.tenant or tenant or concept.tenant,
serial=planned_device.serial,
description=planned_device.description,
)
device.full_clean()
device.save()
apply_partial_width(device, planned_device)
created_devices.append(device)
rack_device_count += 1
ConceptDeployment.objects.create(
concept=concept,
rack=rack,
rack_name=rack_name,
device_count=rack_device_count,
user=user if user and user.is_authenticated else None,
)
return racks, created_devices, warnings
+178
View File
@@ -0,0 +1,178 @@
"""
Geometry for concept rack elevations.
This does not reuse dcim's RackElevationSVG: that renderer walks dcim.Device
querysets and knows nothing about partial-width slots. Computing the geometry here
keeps the plugin independent of NetBox internals and lets the template render plain
inline SVG that follows the UI theme.
"""
from decimal import Decimal
from dcim.choices import DeviceFaceChoices
from .choices import LANE_COUNT
UNIT_HEIGHT = 22
RACK_WIDTH = 240
LEGEND_WIDTH = 32
MARGIN = 6
DEFAULT_COLOR = 'c0c0c0'
RESERVED_COLOR = 'e9ecef'
def foreground_color(bg_color):
"""Return black or white, whichever reads better on the given hex colour."""
bg_color = bg_color.strip('#')
try:
r, g, b = (int(bg_color[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
return '000000'
# Perceived luminance (ITU-R BT.601)
return '000000' if (r * 299 + g * 587 + b * 114) / 1000 > 128 else 'ffffff'
class ElevationUnit:
"""One labelled rack unit row."""
def __init__(self, number, y):
self.number = number
self.y = y
self.height = UNIT_HEIGHT
class ElevationBlock:
"""One rendered device rectangle."""
def __init__(self, device, x, y, width, height, opposite=False):
self.device = device
self.x = x
self.y = y
self.width = width
self.height = height
# True when the device is physically mounted on the other face and only
# shows up here because it is full depth.
self.opposite = opposite
@property
def color(self):
if self.device.role and self.device.role.color:
return self.device.role.color
if self.device.device_type is None:
return RESERVED_COLOR
return DEFAULT_COLOR
@property
def text_color(self):
return foreground_color(self.color)
@property
def label(self):
return self.device.display_label
@property
def sublabel(self):
if self.device.device_type:
return str(self.device.device_type)
return ''
@property
def show_text(self):
return self.height >= 14
class Elevation:
"""Everything the template needs to draw one face of a concept rack."""
def __init__(self, concept, face=DeviceFaceChoices.FACE_FRONT, devices=None):
self.concept = concept
self.face = face
self.units = []
self.blocks = []
self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH
self.width = LEGEND_WIDTH + RACK_WIDTH + MARGIN * 2
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
self._build_units()
self._build_blocks(devices if devices is not None else concept.devices.all())
@property
def face_label(self):
return dict(DeviceFaceChoices).get(self.face, self.face)
def _y_for(self, top_unit):
"""Pixel offset of the top edge of a block whose highest unit boundary is `top_unit`."""
concept = self.concept
if concept.desc_units:
# U1 at the top: the block's top edge is its lowest unit number.
bottom = top_unit
offset = Decimal(bottom) - Decimal(concept.starting_unit)
else:
offset = Decimal(concept.starting_unit + concept.u_height) - Decimal(top_unit)
return float(offset) * UNIT_HEIGHT + MARGIN
def _build_units(self):
for number in self.concept.units:
top = number if self.concept.desc_units else number + 1
self.units.append(ElevationUnit(number, self._y_for(Decimal(top))))
def _build_blocks(self, devices):
for device in devices:
span = device.unit_span
if span is None:
continue
if not device.occupies_face(self.face):
continue
bottom, top = span
lane_start, lane_end = device.lane_span
x = MARGIN + LEGEND_WIDTH + (lane_start / LANE_COUNT) * RACK_WIDTH
width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH
if self.concept.desc_units:
y = self._y_for(bottom)
else:
y = self._y_for(top)
self.blocks.append(ElevationBlock(
device=device,
x=x,
y=y,
width=width,
height=float(top - bottom) * UNIT_HEIGHT,
opposite=device.face != self.face,
))
def get_elevations(concept):
"""Front and rear elevation, sharing one prefetched device list."""
devices = list(concept.devices.select_related('device_type', 'role', 'device_type__manufacturer'))
return (
Elevation(concept, DeviceFaceChoices.FACE_FRONT, devices),
Elevation(concept, DeviceFaceChoices.FACE_REAR, devices),
)
def available_units(concept, u_height=Decimal('1'), face=DeviceFaceChoices.FACE_FRONT, exclude=None):
"""
Unit numbers where a device of the given height would fit on the given face at
full width. Used by the position dropdown in the device form.
"""
devices = [
d for d in concept.devices.select_related('device_type')
if d.position is not None and d.pk != exclude and d.occupies_face(face)
]
low, high = concept.unit_range
result = []
unit = low
while unit + u_height <= high:
blocked = any(
not (unit + u_height <= d.unit_span[0] or d.unit_span[1] <= unit)
for d in devices
)
if not blocked:
result.append(unit)
unit += 1
return result
+117
View File
@@ -0,0 +1,117 @@
import django_filters
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from dcim.models import DeviceRole, DeviceType, Rack, RackType, Site
from netbox.filtersets import NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeviceFilterSet',
'RackConceptFilterSet',
)
class RackConceptFilterSet(NetBoxModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=ConceptStatusChoices
)
tenant_group_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
label=_('Tenant group (ID)')
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label=_('Tenant (ID)')
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label=_('Tenant (slug)')
)
rack_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackType.objects.all(),
label=_('Rack type (ID)')
)
source_rack_id = django_filters.ModelMultipleChoiceFilter(
queryset=Rack.objects.all(),
label=_('Source rack (ID)')
)
default_site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label=_('Default site (ID)')
)
class Meta:
model = RackConcept
fields = (
'id', 'name', 'status', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'max_weight', 'description',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class ConceptDeviceFilterSet(NetBoxModelFilterSet):
concept_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackConcept.objects.all(),
label=_('Rack concept (ID)')
)
concept = django_filters.ModelMultipleChoiceFilter(
field_name='concept__name',
queryset=RackConcept.objects.all(),
to_field_name='name',
label=_('Rack concept (name)')
)
status = django_filters.MultipleChoiceFilter(
choices=ConceptDeviceStatusChoices
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
label=_('Device type (ID)')
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceRole.objects.all(),
label=_('Role (ID)')
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label=_('Tenant (ID)')
)
racked = django_filters.BooleanFilter(
method='filter_racked',
label=_('Has a position assigned')
)
class Meta:
model = ConceptDevice
fields = (
'id', 'name', 'label', 'status', 'position', 'face', 'width_fraction', 'horizontal_position',
'u_height', 'is_full_depth', 'serial', 'description',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(label__icontains=value) |
Q(serial__icontains=value) |
Q(description__icontains=value) |
Q(device_type__model__icontains=value)
)
def filter_racked(self, queryset, name, value):
return queryset.filter(position__isnull=not value)
+458
View File
@@ -0,0 +1,458 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms.fields import (
CommentField, CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeployForm',
'ConceptDeviceBulkEditForm',
'ConceptDeviceFilterForm',
'ConceptDeviceForm',
'ConceptDeviceImportForm',
'RackConceptBulkEditForm',
'RackConceptCloneForm',
'RackConceptFilterForm',
'RackConceptForm',
'RackConceptImportForm',
'RackCopyForm',
)
class RackConceptForm(NetBoxModelForm):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
initial_params={'tenants': '$tenant'},
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
rack_type = DynamicModelChoiceField(
queryset=RackType.objects.all(),
required=False,
label=_('Rack type')
)
rack_role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
label=_('Rack role')
)
default_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Default site')
)
default_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$default_site'},
label=_('Default location')
)
comments = CommentField()
fieldsets = (
FieldSet('name', 'status', 'description', 'tags', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'airflow',
name=_('Geometry')
),
FieldSet(
'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'max_weight',
name=_('Dimensions')
),
FieldSet('default_site', 'default_location', 'rack_role', name=_('Deployment defaults')),
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width',
'u_height', 'starting_unit', 'desc_units', 'airflow', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'max_weight', 'default_site', 'default_location', 'rack_role',
'comments', 'tags',
)
class RackConceptFilterForm(NetBoxModelFilterSetForm):
model = RackConcept
status = forms.MultipleChoiceField(
choices=ConceptStatusChoices,
required=False
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False
)
form_factor = forms.MultipleChoiceField(
choices=RackFormFactorChoices,
required=False
)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'width', 'form_factor', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
)
class RackConceptBulkEditForm(NetBoxModelBulkEditForm):
model = RackConcept
status = forms.ChoiceField(choices=ConceptStatusChoices, required=False)
tenant_group = DynamicModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'}
)
description = forms.CharField(max_length=200, required=False)
comments = CommentField()
nullable_fields = ('tenant_group', 'tenant', 'description', 'comments')
fieldsets = (
FieldSet('status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class RackConceptImportForm(NetBoxModelImportForm):
status = CSVChoiceField(choices=ConceptStatusChoices, required=False)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant', 'form_factor', 'width', 'u_height', 'starting_unit',
'desc_units', 'comments', 'tags',
)
class ConceptDeviceForm(NetBoxModelForm):
concept = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Rack concept')
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platform')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
comments = CommentField()
fieldsets = (
FieldSet('concept', 'name', 'label', 'status', 'description', 'tags', name=_('Planned device')),
FieldSet('device_type', 'role', 'platform', 'serial', name=_('Hardware')),
FieldSet('position', 'face', 'width_fraction', 'horizontal_position', name=_('Placement')),
FieldSet('u_height', 'is_full_depth', name=_('Overrides')),
FieldSet('tenant', name=_('Tenancy')),
)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'description', 'device_type', 'role', 'platform', 'serial',
'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth', 'tenant',
'comments', 'tags',
)
class ConceptDeviceFilterForm(NetBoxModelFilterSetForm):
model = ConceptDevice
concept_id = DynamicModelMultipleChoiceField(
queryset=RackConcept.objects.all(),
required=False,
label=_('Rack concept')
)
status = forms.MultipleChoiceField(choices=ConceptDeviceStatusChoices, required=False)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
face = forms.MultipleChoiceField(choices=DeviceFaceChoices, required=False)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('concept_id', 'status', 'face', name=_('Placement')),
FieldSet('device_type_id', 'role_id', name=_('Hardware')),
)
class ConceptDeviceBulkEditForm(NetBoxModelBulkEditForm):
model = ConceptDevice
status = forms.ChoiceField(choices=ConceptDeviceStatusChoices, required=False)
role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False)
platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False)
tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False)
face = forms.ChoiceField(choices=DeviceFaceChoices, required=False)
width_fraction = forms.ChoiceField(choices=WidthFractionChoices, required=False)
is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect())
description = forms.CharField(max_length=200, required=False)
nullable_fields = ('role', 'platform', 'tenant', 'description', 'is_full_depth')
fieldsets = (
FieldSet('status', 'role', 'platform', 'description', name=_('Planned device')),
FieldSet('face', 'width_fraction', 'is_full_depth', name=_('Placement')),
FieldSet('tenant', name=_('Tenancy')),
)
class ConceptDeviceImportForm(NetBoxModelImportForm):
concept = CSVModelChoiceField(
queryset=RackConcept.objects.all(),
to_field_name='name'
)
device_type = CSVModelChoiceField(
queryset=DeviceType.objects.all(),
to_field_name='model',
required=False
)
role = CSVModelChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='name',
required=False
)
status = CSVChoiceField(choices=ConceptDeviceStatusChoices, required=False)
face = CSVChoiceField(choices=DeviceFaceChoices, required=False)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction',
'horizontal_position', 'u_height', 'is_full_depth', 'description', 'tags',
)
#
# Action forms
#
class RackCopyForm(forms.Form):
"""Copy an existing dcim.Rack into a new blueprint."""
name = forms.CharField(
label=_('Concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices'),
help_text=_('Snapshot every device currently mounted in the rack')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names'),
help_text=_('Unchecked, only the device type and position are copied')
)
description = forms.CharField(max_length=200, required=False)
fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
)
class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants."""
name = forms.CharField(
label=_('New concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy planned devices')
)
fieldsets = (
FieldSet('name', 'status', 'tenant', 'include_devices'),
)
class ConceptDeployForm(forms.Form):
"""Materialise a blueprint as one or more real racks."""
name_pattern = forms.CharField(
label=_('Rack name'),
max_length=100,
help_text=_('Use {n} for the sequence number, e.g. "DC1-R{n}". Zero-pad with {n:02d}.')
)
count = forms.IntegerField(
label=_('Number of racks'),
min_value=1,
max_value=100,
initial=1
)
start_index = forms.IntegerField(
label=_('First sequence number'),
initial=1
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label=_('Site')
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$site'},
label=_('Location')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
create_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Create devices'),
help_text=_('Create a real device for every planned device that has a device type and a position')
)
device_name_pattern = forms.CharField(
max_length=100,
required=False,
label=_('Device name'),
help_text=_('Optional. {rack} and {name} are replaced, e.g. "{rack}-{name}". '
'Empty keeps the planned name.')
)
skip_unnamed_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Skip devices without a name'),
help_text=_('Unchecked, unnamed planned devices are created as unnamed devices')
)
fieldsets = (
FieldSet('name_pattern', 'count', 'start_index', name=_('Racks')),
FieldSet('site', 'location', 'tenant', name=_('Placement')),
FieldSet('create_devices', 'device_name_pattern', 'skip_unnamed_devices', name=_('Devices')),
)
def __init__(self, *args, concept=None, **kwargs):
super().__init__(*args, **kwargs)
self.concept = concept
if concept and not self.is_bound:
self.fields['name_pattern'].initial = concept.name
if concept.default_site:
self.fields['site'].initial = concept.default_site.pk
if concept.default_location:
self.fields['location'].initial = concept.default_location.pk
if concept.tenant:
self.fields['tenant'].initial = concept.tenant.pk
def clean_name_pattern(self):
pattern = self.cleaned_data['name_pattern']
try:
pattern.format(n=1)
except (IndexError, KeyError, ValueError) as exc:
raise forms.ValidationError(
_('Invalid name pattern: {error}').format(error=exc)
)
return pattern
def clean(self):
cleaned = super().clean()
pattern = cleaned.get('name_pattern')
count = cleaned.get('count')
start = cleaned.get('start_index')
site = cleaned.get('site')
if not (pattern and count and start is not None and site):
return cleaned
names = [pattern.format(n=i) for i in range(start, start + count)]
if len(set(names)) != len(names):
raise forms.ValidationError({
'name_pattern': _('The pattern produces duplicate names. Include {n} when creating more than one rack.')
})
clashes = Rack.objects.filter(site=site, name__in=names).values_list('name', flat=True)
if clashes:
raise forms.ValidationError({
'name_pattern': _('These racks already exist at {site}: {names}').format(
site=site, names=', '.join(sorted(clashes))
)
})
cleaned['rack_names'] = names
return cleaned
+157
View File
@@ -0,0 +1,157 @@
"""
Optional interoperability with the two plugins this one is meant to sit next to.
Neither is a hard dependency. Everything here degrades to a no-op when the other
plugin is absent, so the concept plugin works on a bare NetBox 4.6 install.
* netbox_utilities -- partial-width rack devices (several devices per rack unit).
The field names are read from PLUGINS_CONFIG so that a rename on their side is a
configuration change here, not a code change.
* netbox-reorder-rack -- drag & drop ordering of *real* racks. Concepts get their
own drag & drop elevation (see static/, templates/) because that plugin operates on
dcim.Device querysets, which a concept deliberately has none of. Once a concept has
been deployed the resulting rack is an ordinary rack and reorder-rack applies to it.
"""
import logging
from django.apps import apps
from django.conf import settings
logger = logging.getLogger('netbox.plugins.netbox_rack_concept')
__all__ = (
'apply_partial_width',
'get_partial_width_adapter',
'read_partial_width',
'reorder_rack_installed',
)
def _config():
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
def plugin_installed(name):
return name in settings.PLUGINS
def reorder_rack_installed():
return plugin_installed('netbox_reorder_rack')
class PartialWidthAdapter:
"""
Wraps whichever shape netbox_utilities uses to store partial-width data:
either extra fields on dcim.Device, or a side model with a FK to the device.
"""
def __init__(self, model, device_field, fraction_field, position_field):
self.model = model
self.device_field = device_field
self.fraction_field = fraction_field
self.position_field = position_field
def write(self, device, fraction, position):
if self.model is None:
# Fields live directly on the Device.
setattr(device, self.fraction_field, fraction)
setattr(device, self.position_field, position)
device.save(update_fields=[self.fraction_field, self.position_field])
return
self.model.objects.update_or_create(
**{self.device_field: device},
defaults={
self.fraction_field: fraction,
self.position_field: position,
}
)
def read(self, device):
if self.model is None:
return (
getattr(device, self.fraction_field, None),
getattr(device, self.position_field, None),
)
obj = self.model.objects.filter(**{self.device_field: device}).first()
if obj is None:
return None, None
return getattr(obj, self.fraction_field, None), getattr(obj, self.position_field, None)
_ADAPTER_CACHE = {}
def get_partial_width_adapter():
"""Return a PartialWidthAdapter, or None when partial width is unavailable."""
if 'adapter' in _ADAPTER_CACHE:
return _ADAPTER_CACHE['adapter']
adapter = None
config = _config().get('partial_width') or {}
if config.get('enabled') and plugin_installed('netbox_utilities'):
fraction_field = config.get('fraction_field', 'width_fraction')
position_field = config.get('position_field', 'horizontal_position')
model_path = config.get('model')
model = None
if model_path:
try:
model = apps.get_model(model_path)
except (LookupError, ValueError):
logger.warning(
'netbox_rack_concept: partial-width model %r not found; falling back to Device fields. '
'Adjust PLUGINS_CONFIG["netbox_rack_concept"]["partial_width"] to match netbox_utilities.',
model_path
)
if model is None:
device_model = apps.get_model('dcim', 'Device')
field_names = {f.name for f in device_model._meta.get_fields()}
if fraction_field in field_names and position_field in field_names:
adapter = PartialWidthAdapter(None, None, fraction_field, position_field)
else:
logger.warning(
'netbox_rack_concept: netbox_utilities is installed but its partial-width storage could not '
'be located. Partial-width values will not be written when deploying concepts.'
)
else:
adapter = PartialWidthAdapter(
model, config.get('device_field', 'device'), fraction_field, position_field
)
_ADAPTER_CACHE['adapter'] = adapter
return adapter
def apply_partial_width(device, concept_device):
"""
Persist the partial-width placement of a concept device onto the real device that
was created from it. Full-width devices need nothing written.
"""
if concept_device.width_fraction == 1:
return False
adapter = get_partial_width_adapter()
if adapter is None:
return False
try:
adapter.write(device, concept_device.width_fraction, concept_device.horizontal_position)
return True
except Exception as exc: # pragma: no cover - depends on the other plugin's schema
logger.warning('netbox_rack_concept: could not write partial-width data for %s: %s', device, exc)
return False
def read_partial_width(device):
"""Read partial-width placement off a real device when copying a rack into a concept."""
adapter = get_partial_width_adapter()
if adapter is None:
return 1, 0
try:
fraction, position = adapter.read(device)
except Exception as exc: # pragma: no cover
logger.warning('netbox_rack_concept: could not read partial-width data for %s: %s', device, exc)
return 1, 0
return int(fraction or 1), int(position or 0)
@@ -0,0 +1,178 @@
import django.core.validators
import django.db.models.deletion
import taggit.managers
import utilities.json
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
# __latest__ keeps the plugin ordered after every core migration, whichever
# NetBox 4.6 patch release is installed.
('dcim', '__latest__'),
('extras', '__latest__'),
('tenancy', '__latest__'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RackConcept',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
)),
('name', models.CharField(max_length=100, unique=True)),
('status', models.CharField(default='draft', max_length=50)),
('form_factor', models.CharField(blank=True, max_length=50)),
('width', models.PositiveSmallIntegerField(default=19)),
('u_height', models.PositiveSmallIntegerField(default=42, validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(100),
])),
('starting_unit', models.PositiveSmallIntegerField(
default=1, validators=[django.core.validators.MinValueValidator(1)]
)),
('desc_units', models.BooleanField(default=False)),
('outer_width', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_height', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_unit', models.CharField(blank=True, max_length=50)),
('mounting_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('airflow', models.CharField(blank=True, max_length=50)),
('max_weight', models.PositiveIntegerField(blank=True, null=True)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('default_location', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.location'
)),
('default_site', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.site'
)),
('rack_role', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.rackrole'
)),
('rack_type', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='rack_concepts', to='dcim.racktype'
)),
('source_rack', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.rack'
)),
('tenant', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='rack_concepts', to='tenancy.tenant'
)),
('tenant_group', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='rack_concepts', to='tenancy.tenantgroup'
)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'rack concept',
'verbose_name_plural': 'rack concepts',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='ConceptDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
)),
('name', models.CharField(blank=True, max_length=64)),
('status', models.CharField(default='planned', max_length=50)),
('position', models.DecimalField(
blank=True, decimal_places=1, max_digits=4, null=True,
validators=[django.core.validators.MinValueValidator(0.5)]
)),
('face', models.CharField(default='front', max_length=50)),
('width_fraction', models.PositiveSmallIntegerField(default=1)),
('horizontal_position', models.PositiveSmallIntegerField(default=0)),
('u_height', models.DecimalField(
blank=True, decimal_places=1, max_digits=4, null=True,
validators=[django.core.validators.MinValueValidator(0)]
)),
('is_full_depth', models.BooleanField(blank=True, null=True)),
('serial', models.CharField(blank=True, max_length=50)),
('label', models.CharField(blank=True, max_length=64)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('concept', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='devices',
to='netbox_rack_concept.rackconcept'
)),
('device_type', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='concept_devices', to='dcim.devicetype'
)),
('platform', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='concept_devices', to='dcim.platform'
)),
('role', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='concept_devices', to='dcim.devicerole'
)),
('source_device', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.device'
)),
('tenant', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='concept_devices', to='tenancy.tenant'
)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'concept device',
'verbose_name_plural': 'concept devices',
'ordering': ('concept', '-position', 'horizontal_position', 'pk'),
},
),
migrations.CreateModel(
name='ConceptDeployment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('rack_name', models.CharField(max_length=100)),
('device_count', models.PositiveSmallIntegerField(default=0)),
('created', models.DateTimeField(auto_now_add=True)),
('concept', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='deployments',
to='netbox_rack_concept.rackconcept'
)),
('rack', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to='dcim.rack'
)),
('user', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='+', to=settings.AUTH_USER_MODEL
)),
],
options={
'verbose_name': 'concept deployment',
'verbose_name_plural': 'concept deployments',
'ordering': ('-created',),
},
),
migrations.AddIndex(
model_name='conceptdevice',
index=models.Index(fields=['concept', 'position'], name='nbrc_device_concept_pos_idx'),
),
]
+461
View File
@@ -0,0 +1,461 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.choices import (
DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices,
)
from netbox.models import NetBoxModel
from .choices import LANE_COUNT, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
__all__ = (
'ConceptDevice',
'ConceptDeployment',
'RackConcept',
)
class RackConcept(NetBoxModel):
"""
A rack blueprint. It mirrors the geometry of dcim.Rack but is a separate model on
purpose: a concept must never show up in the rack list, in rack elevations or in
utilisation reports. It becomes a real rack only when it is deployed.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=ConceptStatusChoices,
default=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = models.ForeignKey(
to='tenancy.TenantGroup',
on_delete=models.PROTECT,
related_name='rack_concepts',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='rack_concepts',
blank=True,
null=True
)
# Rack geometry, mirroring dcim.RackBase
form_factor = models.CharField(
verbose_name=_('form factor'),
choices=RackFormFactorChoices,
max_length=50,
blank=True
)
rack_type = models.ForeignKey(
to='dcim.RackType',
on_delete=models.PROTECT,
related_name='rack_concepts',
blank=True,
null=True,
help_text=_('Copy the geometry from a rack type instead of defining it here')
)
width = models.PositiveSmallIntegerField(
verbose_name=_('width'),
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN
)
u_height = models.PositiveSmallIntegerField(
verbose_name=_('height (U)'),
default=42,
validators=[MinValueValidator(1), MaxValueValidator(100)]
)
starting_unit = models.PositiveSmallIntegerField(
verbose_name=_('starting unit'),
default=1,
validators=[MinValueValidator(1)]
)
desc_units = models.BooleanField(
verbose_name=_('descending units'),
default=False
)
outer_width = models.PositiveSmallIntegerField(blank=True, null=True)
outer_height = models.PositiveSmallIntegerField(blank=True, null=True)
outer_depth = models.PositiveSmallIntegerField(blank=True, null=True)
outer_unit = models.CharField(
max_length=50,
choices=RackDimensionUnitChoices,
blank=True
)
mounting_depth = models.PositiveSmallIntegerField(blank=True, null=True)
airflow = models.CharField(
max_length=50,
choices=RackAirflowChoices,
blank=True
)
max_weight = models.PositiveIntegerField(blank=True, null=True)
# Defaults applied when the concept is deployed
default_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
help_text=_('Pre-selected when deploying this concept')
)
default_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
rack_role = models.ForeignKey(
to='dcim.RackRole',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
# Provenance
source_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
help_text=_('The rack this concept was copied from')
)
description = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
clone_fields = (
'status', 'tenant_group', 'tenant', 'form_factor', 'rack_type', 'width', 'u_height', 'starting_unit',
'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'max_weight', 'default_site', 'default_location', 'rack_role', 'description',
)
class Meta:
ordering = ('name',)
verbose_name = _('rack concept')
verbose_name_plural = _('rack concepts')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('plugins:netbox_rack_concept:rackconcept', args=[self.pk])
def get_status_color(self):
return ConceptStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if self.tenant and self.tenant_group and self.tenant.group_id != self.tenant_group_id:
raise ValidationError({
'tenant': _('Tenant {tenant} does not belong to group {group}.').format(
tenant=self.tenant, group=self.tenant_group
)
})
def save(self, *args, **kwargs):
# Keep the group in sync so tenant-group filtering works without a join.
if self.tenant and not self.tenant_group:
self.tenant_group = self.tenant.group
super().save(*args, **kwargs)
@property
def units(self):
"""Unit numbers ordered as they are displayed, top first."""
units = range(self.starting_unit, self.starting_unit + self.u_height)
return list(units) if self.desc_units else list(reversed(units))
@property
def unit_range(self):
"""(lowest, highest_exclusive) unit numbers as Decimals."""
return Decimal(self.starting_unit), Decimal(self.starting_unit + self.u_height)
@property
def total_u_used(self):
return sum(d.effective_u_height for d in self.devices.all())
@property
def utilization(self):
if not self.u_height:
return 0
return min(int(self.total_u_used / Decimal(self.u_height) * 100), 100)
class ConceptDevice(NetBoxModel):
"""
A planned device inside a blueprint. It references a real dcim.DeviceType so that
height, depth and manufacturer stay accurate, but it is never a dcim.Device and so
never appears in the device list, in interface counts or in IPAM.
"""
concept = models.ForeignKey(
to=RackConcept,
on_delete=models.CASCADE,
related_name='devices'
)
name = models.CharField(
verbose_name=_('name'),
max_length=64,
blank=True,
help_text=_('Optional. Supports the placeholder {n} when deploying multiple racks.')
)
status = models.CharField(
max_length=50,
choices=ConceptDeviceStatusChoices,
default=ConceptDeviceStatusChoices.STATUS_PLANNED
)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.PROTECT,
related_name='concept_devices',
blank=True,
null=True,
help_text=_('Leave empty to reserve space without committing to a model')
)
role = models.ForeignKey(
to='dcim.DeviceRole',
on_delete=models.PROTECT,
related_name='concept_devices',
blank=True,
null=True
)
platform = models.ForeignKey(
to='dcim.Platform',
on_delete=models.PROTECT,
related_name='concept_devices',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='concept_devices',
blank=True,
null=True,
help_text=_('Overrides the tenant of the concept')
)
# Placement
position = models.DecimalField(
verbose_name=_('position (U)'),
max_digits=4,
decimal_places=1,
blank=True,
null=True,
validators=[MinValueValidator(Decimal('0.5'))],
help_text=_('Lowest unit occupied by the device. Empty means unracked.')
)
face = models.CharField(
verbose_name=_('rack face'),
max_length=50,
choices=DeviceFaceChoices,
default=DeviceFaceChoices.FACE_FRONT
)
width_fraction = models.PositiveSmallIntegerField(
verbose_name=_('width'),
choices=WidthFractionChoices,
default=WidthFractionChoices.FRACTION_FULL,
help_text=_('Horizontal share of the rack unit, as used by netbox_utilities')
)
horizontal_position = models.PositiveSmallIntegerField(
verbose_name=_('horizontal position'),
default=0,
help_text=_('Slot index from the left, starting at 0')
)
# Overrides for placeholders without a device type
u_height = models.DecimalField(
verbose_name=_('height (U)'),
max_digits=4,
decimal_places=1,
blank=True,
null=True,
validators=[MinValueValidator(Decimal('0'))],
help_text=_('Overrides the height of the device type')
)
is_full_depth = models.BooleanField(
blank=True,
null=True,
help_text=_('Overrides the depth of the device type')
)
serial = models.CharField(max_length=50, blank=True)
label = models.CharField(
max_length=64,
blank=True,
help_text=_('Free text shown in the elevation instead of the name')
)
# Provenance
source_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
description = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
clone_fields = (
'concept', 'status', 'device_type', 'role', 'platform', 'tenant', 'face', 'width_fraction',
)
class Meta:
ordering = ('concept', '-position', 'horizontal_position', 'pk')
indexes = (
models.Index(fields=('concept', 'position'), name='nbrc_device_concept_pos_idx'),
)
verbose_name = _('concept device')
verbose_name_plural = _('concept devices')
def __str__(self):
if self.name:
return self.name
if self.device_type:
return f'{self.device_type} ({self.pk})'
return f'Reserved space ({self.pk})'
def get_absolute_url(self):
return reverse('plugins:netbox_rack_concept:conceptdevice', args=[self.pk])
def get_status_color(self):
return ConceptDeviceStatusChoices.colors.get(self.status)
@property
def display_label(self):
return self.label or self.name or (str(self.device_type) if self.device_type else _('Reserved'))
@property
def effective_u_height(self):
if self.u_height is not None:
return self.u_height
if self.device_type:
return Decimal(self.device_type.u_height)
return Decimal('1')
@property
def effective_full_depth(self):
if self.is_full_depth is not None:
return self.is_full_depth
if self.device_type:
return self.device_type.is_full_depth
return True
@property
def lane_span(self):
"""(start, end) lane indices on the LANE_COUNT grid."""
lanes_per_slot = LANE_COUNT // self.width_fraction
start = self.horizontal_position * lanes_per_slot
return start, start + lanes_per_slot
@property
def unit_span(self):
"""(lowest, highest_exclusive) unit numbers, or None when unracked."""
if self.position is None:
return None
return self.position, self.position + self.effective_u_height
def occupies_face(self, face):
return self.effective_full_depth or self.face == face
def conflicts_with(self, other):
"""True when both devices claim the same physical space."""
mine, theirs = self.unit_span, other.unit_span
if mine is None or theirs is None:
return False
if mine[0] >= theirs[1] or theirs[0] >= mine[1]:
return False
# A full-depth device blocks both faces.
if not (self.effective_full_depth or other.effective_full_depth) and self.face != other.face:
return False
my_lanes, their_lanes = self.lane_span, other.lane_span
return my_lanes[0] < their_lanes[1] and their_lanes[0] < my_lanes[1]
def clean(self):
super().clean()
if self.device_type is None and self.u_height is None:
raise ValidationError({
'u_height': _('Specify a device type or an explicit height.')
})
if self.horizontal_position >= self.width_fraction:
raise ValidationError({
'horizontal_position': _('With a width of 1/{fraction} the position must be between 0 and {max}.').format(
fraction=self.width_fraction, max=self.width_fraction - 1
)
})
if self.position is None:
return
if self.position % Decimal('0.5') != 0:
raise ValidationError({'position': _('Position must be a multiple of 0.5U.')})
low, high = self.concept.unit_range
span = self.unit_span
if span[0] < low or span[1] > high:
raise ValidationError({
'position': _('A {height}U device at U{position} does not fit in a rack spanning U{low}-U{high}.').format(
height=self.effective_u_height, position=self.position, low=low, high=high - 1
)
})
siblings = self.concept.devices.exclude(pk=self.pk).filter(position__isnull=False)
for other in siblings.select_related('device_type'):
if self.conflicts_with(other):
raise ValidationError({
'position': _('Overlaps with {device} at U{position}.').format(
device=other, position=other.position
)
})
class ConceptDeployment(models.Model):
"""Audit trail linking a blueprint to the real racks it produced."""
concept = models.ForeignKey(
to=RackConcept,
on_delete=models.CASCADE,
related_name='deployments'
)
rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
rack_name = models.CharField(max_length=100)
device_count = models.PositiveSmallIntegerField(default=0)
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
to='users.User',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
class Meta:
ordering = ('-created',)
verbose_name = _('concept deployment')
verbose_name_plural = _('concept deployments')
def __str__(self):
return f'{self.concept} → {self.rack_name}'
+59
View File
@@ -0,0 +1,59 @@
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
concept_buttons = [
PluginMenuButton(
link='plugins:netbox_rack_concept:rackconcept_add',
title=_('Add'),
icon_class='mdi mdi-plus-thick',
color=ButtonColorChoices.GREEN,
permissions=['netbox_rack_concept.add_rackconcept']
),
PluginMenuButton(
link='plugins:netbox_rack_concept:rackconcept_bulk_import',
title=_('Import'),
icon_class='mdi mdi-upload',
color=ButtonColorChoices.CYAN,
permissions=['netbox_rack_concept.add_rackconcept']
),
]
device_buttons = [
PluginMenuButton(
link='plugins:netbox_rack_concept:conceptdevice_add',
title=_('Add'),
icon_class='mdi mdi-plus-thick',
color=ButtonColorChoices.GREEN,
permissions=['netbox_rack_concept.add_conceptdevice']
),
PluginMenuButton(
link='plugins:netbox_rack_concept:conceptdevice_bulk_import',
title=_('Import'),
icon_class='mdi mdi-upload',
color=ButtonColorChoices.CYAN,
permissions=['netbox_rack_concept.add_conceptdevice']
),
]
menu = PluginMenu(
label=_('Rack Concepts'),
icon_class='mdi mdi-view-grid-plus-outline',
groups=(
(_('Planning'), (
PluginMenuItem(
link='plugins:netbox_rack_concept:rackconcept_list',
link_text=_('Rack Concepts'),
permissions=['netbox_rack_concept.view_rackconcept'],
buttons=concept_buttons
),
PluginMenuItem(
link='plugins:netbox_rack_concept:conceptdevice_list',
link_text=_('Planned Devices'),
permissions=['netbox_rack_concept.view_conceptdevice'],
buttons=device_buttons
),
)),
)
)
+27
View File
@@ -0,0 +1,27 @@
from netbox.search import SearchIndex, register_search
from .models import ConceptDevice, RackConcept
@register_search
class RackConceptIndex(SearchIndex):
model = RackConcept
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('status', 'tenant', 'description')
@register_search
class ConceptDeviceIndex(SearchIndex):
model = ConceptDevice
fields = (
('name', 100),
('label', 110),
('serial', 120),
('description', 500),
('comments', 5000),
)
display_attrs = ('concept', 'status', 'device_type', 'role', 'description')
@@ -0,0 +1,61 @@
/* Concept rack elevations. Colours follow the NetBox theme via CSS variables so the
elevation stays readable in both light and dark mode. */
.concept-elevation {
margin: 0 0.5rem 1rem;
}
.concept-elevation-svg {
max-width: 100%;
height: auto;
user-select: none;
touch-action: none;
}
.concept-elevation .unit-slot {
fill: var(--tblr-bg-surface-secondary, #f6f8fa);
stroke: var(--tblr-border-color, #dadfe5);
stroke-width: 1;
}
.concept-elevation .unit-slot.drop-target {
fill: var(--tblr-primary-lt, #d7e7fb);
}
.concept-elevation .unit-label {
font-size: 10px;
fill: var(--tblr-secondary, #808f9c);
}
.concept-elevation .rack-frame {
stroke: var(--tblr-body-color, #35404a);
stroke-width: 2;
}
.concept-elevation .device-rect {
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 1;
}
.concept-elevation .device-label {
font-size: 11px;
pointer-events: none;
}
.concept-elevation .opposite-hint {
font-size: 9px;
opacity: 0.8;
}
.concept-elevation .device-block.opposite .device-rect {
stroke-dasharray: 3 2;
}
.concept-elevation[data-editable="true"] .device-block:not(.opposite) {
cursor: grab;
}
.concept-elevation .device-block.dragging {
opacity: 0.6;
cursor: grabbing;
}
@@ -0,0 +1,173 @@
/*
* Drag & drop for concept rack elevations.
*
* netbox-reorder-rack cannot be reused here: it moves dcim.Device rows, and a concept
* deliberately has none. The moves are collected client-side and applied server-side in
* a single transaction, so an invalid drop leaves the concept exactly as it was.
*/
(function () {
'use strict';
const container = document.getElementById('concept-elevations');
if (!container || container.dataset.editable !== 'true') {
return;
}
const reorderUrl = container.dataset.reorderUrl;
const startingUnit = parseInt(container.dataset.startingUnit, 10);
const rackHeight = parseInt(container.dataset.uHeight, 10);
const descUnits = container.dataset.descUnits === 'true';
const statusEl = document.getElementById('concept-elevation-status');
function csrfToken() {
const match = document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/);
return match ? decodeURIComponent(match[2]) : '';
}
function setStatus(message, isError) {
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.className = 'small mt-2 ' + (isError ? 'text-danger' : 'text-muted');
}
/* Geometry is read back out of the rendered SVG so the server stays the single
source of truth for sizes. */
function geometry(svg) {
const slot = svg.querySelector('.unit-slot');
if (!slot) return null;
return {
unitHeight: slot.height.baseVal.value,
rackX: slot.x.baseVal.value,
rackWidth: slot.width.baseVal.value
};
}
function svgPoint(svg, event) {
const rect = svg.getBoundingClientRect();
const scale = svg.viewBox.baseVal.width / rect.width;
return {
x: (event.clientX - rect.left) * scale,
y: (event.clientY - rect.top) * scale
};
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
container.querySelectorAll('.concept-elevation').forEach(function (panel) {
const svg = panel.querySelector('svg');
const geo = geometry(svg);
if (!geo) return;
panel.querySelectorAll('.device-block:not(.opposite)').forEach(function (block) {
block.addEventListener('pointerdown', onPointerDown);
function onPointerDown(event) {
// Let plain clicks through to the device link.
if (event.button !== 0) return;
event.preventDefault();
const start = svgPoint(svg, event);
const rect = block.querySelector('.device-rect');
const originX = rect.x.baseVal.value;
const deviceUnits = parseFloat(block.dataset.uHeight);
const oldPosition = parseFloat(block.dataset.position);
const fraction = parseInt(
block.dataset.widthFraction || rect.dataset.widthFraction || '1', 10
);
let moved = false;
let dx = 0;
let dy = 0;
block.classList.add('dragging');
block.setPointerCapture(event.pointerId);
function onMove(moveEvent) {
const now = svgPoint(svg, moveEvent);
dx = now.x - start.x;
dy = now.y - start.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
moved = true;
}
const snappedY = Math.round(dy / geo.unitHeight) * geo.unitHeight;
block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')');
}
function onUp() {
block.removeEventListener('pointermove', onMove);
block.removeEventListener('pointerup', onUp);
block.removeEventListener('pointercancel', onUp);
block.classList.remove('dragging');
if (!moved) {
// Treat it as a click: follow the device link.
block.removeAttribute('transform');
const link = block.querySelector('a');
if (link) window.location = link.getAttribute('href');
return;
}
const unitDelta = Math.round(dy / geo.unitHeight) * (descUnits ? 1 : -1);
const lowest = startingUnit;
const highest = startingUnit + rackHeight - deviceUnits;
const newPosition = clamp(oldPosition + unitDelta, lowest, highest);
let horizontal = 0;
if (fraction > 1) {
const newX = originX + dx;
const slotWidth = geo.rackWidth / fraction;
horizontal = clamp(
Math.round((newX - geo.rackX) / slotWidth), 0, fraction - 1
);
}
submit(block, newPosition, fraction > 1 ? horizontal : null);
}
block.addEventListener('pointermove', onMove);
block.addEventListener('pointerup', onUp);
block.addEventListener('pointercancel', onUp);
}
});
});
function submit(block, position, horizontalPosition) {
const move = {
id: parseInt(block.dataset.deviceId, 10),
position: position
};
if (horizontalPosition !== null) {
move.horizontal_position = horizontalPosition;
}
setStatus('Saving…', false);
fetch(reorderUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken()
},
credentials: 'same-origin',
body: JSON.stringify({ devices: [move] })
})
.then(function (response) {
return response.json().then(function (body) {
return { ok: response.ok, body: body };
});
})
.then(function (result) {
if (result.ok) {
window.location.reload();
} else {
block.removeAttribute('transform');
setStatus(result.body.error || 'The move was rejected.', true);
}
})
.catch(function () {
block.removeAttribute('transform');
setStatus('The move could not be saved.', true);
});
}
})();
+107
View File
@@ -0,0 +1,107 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns
from .models import ConceptDeployment, ConceptDevice, RackConcept
__all__ = (
'ConceptDeploymentTable',
'ConceptDeviceElevationTable',
'ConceptDeviceTable',
'RackConceptTable',
)
class RackConceptTable(NetBoxTable):
name = tables.Column(linkify=True)
status = columns.ChoiceFieldColumn()
tenant_group = tables.Column(linkify=True, verbose_name=_('Tenant group'))
tenant = tables.Column(linkify=True)
rack_type = tables.Column(linkify=True)
device_count = columns.LinkedCountColumn(
viewname='plugins:netbox_rack_concept:conceptdevice_list',
url_params={'concept_id': 'pk'},
verbose_name=_('Devices')
)
utilization = columns.UtilizationColumn(
accessor='utilization',
orderable=False,
verbose_name=_('Utilization')
)
source_rack = tables.Column(linkify=True, verbose_name=_('Copied from'))
comments = columns.MarkdownColumn()
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:rackconcept_list')
class Meta(NetBoxTable.Meta):
model = RackConcept
fields = (
'pk', 'id', 'name', 'status', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width',
'u_height', 'starting_unit', 'desc_units', 'device_count', 'utilization', 'source_rack',
'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'name', 'status', 'tenant', 'u_height', 'width', 'device_count', 'utilization', 'description',
)
class ConceptDeviceTable(NetBoxTable):
name = tables.Column(linkify=True, empty_values=())
concept = tables.Column(linkify=True, verbose_name=_('Concept'))
status = columns.ChoiceFieldColumn()
device_type = tables.Column(linkify=True, verbose_name=_('Type'))
manufacturer = tables.Column(
accessor='device_type__manufacturer',
linkify=True,
verbose_name=_('Manufacturer')
)
role = columns.ColoredLabelColumn()
platform = tables.Column(linkify=True)
tenant = tables.Column(linkify=True)
position = tables.Column(verbose_name=_('Position (U)'))
# A plain column, not ChoiceFieldColumn: WidthFractionChoices carries no colours.
width_fraction = tables.Column(
accessor='get_width_fraction_display',
order_by='width_fraction',
verbose_name=_('Width')
)
source_device = tables.Column(linkify=True, verbose_name=_('Copied from'))
comments = columns.MarkdownColumn()
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptdevice_list')
class Meta(NetBoxTable.Meta):
model = ConceptDevice
fields = (
'pk', 'id', 'name', 'label', 'concept', 'status', 'device_type', 'manufacturer', 'role', 'platform',
'tenant', 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth',
'serial', 'source_device', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'name', 'concept', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction',
)
def render_name(self, record):
return record.display_label
class ConceptDeviceElevationTable(ConceptDeviceTable):
"""Device list shown on the concept detail page, without the redundant concept column."""
class Meta(ConceptDeviceTable.Meta):
default_columns = (
'name', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction', 'horizontal_position',
)
class ConceptDeploymentTable(tables.Table):
rack_name = tables.Column(verbose_name=_('Rack'))
rack = tables.Column(linkify=True, verbose_name=_('Link'))
device_count = tables.Column(verbose_name=_('Devices'))
user = tables.Column(verbose_name=_('Deployed by'))
created = columns.DateTimeColumn(verbose_name=_('Deployed'))
class Meta:
model = ConceptDeployment
fields = ('rack_name', 'rack', 'device_count', 'user', 'created')
attrs = {'class': 'table table-hover object-list'}
empty_text = _('This concept has not been deployed yet.')
+37
View File
@@ -0,0 +1,37 @@
from django.conf import settings
from netbox.plugins import PluginTemplateExtension
from .models import RackConcept
def _enabled():
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}).get('enable_rack_tab', True)
class RackConceptButtons(PluginTemplateExtension):
"""Adds "Copy to concept" to the button row of a rack, and lists derived concepts."""
models = ['dcim.rack']
def buttons(self):
if not _enabled():
return ''
if not self.context['request'].user.has_perm('netbox_rack_concept.add_rackconcept'):
return ''
return self.render('netbox_rack_concept/inc/rack_copy_button.html', extra_context={
'rack': self.context['object'],
})
def right_page(self):
if not _enabled():
return ''
rack = self.context['object']
concepts = RackConcept.objects.restrict(self.context['request'].user, 'view').filter(source_rack=rack)
if not concepts:
return ''
return self.render('netbox_rack_concept/inc/rack_concepts_panel.html', extra_context={
'concepts': concepts,
})
template_extensions = [RackConceptButtons]
@@ -0,0 +1,32 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with name=object.name %}Clone {{ name }}{% endblocktrans %}{% endblock %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<div class="row">
<div class="col col-md-8 offset-md-2">
<div class="card">
<h2 class="card-header">{% blocktrans with name=object.name %}Clone concept {{ name }}{% endblocktrans %}</h2>
<div class="card-body">
<p class="text-muted">
{% blocktrans count counter=object.devices.count %}
The original keeps its {{ counter }} planned device. The clone is independent from this point on.
{% plural %}
The original keeps its {{ counter }} planned devices. The clone is independent from this point on.
{% endblocktrans %}
</p>
{% render_form form %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Create clone" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with name=object.name %}Deploy {{ name }}{% endblocktrans %}{% endblock %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<div class="row">
<div class="col col-md-8 offset-md-2">
<div class="card">
<h2 class="card-header">{% blocktrans with name=object.name %}Deploy concept {{ name }}{% endblocktrans %}</h2>
<div class="card-body">
<p class="text-muted">
{% trans "This creates real racks and devices in NetBox. The concept itself is left untouched and stays reusable." %}
{% blocktrans count counter=deployable_count %}
{{ counter }} planned device will be created per rack.
{% plural %}
{{ counter }} planned devices will be created per rack.
{% endblocktrans %}
</p>
{% render_form form %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Deploy" %}</button>
</div>
</div>
{% if warnings %}
<div class="card">
<h2 class="card-header text-warning">
<i class="mdi mdi-alert"></i> {% trans "Will be skipped" %}
</h2>
<ul class="list-group list-group-flush">
{% for warning in warnings %}
<li class="list-group-item">{{ warning }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,102 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Planned Device" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Copied from" %}</th>
<td>{{ object.source_device|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Hardware" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.device_type.manufacturer|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device type" %}</th>
<td>{{ object.device_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial" %}</th>
<td>{{ object.serial|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Placement" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Position" %}</th>
<td>{% if object.position %}U{{ object.position }}{% else %}{% trans "Not racked" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.effective_u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Face" %}</th>
<td>{{ object.get_face_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_fraction_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Horizontal position" %}</th>
<td>{{ object.horizontal_position }}</td>
</tr>
<tr>
<th scope="row">{% trans "Full depth" %}</th>
<td>{% checkmark object.effective_full_depth %}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,55 @@
{% load i18n %}
{% load l10n %}
{% comment %}
One face of a concept rack. Localization is switched off below because SVG
coordinates must use a dot as the decimal separator whatever the UI language is.
{% endcomment %}
{% localize off %}
<div class="concept-elevation" data-face="{{ elevation.face }}">
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
class="concept-elevation-svg" role="img"
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
{# Unit rows and legend #}
{% for unit in elevation.units %}
<g class="unit">
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
{% endfor %}
{# Devices #}
{% for block in elevation.blocks %}
<g class="device-block {% if block.opposite %}opposite{% endif %}"
data-device-id="{{ block.device.pk }}"
data-position="{{ block.device.position }}"
data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
rx="2" fill="#{{ block.color }}"
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-rect"></rect>
{% if block.show_text %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
</g>
{% endfor %}
<rect x="{{ elevation.legend_width }}" y="6"
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
class="rack-frame" fill="none"></rect>
</svg>
</div>
{% endlocalize %}
@@ -0,0 +1,16 @@
{% load i18n %}
{% load helpers %}
<div class="card">
<h2 class="card-header">{% trans "Rack Concepts" %}</h2>
<table class="table table-hover attr-table">
{% for concept in concepts %}
<tr>
<td>{{ concept|linkify }}</td>
<td class="text-end">{% badge concept.get_status_display bg_color=concept.get_status_color %}</td>
</tr>
{% endfor %}
</table>
<div class="card-footer small text-muted">
{% trans "Concepts copied from this rack. They are planning objects and do not affect this rack." %}
</div>
</div>
@@ -0,0 +1,4 @@
{% load i18n %}
<a href="{% url 'plugins:netbox_rack_concept:rack_copy' pk=rack.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-view-grid-plus-outline"></i> {% trans "Copy to concept" %}
</a>
@@ -0,0 +1,35 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with rack=rack.name %}Copy {{ rack }} to a concept{% endblocktrans %}{% endblock %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<div class="row">
<div class="col col-md-8 offset-md-2">
<div class="card">
<h2 class="card-header">
{% blocktrans with rack=rack.name %}Copy rack {{ rack }} to a concept{% endblocktrans %}
</h2>
<div class="card-body">
<p class="text-muted">
{% blocktrans count counter=device_count %}
The rack contains {{ counter }} racked device, which can be snapshotted into the concept.
{% plural %}
The rack contains {{ counter }} racked devices, which can be snapshotted into the concept.
{% endblocktrans %}
{% trans "Nothing in the rack is modified: the concept is a separate object that does not appear in the rack or device lists." %}
</p>
{% render_form form %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Create concept" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,168 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load static %}
{% block head %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'netbox_rack_concept/elevation.css' %}">
{% endblock %}
{% block extra_controls %}
{{ block.super }}
{% if perms.netbox_rack_concept.add_rackconcept %}
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
</a>
{% endif %}
{% if perms.dcim.add_rack %}
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-rocket-launch-outline"></i> {% trans "Deploy to NetBox" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Rack Concept" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant group" %}</th>
<td>{{ object.tenant_group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Copied from" %}</th>
<td>{{ object.source_rack|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Planned devices" %}</th>
<td>{{ object.devices.count }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space utilization" %}</th>
<td>{% utilization_graph object.utilization %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Geometry" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack type" %}</th>
<td>{{ object.rack_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Form factor" %}</th>
<td>{{ object.get_form_factor_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Starting unit" %}</th>
<td>U{{ object.starting_unit }}</td>
</tr>
<tr>
<th scope="row">{% trans "Descending units" %}</th>
<td>{% checkmark object.desc_units %}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting depth" %}</th>
<td>{{ object.mounting_depth|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Deployment defaults" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.default_site|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{{ object.default_location|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack role" %}</th>
<td>{{ object.rack_role|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">
{% trans "Elevation" %}
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
{% endif %}
</h2>
<div class="card-body">
<div class="d-flex justify-content-around flex-wrap"
id="concept-elevations"
data-reorder-url="{% url 'plugins:netbox_rack_concept:rackconcept_reorder' pk=object.pk %}"
data-editable="{% if perms.netbox_rack_concept.change_conceptdevice %}true{% else %}false{% endif %}"
data-starting-unit="{{ object.starting_unit }}"
data-u-height="{{ object.u_height }}"
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
</div>
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
</div>
{% if perms.netbox_rack_concept.add_conceptdevice %}
<div class="card-footer text-end">
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
</a>
</div>
{% endif %}
</div>
<div class="card">
<h2 class="card-header">{% trans "Deployments" %}</h2>
{% render_table deployments_table %}
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
{{ block.super }}
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends 'generic/object_children.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.netbox_rack_concept.add_conceptdevice %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ request.path }}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
</a>
{% endif %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'netbox_rack_concept'
urlpatterns = [
# Rack concepts
path('concepts/', include(get_model_urls('netbox_rack_concept', 'rackconcept', detail=False))),
path('concepts/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'rackconcept'))),
# Planned devices
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
# Entry point from a real rack
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
]
+395
View File
@@ -0,0 +1,395 @@
import json
from decimal import Decimal, InvalidOperation
from django.contrib import messages
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic import View
from dcim.models import Rack
from netbox.views import generic
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
from . import deploy, filtersets, forms, tables
from .elevation import get_elevations
from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeviceView',
'RackConceptView',
)
#
# Rack concepts
#
@register_model_view(RackConcept, 'list', path='', detail=False)
class RackConceptListView(generic.ObjectListView):
queryset = RackConcept.objects.annotate(
device_count=Count('devices')
).select_related('tenant', 'tenant_group', 'rack_type')
table = tables.RackConceptTable
filterset = filtersets.RackConceptFilterSet
filterset_form = forms.RackConceptFilterForm
@register_model_view(RackConcept)
class RackConceptView(generic.ObjectView):
queryset = RackConcept.objects.select_related('tenant', 'tenant_group', 'rack_type', 'source_rack')
template_name = 'netbox_rack_concept/rackconcept.html'
def get_extra_context(self, request, instance):
front, rear = get_elevations(instance)
deployments = tables.ConceptDeploymentTable(
instance.deployments.select_related('rack', 'user')
)
return {
'front_elevation': front,
'rear_elevation': rear,
'deployments_table': deployments,
}
@register_model_view(RackConcept, 'devices')
class RackConceptDevicesView(generic.ObjectChildrenView):
queryset = RackConcept.objects.all()
child_model = ConceptDevice
table = tables.ConceptDeviceElevationTable
filterset = filtersets.ConceptDeviceFilterSet
template_name = 'netbox_rack_concept/rackconcept_devices.html'
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
permission='netbox_rack_concept.view_conceptdevice',
weight=500
)
def get_children(self, request, parent):
return parent.devices.select_related('device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant')
@register_model_view(RackConcept, 'add', detail=False)
@register_model_view(RackConcept, 'edit')
class RackConceptEditView(generic.ObjectEditView):
queryset = RackConcept.objects.all()
form = forms.RackConceptForm
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
config = settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
obj.u_height = config.get('default_u_height', 42)
obj.width = config.get('default_width', 19)
return obj
@register_model_view(RackConcept, 'delete')
class RackConceptDeleteView(generic.ObjectDeleteView):
queryset = RackConcept.objects.all()
@register_model_view(RackConcept, 'bulk_import', path='import', detail=False)
class RackConceptBulkImportView(generic.BulkImportView):
queryset = RackConcept.objects.all()
model_form = forms.RackConceptImportForm
@register_model_view(RackConcept, 'bulk_edit', path='edit', detail=False)
class RackConceptBulkEditView(generic.BulkEditView):
queryset = RackConcept.objects.annotate(device_count=Count('devices'))
filterset = filtersets.RackConceptFilterSet
table = tables.RackConceptTable
form = forms.RackConceptBulkEditForm
@register_model_view(RackConcept, 'bulk_delete', path='delete', detail=False)
class RackConceptBulkDeleteView(generic.BulkDeleteView):
queryset = RackConcept.objects.annotate(device_count=Count('devices'))
filterset = filtersets.RackConceptFilterSet
table = tables.RackConceptTable
#
# Concept devices
#
@register_model_view(ConceptDevice, 'list', path='', detail=False)
class ConceptDeviceListView(generic.ObjectListView):
queryset = ConceptDevice.objects.select_related(
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant'
)
table = tables.ConceptDeviceTable
filterset = filtersets.ConceptDeviceFilterSet
filterset_form = forms.ConceptDeviceFilterForm
@register_model_view(ConceptDevice)
class ConceptDeviceView(generic.ObjectView):
queryset = ConceptDevice.objects.select_related(
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant', 'source_device'
)
template_name = 'netbox_rack_concept/conceptdevice.html'
@register_model_view(ConceptDevice, 'add', detail=False)
@register_model_view(ConceptDevice, 'edit')
class ConceptDeviceEditView(generic.ObjectEditView):
queryset = ConceptDevice.objects.all()
form = forms.ConceptDeviceForm
@register_model_view(ConceptDevice, 'delete')
class ConceptDeviceDeleteView(generic.ObjectDeleteView):
queryset = ConceptDevice.objects.all()
@register_model_view(ConceptDevice, 'bulk_import', path='import', detail=False)
class ConceptDeviceBulkImportView(generic.BulkImportView):
queryset = ConceptDevice.objects.all()
model_form = forms.ConceptDeviceImportForm
@register_model_view(ConceptDevice, 'bulk_edit', path='edit', detail=False)
class ConceptDeviceBulkEditView(generic.BulkEditView):
queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role')
filterset = filtersets.ConceptDeviceFilterSet
table = tables.ConceptDeviceTable
form = forms.ConceptDeviceBulkEditForm
@register_model_view(ConceptDevice, 'bulk_delete', path='delete', detail=False)
class ConceptDeviceBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role')
filterset = filtersets.ConceptDeviceFilterSet
table = tables.ConceptDeviceTable
#
# Actions: copy, clone, deploy, reorder
#
class RackCopyView(ObjectPermissionRequiredMixin, View):
"""Copy a real rack into a new concept. Reached from the rack detail page."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request, pk):
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
form = forms.RackCopyForm(initial={
'name': _('%(rack)s (concept)') % {'rack': rack.name},
'tenant': rack.tenant_id,
'tenant_group': rack.tenant.group_id if rack.tenant else None,
})
return render(request, 'netbox_rack_concept/rack_copy.html', {
'rack': rack,
'form': form,
'device_count': rack.devices.filter(position__isnull=False).count(),
'return_url': rack.get_absolute_url(),
})
def post(self, request, pk):
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
form = forms.RackCopyForm(request.POST)
if form.is_valid():
try:
concept = deploy.copy_rack_to_concept(
rack=rack,
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
tenant_group=form.cleaned_data['tenant_group'],
description=form.cleaned_data['description'],
include_devices=form.cleaned_data['include_devices'],
include_device_names=form.cleaned_data['include_device_names'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % {
'concept': concept, 'rack': rack
})
return redirect(concept.get_absolute_url())
return render(request, 'netbox_rack_concept/rack_copy.html', {
'rack': rack,
'form': form,
'device_count': rack.devices.filter(position__isnull=False).count(),
'return_url': rack.get_absolute_url(),
})
@register_model_view(RackConcept, 'clone_concept', path='clone')
class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
"""Duplicate a concept to build a variant."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.RackConceptCloneForm(initial={
'name': _('%(name)s (copy)') % {'name': concept.name},
'tenant': concept.tenant_id,
})
return render(request, 'netbox_rack_concept/concept_clone.html', {
'object': concept,
'form': form,
'return_url': concept.get_absolute_url(),
})
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.RackConceptCloneForm(request.POST)
if form.is_valid():
try:
clone = deploy.clone_concept(
concept=concept,
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
include_devices=form.cleaned_data['include_devices'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, _('Cloned %(source)s into %(clone)s.') % {
'source': concept, 'clone': clone
})
return redirect(clone.get_absolute_url())
return render(request, 'netbox_rack_concept/concept_clone.html', {
'object': concept,
'form': form,
'return_url': concept.get_absolute_url(),
})
@register_model_view(RackConcept, 'deploy')
class RackConceptDeployView(ObjectPermissionRequiredMixin, View):
"""Materialise a concept as one or more real racks."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
# The mixin restricts against this view's queryset, so it has to be a concept
# permission. The permission to create racks is checked separately below.
return 'netbox_rack_concept.view_rackconcept'
def _context(self, request, concept, form):
# An unchecked checkbox is simply absent from the POST data.
create_devices = 'create_devices' in form.data if form.is_bound else True
deployable, warnings = deploy.validate_deployment(concept, create_devices=create_devices)
return {
'object': concept,
'form': form,
'deployable_count': len(deployable),
'warnings': warnings,
'return_url': concept.get_absolute_url(),
}
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.ConceptDeployForm(concept=concept)
return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form))
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.ConceptDeployForm(request.POST, concept=concept)
if not request.user.has_perm('dcim.add_rack'):
return HttpResponseForbidden(_('You do not have permission to create racks.'))
if form.is_valid():
try:
racks, devices, warnings = deploy.deploy_concept(
concept=concept,
rack_names=form.cleaned_data['rack_names'],
site=form.cleaned_data['site'],
location=form.cleaned_data['location'],
tenant=form.cleaned_data['tenant'],
create_devices=form.cleaned_data['create_devices'],
device_name_pattern=form.cleaned_data['device_name_pattern'],
skip_unnamed_devices=form.cleaned_data['skip_unnamed_devices'],
user=request.user,
)
except ValidationError as exc:
# Nothing was written: deploy_concept runs in a transaction.
messages.error(request, _('Deployment failed, nothing was created.'))
form.add_error(None, exc)
else:
for warning in warnings:
messages.warning(request, warning)
messages.success(request, _('Created %(racks)s rack(s) and %(devices)s device(s).') % {
'racks': len(racks), 'devices': len(devices)
})
if len(racks) == 1:
return redirect(racks[0].get_absolute_url())
return redirect(
reverse('dcim:rack_list') + f'?site_id={form.cleaned_data["site"].pk}'
)
return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form))
@register_model_view(RackConcept, 'reorder')
class RackConceptReorderView(ObjectPermissionRequiredMixin, View):
"""
Endpoint for the drag & drop elevation. Accepts a JSON body of the shape
{"devices": [{"id": 1, "position": "12.0", "face": "front", "horizontal_position": 0}]}
and applies all moves in one transaction so a rejected move leaves nothing half-applied.
"""
queryset = RackConcept.objects.all()
http_method_names = ['post']
def get_required_permission(self):
return 'netbox_rack_concept.change_conceptdevice'
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
try:
payload = json.loads(request.body or '{}')
moves = payload['devices']
except (json.JSONDecodeError, KeyError, TypeError):
return JsonResponse({'error': _('Malformed request body.')}, status=400)
by_pk = {d.pk: d for d in concept.devices.select_related('device_type')}
updated = []
try:
with transaction.atomic():
for move in moves:
device = by_pk.get(int(move['id']))
if device is None:
raise ValidationError(_('Device %(id)s does not belong to this concept.') % {'id': move['id']})
try:
device.position = Decimal(str(move['position'])) if move.get('position') is not None else None
except InvalidOperation:
raise ValidationError(_('Invalid position %(position)r.') % {'position': move.get('position')})
if 'face' in move:
device.face = move['face']
if 'horizontal_position' in move:
device.horizontal_position = int(move['horizontal_position'])
if 'width_fraction' in move:
device.width_fraction = int(move['width_fraction'])
updated.append(device)
# Validate only after every move is staged, so swapping two devices works.
for device in updated:
device.save()
for device in updated:
device.full_clean()
except ValidationError as exc:
return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
return JsonResponse({'status': 'ok', 'updated': len(updated)})