Compare commits

..
7 Commits
Author SHA1 Message Date
mattieserver 8368ffa2fd prepare 3.9.1 (#480) 2024-04-19 15:55:04 +02:00
Mario 23428c8ad2 set models to private (#476) 2024-04-01 23:26:46 +02:00
Mario c00b8c2cfe Fixes #236: Saved filter doesn't reload show cables (#475)
* fix restore saved filter options

* fix restore saved filter group options

* fix htmx export to xml
2024-03-30 17:29:26 +01:00
Mario 32003e59f9 fix power feeds attributeerror (#474) 2024-03-30 11:17:04 +01:00
Mario bd32642b5c refine group frame color (#472) 2024-03-30 10:13:29 +01:00
Mario f463804087 added group options to api (#466) 2024-03-08 16:19:55 +01:00
Mario b92b012309 Closes #329: Draw rectangles around Sites/Locations/Racks (#462) 2024-03-03 17:52:44 +01:00
13 changed files with 397 additions and 22 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ class TopologyViewsConfig(PluginConfig):
name = "netbox_topology_views"
verbose_name = "Topology views"
description = "An plugin to render topology maps"
version = "3.9.0"
version = "3.9.1"
author = "Mattijs Vanhaverbeke"
author_email = "author@example.com"
base_url = "netbox_topology_views"
+1 -1
View File
@@ -50,4 +50,4 @@ class PowerFeedCoordinateSerializer(NetBoxModelSerializer):
class IndividualOptionsSerializer(NetBoxModelSerializer):
class Meta:
model = IndividualOptions
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "draw_default_layout")
fields = ("ignore_cable_type", "save_coords", "show_unconnected", "show_cables", "show_logical_connections", "show_single_cable_logical_conns", "show_neighbors", "show_circuit", "show_power", "show_wireless", "group_sites", "group_locations", "group_racks", "draw_default_layout")
+28 -1
View File
@@ -3,6 +3,7 @@ import sys
from circuits.models import Circuit
from dcim.models import Device, DeviceRole, PowerFeed, PowerPanel
from extras.models import SavedFilter
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
@@ -108,7 +109,30 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
if request.GET:
save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors = get_query_settings(request)
filter_id, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks,show_neighbors = get_query_settings(request)
# Read options from saved filters as NetBox does not handle custom plugin filters
if "filter_id" in request.GET and request.GET["filter_id"] != '':
try:
saved_filter = SavedFilter.objects.get(pk=filter_id)
saved_filter_params = getattr(saved_filter, 'parameters')
if save_coords == False and 'save_coords' in saved_filter_params: save_coords = saved_filter_params['save_coords']
if show_power == False and 'show_power' in saved_filter_params: show_power = saved_filter_params['show_power']
if show_circuit == False and 'show_circuit' in saved_filter_params: show_circuit = saved_filter_params['show_circuit']
if show_logical_connections == False and 'show_logical_connections' in saved_filter_params: show_logical_connections = saved_filter_params['show_logical_connections']
if show_single_cable_logical_conns == False and 'show_single_cable_logical_conns' in saved_filter_params: show_single_cable_logical_conns = saved_filter_params['show_single_cable_logical_conns']
if show_cables == False and 'show_cables' in saved_filter_params: show_cables = saved_filter_params['show_cables']
if show_wireless == False and 'show_wireless' in saved_filter_params: show_wireless = saved_filter_params['show_wireless']
if group_sites == False and 'group_sites' in saved_filter_params: group_sites = saved_filter_params['group_sites']
if group_locations == False and 'group_locations' in saved_filter_params: group_locations = saved_filter_params['group_locations']
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
except SavedFilter.DoesNotExist: # filter_id not found
pass
except Exception as inst:
print(type(inst))
if 'group' not in request.query_params:
group_id = "default"
else:
@@ -125,6 +149,9 @@ class ExportTopoToXML(PermissionRequiredMixin, ViewSet):
show_circuit=show_circuit,
show_power=show_power,
show_wireless=show_wireless,
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_id=group_id,
)
xml_data = export_data_to_xml(topo_data).decode('utf-8')
+39 -2
View File
@@ -35,7 +35,8 @@ class DeviceFilterForm(
(None, ('q', 'filter_id', 'tag')),
(_('Options'), (
'group', 'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power', 'show_wireless',
'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power', 'show_wireless',
'group_sites', 'group_locations', 'group_racks'
)),
(_('Device'), ('id',)),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
@@ -258,6 +259,15 @@ class DeviceFilterForm(
show_wireless = forms.BooleanField(
label =_('Show Wireless Links'), required=False, initial=False
)
group_sites = forms.BooleanField(
label =_('Group Sites'), required=False, initial=False
)
group_locations = forms.BooleanField(
label =_('Group Locations'), required=False, initial=False
)
group_racks = forms.BooleanField(
label =_('Group Racks'), required=False, initial=False
)
class CoordinateGroupsForm(NetBoxModelForm):
fieldsets = (
@@ -447,6 +457,9 @@ class IndividualOptionsForm(NetBoxModelForm):
'show_circuit',
'show_power',
'show_wireless',
'group_sites',
'group_locations',
'group_racks',
'draw_default_layout',
),
),
@@ -548,6 +561,27 @@ class IndividualOptionsForm(NetBoxModelForm):
help_text=_('Displays wireless connections. These connections are '
'displayed as blue dotted lines.')
)
group_sites = forms.BooleanField(
label =_('Group Sites'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same site.')
)
group_locations = forms.BooleanField(
label =_('Group Locations'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same location.')
)
group_racks = forms.BooleanField(
label =_('Group Racks'),
required=False,
initial=False,
help_text=_('Draws a rectangle around Devices that belong to the '
'same rack.')
)
draw_default_layout = forms.BooleanField(
label = ('Draw Default Layout'),
required=False,
@@ -559,5 +593,8 @@ class IndividualOptionsForm(NetBoxModelForm):
class Meta:
model = IndividualOptions
fields = [
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags', 'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections', 'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power', 'show_wireless', 'draw_default_layout'
'user_id', 'ignore_cable_type', 'preselected_device_roles', 'preselected_tags',
'save_coords', 'show_unconnected', 'show_cables', 'show_logical_connections',
'show_single_cable_logical_conns', 'show_neighbors', 'show_circuit', 'show_power',
'show_wireless', 'group_sites', 'group_locations', 'group_racks', 'draw_default_layout'
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-03-02 16:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_topology_views', '0006_powerpanelcoordinate_powerfeedcoordinate_and_more'),
]
operations = [
migrations.AddField(
model_name='individualoptions',
name='group_locations',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='individualoptions',
name='group_racks',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='individualoptions',
name='group_sites',
field=models.BooleanField(default=False),
),
]
+23
View File
@@ -41,6 +41,8 @@ class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, EventRulesMixin):
__role: Optional[Role] = None
_netbox_private = True
@property
def role(self) -> Role:
if self.__role:
@@ -111,6 +113,8 @@ class CoordinateGroup(NetBoxModel):
blank = True,
)
_netbox_private = True
class Meta:
ordering = ['name']
@@ -138,6 +142,8 @@ class Coordinate(NetBoxModel):
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
@@ -187,6 +193,8 @@ class CircuitCoordinate(NetBoxModel):
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
@@ -236,6 +244,8 @@ class PowerPanelCoordinate(NetBoxModel):
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
@@ -285,6 +295,8 @@ class PowerFeedCoordinate(NetBoxModel):
'Smaller values correspond to a position further up on the monitor.',
)
_netbox_private = True
def get_or_create_default_group(group_id):
# Default group named "default" must always exist in order to make sure
# that coordinate values can be stored even if no coordinate group has been
@@ -374,9 +386,20 @@ class IndividualOptions(NetBoxModel):
show_wireless = models.BooleanField(
default=False
)
group_sites = models.BooleanField(
default=False
)
group_locations = models.BooleanField(
default=False
)
group_racks = models.BooleanField(
default=False
)
draw_default_layout = models.BooleanField(
default=False
)
_netbox_private = True
def __str___(self):
return f"{self.user_id}"
File diff suppressed because one or more lines are too long
+188
View File
@@ -69,6 +69,11 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
title: htmlTitle(node.title)
}))
)
const group_sites = topologyData.options.group_sites
const group_locations = topologyData.options.group_locations
const group_racks = topologyData.options.group_racks
graph = new Network(container, { nodes, edges }, options)
graph.fit()
@@ -133,6 +138,189 @@ const coordSaveCheckbox = document.querySelector('#id_save_coords')
})
}
})
graph.on('afterDrawing', (canvascontext) => {
allRectangles = [];
if(group_sites != null && group_sites == 'on') { drawGroupRectangles(canvascontext, groupedNodeSites, siteRectParams); }
if(group_locations != null && group_locations == 'on') { drawGroupRectangles(canvascontext, groupedNodeLocations, locationRectParams); }
if(group_racks != null && group_racks == 'on') { drawGroupRectangles(canvascontext, groupedNodeRacks, rackRectParams); }
})
graph.on('click', (canvascontext) => {
allRectangles.forEach(key => {
// Is the mouse pointer inside of the current rectangle?
if(canvascontext.pointer.canvas.x > (key.x1 - key.border / 2 - 1) && canvascontext.pointer.canvas.x < (key.x2 + key.border / 2 + 1)
&& canvascontext.pointer.canvas.y > (key.y1 - key.border / 2 - 1) && canvascontext.pointer.canvas.y < (key.y2 + key.border / 2 + 1)) {
// We just want to react when the border has been clicked, not the whole rectangle
if (canvascontext.pointer.canvas.x < (key.x1 + key.border / 2 + 1) || canvascontext.pointer.canvas.x > (key.x2 - key.border / 2 - 1)
|| canvascontext.pointer.canvas.y < (key.y1 + key.border / 2 + 1) || canvascontext.pointer.canvas.y > (key.y2 - key.border / 2 - 1)) {
// Generate an array of affected nodes in order to pass it to the select.Nodes() function
let arr = [];
if(key.category == "Site") {
groupedNodeSites.forEach(subArray => {
subArray.forEach(element => {
if (element[1] == key.id) {
arr.push(element[0]);
}
});
});
}
if(key.category == "Location") {
groupedNodeLocations.forEach(subArray => {
subArray.forEach(element => {
if (element[1] === key.id) {
arr.push(element[0]);
}
});
});
}
if(key.category == "Rack") {
groupedNodeRacks.forEach(subArray => {
subArray.forEach(element => {
if (element[1] === key.id) {
arr.push(element[0]);
}
});
});
}
graph.selectNodes(arr);
}
}
});
})
// Add information on which node belongs to which group (site/location/rack).
// Create an array for each group in order to loop through that arrays later
function combineNodeInfo(typeId, type) {
let nodesArray = [];
// Extract node ids and node type ids from all nodes
for (let [key, value] of nodes._data) {
if (value[typeId] != undefined) {
nodesArray.push([value.id, value[typeId], value[type]]);
}
}
// Split single array above into arrays grouped by node id
let groupedNodeArray = nodesArray.reduce((acc, value) => {
let key = value[1]; // node id
acc[key] = acc[key] || [];
acc[key].push(value);
return acc;
}, {});
return Object.values(groupedNodeArray);
}
var allRectangles = [];
/* Draw a single rectangle with given parameters
rectangle expects an object that consists of the following keys:
ctx: canvas context on which the rectangle should be drawn
x: x-coordinate of top left point of the rectangle
y: y-coordinate of top left point of the rectangle
width: width of rectangle
height: height of rectangle
lineWidth: border width
color: border color
text: a string to be placed where you want it to be
textPaddingX: x-position of the text
textPaddingY: y-position of the text
font: text font */
function drawGroupRectangle(rectangle) {
// Draw rectangle
rectangle.ctx.beginPath();
rectangle.ctx.lineWidth = rectangle.lineWidth;
rectangle.ctx.strokeStyle = rectangle.color;
rectangle.ctx.rect(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
rectangle.ctx.stroke();
// Draw text
rectangle.ctx.font = rectangle.font;
rectangle.ctx.fillStyle = rectangle.color;
rectangle.ctx.fillText(rectangle.text, rectangle.x + rectangle.textPaddingX, rectangle.y + rectangle.textPaddingY);
allRectangles.push({category: rectangle.category, id: rectangle.id, x1: rectangle.x, y1: rectangle.y, x2: rectangle.x + rectangle.width, y2: rectangle.y + rectangle.height, border: rectangle.lineWidth})
}
/* Draw all rectangles of a given group (site/location/rack)
rectParams expects an object that consists of the following keys:
lineWidth: border width (string)
color: border color (string)
paddingX: rectangle x-padding, calculated from the center of a node (int)
paddingY: rectangle y-padding, calculated from the center of a node (int)
textPaddingX: text x-padding, calculated from the lower left point of the text (int)
textPaddingY: text y-padding, calculated from the lower left point of the text (int)
font: css-like font size and font (string) */
function drawGroupRectangles(canvascontext, groupedNodes, rectParams) {
for(let value of Object.entries(groupedNodes)) {
const rectangles = [];
const xValues = [];
const yValues = [];
for(let val of value[1]) {
xValues.push(graph.getPosition(val[0]).x);
yValues.push(graph.getPosition(val[0]).y);
}
const rectX = Math.min(...xValues) - rectParams.paddingX;
const rectY = Math.min(...yValues) - rectParams.paddingY;
const rectSizeX = Math.max(...xValues) - Math.min(...xValues) + 2*rectParams.paddingX;
const rectSizeY = Math.max(...yValues) - Math.min(...yValues) + 2*rectParams.paddingY;
rectangles.push({
ctx: canvascontext,
x: rectX,
y: rectY,
width: rectSizeX,
height: rectSizeY,
lineWidth: rectParams.lineWidth,
color: rectParams.color,
text: value[1][0][2],
textPaddingX: rectParams.textPaddingX,
textPaddingY: rectParams.textPaddingY,
font: rectParams.font,
id: value[1][0][1],
category: rectParams.category
});
rectangles.forEach(function(rectangle) {
drawGroupRectangle(rectangle);
});
}
}
let groupedNodeSites = combineNodeInfo('site_id', 'site');
let siteRectParams = {
lineWidth: "5",
color: "red",
paddingX: 84,
paddingY: 84,
textPaddingX: 8,
textPaddingY: -8,
font: "14px helvetica",
category: "Site"
}
let groupedNodeLocations = combineNodeInfo('location_id', 'location');
let locationRectParams = {
lineWidth: "5",
color: "#337ab7",
paddingX: 77,
paddingY: 77,
textPaddingX: 12,
textPaddingY: 22,
font: "14px helvetica",
category: "Location"
}
let groupedNodeRacks = combineNodeInfo('rack_id', 'rack');
let rackRectParams = {
lineWidth: "5",
color: "green",
paddingX: 70,
paddingY: 70,
textPaddingX: 8,
textPaddingY: 30,
font: "14px helvetica",
category: "Rack"
}
})()
// Download Graph
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netbox_topology_views",
"version": "3.9.0",
"version": "3.9.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
@@ -1,7 +1,7 @@
{
"private": true,
"name": "netbox_topology_views",
"version": "3.9.0",
"version": "3.9.1",
"scripts": {
"bundle": "node bundle.js",
"bundle:styles": "node bundle.js --styles",
+20 -1
View File
@@ -107,6 +107,10 @@ def get_model_role(model: Type[Model]) -> Role:
)
def get_query_settings(request):
filter_id = ''
if "filter_id" in request.GET:
filter_id = request.GET["filter_id"]
save_coords = False
if "save_coords" in request.GET:
if request.GET["save_coords"] == "on":
@@ -154,12 +158,27 @@ def get_query_settings(request):
if request.GET["show_wireless"] == "on" :
show_wireless = True
group_sites = False
if "group_sites" in request.GET:
if request.GET["group_sites"] == "on" :
group_sites = True
group_locations = False
if "group_locations" in request.GET:
if request.GET["group_locations"] == "on" :
group_locations = True
group_racks = False
if "group_racks" in request.GET:
if request.GET["group_racks"] == "on" :
group_racks = True
show_neighbors = False
if "show_neighbors" in request.GET:
if request.GET["show_neighbors"] == "on" :
show_neighbors = True
return save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors
return filter_id, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, show_neighbors
class LinePattern():
wireless = [2, 10, 2, 10]
+55 -2
View File
@@ -27,7 +27,7 @@ from django.db.models.functions import Lower
from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render, get_object_or_404
from django.views.generic import View
from extras.models import Tag
from extras.models import Tag, SavedFilter
from wireless.models import WirelessLink
from netbox.views.generic import (
ObjectView,
@@ -180,6 +180,16 @@ def create_node(
node["id"] = device.pk
if device.site is not None:
node["site"] = device.site.name
node["site_id"] = device.site_id
if device.location is not None:
node["location"] = device.location.name
node["location_id"] = device.location_id
if device.rack is not None:
node["rack"] = device.rack.name
node["rack_id"] = device.rack_id
if device.device_role.color != "":
node["color.border"] = "#" + device.device_role.color
@@ -325,6 +335,9 @@ def get_topology_data(
show_neighbors: bool,
show_power: bool,
show_wireless: bool,
group_sites: bool,
group_locations: bool,
group_racks: bool,
group_id,
):
@@ -338,6 +351,7 @@ def get_topology_data(
nodes_devices = {}
edges = []
nodes = []
options = {}
edge_ids = 0
nodes_circuits: Dict[int, Circuit] = {}
nodes_powerpanel: Dict[int, PowerPanel] = {}
@@ -651,6 +665,13 @@ def get_topology_data(
)
)
if group_locations:
options['group_locations'] = 'on'
if group_racks:
options['group_racks'] = 'on'
if group_sites:
options['group_sites'] = 'on'
for qs_device in queryset:
if qs_device.pk not in nodes_devices and show_unconnected:
nodes_devices[qs_device.pk] = qs_device
@@ -663,6 +684,7 @@ def get_topology_data(
results["nodes"] = nodes
results["edges"] = edges
results["group"] = group_id
results["options"] = options
return results
@@ -688,8 +710,30 @@ class TopologyHomeView(PermissionRequiredMixin, View):
if request.GET:
save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, show_neighbors = get_query_settings(request)
filter_id, save_coords, show_unconnected, show_power, show_circuit, show_logical_connections, show_single_cable_logical_conns, show_cables, show_wireless, group_sites, group_locations, group_racks, show_neighbors = get_query_settings(request)
# Read options from saved filters as NetBox does not handle custom plugin filters
if "filter_id" in request.GET and request.GET["filter_id"] != '':
try:
saved_filter = SavedFilter.objects.get(pk=filter_id)
saved_filter_params = getattr(saved_filter, 'parameters')
if save_coords == False and 'save_coords' in saved_filter_params: save_coords = saved_filter_params['save_coords']
if show_power == False and 'show_power' in saved_filter_params: show_power = saved_filter_params['show_power']
if show_circuit == False and 'show_circuit' in saved_filter_params: show_circuit = saved_filter_params['show_circuit']
if show_logical_connections == False and 'show_logical_connections' in saved_filter_params: show_logical_connections = saved_filter_params['show_logical_connections']
if show_single_cable_logical_conns == False and 'show_single_cable_logical_conns' in saved_filter_params: show_single_cable_logical_conns = saved_filter_params['show_single_cable_logical_conns']
if show_cables == False and 'show_cables' in saved_filter_params: show_cables = saved_filter_params['show_cables']
if show_wireless == False and 'show_wireless' in saved_filter_params: show_wireless = saved_filter_params['show_wireless']
if group_sites == False and 'group_sites' in saved_filter_params: group_sites = saved_filter_params['group_sites']
if group_locations == False and 'group_locations' in saved_filter_params: group_locations = saved_filter_params['group_locations']
if group_racks == False and 'group_racks' in saved_filter_params: group_racks = saved_filter_params['group_racks']
if show_neighbors == False and 'show_neighbors' in saved_filter_params: show_neighbors = saved_filter_params['show_neighbors']
except SavedFilter.DoesNotExist: # filter_id not found
pass
except Exception as inst:
print(type(inst))
if "group" not in request.GET:
group_id = "default"
else:
@@ -708,6 +752,9 @@ class TopologyHomeView(PermissionRequiredMixin, View):
show_circuit=show_circuit,
show_power=show_power,
show_wireless=show_wireless,
group_sites=group_sites,
group_locations=group_locations,
group_racks=group_racks,
group_id=group_id,
)
@@ -729,6 +776,9 @@ class TopologyHomeView(PermissionRequiredMixin, View):
if individualOptions.show_circuit: q['show_circuit'] = "on"
if individualOptions.show_power: q['show_power'] = "on"
if individualOptions.show_wireless: q['show_wireless'] = "on"
if individualOptions.group_sites: q['group_sites'] = "on"
if individualOptions.group_locations: q['group_locations'] = "on"
if individualOptions.group_racks: q['group_racks'] = "on"
if individualOptions.draw_default_layout:
q['draw_init'] = "true"
else:
@@ -1083,6 +1133,9 @@ class TopologyIndividualOptionsView(PermissionRequiredMixin, View):
'show_circuit': queryset.show_circuit,
'show_power': queryset.show_power,
'show_wireless': queryset.show_wireless,
'group_sites': queryset.group_sites,
'group_locations': queryset.group_locations,
'group_racks': queryset.group_racks,
'draw_default_layout': queryset.draw_default_layout,
},
)
+1 -1
View File
@@ -7,7 +7,7 @@ long_description = readme.read_text()
setup(
name="netbox-topology-views",
version="3.9.0",
version="3.9.1",
description="An NetBox plugin to create Topology maps",
long_description=long_description,
long_description_content_type="text/markdown",