diff --git a/README.md b/README.md
index d1738c9..a049108 100644
--- a/README.md
+++ b/README.md
@@ -32,9 +32,41 @@ Dazu:
Teilbreiten-Feature von `netbox_utilities`.
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
* **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,
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
**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) |
| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON |
| `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:
diff --git a/netbox_rack_concept/api/serializers.py b/netbox_rack_concept/api/serializers.py
index 328d8c3..44d7a5a 100644
--- a/netbox_rack_concept/api/serializers.py
+++ b/netbox_rack_concept/api/serializers.py
@@ -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
#
diff --git a/netbox_rack_concept/api/urls.py b/netbox_rack_concept/api/urls.py
index b4ee6b6..a201755 100644
--- a/netbox_rack_concept/api/urls.py
+++ b/netbox_rack_concept/api/urls.py
@@ -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
diff --git a/netbox_rack_concept/api/views.py b/netbox_rack_concept/api/views.py
index 652e2cd..cb3b37e 100644
--- a/netbox_rack_concept/api/views.py
+++ b/netbox_rack_concept/api/views.py
@@ -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
diff --git a/netbox_rack_concept/choices.py b/netbox_rack_concept/choices.py
index 05a499a..aa23285 100644
--- a/netbox_rack_concept/choices.py
+++ b/netbox_rack_concept/choices.py
@@ -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)
diff --git a/netbox_rack_concept/components.py b/netbox_rack_concept/components.py
new file mode 100644
index 0000000..10bddc6
--- /dev/null
+++ b/netbox_rack_concept/components.py
@@ -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
diff --git a/netbox_rack_concept/elevation.py b/netbox_rack_concept/elevation.py
index db6e451..e7683ed 100644
--- a/netbox_rack_concept/elevation.py
+++ b/netbox_rack_concept/elevation.py
@@ -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:
diff --git a/netbox_rack_concept/filtersets.py b/netbox_rack_concept/filtersets.py
index d4f90f4..96f5dd5 100644
--- a/netbox_rack_concept/filtersets.py
+++ b/netbox_rack_concept/filtersets.py
@@ -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)
+ )
diff --git a/netbox_rack_concept/forms.py b/netbox_rack_concept/forms.py
index 4caf9a1..fccbe13 100644
--- a/netbox_rack_concept/forms.py
+++ b/netbox_rack_concept/forms.py
@@ -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')),
+ )
diff --git a/netbox_rack_concept/migrations/0002_cabling.py b/netbox_rack_concept/migrations/0002_cabling.py
new file mode 100644
index 0000000..38a63d4
--- /dev/null
+++ b/netbox_rack_concept/migrations/0002_cabling.py
@@ -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',),
+ },
+ ),
+ ]
diff --git a/netbox_rack_concept/models.py b/netbox_rack_concept/models.py
index f3fc0df..364764c 100644
--- a/netbox_rack_concept/models.py
+++ b/netbox_rack_concept/models.py
@@ -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
+ }
+ })
diff --git a/netbox_rack_concept/navigation.py b/netbox_rack_concept/navigation.py
index bd90061..93040c8 100644
--- a/netbox_rack_concept/navigation.py
+++ b/netbox_rack_concept/navigation.py
@@ -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
+ ),
+ )),
)
)
diff --git a/netbox_rack_concept/search.py b/netbox_rack_concept/search.py
index ae6987a..bf6695f 100644
--- a/netbox_rack_concept/search.py
+++ b/netbox_rack_concept/search.py
@@ -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')
diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.css b/netbox_rack_concept/static/netbox_rack_concept/elevation.css
index e632320..cf09968 100644
--- a/netbox_rack_concept/static/netbox_rack_concept/elevation.css
+++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.css
@@ -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);
diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.js b/netbox_rack_concept/static/netbox_rack_concept/elevation.js
index 3b601a3..a44bf8d 100644
--- a/netbox_rack_concept/static/netbox_rack_concept/elevation.js
+++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.js
@@ -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';
diff --git a/netbox_rack_concept/tables.py b/netbox_rack_concept/tables.py
index 89a6cff..788211a 100644
--- a/netbox_rack_concept/tables.py
+++ b/netbox_rack_concept/tables.py
@@ -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 %}
+ {% trans "Connect" %}
+ {% 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',
+ )
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptcable.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptcable.html
new file mode 100644
index 0000000..c96853e
--- /dev/null
+++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptcable.html
@@ -0,0 +1,74 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+{% load helpers %}
+
+{% block content %}
+
+
+
+
+
+
+ | {% trans "Label" %} |
+ {{ object.label|placeholder }} |
+
+
+ | {% trans "Status" %} |
+ {% badge object.get_status_display bg_color=object.get_status_color %} |
+
+
+ | {% trans "Type" %} |
+ {{ object.get_type_display|placeholder }} |
+
+
+ | {% trans "Color" %} |
+ {% if object.color %} #{{ object.color }}{% else %}{{ ''|placeholder }}{% endif %} |
+
+
+ | {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+
+ {% include 'inc/panels/comments.html' %}
+
+
+
+
+
+
+
+ | {% trans "Concept" %} |
+ {{ object.termination_a.device.concept|linkify }} |
+
+
+ | {% trans "Device" %} |
+ {{ object.termination_a.device|linkify }} |
+
+
+ | {% trans "Component" %} |
+ {{ object.termination_a|linkify }} |
+
+
+
+
+
+
+
+
+ | {% trans "Concept" %} |
+ {{ object.termination_b.device.concept|linkify }} |
+
+
+ | {% trans "Device" %} |
+ {{ object.termination_b.device|linkify }} |
+
+
+ | {% trans "Component" %} |
+ {{ object.termination_b|linkify }} |
+
+
+
+
+
+{% endblock %}
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptcomponent.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptcomponent.html
new file mode 100644
index 0000000..2f6ee3a
--- /dev/null
+++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptcomponent.html
@@ -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 %}
+
+ {% trans "Connect" %}
+
+ {% endif %}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ | {% trans "Device" %} |
+ {{ object.device|linkify }} |
+
+
+ | {% trans "Concept" %} |
+ {{ object.device.concept|linkify }} |
+
+
+ | {% trans "Name" %} |
+ {{ object.name }} |
+
+
+ | {% trans "Label" %} |
+ {{ object.label|placeholder }} |
+
+
+ | {% trans "Component type" %} |
+ {{ object.get_component_type_display }} |
+
+
+ | {% trans "Type" %} |
+ {{ object.port_type|placeholder }} |
+
+ {% if object.is_pass_through %}
+
+ | {% trans "Positions" %} |
+ {{ object.positions }} |
+
+ {% endif %}
+
+ | {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+
+
+ {% if object.is_pass_through %}
+
+
+ {% if paired_components %}
+
+ {% for component, own_position, other_position in paired_components %}
+
+ | {% trans "Position" %} {{ own_position }} |
+ {{ component|linkify }} · {% trans "position" %} {{ other_position }} |
+
+ {% endfor %}
+
+ {% else %}
+
+ {% trans "No mapping to a port on the other side. Sync the device's components again if this looks wrong." %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
+ {% if cable %}
+
+
+ | {% trans "Cable" %} |
+ {{ cable|linkify }} |
+
+
+ | {% trans "Status" %} |
+ {% badge cable.get_status_display bg_color=cable.get_status_color %} |
+
+
+ | {% trans "Connected to" %} |
+
+ {% 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 %}
+ |
+
+
+ {% else %}
+
{% trans "Not connected." %}
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html
index 6f9b1d1..505495d 100644
--- a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html
+++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html
@@ -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 %}
+
+ {% trans "Sync components" %}
+
+ {% endif %}
+{% endblock %}
{% block content %}
@@ -99,4 +109,21 @@
+
+
+
+
+
+ {% render_table component_table %}
+
+
+
{% endblock %}
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_sync_components.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_sync_components.html
new file mode 100644
index 0000000..adb950f
--- /dev/null
+++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_sync_components.html
@@ -0,0 +1,34 @@
+{% extends 'base/layout.html' %}
+{% load i18n %}
+
+{% block title %}{% blocktrans with device=object %}Sync components for {{ device }}{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html
index f94d78b..be79a52 100644
--- a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html
+++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html
@@ -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 %}
-
+ {% if not unit.occupied and can_add_device %}
+
+
+ + {% trans "Add device" %}
+
+ {% else %}
+
+ {% endif %}
{{ unit.number }}
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html
index 3f7b36d..5baf80f 100644
--- a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html
+++ b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html
@@ -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 %}
- {% if perms.netbox_rack_concept.add_conceptdevice %}
-
- {% endif %}
diff --git a/netbox_rack_concept/urls.py b/netbox_rack_concept/urls.py
index 2a7fa68..188544d 100644
--- a/netbox_rack_concept/urls.py
+++ b/netbox_rack_concept/urls.py
@@ -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'))),
+ # Components and cabling
+ path('components/', include(get_model_urls('netbox_rack_concept', 'conceptcomponent', detail=False))),
+ path('components//', include(get_model_urls('netbox_rack_concept', 'conceptcomponent'))),
+ path('cables/', include(get_model_urls('netbox_rack_concept', 'conceptcable', detail=False))),
+ path('cables//', include(get_model_urls('netbox_rack_concept', 'conceptcable'))),
+
# Entry points from real racks
path('racks//copy/', views.RackCopyView.as_view(), name='rack_copy'),
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
diff --git a/netbox_rack_concept/views.py b/netbox_rack_concept/views.py
index 9c7b05f..ee20fec 100644
--- a/netbox_rack_concept/views.py
+++ b/netbox_rack_concept/views.py
@@ -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