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 %} + + {% endif %} {% endblock %} {% block content %} @@ -109,21 +124,4 @@ - -
-
-
-

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

- {% render_table component_table %} -
-
-
{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_components.html b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_components.html new file mode 100644 index 0000000..748bac9 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/conceptdevice_components.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_children.html' %} +{% load i18n %} + +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.netbox_rack_concept.add_conceptcomponent %} + + {% blocktrans %}Add {{ component_type_label }}{% endblocktrans %} + + {% endif %} +{% endblock %} diff --git a/netbox_rack_concept/views.py b/netbox_rack_concept/views.py index ee20fec..470de39 100644 --- a/netbox_rack_concept/views.py +++ b/netbox_rack_concept/views.py @@ -17,6 +17,7 @@ from netbox.views import generic from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view from . import components, deploy, filtersets, forms, tables +from .choices import ConceptComponentTypeChoices from .elevation import get_elevations from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept @@ -140,17 +141,6 @@ class ConceptDeviceView(generic.ObjectView): ) template_name = 'netbox_rack_concept/conceptdevice.html' - def get_extra_context(self, request, instance): - component_table = tables.ConceptComponentTable( - instance.components.select_related('device').order_by('component_type', 'name'), - exclude=('device', 'concept'), - orderable=False, - ) - component_table.configure(request) - return { - 'component_table': component_table, - } - @register_model_view(ConceptDevice, 'sync_components') class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View): @@ -185,6 +175,129 @@ class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View): return redirect(device.get_absolute_url()) +class ConceptDeviceComponentsView(generic.ObjectChildrenView): + """ + Base for the per-type component tabs (Interfaces / Front Ports / ...), mirroring + dcim's own device component tabs one-for-one: same names, paths and tab order. + Since one ConceptComponent model covers every type (see models.py), a subclass only + needs to say which component_type it shows - there's no separate model per tab to + plug in the way dcim.Interface/dcim.FrontPort/etc. do. + """ + queryset = ConceptDevice.objects.all() + child_model = ConceptComponent + table = tables.ConceptComponentTable + filterset = filtersets.ConceptComponentFilterSet + filterset_form = forms.ConceptComponentFilterForm + template_name = 'netbox_rack_concept/conceptdevice_components.html' + component_type = None + + def get_children(self, request, parent): + return ConceptComponent.objects.restrict(request.user, 'view').filter( + device=parent, component_type=self.component_type + ) + + def get_table(self, data, request, bulk_actions=True): + # device/concept/component_type are all implied by "which tab this is" - showing + # them again on every row would just be noise. + table = self.table(data, exclude=('device', 'concept', 'component_type')) + if 'pk' in table.base_columns and bulk_actions: + table.columns.show('pk') + table.configure(request) + return table + + def get_extra_context(self, request, instance): + return { + 'component_type': self.component_type, + 'component_type_label': self.tab.label, + } + + +@register_model_view(ConceptDevice, 'consoleports', path='console-ports') +class ConceptDeviceConsolePortsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_PORT + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_PORT).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=550, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'consoleserverports', path='console-server-ports') +class ConceptDeviceConsoleServerPortsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.components.filter( + component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT + ).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=560, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'powerports', path='power-ports') +class ConceptDevicePowerPortsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_POWER_PORT + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_PORT).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=570, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'poweroutlets', path='power-outlets') +class ConceptDevicePowerOutletsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_POWER_OUTLET + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_OUTLET).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=580, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'interfaces') +class ConceptDeviceInterfacesView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_INTERFACE + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_INTERFACE).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=520, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'frontports', path='front-ports') +class ConceptDeviceFrontPortsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_FRONT_PORT + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_FRONT_PORT).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=530, + hide_if_empty=True + ) + + +@register_model_view(ConceptDevice, 'rearports', path='rear-ports') +class ConceptDeviceRearPortsView(ConceptDeviceComponentsView): + component_type = ConceptComponentTypeChoices.TYPE_REAR_PORT + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_REAR_PORT).count(), + permission='netbox_rack_concept.view_conceptcomponent', + weight=540, + hide_if_empty=True + ) + + @register_model_view(ConceptDevice, 'add', detail=False) @register_model_view(ConceptDevice, 'edit') class ConceptDeviceEditView(generic.ObjectEditView): @@ -249,7 +362,7 @@ class RackCopyView(ObjectPermissionRequiredMixin, View): if form.is_valid(): try: - concept, warnings = deploy.copy_rack_to_concept( + concept, warnings, cable_summary = deploy.copy_rack_to_concept( rack=rack, name=form.cleaned_data['name'], status=form.cleaned_data['status'], @@ -267,9 +380,17 @@ class RackCopyView(ObjectPermissionRequiredMixin, View): else: for warning in warnings: messages.warning(request, warning) - messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % { + if cable_summary['skipped']: + messages.warning(request, _( + '%(count)s cable(s) could not be copied because they reach outside the rack or use more ' + 'than one termination per side.' + ) % {'count': cable_summary['skipped']}) + success_message = _('Created concept %(concept)s from rack %(rack)s.') % { 'concept': concept, 'rack': rack - }) + } + if cable_summary['copied']: + success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']} + messages.success(request, success_message) return redirect(concept.get_absolute_url()) return render(request, 'netbox_rack_concept/rack_copy.html', { @@ -354,7 +475,7 @@ class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View): if form.is_valid(): try: - concept, warnings = deploy.merge_racks_to_concept( + concept, warnings, cable_summary = deploy.merge_racks_to_concept( rack_bottom=form.cleaned_data['rack_bottom'], rack_top=form.cleaned_data['rack_top'], name=form.cleaned_data['name'], @@ -373,11 +494,19 @@ class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View): else: for warning in warnings: messages.warning(request, warning) - messages.success(request, _('Created concept %(concept)s from %(bottom)s and %(top)s.') % { + if cable_summary['skipped']: + messages.warning(request, _( + '%(count)s cable(s) could not be copied because they reach outside the two racks or use ' + 'more than one termination per side.' + ) % {'count': cable_summary['skipped']}) + success_message = _('Created concept %(concept)s from %(bottom)s and %(top)s.') % { 'concept': concept, 'bottom': form.cleaned_data['rack_bottom'], 'top': form.cleaned_data['rack_top'], - }) + } + if cable_summary['copied']: + success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']} + messages.success(request, success_message) return redirect(concept.get_absolute_url()) return render(request, 'netbox_rack_concept/racks_merge.html', {