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
+34
View File
@@ -32,9 +32,41 @@ Dazu:
Teilbreiten-Feature von `netbox_utilities`. Teilbreiten-Feature von `netbox_utilities`.
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät. * **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
* **Platzhalter ohne Gerätetyp** – reservierter Bauraum, nur mit Höhenangabe. * **Platzhalter ohne Gerätetyp** – reservierter Bauraum, nur mit Höhenangabe.
* **Klick auf eine leere HE** öffnet direkt „Add planned device", vorausgefüllt mit
Position und Seite — genau wie bei einer echten Rack-Elevation. Den separaten
„Add planned device"-Knopf gibt es dafür nicht mehr.
* **Verkabelung von Konzept-Geräten** – Interfaces, Front-/Rear-Ports, Power-Ports/
-Outlets und Console-Ports lassen sich mit „Sync components" aus dem Gerätetyp
übernehmen (inkl. Front-↔Rear-Zuordnung von Patchpanels, 1:1 wie beim echten
Gerätetyp) und über „Connect" mit einem Concept Cable verbinden. Platzhalter ohne
Gerätetyp bekommen ihre Komponenten per Hand über „Add component".
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal, * Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
## Verkabelung
Konzept-Geräte lassen sich verkabeln, aber **nicht** über NetBox' echtes `dcim.Cable` —
das kann nur echte `dcim`-Komponenten verbinden. Stattdessen bringt das Plugin ein
eigenes, schlankes Modellpaar mit:
* **ConceptComponent** — ein Interface, Front-/Rear-Port, Power-Port/-Outlet oder
Console-Port an einem geplanten Gerät. Über **„Sync components"** auf der
Geräte-Seite werden sie 1:1 aus den Component-Templates des Gerätetyps erzeugt
(Name, Label, Typ, Positionen) — wiederholbar, ohne Duplikate oder Datenverlust an
bereits verkabelten Komponenten. Ein Gerät ohne Gerätetyp (Platzhalter) hat keine
Templates zum Syncen; seine Komponenten legt man über **„Add component"** von Hand an.
* **ConceptCable** — verbindet zwei `ConceptComponent`s. Beide Enden müssen zu Geräten
im selben Rack-Konzept gehören; eine Komponente kann nur in einem Kabel gleichzeitig
stecken. Erreichbar über **„Connect"** an jeder unverkabelten Komponente.
**Patchpanels funktionieren genauso wie bei echten Geräten**, weil das Plugin exakt
dieselbe Front-↔Rear-Zuordnung ausliest, die NetBox selbst am Gerätetyp pflegt (über
`PortTemplateMapping`, dieselbe Tabelle, die auch echte `FrontPort`/`RearPort`-Objekte
verknüpft). Das hängt also nicht an der Rollenbezeichnung „Patchpanel", sondern schlicht
daran, ob der Gerätetyp Front-/Rear-Ports mit Zuordnung definiert — Kabel an der
Vorderseite eines synchronisierten Patchpanel-Gerätetyps zeigen auf der Detailseite der
Komponente sofort den passenden Rear-Port samt Position an.
## Zusammenspiel mit den anderen Plugins ## Zusammenspiel mit den anderen Plugins
**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat **netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat
@@ -172,6 +204,8 @@ Basis-Pfad: `/api/plugins/rack-concepts/`
| `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) | | `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) |
| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON | | `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON |
| `GET/POST` | `devices/` | Geplante Geräte | | `GET/POST` | `devices/` | Geplante Geräte |
| `GET/POST` | `components/` | Komponenten der geplanten Geräte |
| `GET/POST` | `cables/` | Concept Cables |
Beispiel — zehn Racks aus einem Konzept, erst als Trockenlauf: Beispiel — zehn Racks aus einem Konzept, erst als Trockenlauf:
+35 -1
View File
@@ -11,9 +11,11 @@ from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
from tenancy.models import Tenant from tenancy.models import Tenant
from ..choices import ConceptStatusChoices from ..choices import ConceptStatusChoices
from ..models import ConceptDeployment, ConceptDevice, RackConcept from ..models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptCableSerializer',
'ConceptComponentSerializer',
'ConceptDeploySerializer', 'ConceptDeploySerializer',
'ConceptDeploymentSerializer', 'ConceptDeploymentSerializer',
'ConceptDeviceSerializer', 'ConceptDeviceSerializer',
@@ -81,6 +83,38 @@ class ConceptDeploymentSerializer(serializers.ModelSerializer):
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created') 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 # Action payloads
# #
+2
View File
@@ -7,5 +7,7 @@ app_name = 'netbox_rack_concept-api'
router = NetBoxRouter() router = NetBoxRouter()
router.register('concepts', views.RackConceptViewSet) router.register('concepts', views.RackConceptViewSet)
router.register('devices', views.ConceptDeviceViewSet) router.register('devices', views.ConceptDeviceViewSet)
router.register('components', views.ConceptComponentViewSet)
router.register('cables', views.ConceptCableViewSet)
urlpatterns = router.urls urlpatterns = router.urls
+17 -3
View File
@@ -11,10 +11,10 @@ from netbox.api.viewsets import NetBoxModelViewSet
from .. import filtersets from .. import filtersets
from .. import deploy as deploy_utils from .. import deploy as deploy_utils
from ..elevation import get_elevations from ..elevation import get_elevations
from ..models import ConceptDevice, RackConcept from ..models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
from .serializers import ( from .serializers import (
ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer, ConceptCableSerializer, ConceptComponentSerializer, ConceptDeploySerializer, ConceptDeviceSerializer,
RackConceptSerializer, RackCopySerializer, MergeConceptsSerializer, RackConceptCloneSerializer, RackConceptSerializer, RackCopySerializer,
) )
__all__ = ( __all__ = (
@@ -204,3 +204,17 @@ class ConceptDeviceViewSet(NetBoxModelViewSet):
) )
serializer_class = ConceptDeviceSerializer serializer_class = ConceptDeviceSerializer
filterset_class = filtersets.ConceptDeviceFilterSet 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 # 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. # on this many lanes so that every fraction lands on an integer lane boundary.
LANE_COUNT = 12 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: class ElevationUnit:
"""One labelled rack unit row.""" """One labelled rack unit row."""
def __init__(self, number, y): def __init__(self, number, y, occupied=False):
self.number = number self.number = number
self.y = y self.y = y
self.height = UNIT_HEIGHT 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: class ElevationBlock:
@@ -110,11 +115,14 @@ class Elevation:
self.legend_width = LEGEND_WIDTH self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH self.rack_width = RACK_WIDTH
self.rack_x = RACK_X self.rack_x = RACK_X
self.rack_right = RACK_X + RACK_WIDTH
self.width = RACK_X + RACK_WIDTH + MARGIN self.width = RACK_X + RACK_WIDTH + MARGIN
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2 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_blocks(devices if devices is not None else concept.devices.all())
self._build_units()
@property @property
def face_label(self): def face_label(self):
@@ -134,7 +142,11 @@ class Elevation:
def _build_units(self): def _build_units(self):
for number in self.concept.units: for number in self.concept.units:
top = number if self.concept.desc_units else number + 1 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): def _build_blocks(self, devices):
for device in devices: for device in devices:
+73 -2
View File
@@ -2,14 +2,17 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import CableTypeChoices, LinkStatusChoices
from dcim.models import DeviceRole, DeviceType, Rack, RackType, Site from dcim.models import DeviceRole, DeviceType, Rack, RackType, Site
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices
from .models import ConceptDevice, RackConcept from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptCableFilterSet',
'ConceptComponentFilterSet',
'ConceptDeviceFilterSet', 'ConceptDeviceFilterSet',
'RackConceptFilterSet', 'RackConceptFilterSet',
) )
@@ -115,3 +118,71 @@ class ConceptDeviceFilterSet(NetBoxModelFilterSet):
def filter_racked(self, queryset, name, value): def filter_racked(self, queryset, name, value):
return queryset.filter(position__isnull=not 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 import forms
from django.utils.translation import gettext_lazy as _ 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 dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@@ -12,10 +13,15 @@ from utilities.forms.fields import (
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, \
from .models import ConceptDevice, RackConcept WidthFractionChoices
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptCableForm',
'ConceptCableFilterForm',
'ConceptComponentForm',
'ConceptComponentFilterForm',
'ConceptDeployForm', 'ConceptDeployForm',
'ConceptDeviceBulkEditForm', 'ConceptDeviceBulkEditForm',
'ConceptDeviceFilterForm', 'ConceptDeviceFilterForm',
@@ -631,3 +637,125 @@ class ConceptDeployForm(forms.Form):
}) })
cleaned['rack_names'] = names cleaned['rack_names'] = names
return cleaned 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 django.utils.translation import gettext_lazy as _
from dcim.choices import ( from dcim.choices import (
DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices, CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, RackAirflowChoices, RackDimensionUnitChoices,
RackFormFactorChoices, RackWidthChoices,
) )
from netbox.models import NetBoxModel 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__ = ( __all__ = (
'ConceptCable',
'ConceptComponent',
'ConceptDevice', 'ConceptDevice',
'ConceptDeployment', 'ConceptDeployment',
'ConceptPortMapping',
'RackConcept', 'RackConcept',
) )
@@ -459,3 +466,216 @@ class ConceptDeployment(models.Model):
def __str__(self): def __str__(self):
return f'{self.concept} → {self.rack_name}' 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( menu = PluginMenu(
label=_('Rack Concepts'), label=_('Rack Concepts'),
icon_class='mdi mdi-view-grid-plus-outline', icon_class='mdi mdi-view-grid-plus-outline',
@@ -62,5 +72,18 @@ menu = PluginMenu(
buttons=device_buttons 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 netbox.search import SearchIndex, register_search
from .models import ConceptDevice, RackConcept from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
@register_search @register_search
@@ -25,3 +25,25 @@ class ConceptDeviceIndex(SearchIndex):
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('concept', 'status', 'device_type', 'role', 'description') 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); 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 { .concept-elevation .unit-label {
font-size: 10px; font-size: 10px;
fill: var(--tblr-secondary, #808f9c); fill: var(--tblr-secondary, #808f9c);
@@ -18,12 +18,21 @@
function applyView(view) { function applyView(view) {
const showImages = view !== 'labels-only'; const showImages = view !== 'labels-only';
const showImageLabels = view === 'images-and-labels'; 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) { container.querySelectorAll('image.device-image').forEach(function (el) {
el.classList.toggle('hidden', !showImages); el.classList.toggle('hidden', !showImages);
}); });
container.querySelectorAll('.device-image-label').forEach(function (el) { container.querySelectorAll('.device-image-label').forEach(function (el) {
el.classList.toggle('hidden', !showImageLabels); el.classList.toggle('hidden', !showImageLabels);
}); });
container.querySelectorAll('.device-label').forEach(function (el) {
el.classList.toggle('hidden', hidePlainLabels);
});
} }
let initial = 'images-and-labels'; 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 netbox.tables import NetBoxTable, columns
from .models import ConceptDeployment, ConceptDevice, RackConcept from .models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptCableTable',
'ConceptComponentTable',
'ConceptDeploymentTable', 'ConceptDeploymentTable',
'ConceptDeviceElevationTable', 'ConceptDeviceElevationTable',
'ConceptDeviceTable', 'ConceptDeviceTable',
@@ -105,3 +107,66 @@ class ConceptDeploymentTable(tables.Table):
fields = ('rack_name', 'rack', 'device_count', 'user', 'created') fields = ('rack_name', 'rack', 'device_count', 'user', 'created')
attrs = {'class': 'table table-hover object-list'} attrs = {'class': 'table table-hover object-list'}
empty_text = _('This concept has not been deployed yet.') 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' %} {% extends 'generic/object.html' %}
{% load i18n %} {% load i18n %}
{% load helpers %} {% 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 %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
@@ -99,4 +109,21 @@
</div> </div>
</div> </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 %} {% 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" class="concept-elevation-svg" role="img"
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}"> 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 %} {% for unit in elevation.units %}
<g class="unit"> <g class="unit">
{% 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 }}" <rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}" width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot" class="unit-slot"
data-unit="{{ unit.number }}"></rect> data-unit="{{ unit.number }}"></rect>
{% endif %}
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15" <text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text> class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g> </g>
@@ -150,19 +150,11 @@
data-starting-unit="{{ object.starting_unit }}" data-starting-unit="{{ object.starting_unit }}"
data-u-height="{{ object.u_height }}" data-u-height="{{ object.u_height }}"
data-desc-units="{{ object.desc_units|yesno:'true,false' }}"> 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=front_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %} {% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
</div> </div>
<div id="concept-elevation-status" class="small text-muted mt-2"></div> <div id="concept-elevation-status" class="small text-muted mt-2"></div>
</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>
<div class="card"> <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/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))), 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 # Entry points from real racks
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'), path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'), 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 netbox.views import generic
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view 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 .elevation import get_elevations
from .models import ConceptDevice, RackConcept from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptCableView',
'ConceptComponentView',
'ConceptDeviceView', 'ConceptDeviceView',
'MergeConceptsView', 'MergeConceptsView',
'MergeRacksToConceptView', 'MergeRacksToConceptView',
@@ -138,6 +140,50 @@ class ConceptDeviceView(generic.ObjectView):
) )
template_name = 'netbox_rack_concept/conceptdevice.html' 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, 'add', detail=False)
@register_model_view(ConceptDevice, 'edit') @register_model_view(ConceptDevice, 'edit')
@@ -506,3 +552,87 @@ class RackConceptReorderView(ObjectPermissionRequiredMixin, View):
return JsonResponse({'error': ' '.join(exc.messages)}, status=400) return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
return JsonResponse({'status': 'ok', 'updated': len(updated)}) 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