Fix rear-face image-only labels, click-to-add units, cable concept devices
- Fix "images only" leaving plain labels visible on faces where a device has no image for that side (e.g. no rear_image set): the view selector now hides every plain label outright in that mode, not just the ones an image happens to sit on top of. - Remove the "Add planned device" button from the elevation card. Empty units are now click targets themselves - clicking one opens the add-device form pre-filled with that position and face, exactly like a real rack's elevation. Occupancy is computed per unit per face from the existing device blocks. - Add cabling for concept devices: ConceptComponent (interfaces, front/rear ports, power ports/outlets, console ports) and ConceptCable (a connection between two components, restricted to devices in the same concept). components.sync_components() populates a device's components from its device type's templates, including the front/rear port pass-through mapping copied from DeviceType.port_mappings - so a device with front/rear port templates (a patch panel being the common case) behaves exactly like a real one, without hardcoding anything role-specific. Placeholder devices without a device type get components added by hand. Full CRUD views, tables, filtersets, API endpoints and a "Connect" action wired in from the device detail page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,9 +32,41 @@ Dazu:
|
|||||||
Teilbreiten-Feature von `netbox_utilities`.
|
Teilbreiten-Feature von `netbox_utilities`.
|
||||||
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
|
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
|
||||||
* **Platzhalter ohne Gerätetyp** – reservierter Bauraum, nur mit Höhenangabe.
|
* **Platzhalter ohne Gerätetyp** – reservierter Bauraum, nur mit Höhenangabe.
|
||||||
|
* **Klick auf eine leere HE** öffnet direkt „Add planned device", vorausgefüllt mit
|
||||||
|
Position und Seite — genau wie bei einer echten Rack-Elevation. Den separaten
|
||||||
|
„Add planned device"-Knopf gibt es dafür nicht mehr.
|
||||||
|
* **Verkabelung von Konzept-Geräten** – Interfaces, Front-/Rear-Ports, Power-Ports/
|
||||||
|
-Outlets und Console-Ports lassen sich mit „Sync components" aus dem Gerätetyp
|
||||||
|
übernehmen (inkl. Front-↔Rear-Zuordnung von Patchpanels, 1:1 wie beim echten
|
||||||
|
Gerätetyp) und über „Connect" mit einem Concept Cable verbinden. Platzhalter ohne
|
||||||
|
Gerätetyp bekommen ihre Komponenten per Hand über „Add component".
|
||||||
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
|
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
|
||||||
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
||||||
|
|
||||||
|
## Verkabelung
|
||||||
|
|
||||||
|
Konzept-Geräte lassen sich verkabeln, aber **nicht** über NetBox' echtes `dcim.Cable` —
|
||||||
|
das kann nur echte `dcim`-Komponenten verbinden. Stattdessen bringt das Plugin ein
|
||||||
|
eigenes, schlankes Modellpaar mit:
|
||||||
|
|
||||||
|
* **ConceptComponent** — ein Interface, Front-/Rear-Port, Power-Port/-Outlet oder
|
||||||
|
Console-Port an einem geplanten Gerät. Über **„Sync components"** auf der
|
||||||
|
Geräte-Seite werden sie 1:1 aus den Component-Templates des Gerätetyps erzeugt
|
||||||
|
(Name, Label, Typ, Positionen) — wiederholbar, ohne Duplikate oder Datenverlust an
|
||||||
|
bereits verkabelten Komponenten. Ein Gerät ohne Gerätetyp (Platzhalter) hat keine
|
||||||
|
Templates zum Syncen; seine Komponenten legt man über **„Add component"** von Hand an.
|
||||||
|
* **ConceptCable** — verbindet zwei `ConceptComponent`s. Beide Enden müssen zu Geräten
|
||||||
|
im selben Rack-Konzept gehören; eine Komponente kann nur in einem Kabel gleichzeitig
|
||||||
|
stecken. Erreichbar über **„Connect"** an jeder unverkabelten Komponente.
|
||||||
|
|
||||||
|
**Patchpanels funktionieren genauso wie bei echten Geräten**, weil das Plugin exakt
|
||||||
|
dieselbe Front-↔Rear-Zuordnung ausliest, die NetBox selbst am Gerätetyp pflegt (über
|
||||||
|
`PortTemplateMapping`, dieselbe Tabelle, die auch echte `FrontPort`/`RearPort`-Objekte
|
||||||
|
verknüpft). Das hängt also nicht an der Rollenbezeichnung „Patchpanel", sondern schlicht
|
||||||
|
daran, ob der Gerätetyp Front-/Rear-Ports mit Zuordnung definiert — Kabel an der
|
||||||
|
Vorderseite eines synchronisierten Patchpanel-Gerätetyps zeigen auf der Detailseite der
|
||||||
|
Komponente sofort den passenden Rear-Port samt Position an.
|
||||||
|
|
||||||
## Zusammenspiel mit den anderen Plugins
|
## Zusammenspiel mit den anderen Plugins
|
||||||
|
|
||||||
**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat
|
**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat
|
||||||
@@ -172,6 +204,8 @@ Basis-Pfad: `/api/plugins/rack-concepts/`
|
|||||||
| `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) |
|
| `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) |
|
||||||
| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON |
|
| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON |
|
||||||
| `GET/POST` | `devices/` | Geplante Geräte |
|
| `GET/POST` | `devices/` | Geplante Geräte |
|
||||||
|
| `GET/POST` | `components/` | Komponenten der geplanten Geräte |
|
||||||
|
| `GET/POST` | `cables/` | Concept Cables |
|
||||||
|
|
||||||
Beispiel — zehn Racks aus einem Konzept, erst als Trockenlauf:
|
Beispiel — zehn Racks aus einem Konzept, erst als Trockenlauf:
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
|
|||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
|
|
||||||
from ..choices import ConceptStatusChoices
|
from ..choices import ConceptStatusChoices
|
||||||
from ..models import ConceptDeployment, ConceptDevice, RackConcept
|
from ..models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCableSerializer',
|
||||||
|
'ConceptComponentSerializer',
|
||||||
'ConceptDeploySerializer',
|
'ConceptDeploySerializer',
|
||||||
'ConceptDeploymentSerializer',
|
'ConceptDeploymentSerializer',
|
||||||
'ConceptDeviceSerializer',
|
'ConceptDeviceSerializer',
|
||||||
@@ -81,6 +83,38 @@ class ConceptDeploymentSerializer(serializers.ModelSerializer):
|
|||||||
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created')
|
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created')
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentSerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(
|
||||||
|
view_name='plugins-api:netbox_rack_concept-api:conceptcomponent-detail'
|
||||||
|
)
|
||||||
|
device = ConceptDeviceSerializer(nested=True)
|
||||||
|
is_connected = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptComponent
|
||||||
|
fields = (
|
||||||
|
'id', 'url', 'display_url', 'display', 'device', 'component_type', 'name', 'label', 'port_type',
|
||||||
|
'positions', 'color', 'description', 'is_connected', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
|
)
|
||||||
|
brief_fields = ('id', 'url', 'display', 'name', 'component_type', 'device')
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableSerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(
|
||||||
|
view_name='plugins-api:netbox_rack_concept-api:conceptcable-detail'
|
||||||
|
)
|
||||||
|
termination_a = ConceptComponentSerializer(nested=True)
|
||||||
|
termination_b = ConceptComponentSerializer(nested=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptCable
|
||||||
|
fields = (
|
||||||
|
'id', 'url', 'display_url', 'display', 'termination_a', 'termination_b', 'status', 'type', 'color',
|
||||||
|
'label', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
|
)
|
||||||
|
brief_fields = ('id', 'url', 'display', 'label', 'termination_a', 'termination_b')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Action payloads
|
# Action payloads
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ app_name = 'netbox_rack_concept-api'
|
|||||||
router = NetBoxRouter()
|
router = NetBoxRouter()
|
||||||
router.register('concepts', views.RackConceptViewSet)
|
router.register('concepts', views.RackConceptViewSet)
|
||||||
router.register('devices', views.ConceptDeviceViewSet)
|
router.register('devices', views.ConceptDeviceViewSet)
|
||||||
|
router.register('components', views.ConceptComponentViewSet)
|
||||||
|
router.register('cables', views.ConceptCableViewSet)
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from netbox.api.viewsets import NetBoxModelViewSet
|
|||||||
from .. import filtersets
|
from .. import filtersets
|
||||||
from .. import deploy as deploy_utils
|
from .. import deploy as deploy_utils
|
||||||
from ..elevation import get_elevations
|
from ..elevation import get_elevations
|
||||||
from ..models import ConceptDevice, RackConcept
|
from ..models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer,
|
ConceptCableSerializer, ConceptComponentSerializer, ConceptDeploySerializer, ConceptDeviceSerializer,
|
||||||
RackConceptSerializer, RackCopySerializer,
|
MergeConceptsSerializer, RackConceptCloneSerializer, RackConceptSerializer, RackCopySerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -204,3 +204,17 @@ class ConceptDeviceViewSet(NetBoxModelViewSet):
|
|||||||
)
|
)
|
||||||
serializer_class = ConceptDeviceSerializer
|
serializer_class = ConceptDeviceSerializer
|
||||||
filterset_class = filtersets.ConceptDeviceFilterSet
|
filterset_class = filtersets.ConceptDeviceFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = ConceptComponent.objects.prefetch_related('tags').select_related('device', 'device__concept')
|
||||||
|
serializer_class = ConceptComponentSerializer
|
||||||
|
filterset_class = filtersets.ConceptComponentFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = ConceptCable.objects.prefetch_related('tags').select_related(
|
||||||
|
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
|
||||||
|
)
|
||||||
|
serializer_class = ConceptCableSerializer
|
||||||
|
filterset_class = filtersets.ConceptCableFilterSet
|
||||||
|
|||||||
@@ -59,3 +59,32 @@ class WidthFractionChoices(ChoiceSet):
|
|||||||
# Least common multiple of all supported denominators. The elevation is laid out
|
# Least common multiple of all supported denominators. The elevation is laid out
|
||||||
# on this many lanes so that every fraction lands on an integer lane boundary.
|
# on this many lanes so that every fraction lands on an integer lane boundary.
|
||||||
LANE_COUNT = 12
|
LANE_COUNT = 12
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentTypeChoices(ChoiceSet):
|
||||||
|
"""
|
||||||
|
The kinds of pluggable component a planned device can carry. One shared model
|
||||||
|
covers all of them (see ConceptComponent) rather than mirroring dcim's seven
|
||||||
|
separate component models - a blueprint only needs enough detail to say what a
|
||||||
|
cable plugs into, not full per-type validation.
|
||||||
|
"""
|
||||||
|
TYPE_INTERFACE = 'interface'
|
||||||
|
TYPE_FRONT_PORT = 'front-port'
|
||||||
|
TYPE_REAR_PORT = 'rear-port'
|
||||||
|
TYPE_CONSOLE_PORT = 'console-port'
|
||||||
|
TYPE_CONSOLE_SERVER_PORT = 'console-server-port'
|
||||||
|
TYPE_POWER_PORT = 'power-port'
|
||||||
|
TYPE_POWER_OUTLET = 'power-outlet'
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(TYPE_INTERFACE, 'Interface', 'blue'),
|
||||||
|
(TYPE_FRONT_PORT, 'Front port', 'orange'),
|
||||||
|
(TYPE_REAR_PORT, 'Rear port', 'yellow'),
|
||||||
|
(TYPE_CONSOLE_PORT, 'Console port', 'teal'),
|
||||||
|
(TYPE_CONSOLE_SERVER_PORT, 'Console server port', 'cyan'),
|
||||||
|
(TYPE_POWER_PORT, 'Power port', 'red'),
|
||||||
|
(TYPE_POWER_OUTLET, 'Power outlet', 'pink'),
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Component types that are pass-through ports and can appear in a ConceptPortMapping.
|
||||||
|
PATCH_TYPES = (TYPE_FRONT_PORT, TYPE_REAR_PORT)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Populate a planned device's components from its device type's templates - interfaces,
|
||||||
|
ports, and the front/rear pass-through mapping - so a concept device with a device type
|
||||||
|
can be cabled exactly like a real one. A device type's own component templates are the
|
||||||
|
single source of truth here; nothing about this is specific to any particular role, so
|
||||||
|
a "Patchpanel"-role device gets correct front/rear pass-through behaviour simply because
|
||||||
|
that's what its device type's templates already describe - the same as a real device.
|
||||||
|
|
||||||
|
Placeholder devices without a device type have no templates to sync from; their
|
||||||
|
components, if any, have to be added by hand.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .choices import ConceptComponentTypeChoices
|
||||||
|
from .models import ConceptComponent, ConceptPortMapping
|
||||||
|
|
||||||
|
__all__ = ('sync_components',)
|
||||||
|
|
||||||
|
# (our component type, DeviceType's reverse accessor for that template queryset)
|
||||||
|
TEMPLATE_SOURCES = (
|
||||||
|
(ConceptComponentTypeChoices.TYPE_INTERFACE, 'interfacetemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_CONSOLE_PORT, 'consoleporttemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT, 'consoleserverporttemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_POWER_PORT, 'powerporttemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_POWER_OUTLET, 'poweroutlettemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_FRONT_PORT, 'frontporttemplates'),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_REAR_PORT, 'rearporttemplates'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def sync_components(concept_device):
|
||||||
|
"""
|
||||||
|
Create whichever of the device type's components don't already exist on this
|
||||||
|
device (matched by name), and mirror its front/rear port mapping. Existing
|
||||||
|
components - and any cables already attached to them - are left untouched, so this
|
||||||
|
is safe to re-run after adding extra components by hand or after the device type
|
||||||
|
changes. Returns (components_created, mappings_created).
|
||||||
|
"""
|
||||||
|
device_type = concept_device.device_type
|
||||||
|
if device_type is None:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
existing = {component.name: component for component in concept_device.components.all()}
|
||||||
|
created = 0
|
||||||
|
|
||||||
|
for component_type, accessor in TEMPLATE_SOURCES:
|
||||||
|
for template in getattr(device_type, accessor).all():
|
||||||
|
if template.name in existing:
|
||||||
|
continue
|
||||||
|
component = ConceptComponent.objects.create(
|
||||||
|
device=concept_device,
|
||||||
|
component_type=component_type,
|
||||||
|
name=template.name,
|
||||||
|
label=template.label,
|
||||||
|
port_type=getattr(template, 'type', '') or '',
|
||||||
|
positions=getattr(template, 'positions', 1) or 1,
|
||||||
|
color=getattr(template, 'color', '') or '',
|
||||||
|
)
|
||||||
|
existing[component.name] = component
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
mappings_created = 0
|
||||||
|
for mapping in device_type.port_mappings.select_related('front_port', 'rear_port').all():
|
||||||
|
front = existing.get(mapping.front_port.name)
|
||||||
|
rear = existing.get(mapping.rear_port.name)
|
||||||
|
if not (front and rear):
|
||||||
|
# Shouldn't happen - the templates were just synced above - but a
|
||||||
|
# partially-synced device from before a device type change could hit this.
|
||||||
|
continue
|
||||||
|
_, was_created = ConceptPortMapping.objects.get_or_create(
|
||||||
|
front_component=front,
|
||||||
|
front_position=mapping.front_port_position,
|
||||||
|
defaults={'rear_component': rear, 'rear_position': mapping.rear_port_position}
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
mappings_created += 1
|
||||||
|
|
||||||
|
return created, mappings_created
|
||||||
@@ -38,10 +38,15 @@ def foreground_color(bg_color):
|
|||||||
class ElevationUnit:
|
class ElevationUnit:
|
||||||
"""One labelled rack unit row."""
|
"""One labelled rack unit row."""
|
||||||
|
|
||||||
def __init__(self, number, y):
|
def __init__(self, number, y, occupied=False):
|
||||||
self.number = number
|
self.number = number
|
||||||
self.y = y
|
self.y = y
|
||||||
self.height = UNIT_HEIGHT
|
self.height = UNIT_HEIGHT
|
||||||
|
# Whether *anything* renders over this unit on this face - including a
|
||||||
|
# reduced-opacity "opposite" block for a full-depth device. A unit only counts
|
||||||
|
# as free (and thus click-to-add) when nothing at all sits on it, which folds
|
||||||
|
# back to plain occupied/free for full-width-only setups, same as core NetBox.
|
||||||
|
self.occupied = occupied
|
||||||
|
|
||||||
|
|
||||||
class ElevationBlock:
|
class ElevationBlock:
|
||||||
@@ -110,11 +115,14 @@ class Elevation:
|
|||||||
self.legend_width = LEGEND_WIDTH
|
self.legend_width = LEGEND_WIDTH
|
||||||
self.rack_width = RACK_WIDTH
|
self.rack_width = RACK_WIDTH
|
||||||
self.rack_x = RACK_X
|
self.rack_x = RACK_X
|
||||||
|
self.rack_right = RACK_X + RACK_WIDTH
|
||||||
self.width = RACK_X + RACK_WIDTH + MARGIN
|
self.width = RACK_X + RACK_WIDTH + MARGIN
|
||||||
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
|
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
|
||||||
|
|
||||||
self._build_units()
|
# Blocks first: unit occupancy (used to decide which units are click-to-add)
|
||||||
|
# depends on where they land.
|
||||||
self._build_blocks(devices if devices is not None else concept.devices.all())
|
self._build_blocks(devices if devices is not None else concept.devices.all())
|
||||||
|
self._build_units()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def face_label(self):
|
def face_label(self):
|
||||||
@@ -134,7 +142,11 @@ class Elevation:
|
|||||||
def _build_units(self):
|
def _build_units(self):
|
||||||
for number in self.concept.units:
|
for number in self.concept.units:
|
||||||
top = number if self.concept.desc_units else number + 1
|
top = number if self.concept.desc_units else number + 1
|
||||||
self.units.append(ElevationUnit(number, self._y_for(Decimal(top))))
|
occupied = any(
|
||||||
|
block.device.unit_span and block.device.unit_span[0] <= number < block.device.unit_span[1]
|
||||||
|
for block in self.blocks
|
||||||
|
)
|
||||||
|
self.units.append(ElevationUnit(number, self._y_for(Decimal(top)), occupied))
|
||||||
|
|
||||||
def _build_blocks(self, devices):
|
def _build_blocks(self, devices):
|
||||||
for device in devices:
|
for device in devices:
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import django_filters
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from dcim.choices import CableTypeChoices, LinkStatusChoices
|
||||||
from dcim.models import DeviceRole, DeviceType, Rack, RackType, Site
|
from dcim.models import DeviceRole, DeviceType, Rack, RackType, Site
|
||||||
from netbox.filtersets import NetBoxModelFilterSet
|
from netbox.filtersets import NetBoxModelFilterSet
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices
|
||||||
from .models import ConceptDevice, RackConcept
|
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCableFilterSet',
|
||||||
|
'ConceptComponentFilterSet',
|
||||||
'ConceptDeviceFilterSet',
|
'ConceptDeviceFilterSet',
|
||||||
'RackConceptFilterSet',
|
'RackConceptFilterSet',
|
||||||
)
|
)
|
||||||
@@ -115,3 +118,71 @@ class ConceptDeviceFilterSet(NetBoxModelFilterSet):
|
|||||||
|
|
||||||
def filter_racked(self, queryset, name, value):
|
def filter_racked(self, queryset, name, value):
|
||||||
return queryset.filter(position__isnull=not value)
|
return queryset.filter(position__isnull=not value)
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentFilterSet(NetBoxModelFilterSet):
|
||||||
|
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ConceptDevice.objects.all(),
|
||||||
|
label=_('Concept device (ID)')
|
||||||
|
)
|
||||||
|
concept_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='device__concept',
|
||||||
|
queryset=RackConcept.objects.all(),
|
||||||
|
label=_('Rack concept (ID)')
|
||||||
|
)
|
||||||
|
component_type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=ConceptComponentTypeChoices
|
||||||
|
)
|
||||||
|
connected = django_filters.BooleanFilter(
|
||||||
|
method='filter_connected',
|
||||||
|
label=_('Has a cable attached')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptComponent
|
||||||
|
fields = ('id', 'name', 'label', 'component_type', 'port_type', 'positions', 'description')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(label__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_connected(self, queryset, name, value):
|
||||||
|
# A component is connected once it's referenced by a cable as either end.
|
||||||
|
connected = (
|
||||||
|
Q(pk__in=ConceptCable.objects.values_list('termination_a_id', flat=True)) |
|
||||||
|
Q(pk__in=ConceptCable.objects.values_list('termination_b_id', flat=True))
|
||||||
|
)
|
||||||
|
return queryset.filter(connected) if value else queryset.exclude(connected)
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableFilterSet(NetBoxModelFilterSet):
|
||||||
|
concept_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='termination_a__device__concept',
|
||||||
|
queryset=RackConcept.objects.all(),
|
||||||
|
label=_('Rack concept (ID)')
|
||||||
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=LinkStatusChoices
|
||||||
|
)
|
||||||
|
type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=CableTypeChoices
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptCable
|
||||||
|
fields = ('id', 'status', 'type', 'color', 'label', 'description')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(label__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
|
Q(termination_a__name__icontains=value) |
|
||||||
|
Q(termination_b__name__icontains=value)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
|
from dcim.choices import CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, RackAirflowChoices, \
|
||||||
|
RackFormFactorChoices, RackWidthChoices
|
||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
|
||||||
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
|
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@@ -12,10 +13,15 @@ from utilities.forms.fields import (
|
|||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||||
|
|
||||||
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
|
from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, \
|
||||||
from .models import ConceptDevice, RackConcept
|
WidthFractionChoices
|
||||||
|
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCableForm',
|
||||||
|
'ConceptCableFilterForm',
|
||||||
|
'ConceptComponentForm',
|
||||||
|
'ConceptComponentFilterForm',
|
||||||
'ConceptDeployForm',
|
'ConceptDeployForm',
|
||||||
'ConceptDeviceBulkEditForm',
|
'ConceptDeviceBulkEditForm',
|
||||||
'ConceptDeviceFilterForm',
|
'ConceptDeviceFilterForm',
|
||||||
@@ -631,3 +637,125 @@ class ConceptDeployForm(forms.Form):
|
|||||||
})
|
})
|
||||||
cleaned['rack_names'] = names
|
cleaned['rack_names'] = names
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Cabling
|
||||||
|
#
|
||||||
|
|
||||||
|
class ConceptComponentForm(NetBoxModelForm):
|
||||||
|
device = DynamicModelChoiceField(
|
||||||
|
queryset=ConceptDevice.objects.all(),
|
||||||
|
label=_('Concept device')
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('device', 'component_type', 'name', 'label', 'description', 'tags', name=_('Component')),
|
||||||
|
FieldSet('port_type', 'positions', 'color', name=_('Attributes')),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptComponent
|
||||||
|
fields = (
|
||||||
|
'device', 'component_type', 'name', 'label', 'port_type', 'positions', 'color', 'description', 'tags',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = ConceptComponent
|
||||||
|
|
||||||
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=ConceptDevice.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Concept device')
|
||||||
|
)
|
||||||
|
concept_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=RackConcept.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Rack concept')
|
||||||
|
)
|
||||||
|
component_type = forms.MultipleChoiceField(choices=ConceptComponentTypeChoices, required=False)
|
||||||
|
connected = forms.NullBooleanField(required=False, widget=forms.Select(choices=(
|
||||||
|
('', '---------'), ('true', _('Connected')), ('false', _('Not connected')),
|
||||||
|
)))
|
||||||
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
|
FieldSet('device_id', 'concept_id', 'component_type', 'connected', name=_('Attributes')),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableForm(NetBoxModelForm):
|
||||||
|
"""
|
||||||
|
Mirrors the shape of NetBox's own cable-connect form: pick a device, then a
|
||||||
|
component on it, for each end.
|
||||||
|
"""
|
||||||
|
concept = DynamicModelChoiceField(
|
||||||
|
queryset=RackConcept.objects.all(),
|
||||||
|
label=_('Rack concept'),
|
||||||
|
help_text=_('Both ends must belong to devices in this concept')
|
||||||
|
)
|
||||||
|
termination_a_device = DynamicModelChoiceField(
|
||||||
|
queryset=ConceptDevice.objects.all(),
|
||||||
|
label=_('Device A'),
|
||||||
|
query_params={'concept_id': '$concept'}
|
||||||
|
)
|
||||||
|
termination_a = DynamicModelChoiceField(
|
||||||
|
queryset=ConceptComponent.objects.all(),
|
||||||
|
label=_('Component A'),
|
||||||
|
query_params={'device_id': '$termination_a_device', 'connected': 'false'}
|
||||||
|
)
|
||||||
|
termination_b_device = DynamicModelChoiceField(
|
||||||
|
queryset=ConceptDevice.objects.all(),
|
||||||
|
label=_('Device B'),
|
||||||
|
query_params={'concept_id': '$concept'}
|
||||||
|
)
|
||||||
|
termination_b = DynamicModelChoiceField(
|
||||||
|
queryset=ConceptComponent.objects.all(),
|
||||||
|
label=_('Component B'),
|
||||||
|
query_params={'device_id': '$termination_b_device', 'connected': 'false'}
|
||||||
|
)
|
||||||
|
comments = CommentField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('concept', name=_('Scope')),
|
||||||
|
FieldSet('termination_a_device', 'termination_a', name=_('Side A')),
|
||||||
|
FieldSet('termination_b_device', 'termination_b', name=_('Side B')),
|
||||||
|
FieldSet('status', 'type', 'color', 'label', 'description', 'tags', name=_('Cable')),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConceptCable
|
||||||
|
fields = (
|
||||||
|
'termination_a', 'termination_b', 'status', 'type', 'color', 'label', 'description', 'comments', 'tags',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# concept/termination_*_device are UI-only helpers for the cascading selects
|
||||||
|
# above, not model fields - when editing an existing cable, seed them from the
|
||||||
|
# components already chosen so the dropdowns show the right device on load.
|
||||||
|
instance = self.instance
|
||||||
|
if instance and instance.pk:
|
||||||
|
self.fields['concept'].initial = instance.termination_a.device.concept_id
|
||||||
|
self.fields['termination_a_device'].initial = instance.termination_a.device_id
|
||||||
|
self.fields['termination_b_device'].initial = instance.termination_b.device_id
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = ConceptCable
|
||||||
|
|
||||||
|
concept_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=RackConcept.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Rack concept')
|
||||||
|
)
|
||||||
|
status = forms.MultipleChoiceField(choices=LinkStatusChoices, required=False)
|
||||||
|
type = forms.MultipleChoiceField(choices=CableTypeChoices, required=False)
|
||||||
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
|
FieldSet('concept_id', 'status', 'type', name=_('Attributes')),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import taggit.managers
|
||||||
|
import utilities.fields
|
||||||
|
import utilities.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '__latest__'),
|
||||||
|
('netbox_rack_concept', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConceptComponent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('custom_field_data', models.JSONField(
|
||||||
|
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
|
||||||
|
)),
|
||||||
|
('component_type', models.CharField(max_length=50)),
|
||||||
|
('name', models.CharField(max_length=64)),
|
||||||
|
('label', models.CharField(blank=True, max_length=64)),
|
||||||
|
('port_type', models.CharField(blank=True, max_length=50)),
|
||||||
|
('positions', models.PositiveSmallIntegerField(default=1, validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
])),
|
||||||
|
('color', utilities.fields.ColorField(blank=True)),
|
||||||
|
('description', models.CharField(blank=True, max_length=200)),
|
||||||
|
('device', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, related_name='components',
|
||||||
|
to='netbox_rack_concept.conceptdevice'
|
||||||
|
)),
|
||||||
|
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'concept component',
|
||||||
|
'verbose_name_plural': 'concept components',
|
||||||
|
'ordering': ('device', 'component_type', 'name'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='conceptcomponent',
|
||||||
|
constraint=models.UniqueConstraint(fields=('device', 'name'), name='nbrc_component_unique_device_name'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConceptPortMapping',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('front_position', models.PositiveSmallIntegerField(default=1)),
|
||||||
|
('rear_position', models.PositiveSmallIntegerField(default=1)),
|
||||||
|
('front_component', models.ForeignKey(
|
||||||
|
limit_choices_to={'component_type': 'front-port'},
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, related_name='front_mappings',
|
||||||
|
to='netbox_rack_concept.conceptcomponent'
|
||||||
|
)),
|
||||||
|
('rear_component', models.ForeignKey(
|
||||||
|
limit_choices_to={'component_type': 'rear-port'},
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, related_name='rear_mappings',
|
||||||
|
to='netbox_rack_concept.conceptcomponent'
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='conceptportmapping',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('front_component', 'front_position'), name='nbrc_portmapping_unique_front'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='conceptportmapping',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('rear_component', 'rear_position'), name='nbrc_portmapping_unique_rear'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConceptCable',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('custom_field_data', models.JSONField(
|
||||||
|
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder
|
||||||
|
)),
|
||||||
|
('status', models.CharField(default='connected', max_length=50)),
|
||||||
|
('type', models.CharField(blank=True, max_length=50)),
|
||||||
|
('color', utilities.fields.ColorField(blank=True)),
|
||||||
|
('label', models.CharField(blank=True, max_length=100)),
|
||||||
|
('description', models.CharField(blank=True, max_length=200)),
|
||||||
|
('comments', models.TextField(blank=True)),
|
||||||
|
('termination_a', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, related_name='+',
|
||||||
|
to='netbox_rack_concept.conceptcomponent'
|
||||||
|
)),
|
||||||
|
('termination_b', models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, related_name='+',
|
||||||
|
to='netbox_rack_concept.conceptcomponent'
|
||||||
|
)),
|
||||||
|
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'concept cable',
|
||||||
|
'verbose_name_plural': 'concept cables',
|
||||||
|
'ordering': ('pk',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -7,15 +7,22 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.choices import (
|
from dcim.choices import (
|
||||||
DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices,
|
CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, RackAirflowChoices, RackDimensionUnitChoices,
|
||||||
|
RackFormFactorChoices, RackWidthChoices,
|
||||||
)
|
)
|
||||||
from netbox.models import NetBoxModel
|
from netbox.models import NetBoxModel
|
||||||
|
from utilities.fields import ColorField
|
||||||
|
|
||||||
from .choices import LANE_COUNT, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
|
from .choices import (
|
||||||
|
LANE_COUNT, ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCable',
|
||||||
|
'ConceptComponent',
|
||||||
'ConceptDevice',
|
'ConceptDevice',
|
||||||
'ConceptDeployment',
|
'ConceptDeployment',
|
||||||
|
'ConceptPortMapping',
|
||||||
'RackConcept',
|
'RackConcept',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -459,3 +466,216 @@ class ConceptDeployment(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.concept} → {self.rack_name}'
|
return f'{self.concept} → {self.rack_name}'
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponent(NetBoxModel):
|
||||||
|
"""
|
||||||
|
A pluggable point on a planned device - an interface, a front/rear port, a power
|
||||||
|
port/outlet, or a console port. One shared model covers every kind rather than
|
||||||
|
mirroring dcim's seven separate component models, since a blueprint only needs
|
||||||
|
enough detail to say what a cable plugs into, not full per-type validation.
|
||||||
|
|
||||||
|
Populated either by syncing a device's component templates (see components.py) or
|
||||||
|
added by hand, which is the only option for placeholder devices without a device
|
||||||
|
type.
|
||||||
|
"""
|
||||||
|
device = models.ForeignKey(
|
||||||
|
to=ConceptDevice,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='components'
|
||||||
|
)
|
||||||
|
component_type = models.CharField(
|
||||||
|
verbose_name=_('component type'),
|
||||||
|
max_length=50,
|
||||||
|
choices=ConceptComponentTypeChoices
|
||||||
|
)
|
||||||
|
name = models.CharField(
|
||||||
|
verbose_name=_('name'),
|
||||||
|
max_length=64
|
||||||
|
)
|
||||||
|
label = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
port_type = models.CharField(
|
||||||
|
verbose_name=_('type'),
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text=_('Free text, e.g. "1000base-t" or "lc" - for reference only, not validated')
|
||||||
|
)
|
||||||
|
positions = models.PositiveSmallIntegerField(
|
||||||
|
verbose_name=_('positions'),
|
||||||
|
default=1,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(1024)],
|
||||||
|
help_text=_('Front/rear ports only')
|
||||||
|
)
|
||||||
|
color = ColorField(blank=True)
|
||||||
|
description = models.CharField(max_length=200, blank=True)
|
||||||
|
|
||||||
|
clone_fields = ('device', 'component_type', 'port_type', 'positions')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('device', 'component_type', 'name')
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(fields=('device', 'name'), name='nbrc_component_unique_device_name'),
|
||||||
|
)
|
||||||
|
verbose_name = _('concept component')
|
||||||
|
verbose_name_plural = _('concept components')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('plugins:netbox_rack_concept:conceptcomponent', args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pass_through(self):
|
||||||
|
return self.component_type in ConceptComponentTypeChoices.PATCH_TYPES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cable(self):
|
||||||
|
return ConceptCable.objects.filter(
|
||||||
|
models.Q(termination_a=self) | models.Q(termination_b=self)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self):
|
||||||
|
return self.cable is not None
|
||||||
|
|
||||||
|
def get_paired_components(self):
|
||||||
|
"""
|
||||||
|
For a front or rear port, the port(s) on the other side it passes through to -
|
||||||
|
real patch panel behaviour: a signal entering a front port continues out
|
||||||
|
whichever rear port (and position) it's mapped to, and vice versa.
|
||||||
|
Returns a list of (component, own_position, other_position) tuples.
|
||||||
|
"""
|
||||||
|
if self.component_type == ConceptComponentTypeChoices.TYPE_FRONT_PORT:
|
||||||
|
return [
|
||||||
|
(mapping.rear_component, mapping.front_position, mapping.rear_position)
|
||||||
|
for mapping in self.front_mappings.select_related('rear_component')
|
||||||
|
]
|
||||||
|
if self.component_type == ConceptComponentTypeChoices.TYPE_REAR_PORT:
|
||||||
|
return [
|
||||||
|
(mapping.front_component, mapping.rear_position, mapping.front_position)
|
||||||
|
for mapping in self.rear_mappings.select_related('front_component')
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.positions > 1 and not self.is_pass_through:
|
||||||
|
raise ValidationError({
|
||||||
|
'positions': _('Only front and rear ports can have more than one position.')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptPortMapping(models.Model):
|
||||||
|
"""
|
||||||
|
Maps a front port position to a rear port position, mirroring dcim's PortMapping.
|
||||||
|
Not a NetBoxModel: like its real-world counterpart, this is wiring metadata, not a
|
||||||
|
user-facing object with its own detail page.
|
||||||
|
"""
|
||||||
|
front_component = models.ForeignKey(
|
||||||
|
to=ConceptComponent,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='front_mappings',
|
||||||
|
limit_choices_to={'component_type': ConceptComponentTypeChoices.TYPE_FRONT_PORT}
|
||||||
|
)
|
||||||
|
rear_component = models.ForeignKey(
|
||||||
|
to=ConceptComponent,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='rear_mappings',
|
||||||
|
limit_choices_to={'component_type': ConceptComponentTypeChoices.TYPE_REAR_PORT}
|
||||||
|
)
|
||||||
|
front_position = models.PositiveSmallIntegerField(default=1)
|
||||||
|
rear_position = models.PositiveSmallIntegerField(default=1)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('front_component', 'front_position'), name='nbrc_portmapping_unique_front'
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('rear_component', 'rear_position'), name='nbrc_portmapping_unique_rear'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.front_component}:{self.front_position} → {self.rear_component}:{self.rear_position}'
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.front_component_id and self.rear_component_id:
|
||||||
|
if self.front_component.device_id != self.rear_component.device_id:
|
||||||
|
raise ValidationError({
|
||||||
|
'rear_component': _('Front and rear port must belong to the same device.')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCable(NetBoxModel):
|
||||||
|
"""A planned connection between two components, on planned devices only."""
|
||||||
|
termination_a = models.ForeignKey(
|
||||||
|
to=ConceptComponent,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='+'
|
||||||
|
)
|
||||||
|
termination_b = models.ForeignKey(
|
||||||
|
to=ConceptComponent,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='+'
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=LinkStatusChoices,
|
||||||
|
default=LinkStatusChoices.STATUS_CONNECTED
|
||||||
|
)
|
||||||
|
type = models.CharField(
|
||||||
|
verbose_name=_('type'),
|
||||||
|
max_length=50,
|
||||||
|
choices=CableTypeChoices,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
color = ColorField(blank=True)
|
||||||
|
label = models.CharField(max_length=100, blank=True)
|
||||||
|
description = models.CharField(max_length=200, blank=True)
|
||||||
|
comments = models.TextField(blank=True)
|
||||||
|
|
||||||
|
clone_fields = ('status', 'type', 'color')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('pk',)
|
||||||
|
verbose_name = _('concept cable')
|
||||||
|
verbose_name_plural = _('concept cables')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.label or f'#{self.pk}'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('plugins:netbox_rack_concept:conceptcable', args=[self.pk])
|
||||||
|
|
||||||
|
def get_status_color(self):
|
||||||
|
return LinkStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if not (self.termination_a_id and self.termination_b_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.termination_a_id == self.termination_b_id:
|
||||||
|
raise ValidationError(_('A cable cannot connect a component to itself.'))
|
||||||
|
|
||||||
|
if self.termination_a.device.concept_id != self.termination_b.device.concept_id:
|
||||||
|
raise ValidationError(
|
||||||
|
_('Both ends of a cable must belong to devices in the same rack concept.')
|
||||||
|
)
|
||||||
|
|
||||||
|
for field_name, endpoint in (('termination_a', self.termination_a), ('termination_b', self.termination_b)):
|
||||||
|
conflict = ConceptCable.objects.exclude(pk=self.pk).filter(
|
||||||
|
models.Q(termination_a=endpoint) | models.Q(termination_b=endpoint)
|
||||||
|
).first()
|
||||||
|
if conflict:
|
||||||
|
raise ValidationError({
|
||||||
|
field_name: _('%(component)s is already connected (%(cable)s).') % {
|
||||||
|
'component': endpoint, 'cable': conflict
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ device_buttons = [
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
cable_buttons = [
|
||||||
|
PluginMenuButton(
|
||||||
|
link='plugins:netbox_rack_concept:conceptcable_add',
|
||||||
|
title=_('Add'),
|
||||||
|
icon_class='mdi mdi-plus-thick',
|
||||||
|
color=ButtonColorChoices.GREEN,
|
||||||
|
permissions=['netbox_rack_concept.add_conceptcable']
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
menu = PluginMenu(
|
menu = PluginMenu(
|
||||||
label=_('Rack Concepts'),
|
label=_('Rack Concepts'),
|
||||||
icon_class='mdi mdi-view-grid-plus-outline',
|
icon_class='mdi mdi-view-grid-plus-outline',
|
||||||
@@ -62,5 +72,18 @@ menu = PluginMenu(
|
|||||||
buttons=device_buttons
|
buttons=device_buttons
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
(_('Cabling'), (
|
||||||
|
PluginMenuItem(
|
||||||
|
link='plugins:netbox_rack_concept:conceptcomponent_list',
|
||||||
|
link_text=_('Components'),
|
||||||
|
permissions=['netbox_rack_concept.view_conceptcomponent'],
|
||||||
|
),
|
||||||
|
PluginMenuItem(
|
||||||
|
link='plugins:netbox_rack_concept:conceptcable_list',
|
||||||
|
link_text=_('Cables'),
|
||||||
|
permissions=['netbox_rack_concept.view_conceptcable'],
|
||||||
|
buttons=cable_buttons
|
||||||
|
),
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from netbox.search import SearchIndex, register_search
|
from netbox.search import SearchIndex, register_search
|
||||||
|
|
||||||
from .models import ConceptDevice, RackConcept
|
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@@ -25,3 +25,25 @@ class ConceptDeviceIndex(SearchIndex):
|
|||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
display_attrs = ('concept', 'status', 'device_type', 'role', 'description')
|
display_attrs = ('concept', 'status', 'device_type', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class ConceptComponentIndex(SearchIndex):
|
||||||
|
model = ConceptComponent
|
||||||
|
fields = (
|
||||||
|
('name', 100),
|
||||||
|
('label', 110),
|
||||||
|
('description', 500),
|
||||||
|
)
|
||||||
|
display_attrs = ('device', 'component_type', 'port_type')
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
|
class ConceptCableIndex(SearchIndex):
|
||||||
|
model = ConceptCable
|
||||||
|
fields = (
|
||||||
|
('label', 100),
|
||||||
|
('description', 500),
|
||||||
|
('comments', 5000),
|
||||||
|
)
|
||||||
|
display_attrs = ('status', 'type', 'description')
|
||||||
|
|||||||
@@ -22,6 +22,27 @@
|
|||||||
fill: var(--tblr-primary-lt, #d7e7fb);
|
fill: var(--tblr-primary-lt, #d7e7fb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.concept-elevation .unit-slot.free {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-elevation .unit-slot.free:hover {
|
||||||
|
fill: var(--tblr-primary-lt, #d7e7fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "+ Add device" hint, shown only while hovering an empty unit - same trick as
|
||||||
|
NetBox's own rack elevation. */
|
||||||
|
.concept-elevation .add-device-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
fill: var(--tblr-primary, #206bc4);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concept-elevation a:hover .add-device-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.concept-elevation .unit-label {
|
.concept-elevation .unit-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
fill: var(--tblr-secondary, #808f9c);
|
fill: var(--tblr-secondary, #808f9c);
|
||||||
|
|||||||
@@ -18,12 +18,21 @@
|
|||||||
function applyView(view) {
|
function applyView(view) {
|
||||||
const showImages = view !== 'labels-only';
|
const showImages = view !== 'labels-only';
|
||||||
const showImageLabels = view === 'images-and-labels';
|
const showImageLabels = view === 'images-and-labels';
|
||||||
|
// "Images only" hides every plain label outright, not just the ones sitting
|
||||||
|
// under an image: a device without an image for this face has nothing to
|
||||||
|
// visually cover its label otherwise, which would leave text showing (e.g. on
|
||||||
|
// a rear face where only the front image is set) even though the user picked
|
||||||
|
// "images only".
|
||||||
|
const hidePlainLabels = view === 'images-only';
|
||||||
container.querySelectorAll('image.device-image').forEach(function (el) {
|
container.querySelectorAll('image.device-image').forEach(function (el) {
|
||||||
el.classList.toggle('hidden', !showImages);
|
el.classList.toggle('hidden', !showImages);
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.device-image-label').forEach(function (el) {
|
container.querySelectorAll('.device-image-label').forEach(function (el) {
|
||||||
el.classList.toggle('hidden', !showImageLabels);
|
el.classList.toggle('hidden', !showImageLabels);
|
||||||
});
|
});
|
||||||
|
container.querySelectorAll('.device-label').forEach(function (el) {
|
||||||
|
el.classList.toggle('hidden', hidePlainLabels);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let initial = 'images-and-labels';
|
let initial = 'images-and-labels';
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
from .models import ConceptDeployment, ConceptDevice, RackConcept
|
from .models import ConceptCable, ConceptComponent, ConceptDeployment, ConceptDevice, RackConcept
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCableTable',
|
||||||
|
'ConceptComponentTable',
|
||||||
'ConceptDeploymentTable',
|
'ConceptDeploymentTable',
|
||||||
'ConceptDeviceElevationTable',
|
'ConceptDeviceElevationTable',
|
||||||
'ConceptDeviceTable',
|
'ConceptDeviceTable',
|
||||||
@@ -105,3 +107,66 @@ class ConceptDeploymentTable(tables.Table):
|
|||||||
fields = ('rack_name', 'rack', 'device_count', 'user', 'created')
|
fields = ('rack_name', 'rack', 'device_count', 'user', 'created')
|
||||||
attrs = {'class': 'table table-hover object-list'}
|
attrs = {'class': 'table table-hover object-list'}
|
||||||
empty_text = _('This concept has not been deployed yet.')
|
empty_text = _('This concept has not been deployed yet.')
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptComponentTable(NetBoxTable):
|
||||||
|
name = tables.Column(linkify=True)
|
||||||
|
device = tables.Column(linkify=True, verbose_name=_('Device'))
|
||||||
|
concept = tables.Column(accessor='device__concept', linkify=True, verbose_name=_('Concept'))
|
||||||
|
component_type = columns.ChoiceFieldColumn()
|
||||||
|
color = columns.ColorColumn()
|
||||||
|
connection = tables.Column(
|
||||||
|
accessor='cable', linkify=True, orderable=False, verbose_name=_('Connection'), empty_values=()
|
||||||
|
)
|
||||||
|
connect = tables.TemplateColumn(
|
||||||
|
template_code="""
|
||||||
|
{% load i18n %}
|
||||||
|
{% if not record.is_connected %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptcable_add' %}?concept={{ record.device.concept_id }}&termination_a_device={{ record.device_id }}&termination_a={{ record.pk }}"
|
||||||
|
class="btn btn-sm btn-primary">{% trans "Connect" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
""",
|
||||||
|
verbose_name='',
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
|
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptcomponent_list')
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = ConceptComponent
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'label', 'device', 'concept', 'component_type', 'port_type', 'positions', 'color',
|
||||||
|
'connection', 'connect', 'description', 'tags', 'created', 'last_updated',
|
||||||
|
)
|
||||||
|
default_columns = ('name', 'device', 'component_type', 'port_type', 'connection', 'connect')
|
||||||
|
|
||||||
|
def render_connection(self, value, record):
|
||||||
|
return value if value else _('Not connected')
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCableTable(NetBoxTable):
|
||||||
|
id = tables.Column(linkify=True, verbose_name=_('ID'))
|
||||||
|
label = tables.Column(linkify=True)
|
||||||
|
concept = tables.Column(accessor='termination_a__device__concept', linkify=True, verbose_name=_('Concept'))
|
||||||
|
status = columns.ChoiceFieldColumn()
|
||||||
|
type = columns.ChoiceFieldColumn()
|
||||||
|
color = columns.ColorColumn()
|
||||||
|
termination_a_device = tables.Column(
|
||||||
|
accessor='termination_a__device', linkify=True, verbose_name=_('Device A')
|
||||||
|
)
|
||||||
|
termination_a = tables.Column(linkify=True, verbose_name=_('Component A'))
|
||||||
|
termination_b_device = tables.Column(
|
||||||
|
accessor='termination_b__device', linkify=True, verbose_name=_('Device B')
|
||||||
|
)
|
||||||
|
termination_b = tables.Column(linkify=True, verbose_name=_('Component B'))
|
||||||
|
tags = columns.TagColumn(url_name='plugins:netbox_rack_concept:conceptcable_list')
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = ConceptCable
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'label', 'concept', 'status', 'type', 'color', 'termination_a_device', 'termination_a',
|
||||||
|
'termination_b_device', 'termination_b', 'description', 'tags', 'created', 'last_updated',
|
||||||
|
)
|
||||||
|
default_columns = (
|
||||||
|
'id', 'concept', 'status', 'type', 'termination_a_device', 'termination_a', 'termination_b_device',
|
||||||
|
'termination_b',
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Cable" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Label" %}</th>
|
||||||
|
<td>{{ object.label|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Type" %}</th>
|
||||||
|
<td>{{ object.get_type_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Color" %}</th>
|
||||||
|
<td>{% if object.color %}<span class="badge" style="background-color: #{{ object.color }}"> </span> #{{ object.color }}{% else %}{{ ''|placeholder }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% include 'inc/panels/comments.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Side A" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Concept" %}</th>
|
||||||
|
<td>{{ object.termination_a.device.concept|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Device" %}</th>
|
||||||
|
<td>{{ object.termination_a.device|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Component" %}</th>
|
||||||
|
<td>{{ object.termination_a|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Side B" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Concept" %}</th>
|
||||||
|
<td>{{ object.termination_b.device.concept|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Device" %}</th>
|
||||||
|
<td>{{ object.termination_b.device|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Component" %}</th>
|
||||||
|
<td>{{ object.termination_b|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block extra_controls %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if not cable and perms.netbox_rack_concept.add_conceptcable %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptcable_add' %}?concept={{ object.device.concept_id }}&termination_a_device={{ object.device_id }}&termination_a={{ object.pk }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-ethernet-cable"></i> {% trans "Connect" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Component" %}</h2>
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Device" %}</th>
|
||||||
|
<td>{{ object.device|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Concept" %}</th>
|
||||||
|
<td>{{ object.device.concept|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Name" %}</th>
|
||||||
|
<td>{{ object.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Label" %}</th>
|
||||||
|
<td>{{ object.label|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Component type" %}</th>
|
||||||
|
<td>{{ object.get_component_type_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Type" %}</th>
|
||||||
|
<td>{{ object.port_type|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if object.is_pass_through %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Positions" %}</th>
|
||||||
|
<td>{{ object.positions }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if object.is_pass_through %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Pass-through mapping" %}</h2>
|
||||||
|
{% if paired_components %}
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
{% for component, own_position, other_position in paired_components %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Position" %} {{ own_position }}</th>
|
||||||
|
<td>{{ component|linkify }} · {% trans "position" %} {{ other_position }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted">
|
||||||
|
{% trans "No mapping to a port on the other side. Sync the device's components again if this looks wrong." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Connection" %}</h2>
|
||||||
|
{% if cable %}
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Cable" %}</th>
|
||||||
|
<td>{{ cable|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
|
<td>{% badge cable.get_status_display bg_color=cable.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Connected to" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if cable.termination_a_id == object.pk %}
|
||||||
|
{{ cable.termination_b.device|linkify }} · {{ cable.termination_b|linkify }}
|
||||||
|
{% else %}
|
||||||
|
{{ cable.termination_a.device|linkify }} · {{ cable.termination_a|linkify }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted">{% trans "Not connected." %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block extra_controls %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if object.device_type and perms.netbox_rack_concept.add_conceptcomponent %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_sync_components' pk=object.pk %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="mdi mdi-sync"></i> {% trans "Sync components" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -99,4 +109,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">
|
||||||
|
{% trans "Components" %}
|
||||||
|
{% if perms.netbox_rack_concept.add_conceptcomponent %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}"
|
||||||
|
class="btn btn-sm btn-primary float-end">
|
||||||
|
<i class="mdi mdi-plus-thick"></i> {% trans "Add component" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% render_table component_table %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% blocktrans with device=object %}Sync components for {{ device }}{% endblocktrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card-header">{% trans "Sync components from device type" %}</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
{% blocktrans with device=object device_type=object.device_type %}
|
||||||
|
Creates any interfaces, ports and power connections that {{ device_type }} defines but
|
||||||
|
{{ device }} doesn't have yet, matched by name. Existing components - and any cables already
|
||||||
|
attached to them - are left exactly as they are, so this is safe to run again later, e.g. after
|
||||||
|
adding a component by hand or changing the device type.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "Front and rear ports are linked the same way they are on the device type, so a patch panel's pass-through mapping carries over automatically." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Sync components" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,13 +12,25 @@
|
|||||||
class="concept-elevation-svg" role="img"
|
class="concept-elevation-svg" role="img"
|
||||||
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
||||||
|
|
||||||
{# Unit rows and legend #}
|
{# Unit rows and legend. An empty unit becomes a click target for adding a device
|
||||||
|
here, exactly like a real rack's elevation. #}
|
||||||
{% for unit in elevation.units %}
|
{% for unit in elevation.units %}
|
||||||
<g class="unit">
|
<g class="unit">
|
||||||
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
{% if not unit.occupied and can_add_device %}
|
||||||
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&position={{ unit.number }}&face={{ elevation.face }}&return_url={{ elevation.concept.get_absolute_url }}">
|
||||||
class="unit-slot"
|
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
||||||
data-unit="{{ unit.number }}"></rect>
|
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||||
|
class="unit-slot free"
|
||||||
|
data-unit="{{ unit.number }}"></rect>
|
||||||
|
<text x="{{ elevation.rack_right|add:'-6' }}" y="{{ unit.y }}" dy="15"
|
||||||
|
class="add-device-hint" text-anchor="end">+ {% trans "Add device" %}</text>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||||
|
class="unit-slot"
|
||||||
|
data-unit="{{ unit.number }}"></rect>
|
||||||
|
{% endif %}
|
||||||
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
|
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||||
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -150,19 +150,11 @@
|
|||||||
data-starting-unit="{{ object.starting_unit }}"
|
data-starting-unit="{{ object.starting_unit }}"
|
||||||
data-u-height="{{ object.u_height }}"
|
data-u-height="{{ object.u_height }}"
|
||||||
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
|
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
|
||||||
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
|
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
|
||||||
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
|
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation can_add_device=perms.netbox_rack_concept.add_conceptdevice %}
|
||||||
</div>
|
</div>
|
||||||
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
|
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
{% if perms.netbox_rack_concept.add_conceptdevice %}
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
|
|
||||||
class="btn btn-sm btn-primary">
|
|
||||||
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ urlpatterns = [
|
|||||||
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
|
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
|
||||||
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
|
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
|
||||||
|
|
||||||
|
# Components and cabling
|
||||||
|
path('components/', include(get_model_urls('netbox_rack_concept', 'conceptcomponent', detail=False))),
|
||||||
|
path('components/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptcomponent'))),
|
||||||
|
path('cables/', include(get_model_urls('netbox_rack_concept', 'conceptcable', detail=False))),
|
||||||
|
path('cables/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptcable'))),
|
||||||
|
|
||||||
# Entry points from real racks
|
# Entry points from real racks
|
||||||
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
|
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
|
||||||
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
|
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ from dcim.models import Rack
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
|
|
||||||
from . import deploy, filtersets, forms, tables
|
from . import components, deploy, filtersets, forms, tables
|
||||||
from .elevation import get_elevations
|
from .elevation import get_elevations
|
||||||
from .models import ConceptDevice, RackConcept
|
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ConceptCableView',
|
||||||
|
'ConceptComponentView',
|
||||||
'ConceptDeviceView',
|
'ConceptDeviceView',
|
||||||
'MergeConceptsView',
|
'MergeConceptsView',
|
||||||
'MergeRacksToConceptView',
|
'MergeRacksToConceptView',
|
||||||
@@ -138,6 +140,50 @@ class ConceptDeviceView(generic.ObjectView):
|
|||||||
)
|
)
|
||||||
template_name = 'netbox_rack_concept/conceptdevice.html'
|
template_name = 'netbox_rack_concept/conceptdevice.html'
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
component_table = tables.ConceptComponentTable(
|
||||||
|
instance.components.select_related('device').order_by('component_type', 'name'),
|
||||||
|
exclude=('device', 'concept'),
|
||||||
|
orderable=False,
|
||||||
|
)
|
||||||
|
component_table.configure(request)
|
||||||
|
return {
|
||||||
|
'component_table': component_table,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'sync_components')
|
||||||
|
class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
|
||||||
|
"""Populate a device's components from its device type's templates."""
|
||||||
|
queryset = ConceptDevice.objects.all()
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'netbox_rack_concept.add_conceptcomponent'
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
|
||||||
|
return render(request, 'netbox_rack_concept/conceptdevice_sync_components.html', {
|
||||||
|
'object': device,
|
||||||
|
'return_url': device.get_absolute_url(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
|
||||||
|
|
||||||
|
if device.device_type is None:
|
||||||
|
messages.error(request, _('%(device)s has no device type to sync components from.') % {'device': device})
|
||||||
|
return redirect(device.get_absolute_url())
|
||||||
|
|
||||||
|
created, mapped = components.sync_components(device)
|
||||||
|
if created:
|
||||||
|
messages.success(request, _('Created %(count)s component(s).') % {'count': created})
|
||||||
|
else:
|
||||||
|
messages.info(request, _('No new components to create; everything is already in sync.'))
|
||||||
|
if mapped:
|
||||||
|
messages.success(request, _('Mapped %(count)s front/rear port pair(s).') % {'count': mapped})
|
||||||
|
|
||||||
|
return redirect(device.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConceptDevice, 'add', detail=False)
|
@register_model_view(ConceptDevice, 'add', detail=False)
|
||||||
@register_model_view(ConceptDevice, 'edit')
|
@register_model_view(ConceptDevice, 'edit')
|
||||||
@@ -506,3 +552,87 @@ class RackConceptReorderView(ObjectPermissionRequiredMixin, View):
|
|||||||
return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
|
return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
|
||||||
|
|
||||||
return JsonResponse({'status': 'ok', 'updated': len(updated)})
|
return JsonResponse({'status': 'ok', 'updated': len(updated)})
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Concept components
|
||||||
|
#
|
||||||
|
|
||||||
|
@register_model_view(ConceptComponent, 'list', path='', detail=False)
|
||||||
|
class ConceptComponentListView(generic.ObjectListView):
|
||||||
|
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
|
||||||
|
table = tables.ConceptComponentTable
|
||||||
|
filterset = filtersets.ConceptComponentFilterSet
|
||||||
|
filterset_form = forms.ConceptComponentFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptComponent)
|
||||||
|
class ConceptComponentView(generic.ObjectView):
|
||||||
|
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
|
||||||
|
template_name = 'netbox_rack_concept/conceptcomponent.html'
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
return {
|
||||||
|
'paired_components': instance.get_paired_components(),
|
||||||
|
'cable': instance.cable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptComponent, 'add', detail=False)
|
||||||
|
@register_model_view(ConceptComponent, 'edit')
|
||||||
|
class ConceptComponentEditView(generic.ObjectEditView):
|
||||||
|
queryset = ConceptComponent.objects.all()
|
||||||
|
form = forms.ConceptComponentForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptComponent, 'delete')
|
||||||
|
class ConceptComponentDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = ConceptComponent.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptComponent, 'bulk_delete', path='delete', detail=False)
|
||||||
|
class ConceptComponentBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = ConceptComponent.objects.select_related('device')
|
||||||
|
filterset = filtersets.ConceptComponentFilterSet
|
||||||
|
table = tables.ConceptComponentTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Concept cables
|
||||||
|
#
|
||||||
|
|
||||||
|
@register_model_view(ConceptCable, 'list', path='', detail=False)
|
||||||
|
class ConceptCableListView(generic.ObjectListView):
|
||||||
|
queryset = ConceptCable.objects.select_related(
|
||||||
|
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
|
||||||
|
)
|
||||||
|
table = tables.ConceptCableTable
|
||||||
|
filterset = filtersets.ConceptCableFilterSet
|
||||||
|
filterset_form = forms.ConceptCableFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptCable)
|
||||||
|
class ConceptCableView(generic.ObjectView):
|
||||||
|
queryset = ConceptCable.objects.select_related(
|
||||||
|
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
|
||||||
|
)
|
||||||
|
template_name = 'netbox_rack_concept/conceptcable.html'
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptCable, 'add', detail=False)
|
||||||
|
@register_model_view(ConceptCable, 'edit')
|
||||||
|
class ConceptCableEditView(generic.ObjectEditView):
|
||||||
|
queryset = ConceptCable.objects.all()
|
||||||
|
form = forms.ConceptCableForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptCable, 'delete')
|
||||||
|
class ConceptCableDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = ConceptCable.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptCable, 'bulk_delete', path='delete', detail=False)
|
||||||
|
class ConceptCableBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = ConceptCable.objects.select_related('termination_a', 'termination_b')
|
||||||
|
filterset = filtersets.ConceptCableFilterSet
|
||||||
|
table = tables.ConceptCableTable
|
||||||
|
|||||||
Reference in New Issue
Block a user