From 17cf94ac0d8af923aecbe5074e8f114e318c7f0a Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 16 Sep 2026 09:00:26 +0200 Subject: [PATCH] Add netbox_rack_concept plugin: rack blueprint planning Introduces a NetBox 4.6 plugin for planning rack layouts (RackConcept, ConceptDevice) without creating real dcim.Rack/dcim.Device objects, so blueprints never appear in rack/device lists, utilization reports, or IPAM. Features: - Copy paths: real rack -> concept, concept -> concept (clone), and concept -> one or more real racks (deploy, with name patterning and dry-run support) - Manual concept creation via a dedicated "Rack Concepts" nav menu - Tenant and tenant group assignment on concepts and planned devices - Partial-width device placement (1/1-1/4) with slot-based collision detection, matching netbox_utilities' partial-width feature - Custom drag-and-drop elevation (netbox-reorder-rack operates on real Device querysets, which concepts don't have) with server-side validation of every move - Full REST API, filtering, CSV import, bulk edit, search integration - Optional, config-driven interop with netbox_utilities (partial-width field mapping) and netbox-reorder-rack, degrading gracefully when either plugin is absent Co-Authored-By: Claude Sonnet 5 --- .gitignore | 9 + README.md | 175 +++++++ netbox_rack_concept/__init__.py | 47 ++ netbox_rack_concept/api/__init__.py | 0 netbox_rack_concept/api/serializers.py | 161 ++++++ netbox_rack_concept/api/urls.py | 11 + netbox_rack_concept/api/views.py | 175 +++++++ netbox_rack_concept/choices.py | 61 +++ netbox_rack_concept/deploy.py | 251 ++++++++++ netbox_rack_concept/elevation.py | 178 +++++++ netbox_rack_concept/filtersets.py | 117 +++++ netbox_rack_concept/forms.py | 458 +++++++++++++++++ netbox_rack_concept/integrations.py | 157 ++++++ .../migrations/0001_initial.py | 178 +++++++ netbox_rack_concept/migrations/__init__.py | 0 netbox_rack_concept/models.py | 461 ++++++++++++++++++ netbox_rack_concept/navigation.py | 59 +++ netbox_rack_concept/search.py | 27 + .../static/netbox_rack_concept/elevation.css | 61 +++ .../static/netbox_rack_concept/elevation.js | 173 +++++++ netbox_rack_concept/tables.py | 107 ++++ netbox_rack_concept/template_content.py | 37 ++ .../netbox_rack_concept/concept_clone.html | 32 ++ .../netbox_rack_concept/concept_deploy.html | 48 ++ .../netbox_rack_concept/conceptdevice.html | 102 ++++ .../netbox_rack_concept/inc/elevation.html | 55 +++ .../inc/rack_concepts_panel.html | 16 + .../inc/rack_copy_button.html | 4 + .../netbox_rack_concept/rack_copy.html | 35 ++ .../netbox_rack_concept/rackconcept.html | 168 +++++++ .../rackconcept_devices.html | 12 + netbox_rack_concept/urls.py | 20 + netbox_rack_concept/views.py | 395 +++++++++++++++ pyproject.toml | 31 ++ 34 files changed, 3821 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 netbox_rack_concept/__init__.py create mode 100644 netbox_rack_concept/api/__init__.py create mode 100644 netbox_rack_concept/api/serializers.py create mode 100644 netbox_rack_concept/api/urls.py create mode 100644 netbox_rack_concept/api/views.py create mode 100644 netbox_rack_concept/choices.py create mode 100644 netbox_rack_concept/deploy.py create mode 100644 netbox_rack_concept/elevation.py create mode 100644 netbox_rack_concept/filtersets.py create mode 100644 netbox_rack_concept/forms.py create mode 100644 netbox_rack_concept/integrations.py create mode 100644 netbox_rack_concept/migrations/0001_initial.py create mode 100644 netbox_rack_concept/migrations/__init__.py create mode 100644 netbox_rack_concept/models.py create mode 100644 netbox_rack_concept/navigation.py create mode 100644 netbox_rack_concept/search.py create mode 100644 netbox_rack_concept/static/netbox_rack_concept/elevation.css create mode 100644 netbox_rack_concept/static/netbox_rack_concept/elevation.js create mode 100644 netbox_rack_concept/tables.py create mode 100644 netbox_rack_concept/template_content.py create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/concept_clone.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/concept_deploy.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/inc/rack_concepts_panel.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/inc/rack_copy_button.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/rack_copy.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/rackconcept_devices.html create mode 100644 netbox_rack_concept/urls.py create mode 100644 netbox_rack_concept/views.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb33e4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..84ab36e --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# netbox-rack-concept + +Rack-Blaupausen für NetBox 4.6. Ein „Rack Konzept" ist ein vollständig geplantes Rack +samt Einbauten — aber **kein** echtes Rack und **keine** echten Geräte. Konzepte tauchen +deshalb weder in der Rack-Liste noch in der Geräte-Liste auf, verfälschen keine +Auslastungs-Auswertungen und belegen keine Seriennummern oder IP-Adressen. + +Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte +überführt — ein Mal oder gleich zwanzig Mal. + +## Was das Plugin kann + +| Weg | Wo | +|---|---| +| Echtes Rack → Konzept | Button **„Copy to concept"** auf der Rack-Detailseite | +| Konzept → Konzept (Variante) | Button **„Clone concept"** auf der Konzept-Detailseite | +| Konzept → echte Racks (n-fach) | Button **„Deploy to NetBox"**, mit Namensschema `DC1-R{n:02d}` | +| Konzept von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** | + +Dazu: + +* **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert. + Ein ungültiger Zug wird abgelehnt und ändert nichts. +* **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum + 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. +* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal, + Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. + +## Zusammenspiel mit den anderen Plugins + +**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat +per Definition keine, also bringt dieses Plugin eine eigene Drag-&-Drop-Elevation mit +(`static/netbox_rack_concept/elevation.js`). Sobald ein Konzept ausgerollt ist, ist das +Ergebnis ein ganz normales Rack — ab da greift reorder-rack wie gewohnt. + +> Hinweis zur Version: `netbox-reorder-rack` **v1.1.5 setzt NetBox 4.7 voraus**. Für +> NetBox 4.6.8 die letzte 4.6-taugliche Version pinnen (`netbox-reorder-rack<1.1.5`). + +**netbox_utilities** liefert die Teilbreiten für echte Geräte. Beim Ausrollen schreibt +dieses Plugin die Werte `width_fraction` und `horizontal_position` dorthin zurück; beim +Kopieren eines echten Racks liest es sie von dort. Da die Feldnamen von `netbox_utilities` +nicht öffentlich dokumentiert sind, stehen sie in `PLUGINS_CONFIG` (siehe unten) und +lassen sich ohne Code-Änderung anpassen. Ist `netbox_utilities` nicht installiert oder +passt das Mapping nicht, werden Teilbreiten schlicht nicht geschrieben — alles andere +funktioniert weiter, und es landet eine Warnung im NetBox-Log. + +## Installation + +```bash +source /opt/netbox/venv/bin/activate +pip install /pfad/zu/netbox-rack-concept +``` + +In `configuration.py`: + +```python +PLUGINS = [ + 'netbox_rack_concept', + # optional, in beliebiger Reihenfolge: + 'netbox_utilities', + 'netbox_reorder_rack', +] +``` + +Dann: + +```bash +cd /opt/netbox/netbox +python manage.py migrate +python manage.py collectstatic --no-input +sudo systemctl restart netbox netbox-rq +``` + +> Sollte `migrate` die mitgelieferte Migration nicht anwenden können, weil sich in eurer +> NetBox-Installation Basisfelder unterscheiden: `netbox_rack_concept/migrations/0001_initial.py` +> löschen und `python manage.py makemigrations netbox_rack_concept` laufen lassen. +> Die Migration ist von Hand geschrieben und gegen NetBox 4.6.8 erstellt, aber nicht in +> eurer Umgebung getestet. + +## Konfiguration + +Alle Werte sind optional; gezeigt sind die Defaults. + +```python +PLUGINS_CONFIG = { + 'netbox_rack_concept': { + # Vorbelegung beim Anlegen eines neuen Konzepts + 'default_u_height': 42, + 'default_width': 19, + + # "Copy to concept"-Button auf der Rack-Seite anzeigen + 'enable_rack_tab': True, + + # Status der Objekte, die beim Ausrollen entstehen + 'deployed_rack_status': 'planned', + 'deployed_device_status': 'planned', + + # Anbindung an die Teilbreiten von netbox_utilities. + # 'model' leer lassen, wenn netbox_utilities die Felder direkt an dcim.Device hängt. + 'partial_width': { + 'enabled': True, + 'model': 'netbox_utilities.RackDevicePosition', + 'device_field': 'device', + 'fraction_field': 'width_fraction', + 'position_field': 'horizontal_position', + }, + } +} +``` + +## REST-API + +Basis-Pfad: `/api/plugins/rack-concepts/` + +| Methode | Endpunkt | Zweck | +|---|---|---| +| `GET/POST` | `concepts/` | Konzepte auflisten und anlegen | +| `GET/PATCH/DELETE` | `concepts/{id}/` | Einzelnes Konzept | +| `POST` | `concepts/copy-from-rack/` | Echtes Rack in ein Konzept kopieren | +| `POST` | `concepts/{id}/clone/` | Konzept duplizieren | +| `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) | +| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON | +| `GET/POST` | `devices/` | Geplante Geräte | + +Beispiel — zehn Racks aus einem Konzept, erst als Trockenlauf: + +```bash +curl -s -X POST https://netbox.example/api/plugins/rack-concepts/concepts/3/deploy/ \ + -H "Authorization: Token $NETBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name_pattern": "DC1-R{n:02d}", + "count": 10, + "start_index": 1, + "site": 4, + "location": 12, + "create_devices": true, + "device_name_pattern": "{rack}-{name}", + "dry_run": true + }' +``` + +Antwort: + +```json +{ + "dry_run": true, + "racks": ["DC1-R01", "…", "DC1-R10"], + "devices_per_rack": 7, + "total_devices": 70, + "warnings": ["Blindplatte 3 has no role. NetBox requires one, so it will not be created."] +} +``` + +`dry_run` auf `false` setzen, um es tatsächlich anzulegen. Das Ausrollen läuft in **einer** +Transaktion: schlägt ein Rack oder ein Gerät fehl, wird gar nichts angelegt. + +## Was beim Ausrollen nicht mitkommt + +Ein Konzept beschreibt Bauraum, keine Betriebsdaten. Bewusst **nicht** erzeugt werden: +Kabel, Interfaces jenseits der Device-Type-Templates, IP-Adressen, Seriennummern-Zwang, +Asset-Tags. Geplante Geräte ohne Rolle, ohne Gerätetyp oder ohne Position werden +übersprungen — welche das sind, zeigt die Bestätigungsseite vor dem Ausrollen an. + +## Berechtigungen + +Es gelten die normalen NetBox-Objektberechtigungen: +`netbox_rack_concept.view_rackconcept`, `add_`, `change_`, `delete_` — analog für +`conceptdevice`. Für das Ausrollen wird zusätzlich `dcim.add_rack` geprüft. + +## Lizenz + +MIT diff --git a/netbox_rack_concept/__init__.py b/netbox_rack_concept/__init__.py new file mode 100644 index 0000000..fd7bbba --- /dev/null +++ b/netbox_rack_concept/__init__.py @@ -0,0 +1,47 @@ +from netbox.plugins import PluginConfig + +__version__ = '0.1.0' + + +class RackConceptConfig(PluginConfig): + name = 'netbox_rack_concept' + verbose_name = 'Rack Concepts' + description = 'Rack blueprints: plan racks and their equipment without creating real racks or devices' + version = __version__ + author = 'Louis' + base_url = 'rack-concepts' + min_version = '4.6.0' + max_version = '4.6.99' + menu = 'netbox_rack_concept.navigation.menu' + template_extensions = 'netbox_rack_concept.template_content.template_extensions' + + default_settings = { + # Defaults used when a concept is created from scratch. + 'default_u_height': 42, + 'default_width': 19, + + # Show the "Copy to concept" button on the dcim.Rack detail page. + 'enable_rack_tab': True, + + # Status assigned to the objects created when a concept is deployed. + 'deployed_rack_status': 'planned', + 'deployed_device_status': 'planned', + + # Mapping onto the partial-width implementation of netbox_utilities. + # Adjust these if that plugin renames its fields; set 'enabled' to False + # to never write partial-width data during deployment. + 'partial_width': { + 'enabled': True, + 'model': 'netbox_utilities.RackDevicePosition', + 'device_field': 'device', + 'fraction_field': 'width_fraction', + 'position_field': 'horizontal_position', + }, + } + + def ready(self): + super().ready() + from . import search # noqa: F401 + + +config = RackConceptConfig diff --git a/netbox_rack_concept/api/__init__.py b/netbox_rack_concept/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_rack_concept/api/serializers.py b/netbox_rack_concept/api/serializers.py new file mode 100644 index 0000000..4a34d09 --- /dev/null +++ b/netbox_rack_concept/api/serializers.py @@ -0,0 +1,161 @@ +from rest_framework import serializers + +from dcim.api.serializers import ( + DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer, + RackSerializer, RackTypeSerializer, SiteSerializer, +) +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer + +from ..choices import ConceptStatusChoices +from ..models import ConceptDeployment, ConceptDevice, RackConcept + +__all__ = ( + 'ConceptDeploySerializer', + 'ConceptDeploymentSerializer', + 'ConceptDeviceSerializer', + 'RackConceptCloneSerializer', + 'RackConceptSerializer', + 'RackCopySerializer', +) + + +class RackConceptSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_rack_concept-api:rackconcept-detail' + ) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + tenant_group = TenantGroupSerializer(nested=True, required=False, allow_null=True) + rack_type = RackTypeSerializer(nested=True, required=False, allow_null=True) + rack_role = RackRoleSerializer(nested=True, required=False, allow_null=True) + default_site = SiteSerializer(nested=True, required=False, allow_null=True) + default_location = LocationSerializer(nested=True, required=False, allow_null=True) + source_rack = RackSerializer(nested=True, required=False, allow_null=True) + device_count = serializers.IntegerField(read_only=True) + utilization = serializers.IntegerField(read_only=True) + + class Meta: + model = RackConcept + fields = ( + 'id', 'url', 'display_url', 'display', 'name', 'status', 'description', 'tenant_group', 'tenant', + 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight', + 'default_site', 'default_location', 'rack_role', 'source_rack', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'utilization', + ) + brief_fields = ('id', 'url', 'display', 'name', 'status', 'u_height', 'description') + + +class ConceptDeviceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_rack_concept-api:conceptdevice-detail' + ) + concept = RackConceptSerializer(nested=True) + device_type = DeviceTypeSerializer(nested=True, required=False, allow_null=True) + role = DeviceRoleSerializer(nested=True, required=False, allow_null=True) + platform = PlatformSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + effective_u_height = serializers.DecimalField( + max_digits=4, decimal_places=1, read_only=True + ) + + class Meta: + model = ConceptDevice + fields = ( + 'id', 'url', 'display_url', 'display', 'concept', 'name', 'label', 'status', 'device_type', 'role', + 'platform', 'tenant', 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', + 'is_full_depth', 'effective_u_height', 'serial', 'source_device', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ) + brief_fields = ('id', 'url', 'display', 'name', 'position', 'face', 'device_type') + + +class ConceptDeploymentSerializer(serializers.ModelSerializer): + rack = RackSerializer(nested=True, read_only=True, allow_null=True) + + class Meta: + model = ConceptDeployment + fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created') + + +# +# Action payloads +# + +class RackCopySerializer(serializers.Serializer): + """POST body for copying a real rack into a new concept.""" + rack = serializers.PrimaryKeyRelatedField(queryset=None) + name = serializers.CharField(max_length=100) + status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT) + tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True) + description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='') + include_devices = serializers.BooleanField(default=True) + include_device_names = serializers.BooleanField(default=True) + + def __init__(self, *args, **kwargs): + from dcim.models import Rack + from tenancy.models import Tenant + super().__init__(*args, **kwargs) + self.fields['rack'].queryset = Rack.objects.all() + self.fields['tenant'].queryset = Tenant.objects.all() + + +class RackConceptCloneSerializer(serializers.Serializer): + """POST body for duplicating a concept.""" + name = serializers.CharField(max_length=100) + status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT) + tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True) + include_devices = serializers.BooleanField(default=True) + + def __init__(self, *args, **kwargs): + from tenancy.models import Tenant + super().__init__(*args, **kwargs) + self.fields['tenant'].queryset = Tenant.objects.all() + + +class ConceptDeploySerializer(serializers.Serializer): + """POST body for materialising a concept.""" + name_pattern = serializers.CharField(max_length=100) + count = serializers.IntegerField(min_value=1, max_value=100, default=1) + start_index = serializers.IntegerField(default=1) + site = serializers.PrimaryKeyRelatedField(queryset=None) + location = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True) + tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True) + create_devices = serializers.BooleanField(default=True) + device_name_pattern = serializers.CharField(max_length=100, required=False, allow_blank=True, default='') + skip_unnamed_devices = serializers.BooleanField(default=True) + dry_run = serializers.BooleanField( + default=False, + help_text='Return the planned rack names and warnings without creating anything' + ) + + def __init__(self, *args, **kwargs): + from dcim.models import Location, Site + from tenancy.models import Tenant + super().__init__(*args, **kwargs) + self.fields['site'].queryset = Site.objects.all() + self.fields['location'].queryset = Location.objects.all() + self.fields['tenant'].queryset = Tenant.objects.all() + + def validate(self, attrs): + from dcim.models import Rack + try: + names = [ + attrs['name_pattern'].format(n=i) + for i in range(attrs['start_index'], attrs['start_index'] + attrs['count']) + ] + except (IndexError, KeyError, ValueError) as exc: + raise serializers.ValidationError({'name_pattern': f'Invalid pattern: {exc}'}) + + if len(set(names)) != len(names): + raise serializers.ValidationError({ + 'name_pattern': 'The pattern produces duplicate names. Include {n} when creating more than one rack.' + }) + clashes = list(Rack.objects.filter(site=attrs['site'], name__in=names).values_list('name', flat=True)) + if clashes: + raise serializers.ValidationError({ + 'name_pattern': f'These racks already exist at the site: {", ".join(sorted(clashes))}' + }) + + attrs['rack_names'] = names + return attrs diff --git a/netbox_rack_concept/api/urls.py b/netbox_rack_concept/api/urls.py new file mode 100644 index 0000000..b4ee6b6 --- /dev/null +++ b/netbox_rack_concept/api/urls.py @@ -0,0 +1,11 @@ +from netbox.api.routers import NetBoxRouter + +from . import views + +app_name = 'netbox_rack_concept-api' + +router = NetBoxRouter() +router.register('concepts', views.RackConceptViewSet) +router.register('devices', views.ConceptDeviceViewSet) + +urlpatterns = router.urls diff --git a/netbox_rack_concept/api/views.py b/netbox_rack_concept/api/views.py new file mode 100644 index 0000000..6b645d6 --- /dev/null +++ b/netbox_rack_concept/api/views.py @@ -0,0 +1,175 @@ +from django.core.exceptions import ValidationError +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response + +from dcim.models import Rack +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 .serializers import ( + ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer, + RackCopySerializer, +) + +__all__ = ( + 'ConceptDeviceViewSet', + 'RackConceptViewSet', +) + + +class RackConceptViewSet(NetBoxModelViewSet): + queryset = RackConcept.objects.prefetch_related('tags').annotate( + device_count=Count('devices') + ).select_related('tenant', 'tenant_group', 'rack_type') + serializer_class = RackConceptSerializer + filterset_class = filtersets.RackConceptFilterSet + + @action(detail=False, methods=['post'], url_path='copy-from-rack') + def copy_from_rack(self, request): + """Create a concept from an existing rack, optionally including its devices.""" + serializer = RackCopySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=data['rack'].pk) + tenant = data.get('tenant') + + try: + concept = deploy_utils.copy_rack_to_concept( + rack=rack, + name=data['name'], + status=data['status'], + tenant=tenant, + tenant_group=tenant.group if tenant else None, + description=data['description'], + include_devices=data['include_devices'], + include_device_names=data['include_device_names'], + ) + except ValidationError as exc: + return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages}, + status=status.HTTP_400_BAD_REQUEST) + + return Response( + RackConceptSerializer(concept, context={'request': request}).data, + status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['post']) + def clone(self, request, pk=None): + """Duplicate this concept into a new one.""" + concept = self.get_object() + serializer = RackConceptCloneSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + try: + new_concept = deploy_utils.clone_concept( + concept=concept, + name=data['name'], + status=data['status'], + tenant=data.get('tenant'), + include_devices=data['include_devices'], + ) + except ValidationError as exc: + return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages}, + status=status.HTTP_400_BAD_REQUEST) + + return Response( + RackConceptSerializer(new_concept, context={'request': request}).data, + status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['post']) + def deploy(self, request, pk=None): + """ + Materialise this concept as one or more real racks. Pass dry_run=true to get the + rack names and warnings back without writing anything. + """ + concept = self.get_object() + serializer = ConceptDeploySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + if not request.user.has_perm('dcim.add_rack'): + return Response({'detail': 'Permission denied: dcim.add_rack'}, status=status.HTTP_403_FORBIDDEN) + + planned, warnings = deploy_utils.validate_deployment( + concept, data['create_devices'], data['skip_unnamed_devices'] + ) + + if data['dry_run']: + return Response({ + 'dry_run': True, + 'racks': data['rack_names'], + 'devices_per_rack': len(planned), + 'total_devices': len(planned) * len(data['rack_names']), + 'warnings': warnings, + }) + + try: + racks, devices, warnings = deploy_utils.deploy_concept( + concept=concept, + rack_names=data['rack_names'], + site=data['site'], + location=data.get('location'), + tenant=data.get('tenant'), + create_devices=data['create_devices'], + device_name_pattern=data['device_name_pattern'], + skip_unnamed_devices=data['skip_unnamed_devices'], + user=request.user, + ) + except ValidationError as exc: + return Response( + { + 'detail': 'Deployment failed; nothing was created.', + 'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages, + }, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response({ + 'racks': [{'id': r.pk, 'name': r.name, 'url': r.get_absolute_url()} for r in racks], + 'device_count': len(devices), + 'warnings': warnings, + }, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get']) + def elevation(self, request, pk=None): + """The computed elevation geometry, for external renderers.""" + concept = self.get_object() + front, rear = get_elevations(concept) + return Response({ + 'u_height': concept.u_height, + 'starting_unit': concept.starting_unit, + 'desc_units': concept.desc_units, + 'faces': { + elevation.face: [ + { + 'device_id': block.device.pk, + 'label': block.label, + 'position': block.device.position, + 'u_height': block.device.effective_u_height, + 'width_fraction': block.device.width_fraction, + 'horizontal_position': block.device.horizontal_position, + 'mounted_on_opposite_face': block.opposite, + 'color': block.color, + } + for block in elevation.blocks + ] + for elevation in (front, rear) + }, + }) + + +class ConceptDeviceViewSet(NetBoxModelViewSet): + queryset = ConceptDevice.objects.prefetch_related('tags').select_related( + 'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant' + ) + serializer_class = ConceptDeviceSerializer + filterset_class = filtersets.ConceptDeviceFilterSet diff --git a/netbox_rack_concept/choices.py b/netbox_rack_concept/choices.py new file mode 100644 index 0000000..05a499a --- /dev/null +++ b/netbox_rack_concept/choices.py @@ -0,0 +1,61 @@ +from utilities.choices import ChoiceSet + + +class ConceptStatusChoices(ChoiceSet): + """Lifecycle of a rack blueprint.""" + key = 'RackConcept.status' + + STATUS_DRAFT = 'draft' + STATUS_REVIEW = 'review' + STATUS_APPROVED = 'approved' + STATUS_ARCHIVED = 'archived' + + CHOICES = [ + (STATUS_DRAFT, 'Draft', 'gray'), + (STATUS_REVIEW, 'In review', 'orange'), + (STATUS_APPROVED, 'Approved', 'green'), + (STATUS_ARCHIVED, 'Archived', 'red'), + ] + + +class ConceptDeviceStatusChoices(ChoiceSet): + """ + What a planned device means. This is deliberately *not* dcim's device status: + a blueprint describes intent, not operational state. + """ + key = 'ConceptDevice.status' + + STATUS_PLANNED = 'planned' + STATUS_EXISTING = 'existing' + STATUS_OPTIONAL = 'optional' + STATUS_RESERVED = 'reserved' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_EXISTING, 'Existing', 'green'), + (STATUS_OPTIONAL, 'Optional', 'cyan'), + (STATUS_RESERVED, 'Reserved space', 'gray'), + ] + + +class WidthFractionChoices(ChoiceSet): + """ + Horizontal subdivision of a rack unit, mirroring the partial-width feature of + netbox_utilities. The value is the denominator: 1 = full width, 2 = half, ... + """ + FRACTION_FULL = 1 + FRACTION_HALF = 2 + FRACTION_THIRD = 3 + FRACTION_QUARTER = 4 + + CHOICES = ( + (FRACTION_FULL, 'Full width'), + (FRACTION_HALF, '1/2'), + (FRACTION_THIRD, '1/3'), + (FRACTION_QUARTER, '1/4'), + ) + + +# 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 diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py new file mode 100644 index 0000000..052b096 --- /dev/null +++ b/netbox_rack_concept/deploy.py @@ -0,0 +1,251 @@ +""" +The three copy paths: rack -> concept, concept -> concept, concept -> rack(s). +""" +from django.conf import settings +from django.db import transaction +from django.utils.translation import gettext as _ + +from dcim.models import Device, Rack + +from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices +from .integrations import apply_partial_width, read_partial_width +from .models import ConceptDeployment, ConceptDevice, RackConcept + +__all__ = ( + 'clone_concept', + 'copy_rack_to_concept', + 'deploy_concept', + 'validate_deployment', +) + +GEOMETRY_FIELDS = ( + 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight', +) + + +def _config(): + return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}) + + +# +# dcim.Rack -> RackConcept +# + +@transaction.atomic +def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None, + description='', include_devices=True, include_device_names=True): + """Snapshot a real rack, and optionally its contents, into a new blueprint.""" + concept = RackConcept( + name=name, + status=status, + tenant=tenant, + tenant_group=tenant_group, + description=description, + rack_type=rack.rack_type, + default_site=rack.site, + default_location=rack.location, + rack_role=rack.role, + source_rack=rack, + ) + for field in GEOMETRY_FIELDS: + value = getattr(rack, field, None) + # A rack built from a rack type may leave its own dimension fields empty. + if value in (None, '') and rack.rack_type: + value = getattr(rack.rack_type, field, None) + if value not in (None, ''): + setattr(concept, field, value) + concept.full_clean() + concept.save() + + if include_devices: + devices = Device.objects.filter(rack=rack, position__isnull=False).select_related( + 'device_type', 'role', 'platform', 'tenant' + ) + for device in devices: + fraction, horizontal = read_partial_width(device) + ConceptDevice.objects.create( + concept=concept, + name=(device.name or '') if include_device_names else '', + status=ConceptDeviceStatusChoices.STATUS_EXISTING, + device_type=device.device_type, + role=device.role, + platform=device.platform, + tenant=device.tenant, + position=device.position, + face=device.face, + width_fraction=fraction, + horizontal_position=horizontal, + serial=device.serial if include_device_names else '', + source_device=device, + ) + + return concept + + +# +# RackConcept -> RackConcept +# + +@transaction.atomic +def clone_concept(concept, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, include_devices=True): + """Duplicate a blueprint so variants can be built without touching the original.""" + clone = RackConcept( + name=name, + status=status, + tenant=tenant or concept.tenant, + tenant_group=concept.tenant_group, + description=concept.description, + comments=concept.comments, + rack_type=concept.rack_type, + default_site=concept.default_site, + default_location=concept.default_location, + rack_role=concept.rack_role, + source_rack=concept.source_rack, + ) + for field in GEOMETRY_FIELDS: + setattr(clone, field, getattr(concept, field)) + clone.full_clean() + clone.save() + + if include_devices: + for device in concept.devices.all(): + ConceptDevice.objects.create( + concept=clone, + name=device.name, + label=device.label, + status=device.status, + device_type=device.device_type, + role=device.role, + platform=device.platform, + tenant=device.tenant, + position=device.position, + face=device.face, + width_fraction=device.width_fraction, + horizontal_position=device.horizontal_position, + u_height=device.u_height, + is_full_depth=device.is_full_depth, + serial=device.serial, + description=device.description, + comments=device.comments, + source_device=device.source_device, + ) + + return clone + + +# +# RackConcept -> dcim.Rack +# + +def validate_deployment(concept, create_devices=True, skip_unnamed_devices=True): + """ + Report what a deployment would and would not be able to create. Returned as + (deployable_devices, warnings) so the confirmation page can show it before anything + is written. + """ + warnings = [] + deployable = [] + + if create_devices: + for device in concept.devices.select_related('device_type', 'role'): + if device.position is None: + warnings.append(_('%(device)s has no position and will not be created.') % {'device': device}) + continue + if device.device_type is None: + warnings.append( + _('%(device)s is reserved space without a device type and will not be created.') + % {'device': device} + ) + continue + if device.role is None: + warnings.append( + _('%(device)s has no role. NetBox requires one, so it will not be created.') + % {'device': device} + ) + continue + if not device.name and skip_unnamed_devices: + warnings.append(_('%(device)s has no name and will be skipped.') % {'device': device}) + continue + if device.status == ConceptDeviceStatusChoices.STATUS_OPTIONAL: + warnings.append( + _('%(device)s is marked optional but will be created.') % {'device': device} + ) + deployable.append(device) + + return deployable, warnings + + +@transaction.atomic +def deploy_concept(concept, rack_names, site, location=None, tenant=None, create_devices=True, + device_name_pattern='', skip_unnamed_devices=True, user=None): + """ + Materialise the blueprint as one real rack per name in `rack_names`. + Returns (racks, devices, warnings). + """ + config = _config() + rack_status = config.get('deployed_rack_status', 'planned') + device_status = config.get('deployed_device_status', 'planned') + + planned, warnings = validate_deployment(concept, create_devices, skip_unnamed_devices) + + racks = [] + created_devices = [] + + for rack_name in rack_names: + rack = Rack( + name=rack_name, + site=site, + location=location, + tenant=tenant or concept.tenant, + status=rack_status, + role=concept.rack_role, + description=concept.description, + comments=concept.comments, + ) + if concept.rack_type: + # Let NetBox derive the geometry from the rack type rather than fighting it. + rack.rack_type = concept.rack_type + else: + for field in GEOMETRY_FIELDS: + setattr(rack, field, getattr(concept, field)) + rack.full_clean() + rack.save() + racks.append(rack) + + rack_device_count = 0 + for planned_device in planned: + name = planned_device.name + if name and device_name_pattern: + name = device_name_pattern.format(rack=rack_name, name=planned_device.name) + + device = Device( + name=name or None, + device_type=planned_device.device_type, + role=planned_device.role, + platform=planned_device.platform, + site=site, + location=location, + rack=rack, + position=planned_device.position, + face=planned_device.face, + status=device_status, + tenant=planned_device.tenant or tenant or concept.tenant, + serial=planned_device.serial, + description=planned_device.description, + ) + device.full_clean() + device.save() + apply_partial_width(device, planned_device) + created_devices.append(device) + rack_device_count += 1 + + ConceptDeployment.objects.create( + concept=concept, + rack=rack, + rack_name=rack_name, + device_count=rack_device_count, + user=user if user and user.is_authenticated else None, + ) + + return racks, created_devices, warnings diff --git a/netbox_rack_concept/elevation.py b/netbox_rack_concept/elevation.py new file mode 100644 index 0000000..95cc5fa --- /dev/null +++ b/netbox_rack_concept/elevation.py @@ -0,0 +1,178 @@ +""" +Geometry for concept rack elevations. + +This does not reuse dcim's RackElevationSVG: that renderer walks dcim.Device +querysets and knows nothing about partial-width slots. Computing the geometry here +keeps the plugin independent of NetBox internals and lets the template render plain +inline SVG that follows the UI theme. +""" +from decimal import Decimal + +from dcim.choices import DeviceFaceChoices + +from .choices import LANE_COUNT + +UNIT_HEIGHT = 22 +RACK_WIDTH = 240 +LEGEND_WIDTH = 32 +MARGIN = 6 + +DEFAULT_COLOR = 'c0c0c0' +RESERVED_COLOR = 'e9ecef' + + +def foreground_color(bg_color): + """Return black or white, whichever reads better on the given hex colour.""" + bg_color = bg_color.strip('#') + try: + r, g, b = (int(bg_color[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + return '000000' + # Perceived luminance (ITU-R BT.601) + return '000000' if (r * 299 + g * 587 + b * 114) / 1000 > 128 else 'ffffff' + + +class ElevationUnit: + """One labelled rack unit row.""" + + def __init__(self, number, y): + self.number = number + self.y = y + self.height = UNIT_HEIGHT + + +class ElevationBlock: + """One rendered device rectangle.""" + + def __init__(self, device, x, y, width, height, opposite=False): + self.device = device + self.x = x + self.y = y + self.width = width + self.height = height + # True when the device is physically mounted on the other face and only + # shows up here because it is full depth. + self.opposite = opposite + + @property + def color(self): + if self.device.role and self.device.role.color: + return self.device.role.color + if self.device.device_type is None: + return RESERVED_COLOR + return DEFAULT_COLOR + + @property + def text_color(self): + return foreground_color(self.color) + + @property + def label(self): + return self.device.display_label + + @property + def sublabel(self): + if self.device.device_type: + return str(self.device.device_type) + return '' + + @property + def show_text(self): + return self.height >= 14 + + +class Elevation: + """Everything the template needs to draw one face of a concept rack.""" + + def __init__(self, concept, face=DeviceFaceChoices.FACE_FRONT, devices=None): + self.concept = concept + self.face = face + self.units = [] + self.blocks = [] + + self.legend_width = LEGEND_WIDTH + self.rack_width = RACK_WIDTH + self.width = LEGEND_WIDTH + RACK_WIDTH + MARGIN * 2 + self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2 + + self._build_units() + self._build_blocks(devices if devices is not None else concept.devices.all()) + + @property + def face_label(self): + return dict(DeviceFaceChoices).get(self.face, self.face) + + def _y_for(self, top_unit): + """Pixel offset of the top edge of a block whose highest unit boundary is `top_unit`.""" + concept = self.concept + if concept.desc_units: + # U1 at the top: the block's top edge is its lowest unit number. + bottom = top_unit + offset = Decimal(bottom) - Decimal(concept.starting_unit) + else: + offset = Decimal(concept.starting_unit + concept.u_height) - Decimal(top_unit) + return float(offset) * UNIT_HEIGHT + MARGIN + + 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)))) + + def _build_blocks(self, devices): + for device in devices: + span = device.unit_span + if span is None: + continue + if not device.occupies_face(self.face): + continue + + bottom, top = span + lane_start, lane_end = device.lane_span + x = MARGIN + LEGEND_WIDTH + (lane_start / LANE_COUNT) * RACK_WIDTH + width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH + + if self.concept.desc_units: + y = self._y_for(bottom) + else: + y = self._y_for(top) + + self.blocks.append(ElevationBlock( + device=device, + x=x, + y=y, + width=width, + height=float(top - bottom) * UNIT_HEIGHT, + opposite=device.face != self.face, + )) + + +def get_elevations(concept): + """Front and rear elevation, sharing one prefetched device list.""" + devices = list(concept.devices.select_related('device_type', 'role', 'device_type__manufacturer')) + return ( + Elevation(concept, DeviceFaceChoices.FACE_FRONT, devices), + Elevation(concept, DeviceFaceChoices.FACE_REAR, devices), + ) + + +def available_units(concept, u_height=Decimal('1'), face=DeviceFaceChoices.FACE_FRONT, exclude=None): + """ + Unit numbers where a device of the given height would fit on the given face at + full width. Used by the position dropdown in the device form. + """ + devices = [ + d for d in concept.devices.select_related('device_type') + if d.position is not None and d.pk != exclude and d.occupies_face(face) + ] + low, high = concept.unit_range + result = [] + unit = low + while unit + u_height <= high: + blocked = any( + not (unit + u_height <= d.unit_span[0] or d.unit_span[1] <= unit) + for d in devices + ) + if not blocked: + result.append(unit) + unit += 1 + return result diff --git a/netbox_rack_concept/filtersets.py b/netbox_rack_concept/filtersets.py new file mode 100644 index 0000000..d4f90f4 --- /dev/null +++ b/netbox_rack_concept/filtersets.py @@ -0,0 +1,117 @@ +import django_filters +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +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 + +__all__ = ( + 'ConceptDeviceFilterSet', + 'RackConceptFilterSet', +) + + +class RackConceptFilterSet(NetBoxModelFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=ConceptStatusChoices + ) + tenant_group_id = django_filters.ModelMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + label=_('Tenant group (ID)') + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label=_('Tenant (ID)') + ) + tenant = django_filters.ModelMultipleChoiceFilter( + field_name='tenant__slug', + queryset=Tenant.objects.all(), + to_field_name='slug', + label=_('Tenant (slug)') + ) + rack_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=RackType.objects.all(), + label=_('Rack type (ID)') + ) + source_rack_id = django_filters.ModelMultipleChoiceFilter( + queryset=Rack.objects.all(), + label=_('Source rack (ID)') + ) + default_site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label=_('Default site (ID)') + ) + + class Meta: + model = RackConcept + fields = ( + 'id', 'name', 'status', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', + 'max_weight', 'description', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + +class ConceptDeviceFilterSet(NetBoxModelFilterSet): + concept_id = django_filters.ModelMultipleChoiceFilter( + queryset=RackConcept.objects.all(), + label=_('Rack concept (ID)') + ) + concept = django_filters.ModelMultipleChoiceFilter( + field_name='concept__name', + queryset=RackConcept.objects.all(), + to_field_name='name', + label=_('Rack concept (name)') + ) + status = django_filters.MultipleChoiceFilter( + choices=ConceptDeviceStatusChoices + ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceType.objects.all(), + label=_('Device type (ID)') + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + label=_('Role (ID)') + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label=_('Tenant (ID)') + ) + racked = django_filters.BooleanFilter( + method='filter_racked', + label=_('Has a position assigned') + ) + + class Meta: + model = ConceptDevice + fields = ( + 'id', 'name', 'label', 'status', 'position', 'face', 'width_fraction', 'horizontal_position', + 'u_height', 'is_full_depth', 'serial', '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(serial__icontains=value) | + Q(description__icontains=value) | + Q(device_type__model__icontains=value) + ) + + def filter_racked(self, queryset, name, value): + return queryset.filter(position__isnull=not value) diff --git a/netbox_rack_concept/forms.py b/netbox_rack_concept/forms.py new file mode 100644 index 0000000..f3988b5 --- /dev/null +++ b/netbox_rack_concept/forms.py @@ -0,0 +1,458 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from dcim.choices import DeviceFaceChoices, 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 +from utilities.forms.fields import ( + CommentField, CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + TagFilterField, +) +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import BulkEditNullBooleanSelect + +from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices +from .models import ConceptDevice, RackConcept + +__all__ = ( + 'ConceptDeployForm', + 'ConceptDeviceBulkEditForm', + 'ConceptDeviceFilterForm', + 'ConceptDeviceForm', + 'ConceptDeviceImportForm', + 'RackConceptBulkEditForm', + 'RackConceptCloneForm', + 'RackConceptFilterForm', + 'RackConceptForm', + 'RackConceptImportForm', + 'RackCopyForm', +) + + +class RackConceptForm(NetBoxModelForm): + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + initial_params={'tenants': '$tenant'}, + label=_('Tenant group') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + query_params={'group_id': '$tenant_group'}, + label=_('Tenant') + ) + rack_type = DynamicModelChoiceField( + queryset=RackType.objects.all(), + required=False, + label=_('Rack type') + ) + rack_role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + label=_('Rack role') + ) + default_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Default site') + ) + default_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={'site_id': '$default_site'}, + label=_('Default location') + ) + comments = CommentField() + + fieldsets = ( + FieldSet('name', 'status', 'description', 'tags', name=_('Concept')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( + 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'airflow', + name=_('Geometry') + ), + FieldSet( + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'max_weight', + name=_('Dimensions') + ), + FieldSet('default_site', 'default_location', 'rack_role', name=_('Deployment defaults')), + ) + + class Meta: + model = RackConcept + fields = ( + 'name', 'status', 'description', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width', + 'u_height', 'starting_unit', 'desc_units', 'airflow', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'mounting_depth', 'max_weight', 'default_site', 'default_location', 'rack_role', + 'comments', 'tags', + ) + + +class RackConceptFilterForm(NetBoxModelFilterSetForm): + model = RackConcept + + status = forms.MultipleChoiceField( + choices=ConceptStatusChoices, + required=False + ) + tenant_group_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label=_('Tenant group') + ) + tenant_id = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant') + ) + width = forms.MultipleChoiceField( + choices=RackWidthChoices, + required=False + ) + form_factor = forms.MultipleChoiceField( + choices=RackFormFactorChoices, + required=False + ) + tag = TagFilterField(model) + + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('status', 'width', 'form_factor', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')), + ) + + +class RackConceptBulkEditForm(NetBoxModelBulkEditForm): + model = RackConcept + + status = forms.ChoiceField(choices=ConceptStatusChoices, required=False) + tenant_group = DynamicModelChoiceField(queryset=TenantGroup.objects.all(), required=False) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + query_params={'group_id': '$tenant_group'} + ) + description = forms.CharField(max_length=200, required=False) + comments = CommentField() + + nullable_fields = ('tenant_group', 'tenant', 'description', 'comments') + + fieldsets = ( + FieldSet('status', 'description', name=_('Concept')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + ) + + +class RackConceptImportForm(NetBoxModelImportForm): + status = CSVChoiceField(choices=ConceptStatusChoices, required=False) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = RackConcept + fields = ( + 'name', 'status', 'description', 'tenant', 'form_factor', 'width', 'u_height', 'starting_unit', + 'desc_units', 'comments', 'tags', + ) + + +class ConceptDeviceForm(NetBoxModelForm): + concept = DynamicModelChoiceField( + queryset=RackConcept.objects.all(), + label=_('Rack concept') + ) + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device type') + ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Role') + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Platform') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant') + ) + comments = CommentField() + + fieldsets = ( + FieldSet('concept', 'name', 'label', 'status', 'description', 'tags', name=_('Planned device')), + FieldSet('device_type', 'role', 'platform', 'serial', name=_('Hardware')), + FieldSet('position', 'face', 'width_fraction', 'horizontal_position', name=_('Placement')), + FieldSet('u_height', 'is_full_depth', name=_('Overrides')), + FieldSet('tenant', name=_('Tenancy')), + ) + + class Meta: + model = ConceptDevice + fields = ( + 'concept', 'name', 'label', 'status', 'description', 'device_type', 'role', 'platform', 'serial', + 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth', 'tenant', + 'comments', 'tags', + ) + + +class ConceptDeviceFilterForm(NetBoxModelFilterSetForm): + model = ConceptDevice + + concept_id = DynamicModelMultipleChoiceField( + queryset=RackConcept.objects.all(), + required=False, + label=_('Rack concept') + ) + status = forms.MultipleChoiceField(choices=ConceptDeviceStatusChoices, required=False) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device type') + ) + role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Role') + ) + face = forms.MultipleChoiceField(choices=DeviceFaceChoices, required=False) + tag = TagFilterField(model) + + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('concept_id', 'status', 'face', name=_('Placement')), + FieldSet('device_type_id', 'role_id', name=_('Hardware')), + ) + + +class ConceptDeviceBulkEditForm(NetBoxModelBulkEditForm): + model = ConceptDevice + + status = forms.ChoiceField(choices=ConceptDeviceStatusChoices, required=False) + role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False) + platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False) + tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False) + face = forms.ChoiceField(choices=DeviceFaceChoices, required=False) + width_fraction = forms.ChoiceField(choices=WidthFractionChoices, required=False) + is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect()) + description = forms.CharField(max_length=200, required=False) + + nullable_fields = ('role', 'platform', 'tenant', 'description', 'is_full_depth') + + fieldsets = ( + FieldSet('status', 'role', 'platform', 'description', name=_('Planned device')), + FieldSet('face', 'width_fraction', 'is_full_depth', name=_('Placement')), + FieldSet('tenant', name=_('Tenancy')), + ) + + +class ConceptDeviceImportForm(NetBoxModelImportForm): + concept = CSVModelChoiceField( + queryset=RackConcept.objects.all(), + to_field_name='name' + ) + device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name='model', + required=False + ) + role = CSVModelChoiceField( + queryset=DeviceRole.objects.all(), + to_field_name='name', + required=False + ) + status = CSVChoiceField(choices=ConceptDeviceStatusChoices, required=False) + face = CSVChoiceField(choices=DeviceFaceChoices, required=False) + + class Meta: + model = ConceptDevice + fields = ( + 'concept', 'name', 'label', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction', + 'horizontal_position', 'u_height', 'is_full_depth', 'description', 'tags', + ) + + +# +# Action forms +# + +class RackCopyForm(forms.Form): + """Copy an existing dcim.Rack into a new blueprint.""" + name = forms.CharField( + label=_('Concept name'), + max_length=100 + ) + status = forms.ChoiceField( + choices=ConceptStatusChoices, + initial=ConceptStatusChoices.STATUS_DRAFT + ) + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label=_('Tenant group') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + query_params={'group_id': '$tenant_group'}, + label=_('Tenant') + ) + include_devices = forms.BooleanField( + required=False, + initial=True, + label=_('Copy installed devices'), + help_text=_('Snapshot every device currently mounted in the rack') + ) + include_device_names = forms.BooleanField( + required=False, + initial=True, + label=_('Keep device names'), + help_text=_('Unchecked, only the device type and position are copied') + ) + description = forms.CharField(max_length=200, required=False) + + fieldsets = ( + FieldSet('name', 'status', 'description', name=_('Concept')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('include_devices', 'include_device_names', name=_('Content')), + ) + + +class RackConceptCloneForm(forms.Form): + """Duplicate a blueprint, for building variants.""" + name = forms.CharField( + label=_('New concept name'), + max_length=100 + ) + status = forms.ChoiceField( + choices=ConceptStatusChoices, + initial=ConceptStatusChoices.STATUS_DRAFT + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant') + ) + include_devices = forms.BooleanField( + required=False, + initial=True, + label=_('Copy planned devices') + ) + + fieldsets = ( + FieldSet('name', 'status', 'tenant', 'include_devices'), + ) + + +class ConceptDeployForm(forms.Form): + """Materialise a blueprint as one or more real racks.""" + name_pattern = forms.CharField( + label=_('Rack name'), + max_length=100, + help_text=_('Use {n} for the sequence number, e.g. "DC1-R{n}". Zero-pad with {n:02d}.') + ) + count = forms.IntegerField( + label=_('Number of racks'), + min_value=1, + max_value=100, + initial=1 + ) + start_index = forms.IntegerField( + label=_('First sequence number'), + initial=1 + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label=_('Site') + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={'site_id': '$site'}, + label=_('Location') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant') + ) + create_devices = forms.BooleanField( + required=False, + initial=True, + label=_('Create devices'), + help_text=_('Create a real device for every planned device that has a device type and a position') + ) + device_name_pattern = forms.CharField( + max_length=100, + required=False, + label=_('Device name'), + help_text=_('Optional. {rack} and {name} are replaced, e.g. "{rack}-{name}". ' + 'Empty keeps the planned name.') + ) + skip_unnamed_devices = forms.BooleanField( + required=False, + initial=True, + label=_('Skip devices without a name'), + help_text=_('Unchecked, unnamed planned devices are created as unnamed devices') + ) + + fieldsets = ( + FieldSet('name_pattern', 'count', 'start_index', name=_('Racks')), + FieldSet('site', 'location', 'tenant', name=_('Placement')), + FieldSet('create_devices', 'device_name_pattern', 'skip_unnamed_devices', name=_('Devices')), + ) + + def __init__(self, *args, concept=None, **kwargs): + super().__init__(*args, **kwargs) + self.concept = concept + if concept and not self.is_bound: + self.fields['name_pattern'].initial = concept.name + if concept.default_site: + self.fields['site'].initial = concept.default_site.pk + if concept.default_location: + self.fields['location'].initial = concept.default_location.pk + if concept.tenant: + self.fields['tenant'].initial = concept.tenant.pk + + def clean_name_pattern(self): + pattern = self.cleaned_data['name_pattern'] + try: + pattern.format(n=1) + except (IndexError, KeyError, ValueError) as exc: + raise forms.ValidationError( + _('Invalid name pattern: {error}').format(error=exc) + ) + return pattern + + def clean(self): + cleaned = super().clean() + pattern = cleaned.get('name_pattern') + count = cleaned.get('count') + start = cleaned.get('start_index') + site = cleaned.get('site') + if not (pattern and count and start is not None and site): + return cleaned + + names = [pattern.format(n=i) for i in range(start, start + count)] + if len(set(names)) != len(names): + raise forms.ValidationError({ + 'name_pattern': _('The pattern produces duplicate names. Include {n} when creating more than one rack.') + }) + clashes = Rack.objects.filter(site=site, name__in=names).values_list('name', flat=True) + if clashes: + raise forms.ValidationError({ + 'name_pattern': _('These racks already exist at {site}: {names}').format( + site=site, names=', '.join(sorted(clashes)) + ) + }) + cleaned['rack_names'] = names + return cleaned diff --git a/netbox_rack_concept/integrations.py b/netbox_rack_concept/integrations.py new file mode 100644 index 0000000..f7da7a6 --- /dev/null +++ b/netbox_rack_concept/integrations.py @@ -0,0 +1,157 @@ +""" +Optional interoperability with the two plugins this one is meant to sit next to. + +Neither is a hard dependency. Everything here degrades to a no-op when the other +plugin is absent, so the concept plugin works on a bare NetBox 4.6 install. + +* netbox_utilities -- partial-width rack devices (several devices per rack unit). + The field names are read from PLUGINS_CONFIG so that a rename on their side is a + configuration change here, not a code change. + +* netbox-reorder-rack -- drag & drop ordering of *real* racks. Concepts get their + own drag & drop elevation (see static/, templates/) because that plugin operates on + dcim.Device querysets, which a concept deliberately has none of. Once a concept has + been deployed the resulting rack is an ordinary rack and reorder-rack applies to it. +""" +import logging + +from django.apps import apps +from django.conf import settings + +logger = logging.getLogger('netbox.plugins.netbox_rack_concept') + +__all__ = ( + 'apply_partial_width', + 'get_partial_width_adapter', + 'read_partial_width', + 'reorder_rack_installed', +) + + +def _config(): + return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}) + + +def plugin_installed(name): + return name in settings.PLUGINS + + +def reorder_rack_installed(): + return plugin_installed('netbox_reorder_rack') + + +class PartialWidthAdapter: + """ + Wraps whichever shape netbox_utilities uses to store partial-width data: + either extra fields on dcim.Device, or a side model with a FK to the device. + """ + + def __init__(self, model, device_field, fraction_field, position_field): + self.model = model + self.device_field = device_field + self.fraction_field = fraction_field + self.position_field = position_field + + def write(self, device, fraction, position): + if self.model is None: + # Fields live directly on the Device. + setattr(device, self.fraction_field, fraction) + setattr(device, self.position_field, position) + device.save(update_fields=[self.fraction_field, self.position_field]) + return + + self.model.objects.update_or_create( + **{self.device_field: device}, + defaults={ + self.fraction_field: fraction, + self.position_field: position, + } + ) + + def read(self, device): + if self.model is None: + return ( + getattr(device, self.fraction_field, None), + getattr(device, self.position_field, None), + ) + obj = self.model.objects.filter(**{self.device_field: device}).first() + if obj is None: + return None, None + return getattr(obj, self.fraction_field, None), getattr(obj, self.position_field, None) + + +_ADAPTER_CACHE = {} + + +def get_partial_width_adapter(): + """Return a PartialWidthAdapter, or None when partial width is unavailable.""" + if 'adapter' in _ADAPTER_CACHE: + return _ADAPTER_CACHE['adapter'] + + adapter = None + config = _config().get('partial_width') or {} + + if config.get('enabled') and plugin_installed('netbox_utilities'): + fraction_field = config.get('fraction_field', 'width_fraction') + position_field = config.get('position_field', 'horizontal_position') + model_path = config.get('model') + model = None + + if model_path: + try: + model = apps.get_model(model_path) + except (LookupError, ValueError): + logger.warning( + 'netbox_rack_concept: partial-width model %r not found; falling back to Device fields. ' + 'Adjust PLUGINS_CONFIG["netbox_rack_concept"]["partial_width"] to match netbox_utilities.', + model_path + ) + + if model is None: + device_model = apps.get_model('dcim', 'Device') + field_names = {f.name for f in device_model._meta.get_fields()} + if fraction_field in field_names and position_field in field_names: + adapter = PartialWidthAdapter(None, None, fraction_field, position_field) + else: + logger.warning( + 'netbox_rack_concept: netbox_utilities is installed but its partial-width storage could not ' + 'be located. Partial-width values will not be written when deploying concepts.' + ) + else: + adapter = PartialWidthAdapter( + model, config.get('device_field', 'device'), fraction_field, position_field + ) + + _ADAPTER_CACHE['adapter'] = adapter + return adapter + + +def apply_partial_width(device, concept_device): + """ + Persist the partial-width placement of a concept device onto the real device that + was created from it. Full-width devices need nothing written. + """ + if concept_device.width_fraction == 1: + return False + adapter = get_partial_width_adapter() + if adapter is None: + return False + try: + adapter.write(device, concept_device.width_fraction, concept_device.horizontal_position) + return True + except Exception as exc: # pragma: no cover - depends on the other plugin's schema + logger.warning('netbox_rack_concept: could not write partial-width data for %s: %s', device, exc) + return False + + +def read_partial_width(device): + """Read partial-width placement off a real device when copying a rack into a concept.""" + adapter = get_partial_width_adapter() + if adapter is None: + return 1, 0 + try: + fraction, position = adapter.read(device) + except Exception as exc: # pragma: no cover + logger.warning('netbox_rack_concept: could not read partial-width data for %s: %s', device, exc) + return 1, 0 + return int(fraction or 1), int(position or 0) diff --git a/netbox_rack_concept/migrations/0001_initial.py b/netbox_rack_concept/migrations/0001_initial.py new file mode 100644 index 0000000..11f0222 --- /dev/null +++ b/netbox_rack_concept/migrations/0001_initial.py @@ -0,0 +1,178 @@ +import django.core.validators +import django.db.models.deletion +import taggit.managers +import utilities.json +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + # __latest__ keeps the plugin ordered after every core migration, whichever + # NetBox 4.6 patch release is installed. + ('dcim', '__latest__'), + ('extras', '__latest__'), + ('tenancy', '__latest__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RackConcept', + 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 + )), + ('name', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(default='draft', max_length=50)), + ('form_factor', models.CharField(blank=True, max_length=50)), + ('width', models.PositiveSmallIntegerField(default=19)), + ('u_height', models.PositiveSmallIntegerField(default=42, validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(100), + ])), + ('starting_unit', models.PositiveSmallIntegerField( + default=1, validators=[django.core.validators.MinValueValidator(1)] + )), + ('desc_units', models.BooleanField(default=False)), + ('outer_width', models.PositiveSmallIntegerField(blank=True, null=True)), + ('outer_height', models.PositiveSmallIntegerField(blank=True, null=True)), + ('outer_depth', models.PositiveSmallIntegerField(blank=True, null=True)), + ('outer_unit', models.CharField(blank=True, max_length=50)), + ('mounting_depth', models.PositiveSmallIntegerField(blank=True, null=True)), + ('airflow', models.CharField(blank=True, max_length=50)), + ('max_weight', models.PositiveIntegerField(blank=True, null=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('default_location', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.location' + )), + ('default_site', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.site' + )), + ('rack_role', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.rackrole' + )), + ('rack_type', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='rack_concepts', to='dcim.racktype' + )), + ('source_rack', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.rack' + )), + ('tenant', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='rack_concepts', to='tenancy.tenant' + )), + ('tenant_group', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='rack_concepts', to='tenancy.tenantgroup' + )), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'rack concept', + 'verbose_name_plural': 'rack concepts', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='ConceptDevice', + 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 + )), + ('name', models.CharField(blank=True, max_length=64)), + ('status', models.CharField(default='planned', max_length=50)), + ('position', models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True, + validators=[django.core.validators.MinValueValidator(0.5)] + )), + ('face', models.CharField(default='front', max_length=50)), + ('width_fraction', models.PositiveSmallIntegerField(default=1)), + ('horizontal_position', models.PositiveSmallIntegerField(default=0)), + ('u_height', models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True, + validators=[django.core.validators.MinValueValidator(0)] + )), + ('is_full_depth', models.BooleanField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('concept', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='devices', + to='netbox_rack_concept.rackconcept' + )), + ('device_type', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='concept_devices', to='dcim.devicetype' + )), + ('platform', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='concept_devices', to='dcim.platform' + )), + ('role', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='concept_devices', to='dcim.devicerole' + )), + ('source_device', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.device' + )), + ('tenant', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='concept_devices', to='tenancy.tenant' + )), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'concept device', + 'verbose_name_plural': 'concept devices', + 'ordering': ('concept', '-position', 'horizontal_position', 'pk'), + }, + ), + migrations.CreateModel( + name='ConceptDeployment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('rack_name', models.CharField(max_length=100)), + ('device_count', models.PositiveSmallIntegerField(default=0)), + ('created', models.DateTimeField(auto_now_add=True)), + ('concept', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='deployments', + to='netbox_rack_concept.rackconcept' + )), + ('rack', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to='dcim.rack' + )), + ('user', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='+', to=settings.AUTH_USER_MODEL + )), + ], + options={ + 'verbose_name': 'concept deployment', + 'verbose_name_plural': 'concept deployments', + 'ordering': ('-created',), + }, + ), + migrations.AddIndex( + model_name='conceptdevice', + index=models.Index(fields=['concept', 'position'], name='nbrc_device_concept_pos_idx'), + ), + ] diff --git a/netbox_rack_concept/migrations/__init__.py b/netbox_rack_concept/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_rack_concept/models.py b/netbox_rack_concept/models.py new file mode 100644 index 0000000..f3fc0df --- /dev/null +++ b/netbox_rack_concept/models.py @@ -0,0 +1,461 @@ +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from dcim.choices import ( + DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices, +) +from netbox.models import NetBoxModel + +from .choices import LANE_COUNT, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices + +__all__ = ( + 'ConceptDevice', + 'ConceptDeployment', + 'RackConcept', +) + + +class RackConcept(NetBoxModel): + """ + A rack blueprint. It mirrors the geometry of dcim.Rack but is a separate model on + purpose: a concept must never show up in the rack list, in rack elevations or in + utilisation reports. It becomes a real rack only when it is deployed. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=ConceptStatusChoices, + default=ConceptStatusChoices.STATUS_DRAFT + ) + tenant_group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.PROTECT, + related_name='rack_concepts', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='rack_concepts', + blank=True, + null=True + ) + + # Rack geometry, mirroring dcim.RackBase + form_factor = models.CharField( + verbose_name=_('form factor'), + choices=RackFormFactorChoices, + max_length=50, + blank=True + ) + rack_type = models.ForeignKey( + to='dcim.RackType', + on_delete=models.PROTECT, + related_name='rack_concepts', + blank=True, + null=True, + help_text=_('Copy the geometry from a rack type instead of defining it here') + ) + width = models.PositiveSmallIntegerField( + verbose_name=_('width'), + choices=RackWidthChoices, + default=RackWidthChoices.WIDTH_19IN + ) + u_height = models.PositiveSmallIntegerField( + verbose_name=_('height (U)'), + default=42, + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + starting_unit = models.PositiveSmallIntegerField( + verbose_name=_('starting unit'), + default=1, + validators=[MinValueValidator(1)] + ) + desc_units = models.BooleanField( + verbose_name=_('descending units'), + default=False + ) + outer_width = models.PositiveSmallIntegerField(blank=True, null=True) + outer_height = models.PositiveSmallIntegerField(blank=True, null=True) + outer_depth = models.PositiveSmallIntegerField(blank=True, null=True) + outer_unit = models.CharField( + max_length=50, + choices=RackDimensionUnitChoices, + blank=True + ) + mounting_depth = models.PositiveSmallIntegerField(blank=True, null=True) + airflow = models.CharField( + max_length=50, + choices=RackAirflowChoices, + blank=True + ) + max_weight = models.PositiveIntegerField(blank=True, null=True) + + # Defaults applied when the concept is deployed + default_site = models.ForeignKey( + to='dcim.Site', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + help_text=_('Pre-selected when deploying this concept') + ) + default_location = models.ForeignKey( + to='dcim.Location', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + rack_role = models.ForeignKey( + to='dcim.RackRole', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + + # Provenance + source_rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + help_text=_('The rack this concept was copied from') + ) + + description = models.CharField(max_length=200, blank=True) + comments = models.TextField(blank=True) + + clone_fields = ( + 'status', 'tenant_group', 'tenant', 'form_factor', 'rack_type', 'width', 'u_height', 'starting_unit', + 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', + 'max_weight', 'default_site', 'default_location', 'rack_role', 'description', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('rack concept') + verbose_name_plural = _('rack concepts') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('plugins:netbox_rack_concept:rackconcept', args=[self.pk]) + + def get_status_color(self): + return ConceptStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + if self.tenant and self.tenant_group and self.tenant.group_id != self.tenant_group_id: + raise ValidationError({ + 'tenant': _('Tenant {tenant} does not belong to group {group}.').format( + tenant=self.tenant, group=self.tenant_group + ) + }) + + def save(self, *args, **kwargs): + # Keep the group in sync so tenant-group filtering works without a join. + if self.tenant and not self.tenant_group: + self.tenant_group = self.tenant.group + super().save(*args, **kwargs) + + @property + def units(self): + """Unit numbers ordered as they are displayed, top first.""" + units = range(self.starting_unit, self.starting_unit + self.u_height) + return list(units) if self.desc_units else list(reversed(units)) + + @property + def unit_range(self): + """(lowest, highest_exclusive) unit numbers as Decimals.""" + return Decimal(self.starting_unit), Decimal(self.starting_unit + self.u_height) + + @property + def total_u_used(self): + return sum(d.effective_u_height for d in self.devices.all()) + + @property + def utilization(self): + if not self.u_height: + return 0 + return min(int(self.total_u_used / Decimal(self.u_height) * 100), 100) + + +class ConceptDevice(NetBoxModel): + """ + A planned device inside a blueprint. It references a real dcim.DeviceType so that + height, depth and manufacturer stay accurate, but it is never a dcim.Device and so + never appears in the device list, in interface counts or in IPAM. + """ + concept = models.ForeignKey( + to=RackConcept, + on_delete=models.CASCADE, + related_name='devices' + ) + name = models.CharField( + verbose_name=_('name'), + max_length=64, + blank=True, + help_text=_('Optional. Supports the placeholder {n} when deploying multiple racks.') + ) + status = models.CharField( + max_length=50, + choices=ConceptDeviceStatusChoices, + default=ConceptDeviceStatusChoices.STATUS_PLANNED + ) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.PROTECT, + related_name='concept_devices', + blank=True, + null=True, + help_text=_('Leave empty to reserve space without committing to a model') + ) + role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.PROTECT, + related_name='concept_devices', + blank=True, + null=True + ) + platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.PROTECT, + related_name='concept_devices', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='concept_devices', + blank=True, + null=True, + help_text=_('Overrides the tenant of the concept') + ) + + # Placement + position = models.DecimalField( + verbose_name=_('position (U)'), + max_digits=4, + decimal_places=1, + blank=True, + null=True, + validators=[MinValueValidator(Decimal('0.5'))], + help_text=_('Lowest unit occupied by the device. Empty means unracked.') + ) + face = models.CharField( + verbose_name=_('rack face'), + max_length=50, + choices=DeviceFaceChoices, + default=DeviceFaceChoices.FACE_FRONT + ) + width_fraction = models.PositiveSmallIntegerField( + verbose_name=_('width'), + choices=WidthFractionChoices, + default=WidthFractionChoices.FRACTION_FULL, + help_text=_('Horizontal share of the rack unit, as used by netbox_utilities') + ) + horizontal_position = models.PositiveSmallIntegerField( + verbose_name=_('horizontal position'), + default=0, + help_text=_('Slot index from the left, starting at 0') + ) + + # Overrides for placeholders without a device type + u_height = models.DecimalField( + verbose_name=_('height (U)'), + max_digits=4, + decimal_places=1, + blank=True, + null=True, + validators=[MinValueValidator(Decimal('0'))], + help_text=_('Overrides the height of the device type') + ) + is_full_depth = models.BooleanField( + blank=True, + null=True, + help_text=_('Overrides the depth of the device type') + ) + + serial = models.CharField(max_length=50, blank=True) + label = models.CharField( + max_length=64, + blank=True, + help_text=_('Free text shown in the elevation instead of the name') + ) + + # Provenance + source_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + + description = models.CharField(max_length=200, blank=True) + comments = models.TextField(blank=True) + + clone_fields = ( + 'concept', 'status', 'device_type', 'role', 'platform', 'tenant', 'face', 'width_fraction', + ) + + class Meta: + ordering = ('concept', '-position', 'horizontal_position', 'pk') + indexes = ( + models.Index(fields=('concept', 'position'), name='nbrc_device_concept_pos_idx'), + ) + verbose_name = _('concept device') + verbose_name_plural = _('concept devices') + + def __str__(self): + if self.name: + return self.name + if self.device_type: + return f'{self.device_type} ({self.pk})' + return f'Reserved space ({self.pk})' + + def get_absolute_url(self): + return reverse('plugins:netbox_rack_concept:conceptdevice', args=[self.pk]) + + def get_status_color(self): + return ConceptDeviceStatusChoices.colors.get(self.status) + + @property + def display_label(self): + return self.label or self.name or (str(self.device_type) if self.device_type else _('Reserved')) + + @property + def effective_u_height(self): + if self.u_height is not None: + return self.u_height + if self.device_type: + return Decimal(self.device_type.u_height) + return Decimal('1') + + @property + def effective_full_depth(self): + if self.is_full_depth is not None: + return self.is_full_depth + if self.device_type: + return self.device_type.is_full_depth + return True + + @property + def lane_span(self): + """(start, end) lane indices on the LANE_COUNT grid.""" + lanes_per_slot = LANE_COUNT // self.width_fraction + start = self.horizontal_position * lanes_per_slot + return start, start + lanes_per_slot + + @property + def unit_span(self): + """(lowest, highest_exclusive) unit numbers, or None when unracked.""" + if self.position is None: + return None + return self.position, self.position + self.effective_u_height + + def occupies_face(self, face): + return self.effective_full_depth or self.face == face + + def conflicts_with(self, other): + """True when both devices claim the same physical space.""" + mine, theirs = self.unit_span, other.unit_span + if mine is None or theirs is None: + return False + if mine[0] >= theirs[1] or theirs[0] >= mine[1]: + return False + # A full-depth device blocks both faces. + if not (self.effective_full_depth or other.effective_full_depth) and self.face != other.face: + return False + my_lanes, their_lanes = self.lane_span, other.lane_span + return my_lanes[0] < their_lanes[1] and their_lanes[0] < my_lanes[1] + + def clean(self): + super().clean() + + if self.device_type is None and self.u_height is None: + raise ValidationError({ + 'u_height': _('Specify a device type or an explicit height.') + }) + + if self.horizontal_position >= self.width_fraction: + raise ValidationError({ + 'horizontal_position': _('With a width of 1/{fraction} the position must be between 0 and {max}.').format( + fraction=self.width_fraction, max=self.width_fraction - 1 + ) + }) + + if self.position is None: + return + + if self.position % Decimal('0.5') != 0: + raise ValidationError({'position': _('Position must be a multiple of 0.5U.')}) + + low, high = self.concept.unit_range + span = self.unit_span + if span[0] < low or span[1] > high: + raise ValidationError({ + 'position': _('A {height}U device at U{position} does not fit in a rack spanning U{low}-U{high}.').format( + height=self.effective_u_height, position=self.position, low=low, high=high - 1 + ) + }) + + siblings = self.concept.devices.exclude(pk=self.pk).filter(position__isnull=False) + for other in siblings.select_related('device_type'): + if self.conflicts_with(other): + raise ValidationError({ + 'position': _('Overlaps with {device} at U{position}.').format( + device=other, position=other.position + ) + }) + + +class ConceptDeployment(models.Model): + """Audit trail linking a blueprint to the real racks it produced.""" + concept = models.ForeignKey( + to=RackConcept, + on_delete=models.CASCADE, + related_name='deployments' + ) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + rack_name = models.CharField(max_length=100) + device_count = models.PositiveSmallIntegerField(default=0) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey( + to='users.User', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + + class Meta: + ordering = ('-created',) + verbose_name = _('concept deployment') + verbose_name_plural = _('concept deployments') + + def __str__(self): + return f'{self.concept} → {self.rack_name}' diff --git a/netbox_rack_concept/navigation.py b/netbox_rack_concept/navigation.py new file mode 100644 index 0000000..416e0a7 --- /dev/null +++ b/netbox_rack_concept/navigation.py @@ -0,0 +1,59 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +concept_buttons = [ + PluginMenuButton( + link='plugins:netbox_rack_concept:rackconcept_add', + title=_('Add'), + icon_class='mdi mdi-plus-thick', + color=ButtonColorChoices.GREEN, + permissions=['netbox_rack_concept.add_rackconcept'] + ), + PluginMenuButton( + link='plugins:netbox_rack_concept:rackconcept_bulk_import', + title=_('Import'), + icon_class='mdi mdi-upload', + color=ButtonColorChoices.CYAN, + permissions=['netbox_rack_concept.add_rackconcept'] + ), +] + +device_buttons = [ + PluginMenuButton( + link='plugins:netbox_rack_concept:conceptdevice_add', + title=_('Add'), + icon_class='mdi mdi-plus-thick', + color=ButtonColorChoices.GREEN, + permissions=['netbox_rack_concept.add_conceptdevice'] + ), + PluginMenuButton( + link='plugins:netbox_rack_concept:conceptdevice_bulk_import', + title=_('Import'), + icon_class='mdi mdi-upload', + color=ButtonColorChoices.CYAN, + permissions=['netbox_rack_concept.add_conceptdevice'] + ), +] + +menu = PluginMenu( + label=_('Rack Concepts'), + icon_class='mdi mdi-view-grid-plus-outline', + groups=( + (_('Planning'), ( + PluginMenuItem( + link='plugins:netbox_rack_concept:rackconcept_list', + link_text=_('Rack Concepts'), + permissions=['netbox_rack_concept.view_rackconcept'], + buttons=concept_buttons + ), + PluginMenuItem( + link='plugins:netbox_rack_concept:conceptdevice_list', + link_text=_('Planned Devices'), + permissions=['netbox_rack_concept.view_conceptdevice'], + buttons=device_buttons + ), + )), + ) +) diff --git a/netbox_rack_concept/search.py b/netbox_rack_concept/search.py new file mode 100644 index 0000000..ae6987a --- /dev/null +++ b/netbox_rack_concept/search.py @@ -0,0 +1,27 @@ +from netbox.search import SearchIndex, register_search + +from .models import ConceptDevice, RackConcept + + +@register_search +class RackConceptIndex(SearchIndex): + model = RackConcept + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('status', 'tenant', 'description') + + +@register_search +class ConceptDeviceIndex(SearchIndex): + model = ConceptDevice + fields = ( + ('name', 100), + ('label', 110), + ('serial', 120), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('concept', 'status', 'device_type', 'role', 'description') diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.css b/netbox_rack_concept/static/netbox_rack_concept/elevation.css new file mode 100644 index 0000000..88bfeb9 --- /dev/null +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.css @@ -0,0 +1,61 @@ +/* Concept rack elevations. Colours follow the NetBox theme via CSS variables so the + elevation stays readable in both light and dark mode. */ + +.concept-elevation { + margin: 0 0.5rem 1rem; +} + +.concept-elevation-svg { + max-width: 100%; + height: auto; + user-select: none; + touch-action: none; +} + +.concept-elevation .unit-slot { + fill: var(--tblr-bg-surface-secondary, #f6f8fa); + stroke: var(--tblr-border-color, #dadfe5); + stroke-width: 1; +} + +.concept-elevation .unit-slot.drop-target { + fill: var(--tblr-primary-lt, #d7e7fb); +} + +.concept-elevation .unit-label { + font-size: 10px; + fill: var(--tblr-secondary, #808f9c); +} + +.concept-elevation .rack-frame { + stroke: var(--tblr-body-color, #35404a); + stroke-width: 2; +} + +.concept-elevation .device-rect { + stroke: rgba(0, 0, 0, 0.35); + stroke-width: 1; +} + +.concept-elevation .device-label { + font-size: 11px; + pointer-events: none; +} + +.concept-elevation .opposite-hint { + font-size: 9px; + opacity: 0.8; +} + +.concept-elevation .device-block.opposite .device-rect { + stroke-dasharray: 3 2; +} + +.concept-elevation[data-editable="true"] .device-block:not(.opposite) { + cursor: grab; +} + +.concept-elevation .device-block.dragging { + opacity: 0.6; + cursor: grabbing; +} diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.js b/netbox_rack_concept/static/netbox_rack_concept/elevation.js new file mode 100644 index 0000000..33ea6d7 --- /dev/null +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.js @@ -0,0 +1,173 @@ +/* + * Drag & drop for concept rack elevations. + * + * netbox-reorder-rack cannot be reused here: it moves dcim.Device rows, and a concept + * deliberately has none. The moves are collected client-side and applied server-side in + * a single transaction, so an invalid drop leaves the concept exactly as it was. + */ +(function () { + 'use strict'; + + const container = document.getElementById('concept-elevations'); + if (!container || container.dataset.editable !== 'true') { + return; + } + + const reorderUrl = container.dataset.reorderUrl; + const startingUnit = parseInt(container.dataset.startingUnit, 10); + const rackHeight = parseInt(container.dataset.uHeight, 10); + const descUnits = container.dataset.descUnits === 'true'; + const statusEl = document.getElementById('concept-elevation-status'); + + function csrfToken() { + const match = document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/); + return match ? decodeURIComponent(match[2]) : ''; + } + + function setStatus(message, isError) { + if (!statusEl) return; + statusEl.textContent = message || ''; + statusEl.className = 'small mt-2 ' + (isError ? 'text-danger' : 'text-muted'); + } + + /* Geometry is read back out of the rendered SVG so the server stays the single + source of truth for sizes. */ + function geometry(svg) { + const slot = svg.querySelector('.unit-slot'); + if (!slot) return null; + return { + unitHeight: slot.height.baseVal.value, + rackX: slot.x.baseVal.value, + rackWidth: slot.width.baseVal.value + }; + } + + function svgPoint(svg, event) { + const rect = svg.getBoundingClientRect(); + const scale = svg.viewBox.baseVal.width / rect.width; + return { + x: (event.clientX - rect.left) * scale, + y: (event.clientY - rect.top) * scale + }; + } + + function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); + } + + container.querySelectorAll('.concept-elevation').forEach(function (panel) { + const svg = panel.querySelector('svg'); + const geo = geometry(svg); + if (!geo) return; + + panel.querySelectorAll('.device-block:not(.opposite)').forEach(function (block) { + block.addEventListener('pointerdown', onPointerDown); + + function onPointerDown(event) { + // Let plain clicks through to the device link. + if (event.button !== 0) return; + event.preventDefault(); + + const start = svgPoint(svg, event); + const rect = block.querySelector('.device-rect'); + const originX = rect.x.baseVal.value; + const deviceUnits = parseFloat(block.dataset.uHeight); + const oldPosition = parseFloat(block.dataset.position); + const fraction = parseInt( + block.dataset.widthFraction || rect.dataset.widthFraction || '1', 10 + ); + let moved = false; + let dx = 0; + let dy = 0; + + block.classList.add('dragging'); + block.setPointerCapture(event.pointerId); + + function onMove(moveEvent) { + const now = svgPoint(svg, moveEvent); + dx = now.x - start.x; + dy = now.y - start.y; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + moved = true; + } + const snappedY = Math.round(dy / geo.unitHeight) * geo.unitHeight; + block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')'); + } + + function onUp() { + block.removeEventListener('pointermove', onMove); + block.removeEventListener('pointerup', onUp); + block.removeEventListener('pointercancel', onUp); + block.classList.remove('dragging'); + + if (!moved) { + // Treat it as a click: follow the device link. + block.removeAttribute('transform'); + const link = block.querySelector('a'); + if (link) window.location = link.getAttribute('href'); + return; + } + + const unitDelta = Math.round(dy / geo.unitHeight) * (descUnits ? 1 : -1); + const lowest = startingUnit; + const highest = startingUnit + rackHeight - deviceUnits; + const newPosition = clamp(oldPosition + unitDelta, lowest, highest); + + let horizontal = 0; + if (fraction > 1) { + const newX = originX + dx; + const slotWidth = geo.rackWidth / fraction; + horizontal = clamp( + Math.round((newX - geo.rackX) / slotWidth), 0, fraction - 1 + ); + } + + submit(block, newPosition, fraction > 1 ? horizontal : null); + } + + block.addEventListener('pointermove', onMove); + block.addEventListener('pointerup', onUp); + block.addEventListener('pointercancel', onUp); + } + }); + }); + + function submit(block, position, horizontalPosition) { + const move = { + id: parseInt(block.dataset.deviceId, 10), + position: position + }; + if (horizontalPosition !== null) { + move.horizontal_position = horizontalPosition; + } + + setStatus('Saving…', false); + + fetch(reorderUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken() + }, + credentials: 'same-origin', + body: JSON.stringify({ devices: [move] }) + }) + .then(function (response) { + return response.json().then(function (body) { + return { ok: response.ok, body: body }; + }); + }) + .then(function (result) { + if (result.ok) { + window.location.reload(); + } else { + block.removeAttribute('transform'); + setStatus(result.body.error || 'The move was rejected.', true); + } + }) + .catch(function () { + block.removeAttribute('transform'); + setStatus('The move could not be saved.', true); + }); + } +})(); diff --git a/netbox_rack_concept/tables.py b/netbox_rack_concept/tables.py new file mode 100644 index 0000000..89a6cff --- /dev/null +++ b/netbox_rack_concept/tables.py @@ -0,0 +1,107 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ + +from netbox.tables import NetBoxTable, columns + +from .models import ConceptDeployment, ConceptDevice, RackConcept + +__all__ = ( + 'ConceptDeploymentTable', + 'ConceptDeviceElevationTable', + 'ConceptDeviceTable', + 'RackConceptTable', +) + + +class RackConceptTable(NetBoxTable): + name = tables.Column(linkify=True) + status = columns.ChoiceFieldColumn() + tenant_group = tables.Column(linkify=True, verbose_name=_('Tenant group')) + tenant = tables.Column(linkify=True) + rack_type = tables.Column(linkify=True) + device_count = columns.LinkedCountColumn( + viewname='plugins:netbox_rack_concept:conceptdevice_list', + url_params={'concept_id': 'pk'}, + verbose_name=_('Devices') + ) + utilization = columns.UtilizationColumn( + accessor='utilization', + orderable=False, + verbose_name=_('Utilization') + ) + source_rack = tables.Column(linkify=True, verbose_name=_('Copied from')) + comments = columns.MarkdownColumn() + tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:rackconcept_list') + + class Meta(NetBoxTable.Meta): + model = RackConcept + fields = ( + 'pk', 'id', 'name', 'status', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width', + 'u_height', 'starting_unit', 'desc_units', 'device_count', 'utilization', 'source_rack', + 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'name', 'status', 'tenant', 'u_height', 'width', 'device_count', 'utilization', 'description', + ) + + +class ConceptDeviceTable(NetBoxTable): + name = tables.Column(linkify=True, empty_values=()) + concept = tables.Column(linkify=True, verbose_name=_('Concept')) + status = columns.ChoiceFieldColumn() + device_type = tables.Column(linkify=True, verbose_name=_('Type')) + manufacturer = tables.Column( + accessor='device_type__manufacturer', + linkify=True, + verbose_name=_('Manufacturer') + ) + role = columns.ColoredLabelColumn() + platform = tables.Column(linkify=True) + tenant = tables.Column(linkify=True) + position = tables.Column(verbose_name=_('Position (U)')) + # A plain column, not ChoiceFieldColumn: WidthFractionChoices carries no colours. + width_fraction = tables.Column( + accessor='get_width_fraction_display', + order_by='width_fraction', + verbose_name=_('Width') + ) + source_device = tables.Column(linkify=True, verbose_name=_('Copied from')) + comments = columns.MarkdownColumn() + tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptdevice_list') + + class Meta(NetBoxTable.Meta): + model = ConceptDevice + fields = ( + 'pk', 'id', 'name', 'label', 'concept', 'status', 'device_type', 'manufacturer', 'role', 'platform', + 'tenant', 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth', + 'serial', 'source_device', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'name', 'concept', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction', + ) + + def render_name(self, record): + return record.display_label + + +class ConceptDeviceElevationTable(ConceptDeviceTable): + """Device list shown on the concept detail page, without the redundant concept column.""" + + class Meta(ConceptDeviceTable.Meta): + default_columns = ( + 'name', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction', 'horizontal_position', + ) + + +class ConceptDeploymentTable(tables.Table): + rack_name = tables.Column(verbose_name=_('Rack')) + rack = tables.Column(linkify=True, verbose_name=_('Link')) + device_count = tables.Column(verbose_name=_('Devices')) + user = tables.Column(verbose_name=_('Deployed by')) + created = columns.DateTimeColumn(verbose_name=_('Deployed')) + + class Meta: + model = ConceptDeployment + fields = ('rack_name', 'rack', 'device_count', 'user', 'created') + attrs = {'class': 'table table-hover object-list'} + empty_text = _('This concept has not been deployed yet.') diff --git a/netbox_rack_concept/template_content.py b/netbox_rack_concept/template_content.py new file mode 100644 index 0000000..d57b8e0 --- /dev/null +++ b/netbox_rack_concept/template_content.py @@ -0,0 +1,37 @@ +from django.conf import settings + +from netbox.plugins import PluginTemplateExtension + +from .models import RackConcept + + +def _enabled(): + return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}).get('enable_rack_tab', True) + + +class RackConceptButtons(PluginTemplateExtension): + """Adds "Copy to concept" to the button row of a rack, and lists derived concepts.""" + models = ['dcim.rack'] + + def buttons(self): + if not _enabled(): + return '' + if not self.context['request'].user.has_perm('netbox_rack_concept.add_rackconcept'): + return '' + return self.render('netbox_rack_concept/inc/rack_copy_button.html', extra_context={ + 'rack': self.context['object'], + }) + + def right_page(self): + if not _enabled(): + return '' + rack = self.context['object'] + concepts = RackConcept.objects.restrict(self.context['request'].user, 'view').filter(source_rack=rack) + if not concepts: + return '' + return self.render('netbox_rack_concept/inc/rack_concepts_panel.html', extra_context={ + 'concepts': concepts, + }) + + +template_extensions = [RackConceptButtons] diff --git a/netbox_rack_concept/templates/netbox_rack_concept/concept_clone.html b/netbox_rack_concept/templates/netbox_rack_concept/concept_clone.html new file mode 100644 index 0000000..34c00fd --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/concept_clone.html @@ -0,0 +1,32 @@ +{% extends 'base/layout.html' %} +{% load i18n %} +{% load form_helpers %} + +{% block title %}{% blocktrans with name=object.name %}Clone {{ name }}{% endblocktrans %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+
+

{% blocktrans with name=object.name %}Clone concept {{ name }}{% endblocktrans %}

+
+

+ {% blocktrans count counter=object.devices.count %} + The original keeps its {{ counter }} planned device. The clone is independent from this point on. + {% plural %} + The original keeps its {{ counter }} planned devices. The clone is independent from this point on. + {% endblocktrans %} +

+ {% render_form form %} +
+ +
+
+
+
+{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/concept_deploy.html b/netbox_rack_concept/templates/netbox_rack_concept/concept_deploy.html new file mode 100644 index 0000000..900504e --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/concept_deploy.html @@ -0,0 +1,48 @@ +{% extends 'base/layout.html' %} +{% load i18n %} +{% load form_helpers %} + +{% block title %}{% blocktrans with name=object.name %}Deploy {{ name }}{% endblocktrans %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ +
+

{% blocktrans with name=object.name %}Deploy concept {{ name }}{% endblocktrans %}

+
+

+ {% trans "This creates real racks and devices in NetBox. The concept itself is left untouched and stays reusable." %} + {% blocktrans count counter=deployable_count %} + {{ counter }} planned device will be created per rack. + {% plural %} + {{ counter }} planned devices will be created per rack. + {% endblocktrans %} +

+ {% render_form form %} +
+ +
+ + {% if warnings %} +
+

+ {% trans "Will be skipped" %} +

+
    + {% for warning in warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+
+{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html new file mode 100644 index 0000000..6f9b1d1 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html @@ -0,0 +1,102 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} + +{% block content %} +
+
+
+

{% trans "Planned Device" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Concept" %}{{ object.concept|linkify }}
{% trans "Name" %}{{ object.name|placeholder }}
{% trans "Label" %}{{ object.label|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Tenant" %}{{ object.tenant|linkify|placeholder }}
{% trans "Copied from" %}{{ object.source_device|linkify|placeholder }}
+
+ {% include 'inc/panels/comments.html' %} +
+ +
+
+

{% trans "Hardware" %}

+ + + + + + + + + + + + + + + + + + + + + +
{% trans "Manufacturer" %}{{ object.device_type.manufacturer|linkify|placeholder }}
{% trans "Device type" %}{{ object.device_type|linkify|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Serial" %}{{ object.serial|placeholder }}
+
+ +
+

{% trans "Placement" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Position" %}{% if object.position %}U{{ object.position }}{% else %}{% trans "Not racked" %}{% endif %}
{% trans "Height" %}{{ object.effective_u_height }}U
{% trans "Face" %}{{ object.get_face_display }}
{% trans "Width" %}{{ object.get_width_fraction_display }}
{% trans "Horizontal position" %}{{ object.horizontal_position }}
{% trans "Full depth" %}{% checkmark object.effective_full_depth %}
+
+
+
+{% 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 new file mode 100644 index 0000000..4cdda51 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html @@ -0,0 +1,55 @@ +{% load i18n %} +{% load l10n %} +{% comment %} + One face of a concept rack. Localization is switched off below because SVG + coordinates must use a dot as the decimal separator whatever the UI language is. +{% endcomment %} +{% localize off %} +
+
{{ elevation.face_label|title }}
+ + + {# Unit rows and legend #} + {% for unit in elevation.units %} + + + {{ unit.number }} + + {% endfor %} + + {# Devices #} + {% for block in elevation.blocks %} + + + + {% if block.show_text %} + + {{ block.label }}{% if block.opposite %} ({% trans "rear" %}){% endif %} + + {% endif %} + {{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }} + + + {% endfor %} + + + +
+{% endlocalize %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_concepts_panel.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_concepts_panel.html new file mode 100644 index 0000000..b40a7ed --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_concepts_panel.html @@ -0,0 +1,16 @@ +{% load i18n %} +{% load helpers %} +
+

{% trans "Rack Concepts" %}

+ + {% for concept in concepts %} + + + + + {% endfor %} +
{{ concept|linkify }}{% badge concept.get_status_display bg_color=concept.get_status_color %}
+ +
diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_copy_button.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_copy_button.html new file mode 100644 index 0000000..6860b10 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_copy_button.html @@ -0,0 +1,4 @@ +{% load i18n %} + + {% trans "Copy to concept" %} + diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rack_copy.html b/netbox_rack_concept/templates/netbox_rack_concept/rack_copy.html new file mode 100644 index 0000000..c4e67f0 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/rack_copy.html @@ -0,0 +1,35 @@ +{% extends 'base/layout.html' %} +{% load i18n %} +{% load form_helpers %} + +{% block title %}{% blocktrans with rack=rack.name %}Copy {{ rack }} to a concept{% endblocktrans %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+
+

+ {% blocktrans with rack=rack.name %}Copy rack {{ rack }} to a concept{% endblocktrans %} +

+
+

+ {% blocktrans count counter=device_count %} + The rack contains {{ counter }} racked device, which can be snapshotted into the concept. + {% plural %} + The rack contains {{ counter }} racked devices, which can be snapshotted into the concept. + {% endblocktrans %} + {% trans "Nothing in the rack is modified: the concept is a separate object that does not appear in the rack or device lists." %} +

+ {% render_form form %} +
+ +
+
+
+
+{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html new file mode 100644 index 0000000..6b83073 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html @@ -0,0 +1,168 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load static %} + +{% block head %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {{ block.super }} + {% if perms.netbox_rack_concept.add_rackconcept %} + + {% trans "Clone concept" %} + + {% endif %} + {% if perms.dcim.add_rack %} + + {% trans "Deploy to NetBox" %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+

{% trans "Rack Concept" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Tenant group" %}{{ object.tenant_group|linkify|placeholder }}
{% trans "Tenant" %}{{ object.tenant|linkify|placeholder }}
{% trans "Copied from" %}{{ object.source_rack|linkify|placeholder }}
{% trans "Planned devices" %}{{ object.devices.count }}
{% trans "Space utilization" %}{% utilization_graph object.utilization %}
+
+ +
+

{% trans "Geometry" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Rack type" %}{{ object.rack_type|linkify|placeholder }}
{% trans "Form factor" %}{{ object.get_form_factor_display|placeholder }}
{% trans "Width" %}{{ object.get_width_display }}
{% trans "Height" %}{{ object.u_height }}U
{% trans "Starting unit" %}U{{ object.starting_unit }}
{% trans "Descending units" %}{% checkmark object.desc_units %}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
{% trans "Mounting depth" %}{{ object.mounting_depth|placeholder }}
+
+ +
+

{% trans "Deployment defaults" %}

+ + + + + + + + + + + + + +
{% trans "Site" %}{{ object.default_site|linkify|placeholder }}
{% trans "Location" %}{{ object.default_location|linkify|placeholder }}
{% trans "Rack role" %}{{ object.rack_role|linkify|placeholder }}
+
+ + {% include 'inc/panels/comments.html' %} +
+ +
+
+

+ {% trans "Elevation" %} + {% if perms.netbox_rack_concept.change_conceptdevice %} + {% trans "Drag devices to move them" %} + {% endif %} +

+
+
+ {% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %} + {% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %} +
+
+
+ {% if perms.netbox_rack_concept.add_conceptdevice %} + + {% endif %} +
+ +
+

{% trans "Deployments" %}

+ {% render_table deployments_table %} +
+
+
+{% endblock %} + +{% block javascript %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept_devices.html b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept_devices.html new file mode 100644 index 0000000..2edaf29 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept_devices.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_children.html' %} +{% load i18n %} + +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.netbox_rack_concept.add_conceptdevice %} + + {% trans "Add planned device" %} + + {% endif %} +{% endblock %} diff --git a/netbox_rack_concept/urls.py b/netbox_rack_concept/urls.py new file mode 100644 index 0000000..0ef9bf7 --- /dev/null +++ b/netbox_rack_concept/urls.py @@ -0,0 +1,20 @@ +from django.urls import include, path + +from utilities.urls import get_model_urls + +from . import views + +app_name = 'netbox_rack_concept' + +urlpatterns = [ + # Rack concepts + path('concepts/', include(get_model_urls('netbox_rack_concept', 'rackconcept', detail=False))), + path('concepts//', include(get_model_urls('netbox_rack_concept', 'rackconcept'))), + + # Planned devices + path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))), + path('devices//', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))), + + # Entry point from a real rack + path('racks//copy/', views.RackCopyView.as_view(), name='rack_copy'), +] diff --git a/netbox_rack_concept/views.py b/netbox_rack_concept/views.py new file mode 100644 index 0000000..c1c7db2 --- /dev/null +++ b/netbox_rack_concept/views.py @@ -0,0 +1,395 @@ +import json +from decimal import Decimal, InvalidOperation + +from django.contrib import messages +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Count +from django.http import HttpResponseForbidden, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic import View + +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 .elevation import get_elevations +from .models import ConceptDevice, RackConcept + +__all__ = ( + 'ConceptDeviceView', + 'RackConceptView', +) + + +# +# Rack concepts +# + +@register_model_view(RackConcept, 'list', path='', detail=False) +class RackConceptListView(generic.ObjectListView): + queryset = RackConcept.objects.annotate( + device_count=Count('devices') + ).select_related('tenant', 'tenant_group', 'rack_type') + table = tables.RackConceptTable + filterset = filtersets.RackConceptFilterSet + filterset_form = forms.RackConceptFilterForm + + +@register_model_view(RackConcept) +class RackConceptView(generic.ObjectView): + queryset = RackConcept.objects.select_related('tenant', 'tenant_group', 'rack_type', 'source_rack') + template_name = 'netbox_rack_concept/rackconcept.html' + + def get_extra_context(self, request, instance): + front, rear = get_elevations(instance) + deployments = tables.ConceptDeploymentTable( + instance.deployments.select_related('rack', 'user') + ) + return { + 'front_elevation': front, + 'rear_elevation': rear, + 'deployments_table': deployments, + } + + +@register_model_view(RackConcept, 'devices') +class RackConceptDevicesView(generic.ObjectChildrenView): + queryset = RackConcept.objects.all() + child_model = ConceptDevice + table = tables.ConceptDeviceElevationTable + filterset = filtersets.ConceptDeviceFilterSet + template_name = 'netbox_rack_concept/rackconcept_devices.html' + tab = ViewTab( + label=_('Devices'), + badge=lambda obj: obj.devices.count(), + permission='netbox_rack_concept.view_conceptdevice', + weight=500 + ) + + def get_children(self, request, parent): + return parent.devices.select_related('device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant') + + +@register_model_view(RackConcept, 'add', detail=False) +@register_model_view(RackConcept, 'edit') +class RackConceptEditView(generic.ObjectEditView): + queryset = RackConcept.objects.all() + form = forms.RackConceptForm + + def alter_object(self, obj, request, url_args, url_kwargs): + if not obj.pk: + config = settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}) + obj.u_height = config.get('default_u_height', 42) + obj.width = config.get('default_width', 19) + return obj + + +@register_model_view(RackConcept, 'delete') +class RackConceptDeleteView(generic.ObjectDeleteView): + queryset = RackConcept.objects.all() + + +@register_model_view(RackConcept, 'bulk_import', path='import', detail=False) +class RackConceptBulkImportView(generic.BulkImportView): + queryset = RackConcept.objects.all() + model_form = forms.RackConceptImportForm + + +@register_model_view(RackConcept, 'bulk_edit', path='edit', detail=False) +class RackConceptBulkEditView(generic.BulkEditView): + queryset = RackConcept.objects.annotate(device_count=Count('devices')) + filterset = filtersets.RackConceptFilterSet + table = tables.RackConceptTable + form = forms.RackConceptBulkEditForm + + +@register_model_view(RackConcept, 'bulk_delete', path='delete', detail=False) +class RackConceptBulkDeleteView(generic.BulkDeleteView): + queryset = RackConcept.objects.annotate(device_count=Count('devices')) + filterset = filtersets.RackConceptFilterSet + table = tables.RackConceptTable + + +# +# Concept devices +# + +@register_model_view(ConceptDevice, 'list', path='', detail=False) +class ConceptDeviceListView(generic.ObjectListView): + queryset = ConceptDevice.objects.select_related( + 'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant' + ) + table = tables.ConceptDeviceTable + filterset = filtersets.ConceptDeviceFilterSet + filterset_form = forms.ConceptDeviceFilterForm + + +@register_model_view(ConceptDevice) +class ConceptDeviceView(generic.ObjectView): + queryset = ConceptDevice.objects.select_related( + 'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant', 'source_device' + ) + template_name = 'netbox_rack_concept/conceptdevice.html' + + +@register_model_view(ConceptDevice, 'add', detail=False) +@register_model_view(ConceptDevice, 'edit') +class ConceptDeviceEditView(generic.ObjectEditView): + queryset = ConceptDevice.objects.all() + form = forms.ConceptDeviceForm + + +@register_model_view(ConceptDevice, 'delete') +class ConceptDeviceDeleteView(generic.ObjectDeleteView): + queryset = ConceptDevice.objects.all() + + +@register_model_view(ConceptDevice, 'bulk_import', path='import', detail=False) +class ConceptDeviceBulkImportView(generic.BulkImportView): + queryset = ConceptDevice.objects.all() + model_form = forms.ConceptDeviceImportForm + + +@register_model_view(ConceptDevice, 'bulk_edit', path='edit', detail=False) +class ConceptDeviceBulkEditView(generic.BulkEditView): + queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role') + filterset = filtersets.ConceptDeviceFilterSet + table = tables.ConceptDeviceTable + form = forms.ConceptDeviceBulkEditForm + + +@register_model_view(ConceptDevice, 'bulk_delete', path='delete', detail=False) +class ConceptDeviceBulkDeleteView(generic.BulkDeleteView): + queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role') + filterset = filtersets.ConceptDeviceFilterSet + table = tables.ConceptDeviceTable + + +# +# Actions: copy, clone, deploy, reorder +# + +class RackCopyView(ObjectPermissionRequiredMixin, View): + """Copy a real rack into a new concept. Reached from the rack detail page.""" + queryset = RackConcept.objects.all() + + def get_required_permission(self): + return 'netbox_rack_concept.add_rackconcept' + + def get(self, request, pk): + rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk) + form = forms.RackCopyForm(initial={ + 'name': _('%(rack)s (concept)') % {'rack': rack.name}, + 'tenant': rack.tenant_id, + 'tenant_group': rack.tenant.group_id if rack.tenant else None, + }) + return render(request, 'netbox_rack_concept/rack_copy.html', { + 'rack': rack, + 'form': form, + 'device_count': rack.devices.filter(position__isnull=False).count(), + 'return_url': rack.get_absolute_url(), + }) + + def post(self, request, pk): + rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk) + form = forms.RackCopyForm(request.POST) + + if form.is_valid(): + try: + concept = deploy.copy_rack_to_concept( + rack=rack, + name=form.cleaned_data['name'], + status=form.cleaned_data['status'], + tenant=form.cleaned_data['tenant'], + tenant_group=form.cleaned_data['tenant_group'], + description=form.cleaned_data['description'], + include_devices=form.cleaned_data['include_devices'], + include_device_names=form.cleaned_data['include_device_names'], + ) + except ValidationError as exc: + form.add_error(None, exc) + else: + messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % { + 'concept': concept, 'rack': rack + }) + return redirect(concept.get_absolute_url()) + + return render(request, 'netbox_rack_concept/rack_copy.html', { + 'rack': rack, + 'form': form, + 'device_count': rack.devices.filter(position__isnull=False).count(), + 'return_url': rack.get_absolute_url(), + }) + + +@register_model_view(RackConcept, 'clone_concept', path='clone') +class RackConceptCloneView(ObjectPermissionRequiredMixin, View): + """Duplicate a concept to build a variant.""" + queryset = RackConcept.objects.all() + + def get_required_permission(self): + return 'netbox_rack_concept.add_rackconcept' + + def get(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + form = forms.RackConceptCloneForm(initial={ + 'name': _('%(name)s (copy)') % {'name': concept.name}, + 'tenant': concept.tenant_id, + }) + return render(request, 'netbox_rack_concept/concept_clone.html', { + 'object': concept, + 'form': form, + 'return_url': concept.get_absolute_url(), + }) + + def post(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + form = forms.RackConceptCloneForm(request.POST) + + if form.is_valid(): + try: + clone = deploy.clone_concept( + concept=concept, + name=form.cleaned_data['name'], + status=form.cleaned_data['status'], + tenant=form.cleaned_data['tenant'], + include_devices=form.cleaned_data['include_devices'], + ) + except ValidationError as exc: + form.add_error(None, exc) + else: + messages.success(request, _('Cloned %(source)s into %(clone)s.') % { + 'source': concept, 'clone': clone + }) + return redirect(clone.get_absolute_url()) + + return render(request, 'netbox_rack_concept/concept_clone.html', { + 'object': concept, + 'form': form, + 'return_url': concept.get_absolute_url(), + }) + + +@register_model_view(RackConcept, 'deploy') +class RackConceptDeployView(ObjectPermissionRequiredMixin, View): + """Materialise a concept as one or more real racks.""" + queryset = RackConcept.objects.all() + + def get_required_permission(self): + # The mixin restricts against this view's queryset, so it has to be a concept + # permission. The permission to create racks is checked separately below. + return 'netbox_rack_concept.view_rackconcept' + + def _context(self, request, concept, form): + # An unchecked checkbox is simply absent from the POST data. + create_devices = 'create_devices' in form.data if form.is_bound else True + deployable, warnings = deploy.validate_deployment(concept, create_devices=create_devices) + return { + 'object': concept, + 'form': form, + 'deployable_count': len(deployable), + 'warnings': warnings, + 'return_url': concept.get_absolute_url(), + } + + def get(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + form = forms.ConceptDeployForm(concept=concept) + return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form)) + + def post(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + form = forms.ConceptDeployForm(request.POST, concept=concept) + + if not request.user.has_perm('dcim.add_rack'): + return HttpResponseForbidden(_('You do not have permission to create racks.')) + + if form.is_valid(): + try: + racks, devices, warnings = deploy.deploy_concept( + concept=concept, + rack_names=form.cleaned_data['rack_names'], + site=form.cleaned_data['site'], + location=form.cleaned_data['location'], + tenant=form.cleaned_data['tenant'], + create_devices=form.cleaned_data['create_devices'], + device_name_pattern=form.cleaned_data['device_name_pattern'], + skip_unnamed_devices=form.cleaned_data['skip_unnamed_devices'], + user=request.user, + ) + except ValidationError as exc: + # Nothing was written: deploy_concept runs in a transaction. + messages.error(request, _('Deployment failed, nothing was created.')) + form.add_error(None, exc) + else: + for warning in warnings: + messages.warning(request, warning) + messages.success(request, _('Created %(racks)s rack(s) and %(devices)s device(s).') % { + 'racks': len(racks), 'devices': len(devices) + }) + if len(racks) == 1: + return redirect(racks[0].get_absolute_url()) + return redirect( + reverse('dcim:rack_list') + f'?site_id={form.cleaned_data["site"].pk}' + ) + + return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form)) + + +@register_model_view(RackConcept, 'reorder') +class RackConceptReorderView(ObjectPermissionRequiredMixin, View): + """ + Endpoint for the drag & drop elevation. Accepts a JSON body of the shape + {"devices": [{"id": 1, "position": "12.0", "face": "front", "horizontal_position": 0}]} + and applies all moves in one transaction so a rejected move leaves nothing half-applied. + """ + queryset = RackConcept.objects.all() + http_method_names = ['post'] + + def get_required_permission(self): + return 'netbox_rack_concept.change_conceptdevice' + + def post(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + + try: + payload = json.loads(request.body or '{}') + moves = payload['devices'] + except (json.JSONDecodeError, KeyError, TypeError): + return JsonResponse({'error': _('Malformed request body.')}, status=400) + + by_pk = {d.pk: d for d in concept.devices.select_related('device_type')} + updated = [] + + try: + with transaction.atomic(): + for move in moves: + device = by_pk.get(int(move['id'])) + if device is None: + raise ValidationError(_('Device %(id)s does not belong to this concept.') % {'id': move['id']}) + try: + device.position = Decimal(str(move['position'])) if move.get('position') is not None else None + except InvalidOperation: + raise ValidationError(_('Invalid position %(position)r.') % {'position': move.get('position')}) + if 'face' in move: + device.face = move['face'] + if 'horizontal_position' in move: + device.horizontal_position = int(move['horizontal_position']) + if 'width_fraction' in move: + device.width_fraction = int(move['width_fraction']) + updated.append(device) + + # Validate only after every move is staged, so swapping two devices works. + for device in updated: + device.save() + for device in updated: + device.full_clean() + except ValidationError as exc: + return JsonResponse({'error': ' '.join(exc.messages)}, status=400) + + return JsonResponse({'status': 'ok', 'updated': len(updated)}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bc485a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "netbox-rack-concept" +version = "0.1.0" +description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [{ name = "Louis" }] +keywords = ["netbox", "netbox-plugin", "dcim", "planning"] +classifiers = [ + "Framework :: Django", + "Programming Language :: Python :: 3.12", + "Intended Audience :: System Administrators", +] + +[project.urls] +Homepage = "https://github.com/example/netbox-rack-concept" + +[tool.setuptools.packages.find] +include = ["netbox_rack_concept*"] + +[tool.setuptools.package-data] +netbox_rack_concept = [ + "templates/netbox_rack_concept/*.html", + "templates/netbox_rack_concept/inc/*.html", + "static/netbox_rack_concept/*", +]