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:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}&termination_a_device={{ record.device_id }}&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 }}"> </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 }}&termination_a_device={{ object.device_id }}&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 }} · {% 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 }} · {{ cable.termination_b|linkify }}
|
||||
{% else %}
|
||||
{{ cable.termination_a.device|linkify }} · {{ 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 }}&position={{ unit.number }}&face={{ elevation.face }}&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">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user