├── docs └── images │ ├── 1_device_page.png │ └── 2_interface_comparison.png ├── .gitignore ├── netbox_interface_sync ├── templates │ └── netbox_interface_sync │ │ ├── number_of_interfaces_panel.html │ │ ├── compare_components_button.html │ │ └── components_comparison.html ├── utils.py ├── template_content.py ├── __init__.py ├── urls.py ├── comparison.py └── views.py ├── setup.py ├── README.md └── README_ru.md /docs/images/1_device_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drygdryg/netbox-plugin-interface-sync/HEAD/docs/images/1_device_page.png -------------------------------------------------------------------------------- /docs/images/2_interface_comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drygdryg/netbox-plugin-interface-sync/HEAD/docs/images/2_interface_comparison.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST -------------------------------------------------------------------------------- /netbox_interface_sync/templates/netbox_interface_sync/number_of_interfaces_panel.html: -------------------------------------------------------------------------------- 1 | {% if config.include_interfaces_panel %} 2 |
3 |
Number of interfaces
4 |
5 | Total interfaces: {{ interfaces|length }}
6 | {% if config.exclude_virtual_interfaces %} 7 | Non-virtual interfaces: {{ real_interfaces|length }}
8 | {% endif %} 9 | Interfaces in the assigned device type: {{ interface_templates|length }} 10 |
11 |
12 | {% endif %} -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('README.md', encoding='utf-8') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name='netbox-interface-sync', 8 | version='0.2.0', 9 | description='Syncing interfaces with the interfaces from device type for NetBox devices', 10 | long_description=long_description, 11 | long_description_content_type='text/markdown', 12 | author='Victor Golovanenko', 13 | author_email='drygdryg2014@yandex.com', 14 | license='GPL-3.0', 15 | install_requires=['attrs>=21.1.0'], 16 | packages=["netbox_interface_sync"], 17 | package_data={"netbox_interface_sync": ["templates/netbox_interface_sync/*.html"]}, 18 | zip_safe=False 19 | ) 20 | -------------------------------------------------------------------------------- /netbox_interface_sync/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Iterable, List 3 | from django.conf import settings 4 | 5 | config = settings.PLUGINS_CONFIG['netbox_interface_sync'] 6 | 7 | 8 | def split(s): 9 | for x, y in re.findall(r"(\d*)(\D*)", s): 10 | yield "", int(x or "0") 11 | yield y, 0 12 | 13 | 14 | def natural_keys(c): 15 | return tuple(split(c)) 16 | 17 | 18 | def human_sorted(iterable: Iterable): 19 | return sorted(iterable, key=natural_keys) 20 | 21 | 22 | def make_integer_list(lst: List[str]): 23 | return [int(i) for i in lst if i.isdigit()] 24 | 25 | 26 | def get_permissions_for_model(model, actions: Iterable[str]) -> List[str]: 27 | """ 28 | Resolve a list of permissions for a given model (or instance). 29 | 30 | :param model: A model or instance 31 | :param actions: List of actions: view, add, change, or delete 32 | """ 33 | permissions = [] 34 | for action in actions: 35 | if action not in ("view", "add", "change", "delete"): 36 | raise ValueError(f"Unsupported action: {action}") 37 | permissions.append(f'{model._meta.app_label}.{action}_{model._meta.model_name}') 38 | 39 | return permissions 40 | -------------------------------------------------------------------------------- /netbox_interface_sync/template_content.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginTemplateExtension 2 | from dcim.models import Interface, InterfaceTemplate 3 | 4 | 5 | class DeviceViewExtension(PluginTemplateExtension): 6 | model = "dcim.device" 7 | 8 | def buttons(self): 9 | """Implements a compare button at the top of the page""" 10 | obj = self.context['object'] 11 | return self.render("netbox_interface_sync/compare_components_button.html", extra_context={ 12 | "device": obj 13 | }) 14 | 15 | def right_page(self): 16 | """Implements a panel with the number of interfaces on the right side of the page""" 17 | obj = self.context['object'] 18 | interfaces = Interface.objects.filter(device=obj) 19 | real_interfaces = interfaces.exclude(type__in=["virtual", "lag"]) 20 | interface_templates = InterfaceTemplate.objects.filter(device_type=obj.device_type) 21 | 22 | return self.render("netbox_interface_sync/number_of_interfaces_panel.html", extra_context={ 23 | "interfaces": interfaces, 24 | "real_interfaces": real_interfaces, 25 | "interface_templates": interface_templates 26 | }) 27 | 28 | 29 | template_extensions = [DeviceViewExtension] 30 | -------------------------------------------------------------------------------- /netbox_interface_sync/__init__.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginConfig 2 | 3 | 4 | class Config(PluginConfig): 5 | name = 'netbox_interface_sync' 6 | verbose_name = 'NetBox interface synchronization' 7 | description = 'Compare and synchronize components (interfaces, ports, outlets, etc.) between NetBox device types ' \ 8 | 'and devices' 9 | version = '0.2.0' 10 | author = 'Victor Golovanenko' 11 | author_email = 'drygdryg2014@yandex.ru' 12 | default_settings = { 13 | # Ignore case and spaces in names when matching components between device type and device 14 | 'name_comparison': { 15 | 'case-insensitive': True, 16 | 'space-insensitive': True 17 | }, 18 | # Exclude virtual interfaces (bridge, link aggregation group (LAG), "virtual") from comparison 19 | 'exclude_virtual_interfaces': True, 20 | # Add a panel with information about the number of interfaces to the device page 21 | 'include_interfaces_panel': False, 22 | # Consider component descriptions when comparing. If this option is set to True, then take into account 23 | # component descriptions when comparing components and synchronizing their attributes, otherwise - ignore 24 | 'sync_descriptions': True 25 | } 26 | 27 | 28 | config = Config 29 | -------------------------------------------------------------------------------- /netbox_interface_sync/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | # Define a list of URL patterns to be imported by NetBox. Each pattern maps a URL to 7 | # a specific view so that it can be accessed by users. 8 | urlpatterns = ( 9 | path( 10 | "consoleport-comparison//", 11 | views.ConsolePortComparisonView.as_view(), 12 | name="consoleport_comparison", 13 | ), 14 | path( 15 | "consoleserverport-comparison//", 16 | views.ConsoleServerPortComparisonView.as_view(), 17 | name="consoleserverport_comparison", 18 | ), 19 | path( 20 | "interface-comparison//", 21 | views.InterfaceComparisonView.as_view(), 22 | name="interface_comparison", 23 | ), 24 | path( 25 | "powerport-comparison//", 26 | views.PowerPortComparisonView.as_view(), 27 | name="powerport_comparison", 28 | ), 29 | path( 30 | "poweroutlet-comparison//", 31 | views.PowerOutletComparisonView.as_view(), 32 | name="poweroutlet_comparison", 33 | ), 34 | # path( 35 | # "frontport-comparison//", 36 | # views.FrontPortComparisonView.as_view(), 37 | # name="frontport_comparison", 38 | # ), 39 | path( 40 | "rearport-comparison//", 41 | views.RearPortComparisonView.as_view(), 42 | name="rearport_comparison", 43 | ), 44 | path( 45 | "devicebay-comparison//", 46 | views.DeviceBayComparisonView.as_view(), 47 | name="devicebay_comparison", 48 | ), 49 | ) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netbox-interface-sync 2 | [Русская версия](./README_ru.md) 3 | ## Overview 4 | This plugin allows you to compare and synchronize interfaces between devices and device types in NetBox. It can be useful for finding and correcting inconsistencies between interfaces. 5 | Tested with NetBox versions 2.10, 2.11 6 | ## Installation 7 | If your NetBox installation uses virtualenv, activate it like this: 8 | ``` 9 | source /opt/netbox/venv/bin/activate 10 | ``` 11 | Install the plugin from PyPI: 12 | ``` 13 | pip install netbox-interface-sync 14 | ``` 15 | or clone this repository, then go to the folder with it and install the plugin: 16 | ``` 17 | pip install . 18 | ``` 19 | To enable to plugin, add the plugin's name to the `PLUGINS` list in `configuration.py` (it's usually located in `/opt/netbox/netbox/netbox/`) like so: 20 | ``` 21 | PLUGINS = [ 22 | 'netbox_interface_sync' 23 | ] 24 | ``` 25 | Don't forget to restart NetBox: 26 | ``` 27 | sudo systemctl restart netbox 28 | ``` 29 | ## Usage 30 | To compare the interfaces, open the page of the desired device and find the "Interface sync" button: 31 | ![Device page](docs/images/1_device_page.png) 32 | Mark the required actions with the checkboxes and click "Apply". 33 | ![Interface comparison](docs/images/2_interface_comparison.png) 34 | ### Plugin settings 35 | If you want to override the default values, configure the `PLUGINS_CONFIG` in your `configuration.py`: 36 | ``` 37 | PLUGINS_CONFIG = { 38 | 'netbox_interface_sync': { 39 | 'exclude_virtual_interfaces': True 40 | } 41 | } 42 | ``` 43 | | Setting | Default value | Description | 44 | | --- | --- | --- | 45 | | exclude_virtual_interfaces | `True` | Exclude virtual interfaces (VLANs, LAGs) from comparison 46 | -------------------------------------------------------------------------------- /README_ru.md: -------------------------------------------------------------------------------- 1 | # netbox-interface-sync 2 | [English version](./README.md) 3 | ## Обзор 4 | Плагин для NetBox, позволяющий сравнивать и синхронизировать интерфейсы между устройствами (devices) и типами устройств (device types). Полезен для поиска и исправления несоответствий между интерфейсами. Работа проверена с NetBox версий 2.10, 2.11 5 | ## Установка 6 | Если NetBox использует virtualenv, то активируйте его, например, так: 7 | ``` 8 | source /opt/netbox/venv/bin/activate 9 | ``` 10 | Установите плагин из репозитория PyPI: 11 | ``` 12 | pip install netbox-interface-sync 13 | ``` 14 | или клонируйте этот репозиторий, затем перейдите в папку с ним и установите плагин: 15 | ``` 16 | pip install . 17 | ``` 18 | Включите плагин в файле `configuration.py` (обычно он находится в `/opt/netbox/netbox/netbox/`), добавьте его имя в список `PLUGINS`: 19 | ``` 20 | PLUGINS = [ 21 | 'netbox_interface_sync' 22 | ] 23 | ``` 24 | Перезапустите NetBox: 25 | ``` 26 | sudo systemctl restart netbox 27 | ``` 28 | ## Использование 29 | Для того чтобы сравнить интерфейсы, откройте страницу нужного устройства и найдите кнопку "Interface sync" справа сверху: 30 | ![Device page](docs/images/1_device_page.png) 31 | Отметьте требуемые действия напротив интерфейсов флажками и нажмите "Apply". 32 | ![Interface comparison](docs/images/2_interface_comparison.png) 33 | ### Настройки плагина 34 | Если вы хотите переопределить значения по умолчанию, настройте переменную `PLUGINS_CONFIG` в вашем файле `configuration.py`: 35 | ``` 36 | PLUGINS_CONFIG = { 37 | 'netbox_interface_sync': { 38 | 'exclude_virtual_interfaces': True 39 | } 40 | } 41 | ``` 42 | | Настройка | Значение по умолчанию | Описание | 43 | | --- | --- | --- | 44 | | exclude_virtual_interfaces | `True` | Не учитывать виртуальные интерфейсы (VLAN, LAG) при сравнении 45 | -------------------------------------------------------------------------------- /netbox_interface_sync/templates/netbox_interface_sync/compare_components_button.html: -------------------------------------------------------------------------------- 1 | {% if perms.dcim.change_device %} 2 | 65 | {% endif %} -------------------------------------------------------------------------------- /netbox_interface_sync/comparison.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import attr 4 | from attrs import fields 5 | from django.conf import settings 6 | 7 | from netbox.models import PrimaryModel 8 | 9 | config = settings.PLUGINS_CONFIG["netbox_interface_sync"] 10 | SYNC_DESCRIPTIONS: bool = config["sync_descriptions"] 11 | 12 | 13 | @attr.s(frozen=True, auto_attribs=True) 14 | class BaseComparison: 15 | """Common fields of a device component""" 16 | # Do not compare IDs 17 | id: int = attr.ib(eq=False, metadata={'printable': False, 'netbox_exportable': False}) 18 | # Compare names case-insensitively and spaces-insensitively 19 | name: str = attr.ib(metadata={'printable': False}) 20 | label: str = attr.ib() 21 | # Compare descriptions if it is set by the configuration 22 | description: str = attr.ib(eq=SYNC_DESCRIPTIONS, metadata={'synced': SYNC_DESCRIPTIONS}) 23 | # Do not compare `is_template` properties 24 | is_template: bool = attr.ib( 25 | default=False, kw_only=True, eq=False, 26 | metadata={'printable': False, 'netbox_exportable': False} 27 | ) 28 | 29 | @property 30 | def fields_display(self) -> str: 31 | """Generate human-readable list of printable fields to display in the comparison table""" 32 | fields_to_display = [] 33 | for field in fields(self.__class__): 34 | if not field.metadata.get('printable', True): 35 | continue 36 | field_value = getattr(self, field.name) 37 | if not field_value: 38 | continue 39 | field_caption = field.metadata.get('displayed_caption') or field.name.replace('_', ' ').capitalize() 40 | if isinstance(field_value, BaseComparison): 41 | field_value = f'{field_value.name} (ID: {field_value.id})' 42 | fields_to_display.append(f'{field_caption}: {field_value}') 43 | return '\n'.join(fields_to_display) 44 | 45 | def get_fields_for_netbox_component(self, sync=False): 46 | """ 47 | Returns a dict of fields and values for creating or updating a NetBox component object 48 | :param sync: if True, returns fields for syncing an existing component, otherwise - for creating a new one. 49 | """ 50 | 51 | def field_filter(field: attr.Attribute, _): 52 | result = field.metadata.get('netbox_exportable', True) 53 | if sync: 54 | result &= field.metadata.get('synced', True) 55 | return result 56 | 57 | return attr.asdict(self, recurse=True, filter=field_filter) 58 | 59 | 60 | @attr.s(frozen=True, auto_attribs=True) 61 | class BaseTypedComparison(BaseComparison): 62 | """Common fields of a device typed component""" 63 | type: str = attr.ib(metadata={'printable': False}) 64 | type_display: str = attr.ib(eq=False, metadata={'displayed_caption': 'Type', 'netbox_exportable': False}) 65 | 66 | 67 | @attr.s(frozen=True, auto_attribs=True) 68 | class ConsolePortComparison(BaseTypedComparison): 69 | """A unified way to represent the consoleport and consoleport template""" 70 | pass 71 | 72 | 73 | @attr.s(frozen=True, auto_attribs=True) 74 | class ConsoleServerPortComparison(BaseTypedComparison): 75 | """A unified way to represent the consoleserverport and consoleserverport template""" 76 | pass 77 | 78 | 79 | @attr.s(frozen=True, auto_attribs=True) 80 | class PowerPortComparison(BaseTypedComparison): 81 | """A unified way to represent the power port and power port template""" 82 | maximum_draw: str = attr.ib() 83 | allocated_draw: str = attr.ib() 84 | 85 | 86 | @attr.s(frozen=True, auto_attribs=True) 87 | class PowerOutletComparison(BaseTypedComparison): 88 | """A unified way to represent the power outlet and power outlet template""" 89 | power_port: PowerPortComparison = attr.ib() 90 | feed_leg: str = attr.ib() 91 | 92 | 93 | @attr.s(frozen=True, auto_attribs=True) 94 | class InterfaceComparison(BaseTypedComparison): 95 | """A unified way to represent the interface and interface template""" 96 | mgmt_only: bool = attr.ib() 97 | 98 | 99 | @attr.s(frozen=True, auto_attribs=True) 100 | class FrontPortComparison(BaseTypedComparison): 101 | """A unified way to represent the front port and front port template""" 102 | color: str = attr.ib() 103 | # rear_port_id: int 104 | rear_port_position: int = attr.ib(metadata={'displayed_caption': 'Position'}) 105 | 106 | 107 | @attr.s(frozen=True, auto_attribs=True) 108 | class RearPortComparison(BaseTypedComparison): 109 | """A unified way to represent the rear port and rear port template""" 110 | color: str = attr.ib() 111 | positions: int = attr.ib() 112 | 113 | 114 | @attr.s(frozen=True, auto_attribs=True) 115 | class DeviceBayComparison(BaseComparison): 116 | """A unified way to represent the device bay and device bay template""" 117 | pass 118 | 119 | 120 | def from_netbox_object(netbox_object: PrimaryModel) -> Optional[BaseComparison]: 121 | """Makes a comparison object from the NetBox object""" 122 | type_map = { 123 | "DeviceBay": DeviceBayComparison, 124 | "Interface": InterfaceComparison, 125 | "FrontPort": FrontPortComparison, 126 | "RearPort": RearPortComparison, 127 | "ConsolePort": ConsolePortComparison, 128 | "ConsoleServerPort": ConsoleServerPortComparison, 129 | "PowerPort": PowerPortComparison, 130 | "PowerOutlet": PowerOutletComparison 131 | } 132 | 133 | obj_name = netbox_object._meta.object_name 134 | if obj_name.endswith("Template"): 135 | is_template = True 136 | obj_name = obj_name[:-8] # TODO: use `removesuffix` introduced in Python 3.9 137 | else: 138 | is_template = False 139 | 140 | comparison = type_map.get(obj_name) 141 | if not comparison: 142 | return 143 | 144 | values = {} 145 | for field in fields(comparison): 146 | if field.name == "is_template": 147 | continue 148 | if field.name == "type_display": 149 | values[field.name] = netbox_object.get_type_display() 150 | else: 151 | field_value = getattr(netbox_object, field.name) 152 | if isinstance(field_value, PrimaryModel): 153 | field_value = from_netbox_object(field_value) 154 | values[field.name] = field_value 155 | 156 | return comparison(**values, is_template=is_template) 157 | -------------------------------------------------------------------------------- /netbox_interface_sync/templates/netbox_interface_sync/components_comparison.html: -------------------------------------------------------------------------------- 1 | {% extends 'base/layout.html' %} 2 | 3 | {% block title %}{{ device }} - {{ component_type_name|capfirst }} comparison{% endblock %} 4 | {% block header %} 5 | 12 | {{ block.super }} 13 | {% endblock %} 14 | 15 | {% block content %} 16 | 27 | 43 | 44 |
45 | {% csrf_token %} 46 |
47 | 48 | {% if templates_count == components_count %} 49 | 52 | {% else %} 53 | 58 | {% endif %} 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 76 | 77 | 83 | 89 | 90 | 91 | 92 | {% for component_template, component in comparison_items %} 93 | 94 | {% if component_template %} 95 | 102 | 103 | 111 | {% else %} 112 | 113 | 114 | 115 | {% endif %} 116 | 117 | {% if component %} 118 | 125 | 126 | 134 | 142 | {% else %} 143 | 144 | 145 | 146 | 147 | {% endif %} 148 | 149 | {% endfor %} 150 | 151 |
50 | The device and device type have the same number of {{ component_type_name }}. 51 | 54 | The device and device type have different number of {{ component_type_name }}.
55 | Device: {{ components_count }}
56 | Device type: {{ templates_count }} 57 |
Device typeActionsDeviceActions
NameAttributes 70 | 74 | NameAttributes 78 | 82 | 84 | 88 |
96 | {% if component and component_template.name != component.name %} 97 | {{ component_template.name }} 98 | {% else %} 99 | {{ component_template.name }} 100 | {% endif %} 101 | {{ component_template.fields_display }} 104 | {% if not component %} 105 | 109 | {% endif %} 110 |     119 | {% if component_template and component_template.name != component.name %} 120 | {{ component.name }} 121 | {% else %} 122 | {{ component.name }} 123 | {% endif %} 124 | {{ component.fields_display }} 127 | {% if not component_template %} 128 | 132 | {% endif %} 133 | 135 | {% if component_template and component_template != component %} 136 | 140 | {% endif %} 141 |     
152 |
153 |
154 | 155 |
156 |
157 | 158 | {% endblock %} 159 | -------------------------------------------------------------------------------- /netbox_interface_sync/views.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import Type, Tuple 3 | 4 | from django.db.models import QuerySet 5 | from django.shortcuts import get_object_or_404, redirect, render 6 | from django.views.generic import View 7 | from dcim.models import (Device, Interface, InterfaceTemplate, PowerPort, PowerPortTemplate, ConsolePort, 8 | ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, DeviceBay, 9 | DeviceBayTemplate, FrontPort, FrontPortTemplate, PowerOutlet, PowerOutletTemplate, RearPort, 10 | RearPortTemplate) 11 | from django.contrib.auth.mixins import PermissionRequiredMixin 12 | from django.conf import settings 13 | from django.contrib import messages 14 | 15 | from netbox.models import PrimaryModel 16 | from dcim.constants import VIRTUAL_IFACE_TYPES 17 | 18 | from . import comparison 19 | from .utils import get_permissions_for_model, make_integer_list, human_sorted 20 | 21 | config = settings.PLUGINS_CONFIG['netbox_interface_sync'] 22 | ComparisonTableRow = namedtuple('ComparisonTableRow', ('component_template', 'component')) 23 | 24 | 25 | class GenericComparisonView(PermissionRequiredMixin, View): 26 | """ 27 | Generic object comparison view 28 | 29 | obj_model: Model of the object involved in the comparison (for example, Interface) 30 | obj_template_model: Model of the object template involved in the comparison (for example, InterfaceTemplate) 31 | """ 32 | obj_model: Type[PrimaryModel] = None 33 | obj_template_model: Type[PrimaryModel] = None 34 | 35 | def get_permission_required(self): 36 | # User must have permission to view the device whose components are being compared 37 | permissions = ["dcim.view_device"] 38 | 39 | # Resolve permissions related to the object and the object template 40 | permissions.extend(get_permissions_for_model(self.obj_model, ("view", "add", "change", "delete"))) 41 | permissions.extend(get_permissions_for_model(self.obj_template_model, ("view",))) 42 | 43 | return permissions 44 | 45 | @staticmethod 46 | def filter_comparison_components(component_templates: QuerySet, components: QuerySet) -> Tuple[QuerySet, QuerySet]: 47 | """Override this in the inherited View to implement special comparison objects filtering logic""" 48 | return component_templates, components 49 | 50 | def _fetch_comparison_objects(self, device_id: int): 51 | self.device = get_object_or_404(Device, id=device_id) 52 | component_templates = self.obj_template_model.objects.filter(device_type_id=self.device.device_type.id) 53 | components = self.obj_model.objects.filter(device_id=device_id) 54 | self.component_templates, self.components = self.filter_comparison_components(component_templates, components) 55 | self.comparison_component_templates = [comparison.from_netbox_object(obj) for obj in self.component_templates] 56 | self.comparison_components = [comparison.from_netbox_object(obj) for obj in self.components] 57 | 58 | name_comparison_config = config['name_comparison'] 59 | 60 | def name_key(obj_name: str) -> str: 61 | name = obj_name 62 | if name_comparison_config.get('case-insensitive'): 63 | name = name.lower() 64 | if name_comparison_config.get('space-insensitive'): 65 | name = name.replace(' ', '') 66 | return name 67 | 68 | component_templates_dict = {name_key(obj.name): obj for obj in self.comparison_component_templates} 69 | components_dict = {name_key(obj.name): obj for obj in self.comparison_components} 70 | 71 | self.comparison_table = tuple( 72 | ComparisonTableRow( 73 | component_template=component_templates_dict.get(component_name), 74 | component=components_dict.get(component_name) 75 | ) 76 | for component_name in human_sorted(set().union(component_templates_dict.keys(), components_dict.keys())) 77 | ) 78 | 79 | def get(self, request, device_id): 80 | self._fetch_comparison_objects(device_id) 81 | 82 | return render(request, "netbox_interface_sync/components_comparison.html", { 83 | "component_type_name": self.obj_model._meta.verbose_name_plural, 84 | "comparison_items": self.comparison_table, 85 | "templates_count": len(self.comparison_component_templates), 86 | "components_count": len(self.comparison_components), 87 | "device": self.device, 88 | }) 89 | 90 | def post(self, request, device_id): 91 | components_to_add = make_integer_list(request.POST.getlist("add")) 92 | components_to_delete = make_integer_list(request.POST.getlist("remove")) 93 | components_to_sync = make_integer_list(request.POST.getlist("sync")) 94 | if not any((components_to_add, components_to_delete, components_to_sync)): 95 | messages.warning(request, "No actions selected") 96 | return redirect(request.path) 97 | 98 | self._fetch_comparison_objects(device_id) 99 | 100 | component_ids_to_delete = [] 101 | components_to_bulk_create = [] 102 | synced_count = 0 103 | for template, component in self.comparison_table: 104 | if template and (template.id in components_to_add): 105 | # Add component to the device from the template 106 | components_to_bulk_create.append( 107 | self.obj_model(device=self.device, **template.get_fields_for_netbox_component()) 108 | ) 109 | elif component and (component.id in components_to_delete): 110 | # Delete component from the device 111 | component_ids_to_delete.append(component.id) 112 | elif (template and component) and (component.id in components_to_sync): 113 | # Update component attributes from the template 114 | synced_count += self.components.filter(id=component.id).update( 115 | **template.get_fields_for_netbox_component(sync=True) 116 | ) 117 | 118 | deleted_count = self.obj_model.objects.filter(id__in=component_ids_to_delete).delete()[0] 119 | created_count = len(self.obj_model.objects.bulk_create(components_to_bulk_create)) 120 | 121 | # Generating result message 122 | component_type_name = self.obj_model._meta.verbose_name_plural 123 | message = [] 124 | if synced_count > 0: 125 | message.append(f"synced {synced_count} {component_type_name}") 126 | if created_count > 0: 127 | message.append(f"created {created_count} {component_type_name}") 128 | if deleted_count > 0: 129 | message.append(f"deleted {deleted_count} {component_type_name}") 130 | messages.success(request, "; ".join(message).capitalize()) 131 | 132 | return redirect(request.path) 133 | 134 | 135 | class ConsolePortComparisonView(GenericComparisonView): 136 | """Comparison of console ports between a device and a device type and beautiful visualization""" 137 | obj_model = ConsolePort 138 | obj_template_model = ConsolePortTemplate 139 | 140 | 141 | class ConsoleServerPortComparisonView(GenericComparisonView): 142 | """Comparison of console server ports between a device and a device type and beautiful visualization""" 143 | obj_model = ConsoleServerPort 144 | obj_template_model = ConsoleServerPortTemplate 145 | 146 | 147 | class InterfaceComparisonView(GenericComparisonView): 148 | """Comparison of interfaces between a device and a device type and beautiful visualization""" 149 | obj_model = Interface 150 | obj_template_model = InterfaceTemplate 151 | 152 | @staticmethod 153 | def filter_comparison_components(component_templates: QuerySet, components: QuerySet) -> Tuple[QuerySet, QuerySet]: 154 | if config["exclude_virtual_interfaces"]: 155 | components = components.exclude(type__in=VIRTUAL_IFACE_TYPES) 156 | component_templates = component_templates.exclude(type__in=VIRTUAL_IFACE_TYPES) 157 | return component_templates, components 158 | 159 | 160 | class PowerPortComparisonView(GenericComparisonView): 161 | """Comparison of power ports between a device and a device type and beautiful visualization""" 162 | obj_model = PowerPort 163 | obj_template_model = PowerPortTemplate 164 | 165 | 166 | class PowerOutletComparisonView(GenericComparisonView): 167 | """Comparison of power outlets between a device and a device type and beautiful visualization""" 168 | obj_model = PowerOutlet 169 | obj_template_model = PowerOutletTemplate 170 | 171 | def post(self, request, device_id): 172 | device = get_object_or_404(Device.objects.filter(id=device_id)) 173 | 174 | poweroutlets = device.poweroutlets.all() 175 | poweroutlets_templates = PowerOutletTemplate.objects.filter(device_type=device.device_type) 176 | 177 | # Generating result message 178 | message = [] 179 | created = 0 180 | updated = 0 181 | fixed = 0 182 | 183 | remove_from_device = filter( 184 | lambda i: i in poweroutlets.values_list("id", flat=True), 185 | map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove"))) 186 | ) 187 | 188 | # Remove selected power outlets from the device and count them 189 | deleted = PowerOutlet.objects.filter(id__in=remove_from_device).delete()[0] 190 | 191 | # Get device power ports to check dependency between power outlets 192 | device_pp = PowerPort.objects.filter(device_id=device.id) 193 | 194 | matching = {} 195 | mismatch = False 196 | for i in poweroutlets_templates: 197 | found = False 198 | if i.power_port_id is not None: 199 | ppt = PowerPortTemplate.objects.get(id=i.power_port_id) 200 | for pp in device_pp: 201 | if pp.name == ppt.name: 202 | # Save matching to add the correct power port later 203 | matching[i.id] = pp.id 204 | found = True 205 | 206 | # If at least one power port is not found in device there is a dependency 207 | # Better not to sync at all 208 | if not found: 209 | mismatch = True 210 | break 211 | 212 | if not mismatch: 213 | add_to_device = filter( 214 | lambda i: i in poweroutlets_templates.values_list("id", flat=True), 215 | map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add"))) 216 | ) 217 | 218 | # Add selected component to the device and count them 219 | add_to_device_component = PowerOutletTemplate.objects.filter(id__in=add_to_device) 220 | 221 | bulk_create = [] 222 | updated = 0 223 | keys_to_avoid = ["id"] 224 | 225 | if not config["compare_description"]: 226 | keys_to_avoid.append("description") 227 | 228 | for i in add_to_device_component.values(): 229 | to_create = False 230 | 231 | try: 232 | # If power outlets already exists, update and do not recreate 233 | po = device.poweroutlets.get(name=i["name"]) 234 | except PowerOutlet.DoesNotExist: 235 | po = PowerOutlet() 236 | po.device = device 237 | to_create = True 238 | 239 | # Copy all fields from template 240 | for k in i.keys(): 241 | if k not in keys_to_avoid: 242 | setattr(po, k, i[k]) 243 | po.power_port_id = matching.get(i["id"], None) 244 | 245 | if to_create: 246 | bulk_create.append(po) 247 | else: 248 | po.save() 249 | updated += 1 250 | 251 | created = len(PowerOutlet.objects.bulk_create(bulk_create)) 252 | 253 | # Getting and validating a list of components to rename 254 | fix_name_components = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), poweroutlets) 255 | 256 | # Casting component templates into Unified objects for proper comparison with component for renaming 257 | unified_component_templates = [ 258 | PowerOutletComparison(i.id, i.name, i.label, i.description, i.type, i.get_type_display(), 259 | power_port_name=PowerPortTemplate.objects.get(id=i.power_port_id).name 260 | if i.power_port_id is not None else "", 261 | feed_leg=i.feed_leg, is_template=True) 262 | for i in poweroutlets_templates] 263 | 264 | # Rename selected power outlets 265 | fixed = 0 266 | for component in fix_name_components: 267 | unified_poweroutlet = PowerOutletComparison( 268 | component.id, component.name, component.label, component.description, component.type, 269 | component.get_type_display(), 270 | power_port_name=PowerPort.objects.get(id=component.power_port_id).name 271 | if component.power_port_id is not None else "", 272 | feed_leg=component.feed_leg 273 | ) 274 | try: 275 | # Try to extract a component template with the corresponding name 276 | corresponding_template = unified_component_templates[ 277 | unified_component_templates.index(unified_poweroutlet) 278 | ] 279 | component.name = corresponding_template.name 280 | component.save() 281 | fixed += 1 282 | except ValueError: 283 | pass 284 | else: 285 | messages.error(request, "Dependency detected, sync power ports first!") 286 | 287 | if created > 0: 288 | message.append(f"created {created} power outlets") 289 | if updated > 0: 290 | message.append(f"updated {updated} power outlets") 291 | if deleted > 0: 292 | message.append(f"deleted {deleted} power outlets") 293 | if fixed > 0: 294 | message.append(f"fixed {fixed} power outlets") 295 | 296 | messages.info(request, "; ".join(message).capitalize()) 297 | 298 | return redirect(request.path) 299 | 300 | 301 | class RearPortComparisonView(GenericComparisonView): 302 | """Comparison of rear ports between a device and a device type and beautiful visualization""" 303 | obj_model = RearPort 304 | obj_template_model = RearPortTemplate 305 | 306 | 307 | class DeviceBayComparisonView(GenericComparisonView): 308 | """Comparison of device bays between a device and a device type and beautiful visualization""" 309 | obj_model = DeviceBay 310 | obj_template_model = DeviceBayTemplate 311 | # 312 | # 313 | # class FrontPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View): 314 | # """Comparison of front ports between a device and a device type and beautiful visualization""" 315 | # permission_required = get_permissions_for_object("dcim", "frontport") 316 | # 317 | # def get(self, request, device_id): 318 | # 319 | # device = get_object_or_404(Device.objects.filter(id=device_id)) 320 | # 321 | # frontports = device.frontports.all() 322 | # frontports_templates = FrontPortTemplate.objects.filter(device_type=device.device_type) 323 | # 324 | # unified_frontports = [ 325 | # FrontPortComparison( 326 | # i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, i.rear_port_position) 327 | # for i in frontports] 328 | # unified_frontports_templates = [ 329 | # FrontPortComparison( 330 | # i.id, i.name, i.label, i.description, i.type, i.get_type_display(), i.color, 331 | # i.rear_port_position, is_template=True) 332 | # for i in frontports_templates] 333 | # 334 | # return get_components(request, device, frontports, unified_frontports, unified_frontports_templates) 335 | # 336 | # def post(self, request, device_id): 337 | # form = ComponentComparisonForm(request.POST) 338 | # if form.is_valid(): 339 | # device = get_object_or_404(Device.objects.filter(id=device_id)) 340 | # 341 | # frontports = device.frontports.all() 342 | # frontports_templates = FrontPortTemplate.objects.filter(device_type=device.device_type) 343 | # 344 | # # Generating result message 345 | # message = [] 346 | # created = 0 347 | # updated = 0 348 | # fixed = 0 349 | # 350 | # remove_from_device = filter( 351 | # lambda i: i in frontports.values_list("id", flat=True), 352 | # map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device"))) 353 | # ) 354 | # 355 | # # Remove selected front ports from the device and count them 356 | # deleted = FrontPort.objects.filter(id__in=remove_from_device).delete()[0] 357 | # 358 | # # Get device rear ports to check dependency between front ports 359 | # device_rp = RearPort.objects.filter(device_id=device.id) 360 | # 361 | # matching = {} 362 | # mismatch = False 363 | # for i in frontports_templates: 364 | # found = False 365 | # if i.rear_port_id is not None: 366 | # rpt = RearPortTemplate.objects.get(id=i.rear_port_id) 367 | # for rp in device_rp: 368 | # if rp.name == rpt.name: 369 | # # Save matching to add the correct rear port later 370 | # matching[i.id] = rp.id 371 | # found = True 372 | # 373 | # # If at least one rear port is not found in device there is a dependency 374 | # # Better not to sync at all 375 | # if not found: 376 | # mismatch = True 377 | # break 378 | # 379 | # if not mismatch: 380 | # add_to_device = filter( 381 | # lambda i: i in frontports_templates.values_list("id", flat=True), 382 | # map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add_to_device"))) 383 | # ) 384 | # 385 | # # Add selected component to the device and count them 386 | # add_to_device_component = FrontPortTemplate.objects.filter(id__in=add_to_device) 387 | # 388 | # bulk_create = [] 389 | # updated = 0 390 | # keys_to_avoid = ["id"] 391 | # 392 | # if not config["compare_description"]: 393 | # keys_to_avoid.append("description") 394 | # 395 | # for i in add_to_device_component.values(): 396 | # to_create = False 397 | # 398 | # try: 399 | # # If front port already exists, update and do not recreate 400 | # fp = device.frontports.get(name=i["name"]) 401 | # except FrontPort.DoesNotExist: 402 | # fp = FrontPort() 403 | # fp.device = device 404 | # to_create = True 405 | # 406 | # # Copy all fields from template 407 | # for k in i.keys(): 408 | # if k not in keys_to_avoid: 409 | # setattr(fp, k, i[k]) 410 | # fp.rear_port_id = matching.get(i["id"], None) 411 | # 412 | # if to_create: 413 | # bulk_create.append(fp) 414 | # else: 415 | # fp.save() 416 | # updated += 1 417 | # 418 | # created = len(FrontPort.objects.bulk_create(bulk_create)) 419 | # 420 | # # Getting and validating a list of components to rename 421 | # fix_name_components = filter(lambda i: str(i.id) in request.POST.getlist("fix_name"), frontports) 422 | # 423 | # # Casting component templates into Unified objects for proper comparison with component for renaming 424 | # unified_frontports_templates = [ 425 | # FrontPortComparison( 426 | # i.id, i.name, i.label, i.description, i.type, i.get_type_display(), 427 | # i.color, i.rear_port_position, is_template=True) 428 | # for i in frontports_templates] 429 | # # Rename selected front ports 430 | # fixed = 0 431 | # for component in fix_name_components: 432 | # unified_frontport = FrontPortComparison( 433 | # component.id, component.name, component.label, component.description, component.type, 434 | # component.get_type_display(), component.color, component.rear_port_position 435 | # ) 436 | # 437 | # try: 438 | # # Try to extract a component template with the corresponding name 439 | # corresponding_template = unified_frontports_templates[ 440 | # unified_frontports_templates.index(unified_frontport) 441 | # ] 442 | # component.name = corresponding_template.name 443 | # component.save() 444 | # fixed += 1 445 | # except ValueError: 446 | # pass 447 | # else: 448 | # messages.error(request, "Dependency detected, sync rear ports first!") 449 | # 450 | # if created > 0: 451 | # message.append(f"created {created} front ports") 452 | # if updated > 0: 453 | # message.append(f"updated {updated} front ports") 454 | # if deleted > 0: 455 | # message.append(f"deleted {deleted} front ports") 456 | # if fixed > 0: 457 | # message.append(f"fixed {fixed} front ports") 458 | # 459 | # messages.info(request, "; ".join(message).capitalize()) 460 | # 461 | # return redirect(request.path) 462 | --------------------------------------------------------------------------------