diff --git a/README.md b/README.md index a049108..5000021 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,15 @@ Dazu: * **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". +* **Verkabelung von Konzept-Geräten, 1:1 wie bei echten Geräten** – auf der + Geräte-Detailseite gibt es dieselben Tabs wie bei einem echten `dcim.Device`: + Interfaces, Front Ports, Rear Ports, Console Ports, Console Server Ports, Power + Ports, Power Outlets (gleiche Namen, gleiche Reihenfolge, Tab nur sichtbar wenn + belegt). „Sync components" übernimmt sie 1:1 aus den Component-Templates des + Gerätetyps, inkl. Front-↔Rear-Zuordnung von Patchpanels. Über „Connect" an jeder + unverkabelten Komponente wird sie mit einem Concept Cable verbunden. Ein „Add + Components"-Dropdown (genau wie am echten Gerät) legt neue Komponenten pro Typ an — + nötig für Platzhalter ohne Gerätetyp, die nichts zum Syncen haben. * Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal, Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. @@ -67,6 +71,18 @@ daran, ob der Gerätetyp Front-/Rear-Ports mit Zuordnung definiert — Kabel an Vorderseite eines synchronisierten Patchpanel-Gerätetyps zeigen auf der Detailseite der Komponente sofort den passenden Rear-Port samt Position an. +**Beim Kopieren eines echten Racks in ein Konzept** (und beim Zusammenführen zweier +echter Racks, siehe unten) werden die Komponenten aller übernommenen Geräte automatisch +synchronisiert, und jedes echte Kabel, dessen **beide** Enden innerhalb der kopierten +Rack(s) liegen, wird als `ConceptCable` nachgebaut — Status, Typ, Farbe und Label +inklusive. Kabel, die den Rack verlassen (zum Beispiel zum Uplink-Switch in einem +anderen Rack), lassen sich naturgemäß nicht übernehmen, weil das Gegenstück nicht Teil +des Konzepts ist; ebenso ausgelassen werden Mehrfach-Trunk-Kabel (z.B. MPO), da +`ConceptCable` nur Punkt-zu-Punkt-Verbindungen kennt. Beides wird nach dem Kopieren als +Warnung mit Anzahl gemeldet. Das Nachbilden der Verkabelung gilt nur für den Import +**echter** Racks — beim Klonen oder Zusammenführen von Konzepten (die ja schon eigene +Kabel haben können) bleibt die Verkabelung unverändert, wie bisher. + ## Zusammenspiel mit den anderen Plugins **netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat diff --git a/netbox_rack_concept/__init__.py b/netbox_rack_concept/__init__.py index 04cd399..a982016 100644 --- a/netbox_rack_concept/__init__.py +++ b/netbox_rack_concept/__init__.py @@ -5,10 +5,10 @@ __version__ = '0.1.0' class RackConceptConfig(PluginConfig): name = 'netbox_rack_concept' - verbose_name = 'Rack Concepts' + verbose_name = 'NetBox Rack Concepts' description = 'Rack blueprints: plan racks and their equipment without creating real racks or devices' version = __version__ - author = 'Louis' + author = 'Claude' base_url = 'rack-concepts' min_version = '4.6.0' max_version = '4.6.99' diff --git a/netbox_rack_concept/api/views.py b/netbox_rack_concept/api/views.py index cb3b37e..0215eec 100644 --- a/netbox_rack_concept/api/views.py +++ b/netbox_rack_concept/api/views.py @@ -41,7 +41,7 @@ class RackConceptViewSet(NetBoxModelViewSet): tenant = data.get('tenant') try: - concept, warnings = deploy_utils.copy_rack_to_concept( + concept, warnings, cable_summary = deploy_utils.copy_rack_to_concept( rack=rack, name=data['name'], status=data['status'], @@ -60,6 +60,7 @@ class RackConceptViewSet(NetBoxModelViewSet): result = RackConceptSerializer(concept, context={'request': request}).data result['warnings'] = warnings + result['cables'] = cable_summary return Response(result, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) diff --git a/netbox_rack_concept/components.py b/netbox_rack_concept/components.py index 10bddc6..5e5bbff 100644 --- a/netbox_rack_concept/components.py +++ b/netbox_rack_concept/components.py @@ -9,12 +9,26 @@ that's what its device type's templates already describe - the same as a real de Placeholder devices without a device type have no templates to sync from; their components, if any, have to be added by hand. """ +from django.core.exceptions import ValidationError from django.db import transaction -from .choices import ConceptComponentTypeChoices -from .models import ConceptComponent, ConceptPortMapping +from dcim.models import Cable, ConsolePort, ConsoleServerPort, FrontPort, Interface, PowerOutlet, PowerPort, RearPort -__all__ = ('sync_components',) +from .choices import ConceptComponentTypeChoices +from .models import ConceptCable, ConceptComponent, ConceptPortMapping + +__all__ = ('copy_cables', 'sync_components') + +# (our component type, the real dcim model that can terminate a real cable) +CABLED_MODELS = ( + (ConceptComponentTypeChoices.TYPE_INTERFACE, Interface), + (ConceptComponentTypeChoices.TYPE_CONSOLE_PORT, ConsolePort), + (ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT, ConsoleServerPort), + (ConceptComponentTypeChoices.TYPE_POWER_PORT, PowerPort), + (ConceptComponentTypeChoices.TYPE_POWER_OUTLET, PowerOutlet), + (ConceptComponentTypeChoices.TYPE_FRONT_PORT, FrontPort), + (ConceptComponentTypeChoices.TYPE_REAR_PORT, RearPort), +) # (our component type, DeviceType's reverse accessor for that template queryset) TEMPLATE_SOURCES = ( @@ -77,3 +91,73 @@ def sync_components(concept_device): mappings_created += 1 return created, mappings_created + + +@transaction.atomic +def copy_cables(concept_devices): + """ + Recreate, as ConceptCables, every real cable whose both ends sit on one of the + given concept devices' source devices (i.e. cables internal to whatever rack(s) + were just imported). A cable reaching a device outside that set can't be + represented here - the other end isn't part of the concept - and is skipped, along + with any multi-termination trunk cable, which ConceptCable's plain point-to-point + model can't hold. Only devices whose components have already been synced (see + sync_components) can have their cables copied. + Returns (copied, skipped). + """ + device_map = {cd.source_device_id: cd for cd in concept_devices if cd.source_device_id} + if not device_map: + return 0, 0 + device_ids = set(device_map) + + component_lookup = {} + for component in ConceptComponent.objects.filter(device__in=device_map.values()).select_related('device'): + component_lookup[(component.device.source_device_id, component.name)] = component + + cable_ids = set() + for _, model in CABLED_MODELS: + cable_ids.update( + model.objects.filter(device_id__in=device_ids, cable_id__isnull=False) + .values_list('cable_id', flat=True) + ) + + copied = skipped = 0 + for cable in Cable.objects.filter(pk__in=cable_ids).prefetch_related('terminations'): + a_ends = [ct.termination for ct in cable.terminations.all() if ct.cable_end == 'A'] + b_ends = [ct.termination for ct in cable.terminations.all() if ct.cable_end == 'B'] + if len(a_ends) != 1 or len(b_ends) != 1: + # A multi-termination trunk cable - not something a point-to-point + # ConceptCable can represent. + skipped += 1 + continue + + a, b = a_ends[0], b_ends[0] + a_device_id, b_device_id = getattr(a, 'device_id', None), getattr(b, 'device_id', None) + if a_device_id not in device_ids or b_device_id not in device_ids: + # Reaches a device outside the set being copied. + skipped += 1 + continue + + component_a = component_lookup.get((a_device_id, a.name)) + component_b = component_lookup.get((b_device_id, b.name)) + if not (component_a and component_b): + skipped += 1 + continue + + concept_cable = ConceptCable( + termination_a=component_a, + termination_b=component_b, + status=cable.status, + type=cable.type or '', + color=cable.color or '', + label=cable.label, + ) + try: + concept_cable.full_clean() + except ValidationError: + skipped += 1 + continue + concept_cable.save() + copied += 1 + + return copied, skipped diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py index 3e21b9f..58de894 100644 --- a/netbox_rack_concept/deploy.py +++ b/netbox_rack_concept/deploy.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext as _ from dcim.choices import RackWidthChoices from dcim.models import Device, Rack +from . import components from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices from .integrations import apply_partial_width, read_partial_width from .models import ConceptDeployment, ConceptDevice, RackConcept @@ -54,7 +55,12 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t override lets the concept end up a different size than the source rack (e.g. to plan "what if we had a taller one"); devices that no longer fit are skipped and reported back as warnings rather than aborting the whole copy. - Returns (concept, warnings). + + Copied devices also get their components synced from their device type, and any + cable between two of them is recreated as a ConceptCable - so the copy is cabled + exactly like the source rack wherever both ends of a cable stayed inside it. + Returns (concept, warnings, cable_summary), where cable_summary is + {'copied': int, 'skipped': int}. """ concept = RackConcept( name=name, @@ -90,10 +96,12 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t concept.save() warnings = [] + cable_summary = {'copied': 0, 'skipped': 0} if include_devices: devices = Device.objects.filter(rack=rack, position__isnull=False).select_related( 'device_type', 'role', 'platform', 'tenant' ) + new_devices = [] for device in devices: fraction, horizontal = read_partial_width(device) concept_device = ConceptDevice( @@ -120,8 +128,15 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t ) % {'device': device, 'position': device.position}) continue concept_device.save() + new_devices.append(concept_device) - return concept, warnings + # Recreate each device's components from its device type, then wire up + # whichever cables stayed entirely inside the rack being copied. + for concept_device in new_devices: + components.sync_components(concept_device) + cable_summary['copied'], cable_summary['skipped'] = components.copy_cables(new_devices) + + return concept, warnings, cable_summary # @@ -209,7 +224,12 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi Combine two real racks directly into one new, taller blueprint - e.g. two half-height racks planned as one full-height rack. rack_bottom's devices keep their position; rack_top's are shifted up by rack_bottom's height. Neither source rack is - modified. Returns (concept, warnings). + modified. + + Copied devices also get their components synced from their device type, and any + cable between two of them (from either rack) is recreated as a ConceptCable. + Returns (concept, warnings, cable_summary), where cable_summary is + {'copied': int, 'skipped': int}. """ bottom_height = _rack_geometry(rack_bottom, 'u_height') or rack_bottom.u_height top_height = _rack_geometry(rack_top, 'u_height') or rack_top.u_height @@ -235,6 +255,7 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi concept.save() warnings = [] + new_devices = [] def _copy_from_rack(rack, shift): if not include_devices: @@ -267,11 +288,17 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi ) % {'device': device, 'rack': rack}) continue concept_device.save() + new_devices.append(concept_device) _copy_from_rack(rack_bottom, 0) _copy_from_rack(rack_top, bottom_height) - return concept, warnings + for concept_device in new_devices: + components.sync_components(concept_device) + cable_summary = {'copied': 0, 'skipped': 0} + cable_summary['copied'], cable_summary['skipped'] = components.copy_cables(new_devices) + + return concept, warnings, cable_summary # diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html index 505495d..328015d 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice.html @@ -1,7 +1,6 @@ {% extends 'generic/object.html' %} {% load i18n %} {% load helpers %} -{% load render_table from django_tables2 %} {% block extra_controls %} {{ block.super }} @@ -10,6 +9,22 @@ {% trans "Sync components" %} {% endif %} + {% if perms.netbox_rack_concept.add_conceptcomponent %} +