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 "Cable" %}

+ + + + + + + + + + + + + + + + + + + + + +
{% 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 "Side A" %}

+ + + + + + + + + + + + + +
{% trans "Concept" %}{{ object.termination_a.device.concept|linkify }}
{% trans "Device" %}{{ object.termination_a.device|linkify }}
{% trans "Component" %}{{ object.termination_a|linkify }}
+
+ +
+

{% trans "Side B" %}

+ + + + + + + + + + + + + +
{% 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 "Component" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if object.is_pass_through %} + + + + + {% endif %} + + + + +
{% 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 }}
{% trans "Positions" %}{{ object.positions }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ + {% if object.is_pass_through %} +
+

{% trans "Pass-through mapping" %}

+ {% if paired_components %} + + {% for component, own_position, other_position in paired_components %} + + + + + {% endfor %} +
{% trans "Position" %} {{ own_position }}{{ component|linkify }} · {% trans "position" %} {{ other_position }}
+ {% else %} +
+ {% trans "No mapping to a port on the other side. Sync the device's components again if this looks wrong." %} +
+ {% endif %} +
+ {% endif %} +
+ +
+
+

{% trans "Connection" %}

+ {% 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 @@
+ +
+
+
+

+ {% trans "Components" %} + {% if perms.netbox_rack_concept.add_conceptcomponent %} + + {% trans "Add component" %} + + {% endif %} +

+ {% 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 %} +
+ {% csrf_token %} +
+
+
+

{% trans "Sync components from device type" %}

+
+

+ {% 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 %} +

+

+ {% 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." %} +

+
+ +
+
+
+
+{% 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