diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d48c759 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 0ab80e4..a9ae9bc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # netbox-interface-synchronization ## Overview -This plugin allows you to compare and synchronize interface names and types between devices and device types in NetBox. It can be useful for finding and correcting inconsistencies between interfaces when changing the device type. +MAJOR UPDATE + +Thanks to a massive contribution from bastianleicht the Interface Synchronization plugin has become the Component Synchronization plugin. + +This plugin allows you to compare and synchronize component names and types between devices and device types in NetBox 4+. It can be useful for finding and correcting inconsistencies between components when changing the device type. ## Compatibility -Tested with NetBox versions 4.0.0 - 4.3.1 This plugin is not compatible with Netbox 2 or 3 +Tested with NetBox versions 4.3.1 This plugin is not compatible with Netbox 2 or 3 + ## Installation If your NetBox 4 installation uses virtualenv, activate it like this: ``` @@ -27,7 +32,7 @@ Don't forget to restart NetBox: sudo systemctl restart netbox ``` ## Usage -To sync the interfaces, edit the device and set the new device type and save the device. Then find the "Sync Interfaces" button at the bottom of the page: +To sync the components, edit the device and set the new device type and save the device. Then find the "Sync Components" button at the bottom of the page: ![Device page](docs/images/1_device_page.png) Mark the required actions with the checkboxes and click "Apply". ![Interface comparison](docs/images/2_interface_comparison.png) diff --git a/netbox_interface_synchronization/__init__.py b/netbox_interface_synchronization/__init__.py index ab79ca3..f4c5ea7 100644 --- a/netbox_interface_synchronization/__init__.py +++ b/netbox_interface_synchronization/__init__.py @@ -5,11 +5,16 @@ class Config(PluginConfig): name = 'netbox_interface_synchronization' verbose_name = 'NetBox Interface Synchronization' description = 'Syncing existing interface names and types with those from a new device type in NetBox' - version = '4.1.7' - author = 'Keith Knowles' + version = '5.0.0' + author = 'Keith Knowles and Bastian Leicht' author_email = 'mkknowles@outlook.com' default_settings = { - 'exclude_virtual_interfaces': True + 'exclude_virtual_interfaces': True, + 'include_interfaces_panel': False, + # Compare description during diff + # If compare is true, description will also be synced to device + # Otherwise not. + 'compare_description': True } diff --git a/netbox_interface_synchronization/comparison.py b/netbox_interface_synchronization/comparison.py new file mode 100644 index 0000000..ba2ea28 --- /dev/null +++ b/netbox_interface_synchronization/comparison.py @@ -0,0 +1,225 @@ +from dataclasses import dataclass +from django.conf import settings + +config = settings.PLUGINS_CONFIG["netbox_interface_synchronization"] + + +@dataclass(frozen=True) +class ParentComparison: + """Common fields of a device component""" + + id: int + name: str + label: str + description: str + + def __eq__(self, other): + # Ignore some fields when comparing; ignore component name case and whitespaces + eq = ( + self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "") + ) and (self.label == other.label) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + # Ignore some fields when hashing; ignore component name case and whitespaces + return hash(self.name.lower().replace(" ", "")) + + def __str__(self): + return f"Label: {self.label}\nDescription: {self.description}" + + +@dataclass(frozen=True) +class ParentTypedComparison(ParentComparison): + """Common fields of a device typed component""" + + type: str + type_display: str + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash((self.name.lower().replace(" ", ""), self.type)) + + def __str__(self): + return f"{super().__str__()}\nType: {self.type_display}" + + +@dataclass(frozen=True) +class InterfaceComparison(ParentTypedComparison): + """A unified way to represent the interface and interface template""" + + mgmt_only: bool + is_template: bool = False + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + and (self.mgmt_only == other.mgmt_only) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash((self.name.lower().replace(" ", ""), self.type)) + + def __str__(self): + return f"{super().__str__()}\nManagement only: {self.mgmt_only}" + + +@dataclass(frozen=True) +class FrontPortComparison(ParentTypedComparison): + """A unified way to represent the front port and front port template""" + + color: str + # rear_port_id: int + rear_port_position: int + is_template: bool = False + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + and (self.color == other.color) + and (self.rear_port_position == other.rear_port_position) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash((self.name.lower().replace(" ", ""), self.type)) + + def __str__(self): + return f"{super().__str__()}\nColor: {self.color}\nPosition: {self.rear_port_position}" + + +@dataclass(frozen=True) +class RearPortComparison(ParentTypedComparison): + """A unified way to represent the rear port and rear port template""" + + color: str + positions: int + is_template: bool = False + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + and (self.color == other.color) + and (self.positions == other.positions) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash((self.name.lower().replace(" ", ""), self.type)) + + def __str__(self): + return f"{super().__str__()}\nColor: {self.color}\nPositions: {self.positions}" + + +@dataclass(frozen=True, eq=False) +class ConsolePortComparison(ParentTypedComparison): + """A unified way to represent the consoleport and consoleport template""" + + is_template: bool = False + + +@dataclass(frozen=True, eq=False) +class ConsoleServerPortComparison(ParentTypedComparison): + """A unified way to represent the consoleserverport and consoleserverport template""" + + is_template: bool = False + + +@dataclass(frozen=True) +class PowerPortComparison(ParentTypedComparison): + """A unified way to represent the power port and power port template""" + + maximum_draw: str + allocated_draw: str + is_template: bool = False + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + and (self.maximum_draw == other.maximum_draw) + and (self.allocated_draw == other.allocated_draw) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash((self.name.lower().replace(" ", ""), self.type)) + + def __str__(self): + return f"{super().__str__()}\nMaximum draw: {self.maximum_draw}\nAllocated draw: {self.allocated_draw}" + + +@dataclass(frozen=True) +class PowerOutletComparison(ParentTypedComparison): + """A unified way to represent the power outlet and power outlet template""" + + power_port_name: str = "" + feed_leg: str = "" + is_template: bool = False + + def __eq__(self, other): + eq = ( + (self.name.lower().replace(" ", "") == other.name.lower().replace(" ", "")) + and (self.label == other.label) + and (self.type == other.type) + and (self.power_port_name == other.power_port_name) + and (self.feed_leg == other.feed_leg) + ) + + if config["compare_description"]: + eq = eq and (self.description == other.description) + + return eq + + def __hash__(self): + return hash( + (self.name.lower().replace(" ", ""), self.type, self.power_port_name) + ) + + def __str__(self): + return f"{super().__str__()}\nPower port name: {self.power_port_name}\nFeed leg: {self.feed_leg}" + + +@dataclass(frozen=True, eq=False) +class DeviceBayComparison(ParentComparison): + """A unified way to represent the device bay and device bay template""" + + is_template: bool = False \ No newline at end of file diff --git a/netbox_interface_synchronization/forms.py b/netbox_interface_synchronization/forms.py index 0799ebe..d4f6c40 100644 --- a/netbox_interface_synchronization/forms.py +++ b/netbox_interface_synchronization/forms.py @@ -1,6 +1,6 @@ from django import forms -class InterfaceComparisonForm(forms.Form): +class ComponentComparisonForm(forms.Form): add_to_device = forms.BooleanField(required=False) remove_from_device = forms.BooleanField(required=False) diff --git a/netbox_interface_synchronization/template_content.py b/netbox_interface_synchronization/template_content.py index 0d7aaf5..601561f 100644 --- a/netbox_interface_synchronization/template_content.py +++ b/netbox_interface_synchronization/template_content.py @@ -9,7 +9,7 @@ class DeviceViewExtension(PluginTemplateExtension): def buttons(self): """Implements a compare interfaces button at the top of the page""" obj = self.context['object'] - return self.render("netbox_interface_synchronization/compare_interfaces_button.html", extra_context={ + return self.render("netbox_interface_synchronization/compare_components_button.html", extra_context={ "device": obj }) diff --git a/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_components_button.html b/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_components_button.html new file mode 100644 index 0000000..6863c99 --- /dev/null +++ b/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_components_button.html @@ -0,0 +1,65 @@ +{% if perms.dcim.change_device %} + +{% endif %} \ No newline at end of file diff --git a/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_interfaces_button.html b/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_interfaces_button.html deleted file mode 100644 index 948011a..0000000 --- a/netbox_interface_synchronization/templates/netbox_interface_synchronization/compare_interfaces_button.html +++ /dev/null @@ -1,3 +0,0 @@ - - Sync Interfaces - diff --git a/netbox_interface_synchronization/templates/netbox_interface_synchronization/components_comparison.html b/netbox_interface_synchronization/templates/netbox_interface_synchronization/components_comparison.html new file mode 100644 index 0000000..a28dd5c --- /dev/null +++ b/netbox_interface_synchronization/templates/netbox_interface_synchronization/components_comparison.html @@ -0,0 +1,158 @@ +{% extends 'base/layout.html' %} + +{% block title %}{{ device }} - {{ component_type }} comparison{% endblock %} +{% block header %} + + {{ block.super }} +{% endblock %} + +{% block content %} + + + +
+ {% csrf_token %} +
+ + {% if templates_count == components_count %} + + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + + + + {% for component_template, component in comparison_items %} + + {% if component_template %} + + + + {% else %} + + + + {% endif %} + + {% if component %} + + + + + {% else %} + + + + + {% endif %} + + {% endfor %} + +
+ The device and device type have the same number of {{ component_type }}. + + The device and device type have different number of {{ component_type|lower }}.
+ Device: {{ components_count }}
+ Device type: {{ templates_count }} +
Device typeActionsDeviceActions
NameAttributes + + NameAttributes + + + +
+ {% if component and component_template.name != component.name %} + {{ component_template.name }} + {% else %} + {{ component_template.name }} + {% endif %} + {{ component_template }} + {% if not component %} + + {% endif %} +     + {% if component_template and component_template.name != component.name %} + {{ component.name }} + {% else %} + {{ component.name }} + {% endif %} + {{ component }} + {% if not component_template %} + + {% endif %} + + {% if component_template and component_template.name != component.name %} + + {% endif %} +     
+
+
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/netbox_interface_synchronization/templates/netbox_interface_synchronization/interface_comparison.html b/netbox_interface_synchronization/templates/netbox_interface_synchronization/interface_comparison.html deleted file mode 100644 index 1365dd4..0000000 --- a/netbox_interface_synchronization/templates/netbox_interface_synchronization/interface_comparison.html +++ /dev/null @@ -1,161 +0,0 @@ -{% extends 'base/layout.html' %} - -{% block title %}{{ device }} - Interface comparison{% endblock %} -{% block header %} - - {{ block.super }} -{% endblock %} - -{% block content %} - - - -

-{% if templates_count == interfaces_count %} - The Device Type and Device have the same number of Interfaces. -{% else %} - The Device Type and Device have a different number of Interfaces.
- Device Type: {{ templates_count }}
- Device: {{ interfaces_count }} -{% endif %} -

- -
- - {% csrf_token %} - - - - - - - - - - - {% for template, interface in comparison_items %} - {% if template %} - - - - - - {% else %} - - - - - - {% endif %} - {% endfor %} -
Device TypeActions
NameType - -
- {% if interface and template.name != interface.name %} - {{ template.name }} - {% else %} - {{ template.name }} - {% endif %} - {{ template.type_display }} - {% if not interface %} - - {% endif %} -
   
- - - - - - - - - - - - - - {% for template, interface in comparison_items %} - {% if interface %} - - - - - - - {% else %} - - - - - - - {% endif %} - {% endfor %} -
DeviceActions
NameType - - - -
- {% if template and template.name != interface.name %} - {{ interface.name }} - {% else %} - {{ interface.name }} - {% endif %} - {{ interface.type_display }} - {% if not template %} - - {% endif %} - - {% if template and template.name != interface.name %} - - {% endif %} -
    
-
- -
-
- -{% endblock %} diff --git a/netbox_interface_synchronization/templates/netbox_interface_synchronization/number_of_interfaces_panel.html b/netbox_interface_synchronization/templates/netbox_interface_synchronization/number_of_interfaces_panel.html index 6509bd0..8c9ca60 100644 --- a/netbox_interface_synchronization/templates/netbox_interface_synchronization/number_of_interfaces_panel.html +++ b/netbox_interface_synchronization/templates/netbox_interface_synchronization/number_of_interfaces_panel.html @@ -1,10 +1,12 @@ -
-
Number of Interfaces
-
- Total Interfaces: {{ interfaces|length }}
- {% if config.exclude_virtual_interfaces %} - Physical Interfaces: {{ real_interfaces|length }}
- {% endif %} - Interfaces in the assigned Device Type: {{ interface_templates|length }} +{% if config.include_interfaces_panel %} +
+
Number of interfaces
+
+ Total interfaces: {{ interfaces|length }}
+ {% if config.exclude_virtual_interfaces %} + Non-virtual interfaces: {{ real_interfaces|length }}
+ {% endif %} + Interfaces in the assigned device type: {{ interface_templates|length }} +
-
+{% endif %} diff --git a/netbox_interface_synchronization/urls.py b/netbox_interface_synchronization/urls.py index 955d2eb..0e2ba4b 100644 --- a/netbox_interface_synchronization/urls.py +++ b/netbox_interface_synchronization/urls.py @@ -6,5 +6,44 @@ # Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to # a specific view so that it can be accessed by users. urlpatterns = ( - path("interface-comparison//", views.InterfaceComparisonView.as_view(), name="interface_comparison"), -) + path( + "interface-comparison//", + views.InterfaceComparisonView.as_view(), + name="interface_comparison", + ), + path( + "powerport-comparison//", + views.PowerPortComparisonView.as_view(), + name="powerport_comparison", + ), + path( + "consoleport-comparison//", + views.ConsolePortComparisonView.as_view(), + name="consoleport_comparison", + ), + path( + "consoleserverport-comparison//", + views.ConsoleServerPortComparisonView.as_view(), + name="consoleserverport_comparison", + ), + path( + "poweroutlet-comparison//", + views.PowerOutletComparisonView.as_view(), + name="poweroutlet_comparison", + ), + path( + "frontport-comparison//", + views.FrontPortComparisonView.as_view(), + name="frontport_comparison", + ), + path( + "rearport-comparison//", + views.RearPortComparisonView.as_view(), + name="rearport_comparison", + ), + path( + "devicebay-comparison//", + views.DeviceBayComparisonView.as_view(), + name="devicebay_comparison", + ), +) \ No newline at end of file diff --git a/netbox_interface_synchronization/utils.py b/netbox_interface_synchronization/utils.py index 05c44d4..c96bea5 100644 --- a/netbox_interface_synchronization/utils.py +++ b/netbox_interface_synchronization/utils.py @@ -1,11 +1,16 @@ import re from typing import Iterable -from dataclasses import dataclass +from django.shortcuts import render, redirect +from django.contrib import messages +from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings + +config = settings.PLUGINS_CONFIG['netbox_interface_synchronization'] def split(s): - for x, y in re.findall(r'(\d*)(\D*)', s): - yield '', int(x or '0') + for x, y in re.findall(r"(\d*)(\D*)", s): + yield "", int(x or "0") yield y, 0 @@ -17,20 +22,117 @@ def human_sorted(iterable: Iterable): return sorted(iterable, key=natural_keys) -@dataclass(frozen=True) -class UnifiedInterface: - """A unified way to represent the interface and interface template""" - id: int - name: str - type: str - type_display: str - mgmt_only: bool = False - is_template: bool = False +def get_components(request, device, components, unified_components, unified_component_templates, component_type): + # List of components and components templates presented in the unified format + overall_powers = list(set(unified_component_templates + unified_components)) + overall_powers.sort(key=lambda o: natural_keys(o.name)) + + comparison_templates = [] + comparison_components = [] + for i in overall_powers: + try: + comparison_templates.append( + unified_component_templates[unified_component_templates.index(i)] + ) + except ValueError: + comparison_templates.append(None) + + try: + comparison_components.append( + unified_components[unified_components.index(i)] + ) + except ValueError: + comparison_components.append(None) + + comparison_items = list(zip(comparison_templates, comparison_components)) + return render( + request, + "netbox_interface_synchronization/components_comparison.html", + { + "component_type": component_type, + "comparison_items": comparison_items, + "templates_count": len(unified_component_templates), + "components_count": len(components), + "device": device, + }, + ) + + +def post_components( + request, device, components, component_templates, ObjectType, ObjectTemplateType, unified_component, unified_component_templates, component_type +): + # Manually validating components and component templates lists + add_to_device = filter( + lambda i: i in component_templates.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))), + ) + remove_from_device = filter( + lambda i: i in components.values_list("id", flat=True), + map( + int, + filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device")), + ), + ) + + # Remove selected component from the device and count them + deleted = ObjectType.objects.filter(id__in=remove_from_device).delete()[0] + + # Add selected components to the device and count them + add_to_device_component = ObjectTemplateType.objects.filter(id__in=add_to_device) + + bulk_create = [] + updated = 0 + keys_to_avoid = ["id"] + + if not config["compare_description"]: + keys_to_avoid.append("description") + + for i in add_to_device_component.values(): + to_create = False + + try: + tmp = components.get(name=i["name"]) + except ObjectDoesNotExist: + tmp = ObjectType() + tmp.device = device + to_create = True + + for k in i.keys(): + if k not in keys_to_avoid: + setattr(tmp, k, i[k]) + + if to_create: + bulk_create.append(tmp) + else: + tmp.save() + updated += 1 + + created = len(ObjectType.objects.bulk_create(bulk_create)) + + # Rename selected components + fixed = 0 + for component, component_comparison in unified_component: + try: + # Try to extract a component template with the corresponding name + corresponding_template = unified_component_templates[ + unified_component_templates.index(component_comparison) + ] + component.name = corresponding_template.name + component.save() + fixed += 1 + except ValueError: + pass - def __eq__(self, other): - # Ignore some fields when comparing; ignore interface name case and whitespaces - return (self.name.lower().replace(' ', '') == other.name.lower().replace(' ', '')) and (self.type == other.type) + # Generating result message + message = [] + if created > 0: + message.append(f"created {created} {component_type}") + if updated > 0: + message.append(f"updated {updated} {component_type}") + if deleted > 0: + message.append(f"deleted {deleted} {component_type}") + if fixed > 0: + message.append(f"fixed {fixed} {component_type}") + messages.success(request, "; ".join(message).capitalize()) - def __hash__(self): - # Ignore some fields when hashing; ignore interface name case and whitespaces - return hash((self.name.lower().replace(' ', ''), self.type)) + return redirect(request.path) \ No newline at end of file diff --git a/netbox_interface_synchronization/views.py b/netbox_interface_synchronization/views.py index d1a9d04..438cc79 100644 --- a/netbox_interface_synchronization/views.py +++ b/netbox_interface_synchronization/views.py @@ -1,19 +1,24 @@ -from django.shortcuts import get_object_or_404, render, redirect +from django.shortcuts import get_object_or_404, redirect from django.views.generic import View -from dcim.models import Device, Interface, InterfaceTemplate +from dcim.models import Device, Interface, InterfaceTemplate, PowerPort, PowerPortTemplate, ConsolePort, \ + ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, DeviceBay, DeviceBayTemplate, FrontPort, \ + FrontPortTemplate, PowerOutlet, PowerOutletTemplate, RearPort, RearPortTemplate from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.conf import settings from django.contrib import messages -from .utils import UnifiedInterface, natural_keys -from .forms import InterfaceComparisonForm +from .utils import get_components, post_components +from .comparison import FrontPortComparison, PowerPortComparison, PowerOutletComparison, InterfaceComparison, \ + ConsolePortComparison, ConsoleServerPortComparison, DeviceBayComparison, RearPortComparison +from .forms import ComponentComparisonForm config = settings.PLUGINS_CONFIG['netbox_interface_synchronization'] class InterfaceComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): """Comparison of interfaces between a device and a device type and beautiful visualization""" - permission_required = ("dcim.view_interface", "dcim.add_interface", "dcim.change_interface", "dcim.delete_interface") + permission_required = ( + "dcim.view_interface", "dcim.add_interface", "dcim.change_interface", "dcim.delete_interface") def get(self, request, device_id): device = get_object_or_404(Device.objects.filter(id=device_id)) @@ -22,40 +27,18 @@ def get(self, request, device_id): interfaces = list(filter(lambda i: not i.is_virtual, interfaces)) interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type) - unified_interfaces = [UnifiedInterface(i.id, i.name, i.type, i.get_type_display()) for i in interfaces] + unified_interfaces = [ + InterfaceComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.mgmt_only) for i + in interfaces] unified_interface_templates = [ - UnifiedInterface(i.id, i.name, i.type, i.get_type_display(), i.mgmt_only, is_template=True) for i in interface_templates] - - # List of interfaces and interface templates presented in the unified format - overall_interfaces = list(set(unified_interface_templates + unified_interfaces)) - overall_interfaces.sort(key=lambda o: natural_keys(o.name)) - - comparison_templates = [] - comparison_interfaces = [] - for i in overall_interfaces: - try: - comparison_templates.append(unified_interface_templates[unified_interface_templates.index(i)]) - except ValueError: - comparison_templates.append(None) - - try: - comparison_interfaces.append(unified_interfaces[unified_interfaces.index(i)]) - except ValueError: - comparison_interfaces.append(None) - - comparison_items = list(zip(comparison_templates, comparison_interfaces)) - return render( - request, "netbox_interface_synchronization/interface_comparison.html", - { - "comparison_items": comparison_items, - "templates_count": len(interface_templates), - "interfaces_count": len(interfaces), - "device": device - } - ) + InterfaceComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.mgmt_only, + is_template=True) for i in interface_templates] + + return get_components(request, device, interfaces, unified_interfaces, unified_interface_templates, + "Interfaces") def post(self, request, device_id): - form = InterfaceComparisonForm(request.POST) + form = ComponentComparisonForm(request.POST) if form.is_valid(): device = get_object_or_404(Device.objects.filter(id=device_id)) interfaces = device.vc_interfaces() @@ -63,56 +46,602 @@ def post(self, request, device_id): interfaces = interfaces.exclude(type__in=["virtual", "lag"]) interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type) - # Manually validating interfaces and interface templates lists - add_to_device = filter( - lambda i: i in interface_templates.values_list("id", flat=True), - map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))) + # Getting and validating a list of interfaces to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), interfaces + ) + + unified_interface_templates = [ + InterfaceComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.mgmt_only, + is_template=True) for i in interface_templates] + + unified_interfaces = [] + + for component in fix_name_components: + unified_interfaces.append((component, InterfaceComparison( + component.id, + component.name, + component.label, + component.description, + component.type, + component.get_type_display(), + component.mgmt_only))) + + return post_components(request, device, interfaces, interface_templates, Interface, InterfaceTemplate, + unified_interfaces, unified_interface_templates, "interfaces") + + +class PowerPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of power ports between a device and a device type and beautiful visualization""" + permission_required = ( + "dcim.view_powerport", "dcim.add_powerport", "dcim.change_powerport", "dcim.delete_powerport") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + powerports = device.powerports.all() + powerports_templates = PowerPortTemplate.objects.filter(device_type=device.device_type) + + unified_powerports = [ + PowerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.maximum_draw, + i.allocated_draw) for i in powerports] + unified_powerport_templates = [ + PowerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.maximum_draw, + i.allocated_draw, is_template=True) for i in powerports_templates] + + return get_components(request, device, powerports, unified_powerports, unified_powerport_templates, + "Power ports") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + powerports = device.powerports.all() + powerports_templates = PowerPortTemplate.objects.filter(device_type=device.device_type) + + # Getting and validating a list of power ports to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), powerports + ) + + unified_powerport_templates = [ + PowerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.maximum_draw, + i.allocated_draw, is_template=True) for i in powerports_templates] + + unified_powerports = [] + + for component in fix_name_components: + unified_powerports.append((component, PowerPortComparison( + component.id, + component.name, + component.label, + component.description, + component.type, + component.get_type_display(), + component.maximum_draw, + component.allocated_draw))) + + return post_components(request, device, powerports, powerports_templates, PowerPort, PowerPortTemplate, + unified_powerports, unified_powerport_templates, "power ports") + + +class ConsolePortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of console ports between a device and a device type and beautiful visualization""" + permission_required = ( + "dcim.view_consoleport", "dcim.add_consoleport", "dcim.change_consoleport", "dcim.delete_consoleport") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + consoleports = device.consoleports.all() + consoleports_templates = ConsolePortTemplate.objects.filter(device_type=device.device_type) + + unified_consoleports = [ + ConsolePortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display()) for i in + consoleports] + unified_consoleport_templates = [ + ConsolePortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), is_template=True) + for i in consoleports_templates] + + return get_components(request, device, consoleports, unified_consoleports, unified_consoleport_templates, + "Console ports") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + consoleports = device.consoleports.all() + consoleports_templates = ConsolePortTemplate.objects.filter(device_type=device.device_type) + + # Getting and validating a list of console ports to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), consoleports + ) + + unified_consoleport_templates = [ + ConsolePortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + is_template=True) for i in consoleports_templates] + + unified_consoleports = [] + + for component in fix_name_components: + unified_consoleports.append((component, ConsolePortComparison( + component.id, + component.name, + component.label, + component.description, + component.type, + component.get_type_display()))) + + return post_components(request, device, consoleports, consoleports_templates, ConsolePort, + ConsolePortTemplate, unified_consoleports, unified_consoleport_templates, + "console ports") + + +class ConsoleServerPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of console server ports between a device and a device type and beautiful visualization""" + permission_required = ("dcim.view_consoleserverport", "dcim.add_consoleserverport", "dcim.change_consoleserverport", + "dcim.delete_consoleserverport") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + consoleserverports = device.consoleserverports.all() + consoleserverports_templates = ConsoleServerPortTemplate.objects.filter(device_type=device.device_type) + + unified_consoleserverports = [ + ConsoleServerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display()) for i in + consoleserverports] + unified_consoleserverport_templates = [ + ConsoleServerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + is_template=True) for i in consoleserverports_templates] + + return get_components(request, device, consoleserverports, unified_consoleserverports, + unified_consoleserverport_templates, "Console server ports") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + consoleserverports = device.consoleserverports.all() + consoleserverports_templates = ConsoleServerPortTemplate.objects.filter(device_type=device.device_type) + + # Getting and validating a list of console server ports to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), consoleserverports ) + + unified_consoleserverport_templates = [ + ConsoleServerPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + is_template=True) for i in consoleserverports_templates] + + unified_consoleserverports = [] + + for component in fix_name_components: + unified_consoleserverports.append((component, ConsoleServerPortComparison( + component.id, + component.name, + component.label, + component.description, + component.type, + component.get_type_display()))) + + return post_components(request, device, consoleserverports, consoleserverports_templates, ConsoleServerPort, + ConsoleServerPortTemplate, unified_consoleserverports, + unified_consoleserverport_templates, "console server ports") + + +class PowerOutletComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of power outlets between a device and a device type and beautiful visualization""" + permission_required = ( + "dcim.view_poweroutlet", "dcim.add_poweroutlet", "dcim.change_poweroutlet", "dcim.delete_poweroutlet") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + poweroutlets = device.poweroutlets.all() + poweroutlets_templates = PowerOutletTemplate.objects.filter(device_type=device.device_type) + + unified_poweroutlets = [ + PowerOutletComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + power_port_name=PowerPort.objects.get( + id=i.power_port_id).name if i.power_port_id is not None else "", + feed_leg=i.feed_leg) for i in poweroutlets] + unified_poweroutlet_templates = [ + PowerOutletComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + power_port_name=PowerPortTemplate.objects.get( + id=i.power_port_id).name if i.power_port_id is not None else "", + feed_leg=i.feed_leg, is_template=True) for i in poweroutlets_templates] + + return get_components(request, device, poweroutlets, unified_poweroutlets, unified_poweroutlet_templates, + "Power outlets") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + poweroutlets = device.poweroutlets.all() + poweroutlets_templates = PowerOutletTemplate.objects.filter(device_type=device.device_type) + + # Generating result message + message = [] + created = 0 + updated = 0 + fixed = 0 + remove_from_device = filter( - lambda i: i in interfaces.values_list("id", flat=True), + lambda i: i in poweroutlets.values_list("id", flat=True), map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device"))) ) - # Remove selected interfaces from the device and count them - interfaces_deleted = Interface.objects.filter(id__in=remove_from_device).delete()[0] + # Remove selected power outlets from the device and count them + deleted = PowerOutlet.objects.filter(id__in=remove_from_device).delete()[0] - # Add selected interfaces to the device and count them - add_to_device_interfaces = InterfaceTemplate.objects.filter(id__in=add_to_device) + # Get device power ports to check dependency between power outlets + device_pp = PowerPort.objects.filter(device_id=device.id) - interfaces_created = 0 - for i in add_to_device_interfaces: - ni = Interface(device=device, name=i.name, type=i.type, mgmt_only=i.mgmt_only) - ni.full_clean() - ni.save() - interfaces_created = interfaces_created + 1 + matching = {} + mismatch = False + for i in poweroutlets_templates: + found = False + if i.power_port_id is not None: + ppt = PowerPortTemplate.objects.get(id=i.power_port_id) + for pp in device_pp: + if pp.name == ppt.name: + # Save matching to add the correct power port later + matching[i.id] = pp.id + found = True - # Getting and validating a list of interfaces to rename - fix_name_interfaces = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), interfaces) - # Casting interface templates into UnifiedInterface objects for proper comparison with interfaces for renaming - unified_interface_templates = [ - UnifiedInterface(i.id, i.name, i.type,i.mgmt_only, i.get_type_display()) for i in interface_templates] - - # Rename selected interfaces - interfaces_fixed = 0 - for interface in fix_name_interfaces: - unified_interface = UnifiedInterface(interface.id, interface.name, interface.type, interface.mgmt_only, interface.get_type_display()) - try: - # Try to extract an interface template with the corresponding name - corresponding_template = unified_interface_templates[unified_interface_templates.index(unified_interface)] - interface.name = corresponding_template.name - interface.save() - interfaces_fixed += 1 - except ValueError: - pass + # If at least one power port is not found in device there is a dependency + # Better not to sync at all + if not found: + mismatch = True + break + + if not mismatch: + add_to_device = filter( + lambda i: i in poweroutlets_templates.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))) + ) + + # Add selected component to the device and count them + add_to_device_component = PowerOutletTemplate.objects.filter(id__in=add_to_device) + + bulk_create = [] + updated = 0 + keys_to_avoid = ["id"] + + if not config["compare_description"]: + keys_to_avoid.append("description") + + for i in add_to_device_component.values(): + to_create = False + + try: + # If power outlets already exists, update and do not recreate + po = device.poweroutlets.get(name=i["name"]) + except PowerOutlet.DoesNotExist: + po = PowerOutlet() + po.device = device + to_create = True + + # Copy all fields from template + for k in i.keys(): + if k not in keys_to_avoid: + setattr(po, k, i[k]) + po.power_port_id = matching.get(i["id"], None) + + if to_create: + bulk_create.append(po) + else: + po.save() + updated += 1 + + created = len(PowerOutlet.objects.bulk_create(bulk_create)) + + # Getting and validating a list of components to rename + fix_name_components = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), poweroutlets) + + # Casting component templates into Unified objects for proper comparison with component for renaming + unified_component_templates = [ + PowerOutletComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), + power_port_name=PowerPortTemplate.objects.get( + id=i.power_port_id).name if i.power_port_id is not None else "", + feed_leg=i.feed_leg, is_template=True) for i in poweroutlets_templates] + + # Rename selected power outlets + fixed = 0 + for component in fix_name_components: + unified_poweroutlet = PowerOutletComparison(component.id, component.name, component.label, + component.description, component.type, + component.get_type_display(), + power_port_name=PowerPort.objects.get( + id=component.power_port_id).name if component.power_port_id is not None else "", + feed_leg=component.feed_leg) + + try: + # Try to extract a component template with the corresponding name + corresponding_template = unified_component_templates[ + unified_component_templates.index(unified_poweroutlet)] + component.name = corresponding_template.name + component.save() + fixed += 1 + except ValueError: + pass + else: + messages.error(request, "Dependecy detected, sync power ports first!") + + if created > 0: + message.append(f"created {created} power outlets") + if updated > 0: + message.append(f"updated {updated} power outlets") + if deleted > 0: + message.append(f"deleted {deleted} power outlets") + if fixed > 0: + message.append(f"fixed {fixed} power outlets") + + messages.info(request, "; ".join(message).capitalize()) + + return redirect(request.path) + + +class FrontPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of front ports between a device and a device type and beautiful visualization""" + permission_required = ( + "dcim.view_frontport", "dcim.add_frontport", "dcim.change_frontport", "dcim.delete_frontport") + + def get(self, request, device_id): + + device = get_object_or_404(Device.objects.filter(id=device_id)) + + frontports = device.frontports.all() + frontports_templates = FrontPortTemplate.objects.filter(device_type=device.device_type) + + unified_frontports = [ + FrontPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, + i.rear_port_position) for i in frontports] + unified_frontports_templates = [ + FrontPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, + i.rear_port_position, is_template=True) for i in frontports_templates] + + return get_components(request, device, frontports, unified_frontports, unified_frontports_templates, + "Front ports") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + frontports = device.frontports.all() + frontports_templates = FrontPortTemplate.objects.filter(device_type=device.device_type) # Generating result message message = [] - if interfaces_created > 0: - message.append(f"created {interfaces_created} interfaces") - if interfaces_deleted > 0: - message.append(f"deleted {interfaces_deleted} interfaces") - if interfaces_fixed > 0: - message.append(f"fixed {interfaces_fixed} interfaces") - messages.success(request, "; ".join(message).capitalize()) + created = 0 + updated = 0 + fixed = 0 + + remove_from_device = filter( + lambda i: i in frontports.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device"))) + ) + + # Remove selected front ports from the device and count them + deleted = FrontPort.objects.filter(id__in=remove_from_device).delete()[0] + + # Get device rear ports to check dependency between front ports + device_rp = RearPort.objects.filter(device_id=device.id) + + matching = {} + mismatch = False + for i in frontports_templates: + found = False + if i.rear_port_id is not None: + rpt = RearPortTemplate.objects.get(id=i.rear_port_id) + for rp in device_rp: + if rp.name == rpt.name: + # Save matching to add the correct rear port later + matching[i.id] = rp.id + found = True + + # If at least one rear port is not found in device there is a dependency + # Better not to sync at all + if not found: + mismatch = True + break + + if not mismatch: + add_to_device = filter( + lambda i: i in frontports_templates.values_list("id", flat=True), + map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))) + ) + + # Add selected component to the device and count them + add_to_device_component = FrontPortTemplate.objects.filter(id__in=add_to_device) + + bulk_create = [] + updated = 0 + keys_to_avoid = ["id"] + + if not config["compare_description"]: + keys_to_avoid.append("description") + + for i in add_to_device_component.values(): + to_create = False + + try: + # If fron port already exists, update and do not recreate + fp = device.frontports.get(name=i["name"]) + except FrontPort.DoesNotExist: + fp = FrontPort() + fp.device = device + to_create = True + + # Copy all fields from template + for k in i.keys(): + if k not in keys_to_avoid: + setattr(fp, k, i[k]) + fp.rear_port_id = matching.get(i["id"], None) + + if to_create: + bulk_create.append(fp) + else: + fp.save() + updated += 1 + + created = len(FrontPort.objects.bulk_create(bulk_create)) + + # Getting and validating a list of components to rename + fix_name_components = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), frontports) + + # Casting component templates into Unified objects for proper comparison with component for renaming + unified_frontports_templates = [ + FrontPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, + i.rear_port_position, is_template=True) for i in frontports_templates] + # Rename selected front ports + fixed = 0 + for component in fix_name_components: + unified_frontport = FrontPortComparison(component.id, component.name, component.label, + component.description, component.type, + component.get_type_display(), component.color, + component.rear_port_position) + + try: + # Try to extract a component template with the corresponding name + corresponding_template = unified_frontports_templates[ + unified_frontports_templates.index(unified_frontport)] + component.name = corresponding_template.name + component.save() + fixed += 1 + except ValueError: + pass + else: + messages.error(request, "Dependecy detected, sync rear ports first!") + + if created > 0: + message.append(f"created {created} front ports") + if updated > 0: + message.append(f"updated {updated} front ports") + if deleted > 0: + message.append(f"deleted {deleted} front ports") + if fixed > 0: + message.append(f"fixed {fixed} front ports") + + messages.info(request, "; ".join(message).capitalize()) return redirect(request.path) + + +class RearPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of rear ports between a device and a device type and beautiful visualization""" + permission_required = ("dcim.view_rearport", "dcim.add_rearport", "dcim.change_rearport", "dcim.delete_rearport") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + rearports = device.rearports.all() + rearports_templates = RearPortTemplate.objects.filter(device_type=device.device_type) + + unified_rearports = [ + RearPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, i.positions) + for i in rearports] + unified_rearports_templates = [ + RearPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, i.positions, + is_template=True) for i in rearports_templates] + + return get_components(request, device, rearports, unified_rearports, unified_rearports_templates, "Rear ports") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + rearports = device.rearports.all() + rearports_templates = RearPortTemplate.objects.filter(device_type=device.device_type) + + # Getting and validating a list of rear ports to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), rearports + ) + + unified_rearports_templates = [ + RearPortComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, + i.positions, is_template=True) for i in rearports_templates] + + unified_rearports = [] + + for component in fix_name_components: + unified_rearports.append((component, RearPortComparison( + component.id, + component.name, + component.label, + component.description, + component.type, + component.get_type_display(), + component.color, + component.positions))) + + return post_components(request, device, rearports, rearports_templates, RearPort, RearPortTemplate, + unified_rearports, unified_rearports_templates, "rear ports") + + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + rearports = device.rearports.all() + rearports_templates = RearPortTemplate.objects.filter(device_type=device.device_type) + + return post_components(request, device, rearports, rearports_templates, RearPort, RearPortTemplate) + + +class DeviceBayComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Comparison of device bays between a device and a device type and beautiful visualization""" + permission_required = ( + "dcim.view_devicebay", "dcim.add_devicebay", "dcim.change_devicebay", "dcim.delete_devicebay") + + def get(self, request, device_id): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + devicebays = device.devicebays.all() + devicebays_templates = DeviceBayTemplate.objects.filter(device_type=device.device_type) + + unified_devicebays = [DeviceBayComparison(i.id, i.name, i.label, i.description) for i in devicebays] + unified_devicebay_templates = [ + DeviceBayComparison(i.id, i.name, i.label, i.description, is_template=True) for i in devicebays_templates] + + return get_components(request, device, devicebays, unified_devicebays, unified_devicebay_templates, + "Device bays") + + def post(self, request, device_id): + form = ComponentComparisonForm(request.POST) + if form.is_valid(): + device = get_object_or_404(Device.objects.filter(id=device_id)) + + devicebays = device.devicebays.all() + devicebays_templates = DeviceBayTemplate.objects.filter(device_type=device.device_type) + + # Getting and validating a list of devicebays to rename + fix_name_components = filter( + lambda i: str(i.id) in request.POST.getlist("fix_name"), devicebays + ) + + unified_devicebay_templates = [ + DeviceBayComparison(i.id, i.name, i.label, i.description, is_template=True) for i in + devicebays_templates] + + unified_devicebays = [] + + for component in fix_name_components: + unified_devicebays.append((component, DeviceBayComparison( + component.id, + component.name, + component.label, + component.description + ))) + + return post_components(request, device, devicebays, devicebays_templates, DeviceBay, DeviceBayTemplate, + unified_devicebays, unified_devicebay_templates, "device bays") \ No newline at end of file diff --git a/setup.py b/setup.py index 6793868..96caac1 100644 --- a/setup.py +++ b/setup.py @@ -5,11 +5,11 @@ setup( name='netbox-interface-synchronization', - version='4.1.7', - description='Syncing existing interfaces with the interfaces from a device type template in NetBox 4+', + version='5.0.0', + description='Syncing existing components with the components from a device type template in NetBox 4+', long_description=long_description, long_description_content_type='text/markdown', - author='Keith Knowles', + author='Keith Knowles and Bastian Leicht', author_email='mkknowles@outlook.com', license='GPL-3.0', packages=["netbox_interface_synchronization"],