Compare commits

...

15 Commits

Author SHA1 Message Date
Victor Golovanenko
ba0d9c9736
Refactoring: stage 1 2022-03-03 12:39:16 +03:00
Victor Golovanenko
87d12f05f8
Refactored comparison.py
- Switched to using attrs instead of dataclasses
2022-01-21 11:46:26 +03:00
Victor Golovanenko
eb01474779
Do not display captions for empty attributes in tables 2022-01-20 12:07:21 +03:00
Victor Golovanenko
de4e730514
Merge pull request #7 from rizlas/master
Sync for all components not only interfaces
2022-01-20 09:53:00 +03:00
rizlas
e5573f9a8c Only compare description config 2022-01-18 18:26:15 +01:00
rizlas
1bb75cfe69 Possibility to choose if description is used during diff and also if it should be synced in device 2022-01-18 17:56:57 +01:00
rizlas
e50d8e8633 Clean and renaming 2022-01-12 14:36:55 +01:00
rizlas
861eff8a61 Styling rendered table with bootstrap 4. Removed the double for loop. Added str method to comparison classes. 2022-01-12 12:59:05 +01:00
rizlas
729218f577 Added possibility to exclude interfaces panel 2022-01-11 16:21:11 +01:00
rizlas
00cedbd589 Minor fixes 2022-01-11 14:09:46 +01:00
rizlas
836d850401 Added front ports and rear ports sync 2022-01-11 13:47:43 +01:00
rizlas
d1b2b82d13 Fix interface comparison. Modified global post 2021-12-28 20:29:37 +01:00
rizlas
80112869e0 Fix comparison for every get. Changed signature in centralized get 2021-12-28 18:32:33 +01:00
rizlas
b6bdbf9028 Comparison classes in different files. Inerithance with a parent comparison class. Check all fields in poweroutlet object for sync. Added update strategy if an object already exists. 2021-12-28 15:43:30 +01:00
rizlas
2f104442c3 Added sync for all components. WIP on PowerOutlets. 2021-12-27 17:37:56 +01:00
13 changed files with 901 additions and 288 deletions

View File

@ -4,12 +4,24 @@ from extras.plugins import PluginConfig
class Config(PluginConfig):
name = 'netbox_interface_sync'
verbose_name = 'NetBox interface synchronization'
description = 'Syncing interfaces with the interfaces from device type for NetBox devices'
description = 'Compare and synchronize components (interfaces, ports, outlets, etc.) between NetBox device types ' \
'and devices'
version = '0.2.0'
author = 'Victor Golovanenko'
author_email = 'drygdryg2014@yandex.ru'
default_settings = {
'exclude_virtual_interfaces': True
# Ignore case and spaces in names when matching components between device type and device
'name_comparison': {
'case-insensitive': True,
'space-insensitive': True
},
# Exclude virtual interfaces (bridge, link aggregation group (LAG), "virtual") from comparison
'exclude_virtual_interfaces': True,
# Add a panel with information about the number of interfaces to the device page
'include_interfaces_panel': False,
# Consider component descriptions when comparing. If this option is set to True, then take into account
# component descriptions when comparing components and synchronizing their attributes, otherwise - ignore
'sync_descriptions': True
}

View File

@ -0,0 +1,156 @@
from typing import Optional
import attr
from attrs import fields
from django.conf import settings
from netbox.models import PrimaryModel
config = settings.PLUGINS_CONFIG["netbox_interface_sync"]
SYNC_DESCRIPTIONS: bool = config["sync_descriptions"]
@attr.s(frozen=True, auto_attribs=True)
class BaseComparison:
"""Common fields of a device component"""
# Do not compare IDs
id: int = attr.ib(eq=False, metadata={'printable': False, 'netbox_exportable': False})
# Compare names case-insensitively and spaces-insensitively
name: str = attr.ib(metadata={'printable': False})
label: str = attr.ib()
# Compare descriptions if it is set by the configuration
description: str = attr.ib(eq=SYNC_DESCRIPTIONS, metadata={'synced': SYNC_DESCRIPTIONS})
# Do not compare `is_template` properties
is_template: bool = attr.ib(
default=False, kw_only=True, eq=False,
metadata={'printable': False, 'netbox_exportable': False}
)
@property
def fields_display(self) -> str:
"""Generate human-readable list of printable fields to display in the comparison table"""
fields_to_display = []
for field in fields(self.__class__):
if not field.metadata.get('printable', True):
continue
field_value = getattr(self, field.name)
if not field_value:
continue
field_caption = field.metadata.get('displayed_caption') or field.name.replace('_', ' ').capitalize()
if isinstance(field_value, BaseComparison):
field_value = f'{field_value.name} (ID: {field_value.id})'
fields_to_display.append(f'{field_caption}: {field_value}')
return '\n'.join(fields_to_display)
def get_fields_for_netbox_component(self, sync=False):
"""
Returns a dict of fields and values for creating or updating a NetBox component object
:param sync: if True, returns fields for syncing an existing component, otherwise - for creating a new one.
"""
def field_filter(field: attr.Attribute, _):
result = field.metadata.get('netbox_exportable', True)
if sync:
result &= field.metadata.get('synced', True)
return result
return attr.asdict(self, recurse=True, filter=field_filter)
@attr.s(frozen=True, auto_attribs=True)
class BaseTypedComparison(BaseComparison):
"""Common fields of a device typed component"""
type: str = attr.ib(metadata={'printable': False})
type_display: str = attr.ib(eq=False, metadata={'displayed_caption': 'Type', 'netbox_exportable': False})
@attr.s(frozen=True, auto_attribs=True)
class ConsolePortComparison(BaseTypedComparison):
"""A unified way to represent the consoleport and consoleport template"""
pass
@attr.s(frozen=True, auto_attribs=True)
class ConsoleServerPortComparison(BaseTypedComparison):
"""A unified way to represent the consoleserverport and consoleserverport template"""
pass
@attr.s(frozen=True, auto_attribs=True)
class PowerPortComparison(BaseTypedComparison):
"""A unified way to represent the power port and power port template"""
maximum_draw: str = attr.ib()
allocated_draw: str = attr.ib()
@attr.s(frozen=True, auto_attribs=True)
class PowerOutletComparison(BaseTypedComparison):
"""A unified way to represent the power outlet and power outlet template"""
power_port: PowerPortComparison = attr.ib()
feed_leg: str = attr.ib()
@attr.s(frozen=True, auto_attribs=True)
class InterfaceComparison(BaseTypedComparison):
"""A unified way to represent the interface and interface template"""
mgmt_only: bool = attr.ib()
@attr.s(frozen=True, auto_attribs=True)
class FrontPortComparison(BaseTypedComparison):
"""A unified way to represent the front port and front port template"""
color: str = attr.ib()
# rear_port_id: int
rear_port_position: int = attr.ib(metadata={'displayed_caption': 'Position'})
@attr.s(frozen=True, auto_attribs=True)
class RearPortComparison(BaseTypedComparison):
"""A unified way to represent the rear port and rear port template"""
color: str = attr.ib()
positions: int = attr.ib()
@attr.s(frozen=True, auto_attribs=True)
class DeviceBayComparison(BaseComparison):
"""A unified way to represent the device bay and device bay template"""
pass
def from_netbox_object(netbox_object: PrimaryModel) -> Optional[BaseComparison]:
"""Makes a comparison object from the NetBox object"""
type_map = {
"DeviceBay": DeviceBayComparison,
"Interface": InterfaceComparison,
"FrontPort": FrontPortComparison,
"RearPort": RearPortComparison,
"ConsolePort": ConsolePortComparison,
"ConsoleServerPort": ConsoleServerPortComparison,
"PowerPort": PowerPortComparison,
"PowerOutlet": PowerOutletComparison
}
obj_name = netbox_object._meta.object_name
if obj_name.endswith("Template"):
is_template = True
obj_name = obj_name[:-8] # TODO: use `removesuffix` introduced in Python 3.9
else:
is_template = False
comparison = type_map.get(obj_name)
if not comparison:
return
values = {}
for field in fields(comparison):
if field.name == "is_template":
continue
if field.name == "type_display":
values[field.name] = netbox_object.get_type_display()
else:
field_value = getattr(netbox_object, field.name)
if isinstance(field_value, PrimaryModel):
field_value = from_netbox_object(field_value)
values[field.name] = field_value
return comparison(**values, is_template=is_template)

View File

@ -1,6 +0,0 @@
from django import forms
class InterfaceComparisonForm(forms.Form):
add_to_device = forms.BooleanField(required=False)
remove_from_device = forms.BooleanField(required=False)

View File

@ -6,9 +6,9 @@ class DeviceViewExtension(PluginTemplateExtension):
model = "dcim.device"
def buttons(self):
"""Implements a compare interfaces button at the top of the page"""
"""Implements a compare button at the top of the page"""
obj = self.context['object']
return self.render("netbox_interface_sync/compare_interfaces_button.html", extra_context={
return self.render("netbox_interface_sync/compare_components_button.html", extra_context={
"device": obj
})

View File

@ -0,0 +1,65 @@
{% if perms.dcim.change_device %}
<div class="dropdown">
<button id="device-type-sync" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Device type sync
</button>
<ul class="dropdown-menu" aria-labeled-by="device-type-sync">
{% if perms.dcim.add_consoleport %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:consoleport_comparison' device_id=device.id %}">
Console Ports
</a>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:consoleserverport_comparison' device_id=device.id %}">
Console Server Ports
</a>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:powerport_comparison' device_id=device.id %}">
Power Ports
</a>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:poweroutlet_comparison' device_id=device.id %}">
Power Outlets
</a>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:interface_comparison' device_id=device.id %}">
Interfaces
</a>
</li>
{% endif %}
{# {% if perms.dcim.add_frontport %}#}
{# <li>#}
{# <a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:frontport_comparison' device_id=device.id %}">#}
{# Front Ports#}
{# </a>#}
{# </li>#}
{# {% endif %}#}
{% if perms.dcim.add_rearport %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:rearport_comparison' device_id=device.id %}">
Rear Ports
</a>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_interface_sync:devicebay_comparison' device_id=device.id %}">
Device Bays
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}

View File

@ -1,3 +0,0 @@
<a href="{% url 'plugins:netbox_interface_sync:interface_comparison' device_id=device.id %}" class="btn btn-sm btn-primary">
Interface sync
</a>

View File

@ -0,0 +1,158 @@
{% extends 'base/layout.html' %}
{% block title %}{{ device }} - {{ component_type_name|capfirst }} comparison{% endblock %}
{% block header %}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li class="breadcrumb-item"><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'dcim:device' pk=device.id %}">{{ device }}</a></li>
</ol>
</nav>
{{ block.super }}
{% endblock %}
{% block content %}
<style>
.caption-red {
caption-side: top;
color: red;
}
.caption-green {
caption-side: top;
color: green;
}
</style>
<script>
function toggle(event) {
event = event || window.event;
const src = event.target || event.srcElement || event;
const checkboxes = document.getElementsByName(src.id);
for(const checkbox of checkboxes) checkbox.checked = src.checked;
}
function uncheck(event) {
event = event || window.event;
const src = event.target || event.srcElement || event;
if (src.checked === false) {
document.getElementById(src.name).checked = false;
}
}
</script>
<form method="post">
{% csrf_token %}
<div class="table-responsive-xl">
<table class="table table-hover table-bordered">
{% if templates_count == components_count %}
<caption class="caption-green">
The device and device type have the same number of {{ component_type_name }}.
</caption>
{% else %}
<caption class="caption-red">
The device and device type have different number of {{ component_type_name }}.<br>
Device: {{ components_count }}<br>
Device type: {{ templates_count }}
</caption>
{% endif %}
<thead>
<tr>
<th scope="col" colspan="2">Device type</th>
<th scope="col">Actions</th>
<th scope="col" colspan="2">Device</th>
<th scope="col" colspan="2">Actions</th>
</tr>
<tr>
<th scope="col">Name</th>
<th scope="col">Attributes</th>
<th scope="col">
<label>
<input type="checkbox" id="add" onclick="toggle(this)">
Add to the device
</label>
</th>
<th scope="col">Name</th>
<th scope="col">Attributes</th>
<th scope="col">
<label>
<input type="checkbox" id="remove" onclick="toggle(this)">
Remove
</label>
</th>
<th scope="col">
<label>
<input type="checkbox" id="sync" onclick="toggle(this)">
Sync attributes
</label>
</th>
</tr>
</thead>
<tbody>
{% for component_template, component in comparison_items %}
<tr>
{% if component_template %}
<th scope="row" {% if not component %}class="table-danger"{% endif %}>
{% if component and component_template.name != component.name %}
<span style="background-color: #eab2b2">{{ component_template.name }}</span>
{% else %}
{{ component_template.name }}
{% endif %}
</th>
<td style="white-space:pre" {% if not component %}class="table-danger"{% endif %}>{{ component_template.fields_display }}</td>
<td {% if not component %}class="table-danger"{% endif %}>
{% if not component %}
<label>
<input type="checkbox" name="add" value="{{ component_template.id }}" onclick="uncheck(this)">
Add to device
</label>
{% endif %}
</td>
{% else %}
<th scope="row">&nbsp;</th>
<td>&nbsp;</td>
<td>&nbsp;</td>
{% endif %}
{% if component %}
<th scope="row" {% if not component_template %}class="table-success"{% endif %}>
{% if component_template and component_template.name != component.name %}
<span style="background-color: #cde8c2">{{ component.name }}</span>
{% else %}
{{ component.name }}
{% endif %}
</th>
<td style="white-space:pre" {% if not component_template %}class="table-success"{% endif %}>{{ component.fields_display }}</td>
<td {% if not component_template %}class="table-success"{% endif %}>
{% if not component_template %}
<label>
<input type="checkbox" name="remove" value="{{ component.id }}" onclick="uncheck(this)">
Remove
</label>
{% endif %}
</td>
<td {% if not component_template %}class="table-success"{% endif %}>
{% if component_template and component_template != component %}
<label>
<input type="checkbox" name="sync" value="{{ component.id }}" onclick="uncheck(this)">
Sync attributes
</label>
{% endif %}
</td>
{% else %}
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div>
<input type="submit" value="Apply" class="btn btn-primary" style="float: right;">
</div>
</form>
{% endblock %}

View File

@ -1,161 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}{{ device }} - Interface comparison{% endblock %}
{% block header %}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li class="breadcrumb-item"><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'dcim:device' pk=device.id %}">{{ device }}</a></li>
</ol>
</nav>
{{ block.super }}
{% endblock %}
{% block content %}
<style>
.checkbox-group {
position: absolute;
}
</style>
<script>
function toggle(event) {
event = event || window.event;
var src = event.target || event.srcElement || event;
checkboxes = document.getElementsByName(src.id);
for(var checkbox of checkboxes) checkbox.checked = src.checked;
}
function uncheck(event) {
event = event || window.event;
var src = event.target || event.srcElement || event;
if (src.checked == false) {
document.getElementById(src.name).checked = false;
}
}
</script>
<p>
{% if templates_count == interfaces_count %}
The device and device type have the same number of interfaces.
{% else %}
The device and device type have different number of interfaces.<br>
Device: {{ interfaces_count }}<br>
Device type: {{ templates_count }}
{% endif %}
</p>
<form method="post">
<!-- Interface templates -->
{% csrf_token %}
<table class="table" style="width: 50%; float: left;">
<tr>
<th colspan="2">Device type</th>
<th>Actions</th>
</tr>
<tr>
<th>Name</th>
<th>Type</th>
<th>
<label class="checkbox-group">
<input type="checkbox" id="add_to_device" onclick="toggle(this)">
Add to the device
</label>
</th>
</tr>
{% for template, interface in comparison_items %}
{% if template %}
<tr {% if not interface %}class="danger"{% endif %}>
<td>
{% if interface and template.name != interface.name %}
<span style="background-color: #eab2b2">{{ template.name }}</span>
{% else %}
{{ template.name }}
{% endif %}
</td>
<td>{{ template.type_display }}</td>
<td>
{% if not interface %}
<label class="checkbox-group">
<input type="checkbox" name="add_to_device" value="{{ template.id }}" onclick="uncheck(this)">
Add to device
</label>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
{% endif %}
{% endfor %}
</table>
<table class="table" style="width: 50%; float: right;">
<!-- Interfaces -->
<tr>
<th colspan="2">Device</th>
<th colspan="2">Actions</th>
</tr>
<tr>
<th>Name</th>
<th>Type</th>
<th>
<label class="checkbox-group">
<input type="checkbox" id="remove_from_device" onclick="toggle(this)">
Remove
</label>
</th>
<th>
<label class="checkbox-group">
<input type="checkbox" id="fix_name" onclick="toggle(this)">
Fix the name
</label>
</th>
</tr>
{% for template, interface in comparison_items %}
{% if interface %}
<tr {% if not template %}class="success"{% endif %}>
<td>
{% if template and template.name != interface.name %}
<span style="background-color: #cde8c2">{{ interface.name }}</span>
{% else %}
{{ interface.name }}
{% endif %}
</td>
<td>{{ interface.type_display }}</td>
<td>
{% if not template %}
<label class="checkbox-group">
<input type="checkbox" name="remove_from_device" value="{{ interface.id }}" onclick="uncheck(this)">
Remove
</label>
{% endif %}
</td>
<td>
{% if template and template.name != interface.name %}
<label class="checkbox-group">
<input type="checkbox" name="fix_name" value="{{ interface.id }}" onclick="uncheck(this)">
Fix name
</label>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="text-right">
<input type="submit" value="Apply" class="btn btn-primary">
</div>
</form>
{% endblock %}

View File

@ -1,10 +1,12 @@
<div class="card">
<h5 class="card-header">Number of interfaces</h5>
<div class="card-body">
Total interfaces: {{ interfaces|length }}<br>
{% if config.exclude_virtual_interfaces %}
Non-virtual interfaces: {{ real_interfaces|length }}<br>
{% endif %}
Interfaces in the assigned device type: {{ interface_templates|length }}
{% if config.include_interfaces_panel %}
<div class="card">
<h5 class="card-header">Number of interfaces</h5>
<div class="card-body">
Total interfaces: {{ interfaces|length }}<br>
{% if config.exclude_virtual_interfaces %}
Non-virtual interfaces: {{ real_interfaces|length }}<br>
{% endif %}
Interfaces in the assigned device type: {{ interface_templates|length }}
</div>
</div>
</div>
{% endif %}

View File

@ -6,5 +6,44 @@ from . import views
# 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/<int:device_id>/", views.InterfaceComparisonView.as_view(), name="interface_comparison"),
path(
"consoleport-comparison/<int:device_id>/",
views.ConsolePortComparisonView.as_view(),
name="consoleport_comparison",
),
path(
"consoleserverport-comparison/<int:device_id>/",
views.ConsoleServerPortComparisonView.as_view(),
name="consoleserverport_comparison",
),
path(
"interface-comparison/<int:device_id>/",
views.InterfaceComparisonView.as_view(),
name="interface_comparison",
),
path(
"powerport-comparison/<int:device_id>/",
views.PowerPortComparisonView.as_view(),
name="powerport_comparison",
),
path(
"poweroutlet-comparison/<int:device_id>/",
views.PowerOutletComparisonView.as_view(),
name="poweroutlet_comparison",
),
# path(
# "frontport-comparison/<int:device_id>/",
# views.FrontPortComparisonView.as_view(),
# name="frontport_comparison",
# ),
path(
"rearport-comparison/<int:device_id>/",
views.RearPortComparisonView.as_view(),
name="rearport_comparison",
),
path(
"devicebay-comparison/<int:device_id>/",
views.DeviceBayComparisonView.as_view(),
name="devicebay_comparison",
),
)

View File

@ -1,11 +1,13 @@
import re
from typing import Iterable
from dataclasses import dataclass
from typing import Iterable, List
from django.conf import settings
config = settings.PLUGINS_CONFIG['netbox_interface_sync']
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,19 +19,21 @@ 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
is_template: bool = False
def make_integer_list(lst: List[str]):
return [int(i) for i in lst if i.isdigit()]
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)
def __hash__(self):
# Ignore some fields when hashing; ignore interface name case and whitespaces
return hash((self.name.lower().replace(' ', ''), self.type))
def get_permissions_for_model(model, actions: Iterable[str]) -> List[str]:
"""
Resolve a list of permissions for a given model (or instance).
:param model: A model or instance
:param actions: List of actions: view, add, change, or delete
"""
permissions = []
for action in actions:
if action not in ("view", "add", "change", "delete"):
raise ValueError(f"Unsupported action: {action}")
permissions.append(f'{model._meta.app_label}.{action}_{model._meta.model_name}')
return permissions

View File

@ -1,114 +1,461 @@
from django.shortcuts import get_object_or_404, render, redirect
from collections import namedtuple
from typing import Type, Tuple
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from dcim.models import Device, Interface, InterfaceTemplate
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
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 PermissionRequiredMixin
from django.conf import settings
from django.contrib import messages
from .utils import UnifiedInterface, natural_keys
from .forms import InterfaceComparisonForm
from netbox.models import PrimaryModel
from dcim.constants import VIRTUAL_IFACE_TYPES
from . import comparison
from .utils import get_permissions_for_model, make_integer_list, human_sorted
config = settings.PLUGINS_CONFIG['netbox_interface_sync']
ComparisonTableRow = namedtuple('ComparisonTableRow', ('component_template', 'component'))
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")
class GenericComparisonView(PermissionRequiredMixin, View):
"""
Generic object comparison view
def get(self, request, device_id):
device = get_object_or_404(Device.objects.filter(id=device_id))
interfaces = device.vc_interfaces()
if config["exclude_virtual_interfaces"]:
interfaces = list(filter(lambda i: not i.is_virtual, interfaces))
interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type)
obj_model: Model of the object involved in the comparison (for example, Interface)
obj_template_model: Model of the object template involved in the comparison (for example, InterfaceTemplate)
"""
obj_model: Type[PrimaryModel] = None
obj_template_model: Type[PrimaryModel] = None
unified_interfaces = [UnifiedInterface(i.id, i.name, i.type, i.get_type_display()) for i in interfaces]
unified_interface_templates = [
UnifiedInterface(i.id, i.name, i.type, i.get_type_display(), is_template=True) for i in interface_templates]
def get_permission_required(self):
# User must have permission to view the device whose components are being compared
permissions = ["dcim.view_device"]
# 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))
# Resolve permissions related to the object and the object template
permissions.extend(get_permissions_for_model(self.obj_model, ("view", "add", "change", "delete")))
permissions.extend(get_permissions_for_model(self.obj_template_model, ("view",)))
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)
return permissions
try:
comparison_interfaces.append(unified_interfaces[unified_interfaces.index(i)])
except ValueError:
comparison_interfaces.append(None)
@staticmethod
def filter_comparison_components(component_templates: QuerySet, components: QuerySet) -> Tuple[QuerySet, QuerySet]:
"""Override this in the inherited View to implement special comparison objects filtering logic"""
return component_templates, components
comparison_items = list(zip(comparison_templates, comparison_interfaces))
return render(
request, "netbox_interface_sync/interface_comparison.html",
{
"comparison_items": comparison_items,
"templates_count": len(interface_templates),
"interfaces_count": len(interfaces),
"device": device
}
def _fetch_comparison_objects(self, device_id: int):
self.device = get_object_or_404(Device, id=device_id)
component_templates = self.obj_template_model.objects.filter(device_type_id=self.device.device_type.id)
components = self.obj_model.objects.filter(device_id=device_id)
self.component_templates, self.components = self.filter_comparison_components(component_templates, components)
self.comparison_component_templates = [comparison.from_netbox_object(obj) for obj in self.component_templates]
self.comparison_components = [comparison.from_netbox_object(obj) for obj in self.components]
name_comparison_config = config['name_comparison']
def name_key(obj_name: str) -> str:
name = obj_name
if name_comparison_config.get('case-insensitive'):
name = name.lower()
if name_comparison_config.get('space-insensitive'):
name = name.replace(' ', '')
return name
component_templates_dict = {name_key(obj.name): obj for obj in self.comparison_component_templates}
components_dict = {name_key(obj.name): obj for obj in self.comparison_components}
self.comparison_table = tuple(
ComparisonTableRow(
component_template=component_templates_dict.get(component_name),
component=components_dict.get(component_name)
)
for component_name in human_sorted(set().union(component_templates_dict.keys(), components_dict.keys()))
)
def get(self, request, device_id):
self._fetch_comparison_objects(device_id)
return render(request, "netbox_interface_sync/components_comparison.html", {
"component_type_name": self.obj_model._meta.verbose_name_plural,
"comparison_items": self.comparison_table,
"templates_count": len(self.comparison_component_templates),
"components_count": len(self.comparison_components),
"device": self.device,
})
def post(self, request, device_id):
form = InterfaceComparisonForm(request.POST)
if form.is_valid():
device = get_object_or_404(Device.objects.filter(id=device_id))
interfaces = device.vc_interfaces()
if config["exclude_virtual_interfaces"]:
interfaces = interfaces.exclude(type__in=["virtual", "lag"])
interface_templates = InterfaceTemplate.objects.filter(device_type=device.device_type)
components_to_add = make_integer_list(request.POST.getlist("add"))
components_to_delete = make_integer_list(request.POST.getlist("remove"))
components_to_sync = make_integer_list(request.POST.getlist("sync"))
if not any((components_to_add, components_to_delete, components_to_sync)):
messages.warning(request, "No actions selected")
return redirect(request.path)
# Manually validating interfaces and interface templates lists
self._fetch_comparison_objects(device_id)
component_ids_to_delete = []
components_to_bulk_create = []
synced_count = 0
for template, component in self.comparison_table:
if template and (template.id in components_to_add):
# Add component to the device from the template
components_to_bulk_create.append(
self.obj_model(device=self.device, **template.get_fields_for_netbox_component())
)
elif component and (component.id in components_to_delete):
# Delete component from the device
component_ids_to_delete.append(component.id)
elif (template and component) and (component.id in components_to_sync):
# Update component attributes from the template
synced_count += self.components.filter(id=component.id).update(
**template.get_fields_for_netbox_component(sync=True)
)
deleted_count = self.obj_model.objects.filter(id__in=component_ids_to_delete).delete()[0]
created_count = len(self.obj_model.objects.bulk_create(components_to_bulk_create))
# Generating result message
component_type_name = self.obj_model._meta.verbose_name_plural
message = []
if synced_count > 0:
message.append(f"synced {synced_count} {component_type_name}")
if created_count > 0:
message.append(f"created {created_count} {component_type_name}")
if deleted_count > 0:
message.append(f"deleted {deleted_count} {component_type_name}")
messages.success(request, "; ".join(message).capitalize())
return redirect(request.path)
class ConsolePortComparisonView(GenericComparisonView):
"""Comparison of console ports between a device and a device type and beautiful visualization"""
obj_model = ConsolePort
obj_template_model = ConsolePortTemplate
class ConsoleServerPortComparisonView(GenericComparisonView):
"""Comparison of console server ports between a device and a device type and beautiful visualization"""
obj_model = ConsoleServerPort
obj_template_model = ConsoleServerPortTemplate
class InterfaceComparisonView(GenericComparisonView):
"""Comparison of interfaces between a device and a device type and beautiful visualization"""
obj_model = Interface
obj_template_model = InterfaceTemplate
@staticmethod
def filter_comparison_components(component_templates: QuerySet, components: QuerySet) -> Tuple[QuerySet, QuerySet]:
if config["exclude_virtual_interfaces"]:
components = components.exclude(type__in=VIRTUAL_IFACE_TYPES)
component_templates = component_templates.exclude(type__in=VIRTUAL_IFACE_TYPES)
return component_templates, components
class PowerPortComparisonView(GenericComparisonView):
"""Comparison of power ports between a device and a device type and beautiful visualization"""
obj_model = PowerPort
obj_template_model = PowerPortTemplate
class PowerOutletComparisonView(GenericComparisonView):
"""Comparison of power outlets between a device and a device type and beautiful visualization"""
obj_model = PowerOutlet
obj_template_model = PowerOutletTemplate
def post(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)
# Generating result message
message = []
created = 0
updated = 0
fixed = 0
remove_from_device = filter(
lambda i: i in poweroutlets.values_list("id", flat=True),
map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove")))
)
# Remove selected power outlets from the device and count them
deleted = PowerOutlet.objects.filter(id__in=remove_from_device).delete()[0]
# Get device power ports to check dependency between power outlets
device_pp = PowerPort.objects.filter(device_id=device.id)
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
# 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 interface_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 interfaces.values_list("id", flat=True),
map(int, filter(lambda x: x.isdigit(), request.POST.getlist("remove_from_device")))
lambda i: i in poweroutlets_templates.values_list("id", flat=True),
map(int, filter(lambda x: x.isdigit(), request.POST.getlist("add")))
)
# Remove selected interfaces from the device and count them
interfaces_deleted = Interface.objects.filter(id__in=remove_from_device).delete()[0]
# Add selected component to the device and count them
add_to_device_component = PowerOutletTemplate.objects.filter(id__in=add_to_device)
# Add selected interfaces to the device and count them
add_to_device_interfaces = InterfaceTemplate.objects.filter(id__in=add_to_device)
interfaces_created = len(Interface.objects.bulk_create([
Interface(device=device, name=i.name, type=i.type) for i in add_to_device_interfaces
]))
bulk_create = []
updated = 0
keys_to_avoid = ["id"]
# 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.get_type_display()) for i in interface_templates]
if not config["compare_description"]:
keys_to_avoid.append("description")
for i in add_to_device_component.values():
to_create = False
# Rename selected interfaces
interfaces_fixed = 0
for interface in fix_name_interfaces:
unified_interface = UnifiedInterface(interface.id, interface.name, interface.type, 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
# 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, "Dependency detected, sync power ports first!")
# 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())
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")
return redirect(request.path)
messages.info(request, "; ".join(message).capitalize())
return redirect(request.path)
class RearPortComparisonView(GenericComparisonView):
"""Comparison of rear ports between a device and a device type and beautiful visualization"""
obj_model = RearPort
obj_template_model = RearPortTemplate
class DeviceBayComparisonView(GenericComparisonView):
"""Comparison of device bays between a device and a device type and beautiful visualization"""
obj_model = DeviceBay
obj_template_model = DeviceBayTemplate
#
#
# class FrontPortComparisonView(LoginRequiredMixin, PermissionRequiredMixin, View):
# """Comparison of front ports between a device and a device type and beautiful visualization"""
# permission_required = get_permissions_for_object("dcim", "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)
#
# 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 = []
# 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 front 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, "Dependency 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)

View File

@ -12,7 +12,7 @@ setup(
author='Victor Golovanenko',
author_email='drygdryg2014@yandex.com',
license='GPL-3.0',
install_requires=[],
install_requires=['attrs>=21.1.0'],
packages=["netbox_interface_sync"],
package_data={"netbox_interface_sync": ["templates/netbox_interface_sync/*.html"]},
zip_safe=False