Fix rear-face image-only labels, click-to-add units, cable concept devices

- Fix "images only" leaving plain labels visible on faces where a
  device has no image for that side (e.g. no rear_image set): the
  view selector now hides every plain label outright in that mode,
  not just the ones an image happens to sit on top of.
- Remove the "Add planned device" button from the elevation card.
  Empty units are now click targets themselves - clicking one opens
  the add-device form pre-filled with that position and face, exactly
  like a real rack's elevation. Occupancy is computed per unit per
  face from the existing device blocks.
- Add cabling for concept devices: ConceptComponent (interfaces,
  front/rear ports, power ports/outlets, console ports) and
  ConceptCable (a connection between two components, restricted to
  devices in the same concept). components.sync_components()
  populates a device's components from its device type's templates,
  including the front/rear port pass-through mapping copied from
  DeviceType.port_mappings - so a device with front/rear port
  templates (a patch panel being the common case) behaves exactly
  like a real one, without hardcoding anything role-specific.
  Placeholder devices without a device type get components added by
  hand. Full CRUD views, tables, filtersets, API endpoints and a
  "Connect" action wired in from the device detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:18:50 +02:00
co-authored by Claude Sonnet 5
parent e4817ea83b
commit 63538c2efe
24 changed files with 1293 additions and 33 deletions
+35 -1
View File
@@ -11,9 +11,11 @@ from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
from tenancy.models import Tenant
from ..choices import ConceptStatusChoices
from ..models import ConceptDeployment, ConceptDevice, RackConcept
from ..models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
__all__ = (
'ConceptCableSerializer',
'ConceptComponentSerializer',
'ConceptDeploySerializer',
'ConceptDeploymentSerializer',
'ConceptDeviceSerializer',
@@ -81,6 +83,38 @@ class ConceptDeploymentSerializer(serializers.ModelSerializer):
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created')
class ConceptComponentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:netbox_rack_concept-api:conceptcomponent-detail'
)
device = ConceptDeviceSerializer(nested=True)
is_connected = serializers.BooleanField(read_only=True)
class Meta:
model = ConceptComponent
fields = (
'id', 'url', 'display_url', 'display', 'device', 'component_type', 'name', 'label', 'port_type',
'positions', 'color', 'description', 'is_connected', 'tags', 'custom_fields', 'created', 'last_updated',
)
brief_fields = ('id', 'url', 'display', 'name', 'component_type', 'device')
class ConceptCableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:netbox_rack_concept-api:conceptcable-detail'
)
termination_a = ConceptComponentSerializer(nested=True)
termination_b = ConceptComponentSerializer(nested=True)
class Meta:
model = ConceptCable
fields = (
'id', 'url', 'display_url', 'display', 'termination_a', 'termination_b', 'status', 'type', 'color',
'label', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
)
brief_fields = ('id', 'url', 'display', 'label', 'termination_a', 'termination_b')
#
# Action payloads
#
+2
View File
@@ -7,5 +7,7 @@ app_name = 'netbox_rack_concept-api'
router = NetBoxRouter()
router.register('concepts', views.RackConceptViewSet)
router.register('devices', views.ConceptDeviceViewSet)
router.register('components', views.ConceptComponentViewSet)
router.register('cables', views.ConceptCableViewSet)
urlpatterns = router.urls
+17 -3
View File
@@ -11,10 +11,10 @@ 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 ..models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
from .serializers import (
ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer,
RackConceptSerializer, RackCopySerializer,
ConceptCableSerializer, ConceptComponentSerializer, ConceptDeploySerializer, ConceptDeviceSerializer,
MergeConceptsSerializer, RackConceptCloneSerializer, RackConceptSerializer, RackCopySerializer,
)
__all__ = (
@@ -204,3 +204,17 @@ class ConceptDeviceViewSet(NetBoxModelViewSet):
)
serializer_class = ConceptDeviceSerializer
filterset_class = filtersets.ConceptDeviceFilterSet
class ConceptComponentViewSet(NetBoxModelViewSet):
queryset = ConceptComponent.objects.prefetch_related('tags').select_related('device', 'device__concept')
serializer_class = ConceptComponentSerializer
filterset_class = filtersets.ConceptComponentFilterSet
class ConceptCableViewSet(NetBoxModelViewSet):
queryset = ConceptCable.objects.prefetch_related('tags').select_related(
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
)
serializer_class = ConceptCableSerializer
filterset_class = filtersets.ConceptCableFilterSet
+29
View File
@@ -59,3 +59,32 @@ class WidthFractionChoices(ChoiceSet):
# 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
class ConceptComponentTypeChoices(ChoiceSet):
"""
The kinds of pluggable component a planned device can carry. One shared model
covers all of them (see ConceptComponent) rather than mirroring dcim's seven
separate component models - a blueprint only needs enough detail to say what a
cable plugs into, not full per-type validation.
"""
TYPE_INTERFACE = 'interface'
TYPE_FRONT_PORT = 'front-port'
TYPE_REAR_PORT = 'rear-port'
TYPE_CONSOLE_PORT = 'console-port'
TYPE_CONSOLE_SERVER_PORT = 'console-server-port'
TYPE_POWER_PORT = 'power-port'
TYPE_POWER_OUTLET = 'power-outlet'
CHOICES = [
(TYPE_INTERFACE, 'Interface', 'blue'),
(TYPE_FRONT_PORT, 'Front port', 'orange'),
(TYPE_REAR_PORT, 'Rear port', 'yellow'),
(TYPE_CONSOLE_PORT, 'Console port', 'teal'),
(TYPE_CONSOLE_SERVER_PORT, 'Console server port', 'cyan'),
(TYPE_POWER_PORT, 'Power port', 'red'),
(TYPE_POWER_OUTLET, 'Power outlet', 'pink'),
]
#: Component types that are pass-through ports and can appear in a ConceptPortMapping.
PATCH_TYPES = (TYPE_FRONT_PORT, TYPE_REAR_PORT)
+79
View File
@@ -0,0 +1,79 @@
"""
Populate a planned device's components from its device type's templates - interfaces,
ports, and the front/rear pass-through mapping - so a concept device with a device type
can be cabled exactly like a real one. A device type's own component templates are the
single source of truth here; nothing about this is specific to any particular role, so
a "Patchpanel"-role device gets correct front/rear pass-through behaviour simply because
that's what its device type's templates already describe - the same as a real device.
Placeholder devices without a device type have no templates to sync from; their
components, if any, have to be added by hand.
"""
from django.db import transaction
from .choices import ConceptComponentTypeChoices
from .models import ConceptComponent, ConceptPortMapping
__all__ = ('sync_components',)
# (our component type, DeviceType's reverse accessor for that template queryset)
TEMPLATE_SOURCES = (
(ConceptComponentTypeChoices.TYPE_INTERFACE, 'interfacetemplates'),
(ConceptComponentTypeChoices.TYPE_CONSOLE_PORT, 'consoleporttemplates'),
(ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT, 'consoleserverporttemplates'),
(ConceptComponentTypeChoices.TYPE_POWER_PORT, 'powerporttemplates'),
(ConceptComponentTypeChoices.TYPE_POWER_OUTLET, 'poweroutlettemplates'),
(ConceptComponentTypeChoices.TYPE_FRONT_PORT, 'frontporttemplates'),
(ConceptComponentTypeChoices.TYPE_REAR_PORT, 'rearporttemplates'),
)
@transaction.atomic
def sync_components(concept_device):
"""
Create whichever of the device type's components don't already exist on this
device (matched by name), and mirror its front/rear port mapping. Existing
components - and any cables already attached to them - are left untouched, so this
is safe to re-run after adding extra components by hand or after the device type
changes. Returns (components_created, mappings_created).
"""
device_type = concept_device.device_type
if device_type is None:
return 0, 0
existing = {component.name: component for component in concept_device.components.all()}
created = 0
for component_type, accessor in TEMPLATE_SOURCES:
for template in getattr(device_type, accessor).all():
if template.name in existing:
continue
component = ConceptComponent.objects.create(
device=concept_device,
component_type=component_type,
name=template.name,
label=template.label,
port_type=getattr(template, 'type', '') or '',
positions=getattr(template, 'positions', 1) or 1,
color=getattr(template, 'color', '') or '',
)
existing[component.name] = component
created += 1
mappings_created = 0
for mapping in device_type.port_mappings.select_related('front_port', 'rear_port').all():
front = existing.get(mapping.front_port.name)
rear = existing.get(mapping.rear_port.name)
if not (front and rear):
# Shouldn't happen - the templates were just synced above - but a
# partially-synced device from before a device type change could hit this.
continue
_, was_created = ConceptPortMapping.objects.get_or_create(
front_component=front,
front_position=mapping.front_port_position,
defaults={'rear_component': rear, 'rear_position': mapping.rear_port_position}
)
if was_created:
mappings_created += 1
return created, mappings_created
+15 -3
View File
@@ -38,10 +38,15 @@ def foreground_color(bg_color):
class ElevationUnit:
"""One labelled rack unit row."""
def __init__(self, number, y):
def __init__(self, number, y, occupied=False):
self.number = number
self.y = y
self.height = UNIT_HEIGHT
# Whether *anything* renders over this unit on this face - including a
# reduced-opacity "opposite" block for a full-depth device. A unit only counts
# as free (and thus click-to-add) when nothing at all sits on it, which folds
# back to plain occupied/free for full-width-only setups, same as core NetBox.
self.occupied = occupied
class ElevationBlock:
@@ -110,11 +115,14 @@ class Elevation:
self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH
self.rack_x = RACK_X
self.rack_right = RACK_X + RACK_WIDTH
self.width = RACK_X + RACK_WIDTH + MARGIN
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
self._build_units()
# Blocks first: unit occupancy (used to decide which units are click-to-add)
# depends on where they land.
self._build_blocks(devices if devices is not None else concept.devices.all())
self._build_units()
@property
def face_label(self):
@@ -134,7 +142,11 @@ class Elevation:
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))))
occupied = any(
block.device.unit_span and block.device.unit_span[0] <= number < block.device.unit_span[1]
for block in self.blocks
)
self.units.append(ElevationUnit(number, self._y_for(Decimal(top)), occupied))
def _build_blocks(self, devices):
for device in devices:
+73 -2
View File
@@ -2,14 +2,17 @@ import django_filters
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from dcim.choices import CableTypeChoices, LinkStatusChoices
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
from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = (
'ConceptCableFilterSet',
'ConceptComponentFilterSet',
'ConceptDeviceFilterSet',
'RackConceptFilterSet',
)
@@ -115,3 +118,71 @@ class ConceptDeviceFilterSet(NetBoxModelFilterSet):
def filter_racked(self, queryset, name, value):
return queryset.filter(position__isnull=not value)
class ConceptComponentFilterSet(NetBoxModelFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConceptDevice.objects.all(),
label=_('Concept device (ID)')
)
concept_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__concept',
queryset=RackConcept.objects.all(),
label=_('Rack concept (ID)')
)
component_type = django_filters.MultipleChoiceFilter(
choices=ConceptComponentTypeChoices
)
connected = django_filters.BooleanFilter(
method='filter_connected',
label=_('Has a cable attached')
)
class Meta:
model = ConceptComponent
fields = ('id', 'name', 'label', 'component_type', 'port_type', 'positions', '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(description__icontains=value)
)
def filter_connected(self, queryset, name, value):
# A component is connected once it's referenced by a cable as either end.
connected = (
Q(pk__in=ConceptCable.objects.values_list('termination_a_id', flat=True)) |
Q(pk__in=ConceptCable.objects.values_list('termination_b_id', flat=True))
)
return queryset.filter(connected) if value else queryset.exclude(connected)
class ConceptCableFilterSet(NetBoxModelFilterSet):
concept_id = django_filters.ModelMultipleChoiceFilter(
field_name='termination_a__device__concept',
queryset=RackConcept.objects.all(),
label=_('Rack concept (ID)')
)
status = django_filters.MultipleChoiceFilter(
choices=LinkStatusChoices
)
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
class Meta:
model = ConceptCable
fields = ('id', 'status', 'type', 'color', 'label', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(label__icontains=value) |
Q(description__icontains=value) |
Q(termination_a__name__icontains=value) |
Q(termination_b__name__icontains=value)
)
+131 -3
View File
@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
from dcim.choices import CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, 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
@@ -12,10 +13,15 @@ from utilities.forms.fields import (
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
from .models import ConceptDevice, RackConcept
from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, \
WidthFractionChoices
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = (
'ConceptCableForm',
'ConceptCableFilterForm',
'ConceptComponentForm',
'ConceptComponentFilterForm',
'ConceptDeployForm',
'ConceptDeviceBulkEditForm',
'ConceptDeviceFilterForm',
@@ -631,3 +637,125 @@ class ConceptDeployForm(forms.Form):
})
cleaned['rack_names'] = names
return cleaned
#
# Cabling
#
class ConceptComponentForm(NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=ConceptDevice.objects.all(),
label=_('Concept device')
)
fieldsets = (
FieldSet('device', 'component_type', 'name', 'label', 'description', 'tags', name=_('Component')),
FieldSet('port_type', 'positions', 'color', name=_('Attributes')),
)
class Meta:
model = ConceptComponent
fields = (
'device', 'component_type', 'name', 'label', 'port_type', 'positions', 'color', 'description', 'tags',
)
class ConceptComponentFilterForm(NetBoxModelFilterSetForm):
model = ConceptComponent
device_id = DynamicModelMultipleChoiceField(
queryset=ConceptDevice.objects.all(),
required=False,
label=_('Concept device')
)
concept_id = DynamicModelMultipleChoiceField(
queryset=RackConcept.objects.all(),
required=False,
label=_('Rack concept')
)
component_type = forms.MultipleChoiceField(choices=ConceptComponentTypeChoices, required=False)
connected = forms.NullBooleanField(required=False, widget=forms.Select(choices=(
('', '---------'), ('true', _('Connected')), ('false', _('Not connected')),
)))
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('device_id', 'concept_id', 'component_type', 'connected', name=_('Attributes')),
)
class ConceptCableForm(NetBoxModelForm):
"""
Mirrors the shape of NetBox's own cable-connect form: pick a device, then a
component on it, for each end.
"""
concept = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Rack concept'),
help_text=_('Both ends must belong to devices in this concept')
)
termination_a_device = DynamicModelChoiceField(
queryset=ConceptDevice.objects.all(),
label=_('Device A'),
query_params={'concept_id': '$concept'}
)
termination_a = DynamicModelChoiceField(
queryset=ConceptComponent.objects.all(),
label=_('Component A'),
query_params={'device_id': '$termination_a_device', 'connected': 'false'}
)
termination_b_device = DynamicModelChoiceField(
queryset=ConceptDevice.objects.all(),
label=_('Device B'),
query_params={'concept_id': '$concept'}
)
termination_b = DynamicModelChoiceField(
queryset=ConceptComponent.objects.all(),
label=_('Component B'),
query_params={'device_id': '$termination_b_device', 'connected': 'false'}
)
comments = CommentField()
fieldsets = (
FieldSet('concept', name=_('Scope')),
FieldSet('termination_a_device', 'termination_a', name=_('Side A')),
FieldSet('termination_b_device', 'termination_b', name=_('Side B')),
FieldSet('status', 'type', 'color', 'label', 'description', 'tags', name=_('Cable')),
)
class Meta:
model = ConceptCable
fields = (
'termination_a', 'termination_b', 'status', 'type', 'color', 'label', 'description', 'comments', 'tags',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# concept/termination_*_device are UI-only helpers for the cascading selects
# above, not model fields - when editing an existing cable, seed them from the
# components already chosen so the dropdowns show the right device on load.
instance = self.instance
if instance and instance.pk:
self.fields['concept'].initial = instance.termination_a.device.concept_id
self.fields['termination_a_device'].initial = instance.termination_a.device_id
self.fields['termination_b_device'].initial = instance.termination_b.device_id
class ConceptCableFilterForm(NetBoxModelFilterSetForm):
model = ConceptCable
concept_id = DynamicModelMultipleChoiceField(
queryset=RackConcept.objects.all(),
required=False,
label=_('Rack concept')
)
status = forms.MultipleChoiceField(choices=LinkStatusChoices, required=False)
type = forms.MultipleChoiceField(choices=CableTypeChoices, required=False)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('concept_id', 'status', 'type', name=_('Attributes')),
)
@@ -0,0 +1,113 @@
import django.core.validators
import django.db.models.deletion
import taggit.managers
import utilities.fields
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '__latest__'),
('netbox_rack_concept', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ConceptComponent',
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
)),
('component_type', models.CharField(max_length=50)),
('name', models.CharField(max_length=64)),
('label', models.CharField(blank=True, max_length=64)),
('port_type', models.CharField(blank=True, max_length=50)),
('positions', models.PositiveSmallIntegerField(default=1, validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
])),
('color', utilities.fields.ColorField(blank=True)),
('description', models.CharField(blank=True, max_length=200)),
('device', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='components',
to='netbox_rack_concept.conceptdevice'
)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'concept component',
'verbose_name_plural': 'concept components',
'ordering': ('device', 'component_type', 'name'),
},
),
migrations.AddConstraint(
model_name='conceptcomponent',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='nbrc_component_unique_device_name'),
),
migrations.CreateModel(
name='ConceptPortMapping',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('front_position', models.PositiveSmallIntegerField(default=1)),
('rear_position', models.PositiveSmallIntegerField(default=1)),
('front_component', models.ForeignKey(
limit_choices_to={'component_type': 'front-port'},
on_delete=django.db.models.deletion.CASCADE, related_name='front_mappings',
to='netbox_rack_concept.conceptcomponent'
)),
('rear_component', models.ForeignKey(
limit_choices_to={'component_type': 'rear-port'},
on_delete=django.db.models.deletion.CASCADE, related_name='rear_mappings',
to='netbox_rack_concept.conceptcomponent'
)),
],
),
migrations.AddConstraint(
model_name='conceptportmapping',
constraint=models.UniqueConstraint(
fields=('front_component', 'front_position'), name='nbrc_portmapping_unique_front'
),
),
migrations.AddConstraint(
model_name='conceptportmapping',
constraint=models.UniqueConstraint(
fields=('rear_component', 'rear_position'), name='nbrc_portmapping_unique_rear'
),
),
migrations.CreateModel(
name='ConceptCable',
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
)),
('status', models.CharField(default='connected', max_length=50)),
('type', models.CharField(blank=True, max_length=50)),
('color', utilities.fields.ColorField(blank=True)),
('label', models.CharField(blank=True, max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('termination_a', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+',
to='netbox_rack_concept.conceptcomponent'
)),
('termination_b', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+',
to='netbox_rack_concept.conceptcomponent'
)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'concept cable',
'verbose_name_plural': 'concept cables',
'ordering': ('pk',),
},
),
]
+222 -2
View File
@@ -7,15 +7,22 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.choices import (
DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices,
CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, RackAirflowChoices, RackDimensionUnitChoices,
RackFormFactorChoices, RackWidthChoices,
)
from netbox.models import NetBoxModel
from utilities.fields import ColorField
from .choices import LANE_COUNT, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
from .choices import (
LANE_COUNT, ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices,
)
__all__ = (
'ConceptCable',
'ConceptComponent',
'ConceptDevice',
'ConceptDeployment',
'ConceptPortMapping',
'RackConcept',
)
@@ -459,3 +466,216 @@ class ConceptDeployment(models.Model):
def __str__(self):
return f'{self.concept} → {self.rack_name}'
class ConceptComponent(NetBoxModel):
"""
A pluggable point on a planned device - an interface, a front/rear port, a power
port/outlet, or a console port. One shared model covers every kind rather than
mirroring dcim's seven separate component models, since a blueprint only needs
enough detail to say what a cable plugs into, not full per-type validation.
Populated either by syncing a device's component templates (see components.py) or
added by hand, which is the only option for placeholder devices without a device
type.
"""
device = models.ForeignKey(
to=ConceptDevice,
on_delete=models.CASCADE,
related_name='components'
)
component_type = models.CharField(
verbose_name=_('component type'),
max_length=50,
choices=ConceptComponentTypeChoices
)
name = models.CharField(
verbose_name=_('name'),
max_length=64
)
label = models.CharField(
max_length=64,
blank=True
)
port_type = models.CharField(
verbose_name=_('type'),
max_length=50,
blank=True,
help_text=_('Free text, e.g. "1000base-t" or "lc" - for reference only, not validated')
)
positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1,
validators=[MinValueValidator(1), MaxValueValidator(1024)],
help_text=_('Front/rear ports only')
)
color = ColorField(blank=True)
description = models.CharField(max_length=200, blank=True)
clone_fields = ('device', 'component_type', 'port_type', 'positions')
class Meta:
ordering = ('device', 'component_type', 'name')
constraints = (
models.UniqueConstraint(fields=('device', 'name'), name='nbrc_component_unique_device_name'),
)
verbose_name = _('concept component')
verbose_name_plural = _('concept components')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('plugins:netbox_rack_concept:conceptcomponent', args=[self.pk])
@property
def is_pass_through(self):
return self.component_type in ConceptComponentTypeChoices.PATCH_TYPES
@property
def cable(self):
return ConceptCable.objects.filter(
models.Q(termination_a=self) | models.Q(termination_b=self)
).first()
@property
def is_connected(self):
return self.cable is not None
def get_paired_components(self):
"""
For a front or rear port, the port(s) on the other side it passes through to -
real patch panel behaviour: a signal entering a front port continues out
whichever rear port (and position) it's mapped to, and vice versa.
Returns a list of (component, own_position, other_position) tuples.
"""
if self.component_type == ConceptComponentTypeChoices.TYPE_FRONT_PORT:
return [
(mapping.rear_component, mapping.front_position, mapping.rear_position)
for mapping in self.front_mappings.select_related('rear_component')
]
if self.component_type == ConceptComponentTypeChoices.TYPE_REAR_PORT:
return [
(mapping.front_component, mapping.rear_position, mapping.front_position)
for mapping in self.rear_mappings.select_related('front_component')
]
return []
def clean(self):
super().clean()
if self.positions > 1 and not self.is_pass_through:
raise ValidationError({
'positions': _('Only front and rear ports can have more than one position.')
})
class ConceptPortMapping(models.Model):
"""
Maps a front port position to a rear port position, mirroring dcim's PortMapping.
Not a NetBoxModel: like its real-world counterpart, this is wiring metadata, not a
user-facing object with its own detail page.
"""
front_component = models.ForeignKey(
to=ConceptComponent,
on_delete=models.CASCADE,
related_name='front_mappings',
limit_choices_to={'component_type': ConceptComponentTypeChoices.TYPE_FRONT_PORT}
)
rear_component = models.ForeignKey(
to=ConceptComponent,
on_delete=models.CASCADE,
related_name='rear_mappings',
limit_choices_to={'component_type': ConceptComponentTypeChoices.TYPE_REAR_PORT}
)
front_position = models.PositiveSmallIntegerField(default=1)
rear_position = models.PositiveSmallIntegerField(default=1)
class Meta:
constraints = (
models.UniqueConstraint(
fields=('front_component', 'front_position'), name='nbrc_portmapping_unique_front'
),
models.UniqueConstraint(
fields=('rear_component', 'rear_position'), name='nbrc_portmapping_unique_rear'
),
)
def __str__(self):
return f'{self.front_component}:{self.front_position} → {self.rear_component}:{self.rear_position}'
def clean(self):
super().clean()
if self.front_component_id and self.rear_component_id:
if self.front_component.device_id != self.rear_component.device_id:
raise ValidationError({
'rear_component': _('Front and rear port must belong to the same device.')
})
class ConceptCable(NetBoxModel):
"""A planned connection between two components, on planned devices only."""
termination_a = models.ForeignKey(
to=ConceptComponent,
on_delete=models.CASCADE,
related_name='+'
)
termination_b = models.ForeignKey(
to=ConceptComponent,
on_delete=models.CASCADE,
related_name='+'
)
status = models.CharField(
max_length=50,
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
)
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=CableTypeChoices,
blank=True
)
color = ColorField(blank=True)
label = models.CharField(max_length=100, blank=True)
description = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
clone_fields = ('status', 'type', 'color')
class Meta:
ordering = ('pk',)
verbose_name = _('concept cable')
verbose_name_plural = _('concept cables')
def __str__(self):
return self.label or f'#{self.pk}'
def get_absolute_url(self):
return reverse('plugins:netbox_rack_concept:conceptcable', args=[self.pk])
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if not (self.termination_a_id and self.termination_b_id):
return
if self.termination_a_id == self.termination_b_id:
raise ValidationError(_('A cable cannot connect a component to itself.'))
if self.termination_a.device.concept_id != self.termination_b.device.concept_id:
raise ValidationError(
_('Both ends of a cable must belong to devices in the same rack concept.')
)
for field_name, endpoint in (('termination_a', self.termination_a), ('termination_b', self.termination_b)):
conflict = ConceptCable.objects.exclude(pk=self.pk).filter(
models.Q(termination_a=endpoint) | models.Q(termination_b=endpoint)
).first()
if conflict:
raise ValidationError({
field_name: _('%(component)s is already connected (%(cable)s).') % {
'component': endpoint, 'cable': conflict
}
})
+23
View File
@@ -44,6 +44,16 @@ device_buttons = [
),
]
cable_buttons = [
PluginMenuButton(
link='plugins:netbox_rack_concept:conceptcable_add',
title=_('Add'),
icon_class='mdi mdi-plus-thick',
color=ButtonColorChoices.GREEN,
permissions=['netbox_rack_concept.add_conceptcable']
),
]
menu = PluginMenu(
label=_('Rack Concepts'),
icon_class='mdi mdi-view-grid-plus-outline',
@@ -62,5 +72,18 @@ menu = PluginMenu(
buttons=device_buttons
),
)),
(_('Cabling'), (
PluginMenuItem(
link='plugins:netbox_rack_concept:conceptcomponent_list',
link_text=_('Components'),
permissions=['netbox_rack_concept.view_conceptcomponent'],
),
PluginMenuItem(
link='plugins:netbox_rack_concept:conceptcable_list',
link_text=_('Cables'),
permissions=['netbox_rack_concept.view_conceptcable'],
buttons=cable_buttons
),
)),
)
)
+23 -1
View File
@@ -1,6 +1,6 @@
from netbox.search import SearchIndex, register_search
from .models import ConceptDevice, RackConcept
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
@register_search
@@ -25,3 +25,25 @@ class ConceptDeviceIndex(SearchIndex):
('comments', 5000),
)
display_attrs = ('concept', 'status', 'device_type', 'role', 'description')
@register_search
class ConceptComponentIndex(SearchIndex):
model = ConceptComponent
fields = (
('name', 100),
('label', 110),
('description', 500),
)
display_attrs = ('device', 'component_type', 'port_type')
@register_search
class ConceptCableIndex(SearchIndex):
model = ConceptCable
fields = (
('label', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('status', 'type', 'description')
@@ -22,6 +22,27 @@
fill: var(--tblr-primary-lt, #d7e7fb);
}
.concept-elevation .unit-slot.free {
cursor: pointer;
}
.concept-elevation .unit-slot.free:hover {
fill: var(--tblr-primary-lt, #d7e7fb);
}
/* "+ Add device" hint, shown only while hovering an empty unit - same trick as
NetBox's own rack elevation. */
.concept-elevation .add-device-hint {
font-size: 10px;
fill: var(--tblr-primary, #206bc4);
opacity: 0;
pointer-events: none;
}
.concept-elevation a:hover .add-device-hint {
opacity: 1;
}
.concept-elevation .unit-label {
font-size: 10px;
fill: var(--tblr-secondary, #808f9c);
@@ -18,12 +18,21 @@
function applyView(view) {
const showImages = view !== 'labels-only';
const showImageLabels = view === 'images-and-labels';
// "Images only" hides every plain label outright, not just the ones sitting
// under an image: a device without an image for this face has nothing to
// visually cover its label otherwise, which would leave text showing (e.g. on
// a rear face where only the front image is set) even though the user picked
// "images only".
const hidePlainLabels = view === 'images-only';
container.querySelectorAll('image.device-image').forEach(function (el) {
el.classList.toggle('hidden', !showImages);
});
container.querySelectorAll('.device-image-label').forEach(function (el) {
el.classList.toggle('hidden', !showImageLabels);
});
container.querySelectorAll('.device-label').forEach(function (el) {
el.classList.toggle('hidden', hidePlainLabels);
});
}
let initial = 'images-and-labels';
+66 -1
View File
@@ -3,9 +3,11 @@ from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns
from .models import ConceptDeployment, ConceptDevice, RackConcept
from .models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
__all__ = (
'ConceptCableTable',
'ConceptComponentTable',
'ConceptDeploymentTable',
'ConceptDeviceElevationTable',
'ConceptDeviceTable',
@@ -105,3 +107,66 @@ class ConceptDeploymentTable(tables.Table):
fields = ('rack_name', 'rack', 'device_count', 'user', 'created')
attrs = {'class': 'table table-hover object-list'}
empty_text = _('This concept has not been deployed yet.')
class ConceptComponentTable(NetBoxTable):
name = tables.Column(linkify=True)
device = tables.Column(linkify=True, verbose_name=_('Device'))
concept = tables.Column(accessor='device__concept', linkify=True, verbose_name=_('Concept'))
component_type = columns.ChoiceFieldColumn()
color = columns.ColorColumn()
connection = tables.Column(
accessor='cable', linkify=True, orderable=False, verbose_name=_('Connection'), empty_values=()
)
connect = tables.TemplateColumn(
template_code="""
{% load i18n %}
{% if not record.is_connected %}
<a href="{% url 'plugins:netbox_rack_concept:conceptcable_add' %}?concept={{ record.device.concept_id }}&amp;termination_a_device={{ record.device_id }}&amp;termination_a={{ record.pk }}"
class="btn btn-sm btn-primary">{% trans "Connect" %}</a>
{% endif %}
""",
verbose_name='',
orderable=False
)
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptcomponent_list')
class Meta(NetBoxTable.Meta):
model = ConceptComponent
fields = (
'pk', 'id', 'name', 'label', 'device', 'concept', 'component_type', 'port_type', 'positions', 'color',
'connection', 'connect', 'description', 'tags', 'created', 'last_updated',
)
default_columns = ('name', 'device', 'component_type', 'port_type', 'connection', 'connect')
def render_connection(self, value, record):
return value if value else _('Not connected')
class ConceptCableTable(NetBoxTable):
id = tables.Column(linkify=True, verbose_name=_('ID'))
label = tables.Column(linkify=True)
concept = tables.Column(accessor='termination_a__device__concept', linkify=True, verbose_name=_('Concept'))
status = columns.ChoiceFieldColumn()
type = columns.ChoiceFieldColumn()
color = columns.ColorColumn()
termination_a_device = tables.Column(
accessor='termination_a__device', linkify=True, verbose_name=_('Device A')
)
termination_a = tables.Column(linkify=True, verbose_name=_('Component A'))
termination_b_device = tables.Column(
accessor='termination_b__device', linkify=True, verbose_name=_('Device B')
)
termination_b = tables.Column(linkify=True, verbose_name=_('Component B'))
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptcable_list')
class Meta(NetBoxTable.Meta):
model = ConceptCable
fields = (
'pk', 'id', 'label', 'concept', 'status', 'type', 'color', 'termination_a_device', 'termination_a',
'termination_b_device', 'termination_b', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
'id', 'concept', 'status', 'type', 'termination_a_device', 'termination_a', 'termination_b_device',
'termination_b',
)
@@ -0,0 +1,74 @@
{% 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 "Cable" %}</h2>
<table class="table table-hover attr-table">
<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 "Type" %}</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>{% if object.color %}<span class="badge" style="background-color: #{{ object.color }}">&nbsp;&nbsp;&nbsp;</span> #{{ object.color }}{% else %}{{ ''|placeholder }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|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 "Side A" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.termination_a.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.termination_a.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component" %}</th>
<td>{{ object.termination_a|linkify }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Side B" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.termination_b.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.termination_b.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component" %}</th>
<td>{{ object.termination_b|linkify }}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% block extra_controls %}
{{ block.super }}
{% if not cable and perms.netbox_rack_concept.add_conceptcable %}
<a href="{% url 'plugins:netbox_rack_concept:conceptcable_add' %}?concept={{ object.device.concept_id }}&amp;termination_a_device={{ object.device_id }}&amp;termination_a={{ object.pk }}"
class="btn btn-primary">
<i class="mdi mdi-ethernet-cable"></i> {% trans "Connect" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Component" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.device.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Component type" %}</th>
<td>{{ object.get_component_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.port_type|placeholder }}</td>
</tr>
{% if object.is_pass_through %}
<tr>
<th scope="row">{% trans "Positions" %}</th>
<td>{{ object.positions }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% if object.is_pass_through %}
<div class="card">
<h2 class="card-header">{% trans "Pass-through mapping" %}</h2>
{% if paired_components %}
<table class="table table-hover attr-table">
{% for component, own_position, other_position in paired_components %}
<tr>
<th scope="row">{% trans "Position" %} {{ own_position }}</th>
<td>{{ component|linkify }} &middot; {% trans "position" %} {{ other_position }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "No mapping to a port on the other side. Sync the device's components again if this looks wrong." %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Connection" %}</h2>
{% if cable %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Cable" %}</th>
<td>{{ cable|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge cable.get_status_display bg_color=cable.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Connected to" %}</th>
<td>
{% if cable.termination_a_id == object.pk %}
{{ cable.termination_b.device|linkify }} &middot; {{ cable.termination_b|linkify }}
{% else %}
{{ cable.termination_a.device|linkify }} &middot; {{ cable.termination_a|linkify }}
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="card-body text-muted">{% trans "Not connected." %}</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -1,6 +1,16 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block extra_controls %}
{{ block.super }}
{% if object.device_type and perms.netbox_rack_concept.add_conceptcomponent %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_sync_components' pk=object.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-sync"></i> {% trans "Sync components" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
@@ -99,4 +109,21 @@
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Components" %}
{% if perms.netbox_rack_concept.add_conceptcomponent %}
<a href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}"
class="btn btn-sm btn-primary float-end">
<i class="mdi mdi-plus-thick"></i> {% trans "Add component" %}
</a>
{% endif %}
</h2>
{% render_table component_table %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% block title %}{% blocktrans with device=object %}Sync components for {{ device }}{% 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">{% trans "Sync components from device type" %}</h2>
<div class="card-body">
<p>
{% blocktrans with device=object device_type=object.device_type %}
Creates any interfaces, ports and power connections that {{ device_type }} defines but
{{ device }} doesn't have yet, matched by name. Existing components - and any cables already
attached to them - are left exactly as they are, so this is safe to run again later, e.g. after
adding a component by hand or changing the device type.
{% endblocktrans %}
</p>
<p class="text-muted">
{% trans "Front and rear ports are linked the same way they are on the device type, so a patch panel's pass-through mapping carries over automatically." %}
</p>
</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 "Sync components" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -12,13 +12,25 @@
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 #}
{# Unit rows and legend. An empty unit becomes a click target for adding a device
here, exactly like a real rack's elevation. #}
{% for unit in elevation.units %}
<g class="unit">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% if not unit.occupied and can_add_device %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&amp;position={{ unit.number }}&amp;face={{ elevation.face }}&amp;return_url={{ elevation.concept.get_absolute_url }}">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot free"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.rack_right|add:'-6' }}" y="{{ unit.y }}" dy="15"
class="add-device-hint" text-anchor="end">+ {% trans "Add device" %}</text>
</a>
{% else %}
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% endif %}
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
@@ -150,19 +150,11 @@
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 %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
</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">
+6
View File
@@ -18,6 +18,12 @@ urlpatterns = [
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
# Components and cabling
path('components/', include(get_model_urls('netbox_rack_concept', 'conceptcomponent', detail=False))),
path('components/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptcomponent'))),
path('cables/', include(get_model_urls('netbox_rack_concept', 'conceptcable', detail=False))),
path('cables/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptcable'))),
# Entry points from real racks
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
+132 -2
View File
@@ -16,11 +16,13 @@ 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 . import components, deploy, filtersets, forms, tables
from .elevation import get_elevations
from .models import ConceptDevice, RackConcept
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = (
'ConceptCableView',
'ConceptComponentView',
'ConceptDeviceView',
'MergeConceptsView',
'MergeRacksToConceptView',
@@ -138,6 +140,50 @@ class ConceptDeviceView(generic.ObjectView):
)
template_name = 'netbox_rack_concept/conceptdevice.html'
def get_extra_context(self, request, instance):
component_table = tables.ConceptComponentTable(
instance.components.select_related('device').order_by('component_type', 'name'),
exclude=('device', 'concept'),
orderable=False,
)
component_table.configure(request)
return {
'component_table': component_table,
}
@register_model_view(ConceptDevice, 'sync_components')
class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
"""Populate a device's components from its device type's templates."""
queryset = ConceptDevice.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_conceptcomponent'
def get(self, request, pk):
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
return render(request, 'netbox_rack_concept/conceptdevice_sync_components.html', {
'object': device,
'return_url': device.get_absolute_url(),
})
def post(self, request, pk):
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
if device.device_type is None:
messages.error(request, _('%(device)s has no device type to sync components from.') % {'device': device})
return redirect(device.get_absolute_url())
created, mapped = components.sync_components(device)
if created:
messages.success(request, _('Created %(count)s component(s).') % {'count': created})
else:
messages.info(request, _('No new components to create; everything is already in sync.'))
if mapped:
messages.success(request, _('Mapped %(count)s front/rear port pair(s).') % {'count': mapped})
return redirect(device.get_absolute_url())
@register_model_view(ConceptDevice, 'add', detail=False)
@register_model_view(ConceptDevice, 'edit')
@@ -506,3 +552,87 @@ class RackConceptReorderView(ObjectPermissionRequiredMixin, View):
return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
return JsonResponse({'status': 'ok', 'updated': len(updated)})
#
# Concept components
#
@register_model_view(ConceptComponent, 'list', path='', detail=False)
class ConceptComponentListView(generic.ObjectListView):
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
table = tables.ConceptComponentTable
filterset = filtersets.ConceptComponentFilterSet
filterset_form = forms.ConceptComponentFilterForm
@register_model_view(ConceptComponent)
class ConceptComponentView(generic.ObjectView):
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
template_name = 'netbox_rack_concept/conceptcomponent.html'
def get_extra_context(self, request, instance):
return {
'paired_components': instance.get_paired_components(),
'cable': instance.cable,
}
@register_model_view(ConceptComponent, 'add', detail=False)
@register_model_view(ConceptComponent, 'edit')
class ConceptComponentEditView(generic.ObjectEditView):
queryset = ConceptComponent.objects.all()
form = forms.ConceptComponentForm
@register_model_view(ConceptComponent, 'delete')
class ConceptComponentDeleteView(generic.ObjectDeleteView):
queryset = ConceptComponent.objects.all()
@register_model_view(ConceptComponent, 'bulk_delete', path='delete', detail=False)
class ConceptComponentBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptComponent.objects.select_related('device')
filterset = filtersets.ConceptComponentFilterSet
table = tables.ConceptComponentTable
#
# Concept cables
#
@register_model_view(ConceptCable, 'list', path='', detail=False)
class ConceptCableListView(generic.ObjectListView):
queryset = ConceptCable.objects.select_related(
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
)
table = tables.ConceptCableTable
filterset = filtersets.ConceptCableFilterSet
filterset_form = forms.ConceptCableFilterForm
@register_model_view(ConceptCable)
class ConceptCableView(generic.ObjectView):
queryset = ConceptCable.objects.select_related(
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
)
template_name = 'netbox_rack_concept/conceptcable.html'
@register_model_view(ConceptCable, 'add', detail=False)
@register_model_view(ConceptCable, 'edit')
class ConceptCableEditView(generic.ObjectEditView):
queryset = ConceptCable.objects.all()
form = forms.ConceptCableForm
@register_model_view(ConceptCable, 'delete')
class ConceptCableDeleteView(generic.ObjectDeleteView):
queryset = ConceptCable.objects.all()
@register_model_view(ConceptCable, 'bulk_delete', path='delete', detail=False)
class ConceptCableBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptCable.objects.select_related('termination_a', 'termination_b')
filterset = filtersets.ConceptCableFilterSet
table = tables.ConceptCableTable