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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}'
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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')
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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.')
|
||||||
@@ -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]
|
||||||
@@ -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 %}
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% blocktrans with name=object.name %}Clone concept {{ name }}{% endblocktrans %}</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
{% render_form form %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Create clone" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 offset-md-2">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% blocktrans with name=object.name %}Deploy concept {{ name }}{% endblocktrans %}</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
{% render_form form %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Deploy" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if warnings %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header text-warning">
|
||||||
|
<i class="mdi mdi-alert"></i> {% trans "Will be skipped" %}
|
||||||
|
</h2>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for warning in warnings %}
|
||||||
|
<li class="list-group-item">{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Planned Device" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Concept" %}</th>
|
||||||
|
<td>{{ object.concept|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
|
<td>{{ object.name|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Label" %}</th>
|
||||||
|
<td>{{ object.label|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Tenant" %}</th>
|
||||||
|
<td>{{ object.tenant|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Copied from" %}</th>
|
||||||
|
<td>{{ object.source_device|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% include 'inc/panels/comments.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Hardware" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||||
|
<td>{{ object.device_type.manufacturer|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Device type" %}</th>
|
||||||
|
<td>{{ object.device_type|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Role" %}</th>
|
||||||
|
<td>{{ object.role|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Platform" %}</th>
|
||||||
|
<td>{{ object.platform|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Serial" %}</th>
|
||||||
|
<td>{{ object.serial|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Placement" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Position" %}</th>
|
||||||
|
<td>{% if object.position %}U{{ object.position }}{% else %}{% trans "Not racked" %}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Height" %}</th>
|
||||||
|
<td>{{ object.effective_u_height }}U</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Face" %}</th>
|
||||||
|
<td>{{ object.get_face_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Width" %}</th>
|
||||||
|
<td>{{ object.get_width_fraction_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Horizontal position" %}</th>
|
||||||
|
<td>{{ object.horizontal_position }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Full depth" %}</th>
|
||||||
|
<td>{% checkmark object.effective_full_depth %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<div class="concept-elevation" data-face="{{ elevation.face }}">
|
||||||
|
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
|
||||||
|
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
|
||||||
|
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
|
||||||
|
class="concept-elevation-svg" role="img"
|
||||||
|
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
||||||
|
|
||||||
|
{# Unit rows and legend #}
|
||||||
|
{% for unit in elevation.units %}
|
||||||
|
<g class="unit">
|
||||||
|
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||||
|
class="unit-slot"
|
||||||
|
data-unit="{{ unit.number }}"></rect>
|
||||||
|
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||||
|
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
||||||
|
</g>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Devices #}
|
||||||
|
{% for block in elevation.blocks %}
|
||||||
|
<g class="device-block {% if block.opposite %}opposite{% endif %}"
|
||||||
|
data-device-id="{{ block.device.pk }}"
|
||||||
|
data-position="{{ block.device.position }}"
|
||||||
|
data-u-height="{{ block.device.effective_u_height }}"
|
||||||
|
data-width-fraction="{{ block.device.width_fraction }}">
|
||||||
|
<a href="{{ block.device.get_absolute_url }}">
|
||||||
|
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
|
||||||
|
rx="2" fill="#{{ block.color }}"
|
||||||
|
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||||
|
class="device-rect"></rect>
|
||||||
|
{% if block.show_text %}
|
||||||
|
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||||
|
fill="#{{ block.text_color }}" class="device-label">
|
||||||
|
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
|
||||||
|
</text>
|
||||||
|
{% endif %}
|
||||||
|
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
||||||
|
</a>
|
||||||
|
</g>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<rect x="{{ elevation.legend_width }}" y="6"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
|
||||||
|
class="rack-frame" fill="none"></rect>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endlocalize %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load helpers %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Rack Concepts" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
{% for concept in concepts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ concept|linkify }}</td>
|
||||||
|
<td class="text-end">{% badge concept.get_status_display bg_color=concept.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<div class="card-footer small text-muted">
|
||||||
|
{% trans "Concepts copied from this rack. They are planning objects and do not affect this rack." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:rack_copy' pk=rack.pk %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="mdi mdi-view-grid-plus-outline"></i> {% trans "Copy to concept" %}
|
||||||
|
</a>
|
||||||
@@ -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 %}
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">
|
||||||
|
{% blocktrans with rack=rack.name %}Copy rack {{ rack }} to a concept{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% 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." %}
|
||||||
|
</p>
|
||||||
|
{% render_form form %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Create concept" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" href="{% static 'netbox_rack_concept/elevation.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_controls %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if perms.netbox_rack_concept.add_rackconcept %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.dcim.add_rack %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-rocket-launch-outline"></i> {% trans "Deploy to NetBox" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Rack Concept" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
|
<td>{{ object.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Tenant group" %}</th>
|
||||||
|
<td>{{ object.tenant_group|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Tenant" %}</th>
|
||||||
|
<td>{{ object.tenant|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Copied from" %}</th>
|
||||||
|
<td>{{ object.source_rack|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Planned devices" %}</th>
|
||||||
|
<td>{{ object.devices.count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Space utilization" %}</th>
|
||||||
|
<td>{% utilization_graph object.utilization %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Geometry" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Rack type" %}</th>
|
||||||
|
<td>{{ object.rack_type|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Form factor" %}</th>
|
||||||
|
<td>{{ object.get_form_factor_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Width" %}</th>
|
||||||
|
<td>{{ object.get_width_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Height" %}</th>
|
||||||
|
<td>{{ object.u_height }}U</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Starting unit" %}</th>
|
||||||
|
<td>U{{ object.starting_unit }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Descending units" %}</th>
|
||||||
|
<td>{% checkmark object.desc_units %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Airflow" %}</th>
|
||||||
|
<td>{{ object.get_airflow_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Mounting depth" %}</th>
|
||||||
|
<td>{{ object.mounting_depth|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Deployment defaults" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Site" %}</th>
|
||||||
|
<td>{{ object.default_site|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Location" %}</th>
|
||||||
|
<td>{{ object.default_location|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Rack role" %}</th>
|
||||||
|
<td>{{ object.rack_role|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'inc/panels/comments.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">
|
||||||
|
{% trans "Elevation" %}
|
||||||
|
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
||||||
|
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-around flex-wrap"
|
||||||
|
id="concept-elevations"
|
||||||
|
data-reorder-url="{% url 'plugins:netbox_rack_concept:rackconcept_reorder' pk=object.pk %}"
|
||||||
|
data-editable="{% if perms.netbox_rack_concept.change_conceptdevice %}true{% else %}false{% endif %}"
|
||||||
|
data-starting-unit="{{ object.starting_unit }}"
|
||||||
|
data-u-height="{{ object.u_height }}"
|
||||||
|
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
|
||||||
|
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
|
||||||
|
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
|
||||||
|
</div>
|
||||||
|
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
|
||||||
|
</div>
|
||||||
|
{% if perms.netbox_rack_concept.add_conceptdevice %}
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Deployments" %}</h2>
|
||||||
|
{% render_table deployments_table %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascript %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'generic/object_children.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block bulk_extra_controls %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if perms.netbox_rack_concept.add_conceptdevice %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ request.path }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'rackconcept'))),
|
||||||
|
|
||||||
|
# Planned devices
|
||||||
|
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
|
||||||
|
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
|
||||||
|
|
||||||
|
# Entry point from a real rack
|
||||||
|
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
|
||||||
|
]
|
||||||
@@ -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)})
|
||||||
@@ -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/*",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user