diff --git a/netbox_topology_views/api/serializers.py b/netbox_topology_views/api/serializers.py index 82963eb..5aa3054 100644 --- a/netbox_topology_views/api/serializers.py +++ b/netbox_topology_views/api/serializers.py @@ -2,7 +2,7 @@ from dcim.models import Device, DeviceRole from rest_framework.serializers import ModelSerializer from netbox.api.serializers import NetBoxModelSerializer -from netbox_topology_views.models import RoleImage, IndividualOptions +from netbox_topology_views.models import RoleImage, IndividualOptions, CoordinateGroups, Coordinates class TopologyDummySerializer(ModelSerializer): @@ -22,6 +22,16 @@ class DeviceRoleSerializer(ModelSerializer): model = DeviceRole fields = ("name", "slug", "color", "vm_role", "description") +class CoordinateGroupsSerializer(NetBoxModelSerializer): + class Meta: + model = CoordinateGroups + fields = ("name", "description") + +class CoordinatesSerializer(NetBoxModelSerializer): + class Meta: + model = Coordinates + fields = ("x", "y") + class IndividualOptionsSerializer(NetBoxModelSerializer): class Meta: model = IndividualOptions diff --git a/netbox_topology_views/filters.py b/netbox_topology_views/filters.py index 9a8e70d..324c298 100644 --- a/netbox_topology_views/filters.py +++ b/netbox_topology_views/filters.py @@ -1,6 +1,7 @@ import django_filters from dcim.choices import DeviceStatusChoices from dcim.models import Device, DeviceRole, Location, Rack, Region, Site +from .models import Coordinates from django.db.models import Q from netbox.filtersets import NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -53,3 +54,15 @@ class DeviceFilterSet(TenancyFilterSet, NetBoxModelFilterSet): return queryset qs_filter = Q(name__icontains=value) return queryset.filter(qs_filter) + +class CoordinateFilterSet(NetBoxModelFilterSet): + class Meta: + model = Coordinates + fields = ('id', 'group', 'device', 'x', 'y') + + def search(self, queryset, name, value): + """Perform the filtered search.""" + if not value.strip(): + return queryset + qs_filter = Q(group__icontains=value | Q(device__icontains=value)) + return queryset.filter(qs_filter) \ No newline at end of file diff --git a/netbox_topology_views/forms.py b/netbox_topology_views/forms.py index af1ab06..38bf126 100644 --- a/netbox_topology_views/forms.py +++ b/netbox_topology_views/forms.py @@ -15,12 +15,13 @@ from django.conf import settings from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import ( TagFilterField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField ) -from .models import IndividualOptions +from .models import IndividualOptions, CoordinateGroups, Coordinates class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Device @@ -141,6 +142,55 @@ class DeviceFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label =_("Show Wireless Links"), required=False, initial=False ) +class CoordinateGroupsForm(NetBoxModelForm): + fieldsets = ( + ('Group Details', ('name', 'description')), + ) + + class Meta: + model = CoordinateGroups + fields = ('name', 'description') + +class CoordinatesForm(NetBoxModelForm): + fieldsets = ( + ('Coordinates', ('group', 'device', 'x', 'y')), + ) + + class Meta: + model = Coordinates + fields = ('group', 'device', 'x', 'y') + +class CoordinatesFilterForm(NetBoxModelFilterSetForm): + model = Coordinates + + group = forms.ModelMultipleChoiceField( + queryset=CoordinateGroups.objects.all(), + required=False + ) + + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_("Device"), + query_params={ + "location_id": "$location_id", + "region_id": "$region_id", + "site_id": "$site_id", + "role_id": "$device_role_id", + }, + ) + x = forms.IntegerField( + required=False + ) + + y = forms.IntegerField( + required=False + ) + class IndividualOptionsForm(NetBoxModelForm): fieldsets = ( ( diff --git a/netbox_topology_views/migrations/0004_coordinategroups_coordinates.py b/netbox_topology_views/migrations/0004_coordinategroups_coordinates.py new file mode 100644 index 0000000..8d7c295 --- /dev/null +++ b/netbox_topology_views/migrations/0004_coordinategroups_coordinates.py @@ -0,0 +1,51 @@ +# Generated by Django 4.1.8 on 2023-05-01 08:21 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0171_cabletermination_change_logging'), + ('extras', '0092_delete_jobresult'), + ('netbox_topology_views', '0003_individualoptions_show_neighbors'), + ] + + operations = [ + migrations.CreateModel( + name='CoordinateGroups', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Coordinates', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('x', models.IntegerField()), + ('y', models.IntegerField()), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.device')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='netbox_topology_views.coordinategroups')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['group', 'device'], + 'unique_together': {('device', 'group')}, + }, + ), + ] diff --git a/netbox_topology_views/models.py b/netbox_topology_views/models.py index 941ef34..7eb239e 100644 --- a/netbox_topology_views/models.py +++ b/netbox_topology_views/models.py @@ -1,12 +1,13 @@ from pathlib import Path from typing import Optional -from dcim.models import DeviceRole +from dcim.models import Device, DeviceRole from extras.models import Tag from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db import models from django.templatetags.static import static +from django.urls import reverse from netbox.models import NetBoxModel from netbox.models.features import ( ChangeLoggingMixin, @@ -94,6 +95,63 @@ class RoleImage(ChangeLoggingMixin, ExportTemplatesMixin, WebhooksMixin): return self.get_default_image(dir) return static(f"/{self.image}") +class CoordinateGroups(NetBoxModel): + """ + A coordinate group is used to display the topology for a particular group. + This allows different visualizations with the same devices. + """ + name = models.CharField( + max_length=100, + unique=True, + ) + + description = models.CharField( + max_length=255, + blank = True, + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('plugins:netbox_topology_views:coordinategroups', args=[self.pk]) + +class Coordinates(NetBoxModel): + """ + Coordinates are being used to place devices in a topology view onto a certian + position. Devices belong to one or more coordinate groups. They have to + be unique together. + """ + device = models.ForeignKey(Device, on_delete=models.CASCADE) + group = models.ForeignKey(CoordinateGroups, on_delete=models.CASCADE) + + x = models.IntegerField( + help_text='X-coordinate of the device (horizontal) on the canvas. ' + 'Smaller values correspond to a position further up on the monitor.', + ) + y = models.IntegerField( + help_text='Y-coordinate of the device (vertical) on the canvas. ' + 'Smaller values correspond to a position further to the left on the monitor.', + ) + + class Meta: + ordering = ['group', 'device'] + unique_together = ('device', 'group') + + def __str__(self): + return f'{self.x};{self.y}' + + def get_absolute_url(self): + return reverse('plugins:netbox_topology_views:coordinates', args=[self.pk]) + + def set_xy_from_text_coords(self, coords: str): + xy = coords.split(';') + self.x = xy[0] + self.y = xy[1] + class IndividualOptions(NetBoxModel): CHOICES = ( ('interface', 'interface'), diff --git a/netbox_topology_views/navigation.py b/netbox_topology_views/navigation.py index 32a29af..25b6c90 100644 --- a/netbox_topology_views/navigation.py +++ b/netbox_topology_views/navigation.py @@ -1,10 +1,35 @@ -from extras.plugins import PluginMenu, PluginMenuItem +from extras.plugins import PluginMenu, PluginMenuItem, PluginMenuButton +from utilities.choices import ButtonColorChoices + +coordinategroups_buttons = [ + PluginMenuButton( + link='plugins:netbox_topology_views:coordinategroups_add', + title='Add', + icon_class='mdi mdi-plus-thick', + color=ButtonColorChoices.GREEN, + ) +] + +coordinates_buttons = [ + PluginMenuButton( + link='plugins:netbox_topology_views:coordinates_add', + title='Add', + icon_class='mdi mdi-plus-thick', + color=ButtonColorChoices.GREEN, + ) +] menu = PluginMenu( label='Topology Views', icon_class="mdi mdi-sitemap", groups=( - ('TOPOLOGY', (PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"),),), + ('TOPOLOGY', + ( + PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology"), + PluginMenuItem(link="plugins:netbox_topology_views:coordinategroups_list", link_text="Coordinate Groups", buttons=coordinategroups_buttons), + PluginMenuItem(link="plugins:netbox_topology_views:coordinates_list", link_text="Coordinates", buttons=coordinates_buttons), + ), + ), ('PREFERENCES', ( PluginMenuItem(link="plugins:netbox_topology_views:images", link_text="Images"), diff --git a/netbox_topology_views/search.py b/netbox_topology_views/search.py new file mode 100644 index 0000000..9bae850 --- /dev/null +++ b/netbox_topology_views/search.py @@ -0,0 +1,18 @@ +from netbox.search import SearchIndex, register_search +from .models import CoordinateGroups, Coordinates + +@register_search +class CoordinateGroupsIndex(SearchIndex): + model = CoordinateGroups + fields = ( + ('name', 100), + ('description', 2000), + ) + +@register_search +class CoordinatesIndex(SearchIndex): + model = Coordinates + fields = ( + ('group', 100), + ('device', 200), + ) \ No newline at end of file diff --git a/netbox_topology_views/tables.py b/netbox_topology_views/tables.py new file mode 100644 index 0000000..abcb507 --- /dev/null +++ b/netbox_topology_views/tables.py @@ -0,0 +1,29 @@ +import django_tables2 as tables + +from netbox.tables import NetBoxTable, ChoiceFieldColumn +from .models import CoordinateGroups, Coordinates + +class CoordinateGroupListTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = CoordinateGroups + fields = ('pk', 'name', 'description') + default_columns = ('name', 'description') + +class CoordinateListTable(NetBoxTable): + group = tables.Column( + linkify=True + ) + + device = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = Coordinates + fields = ('pk', 'group', 'device', 'x', 'y') + default_columns = ('id', 'group', 'device', 'x', 'y') + diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinategroups.html b/netbox_topology_views/templates/netbox_topology_views/coordinategroups.html new file mode 100644 index 0000000..a8e0db1 --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinategroups.html @@ -0,0 +1,35 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block title %}Topology Views Coordinate Group{% endblock title %} + +{% block content %} +
+
+
+
+ Coordinate Group +
+
+ + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+{% endblock content %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinategroups_add.html b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_add.html new file mode 100644 index 0000000..1fb7b4e --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_add.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_edit.html' %} + +{% block title %}Add a new Coordinate Group{% endblock title %} diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinategroups_edit.html b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_edit.html new file mode 100644 index 0000000..4f67ce8 --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_edit.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_edit.html' %} + +{% block title %}Edit Coordinate Group{% endblock title %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinategroups_list.html b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_list.html new file mode 100644 index 0000000..f43874c --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinategroups_list.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_list.html' %} + +{% block title %}Coordinate Groups{% endblock title %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinates.html b/netbox_topology_views/templates/netbox_topology_views/coordinates.html new file mode 100644 index 0000000..1ffc230 --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinates.html @@ -0,0 +1,43 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block title %}Topology Views Coordinates{% endblock title %} + +{% block content %} +
+
+
+
+ Coordinates +
+
+ + + + + + + + + + + + + + + + + +
Group{{ object.group }}
Device{{ object.device }}
X-Coordinate{{ object.x }}
Y-Coordinate{{ object.y }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+{% endblock content %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinates_add.html b/netbox_topology_views/templates/netbox_topology_views/coordinates_add.html new file mode 100644 index 0000000..184c857 --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinates_add.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_edit.html' %} + +{% block title %}Add new Device Coordinates{% endblock title %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinates_edit.html b/netbox_topology_views/templates/netbox_topology_views/coordinates_edit.html new file mode 100644 index 0000000..8debe4d --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinates_edit.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_edit.html' %} + +{% block title %}Edit Device Coordinates{% endblock title %} \ No newline at end of file diff --git a/netbox_topology_views/templates/netbox_topology_views/coordinates_list.html b/netbox_topology_views/templates/netbox_topology_views/coordinates_list.html new file mode 100644 index 0000000..6470402 --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/coordinates_list.html @@ -0,0 +1,3 @@ +{% extends 'generic/object_list.html' %} + +{% block title %}Device Coordinates{% endblock title %} \ No newline at end of file diff --git a/netbox_topology_views/urls.py b/netbox_topology_views/urls.py index 8581297..f344c67 100644 --- a/netbox_topology_views/urls.py +++ b/netbox_topology_views/urls.py @@ -1,7 +1,7 @@ from django.urls import path from django.views.generic.base import RedirectView -from . import views +from . import models, 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. @@ -10,4 +10,20 @@ urlpatterns = ( path("topology/", views.TopologyHomeView.as_view(), name="home"), path("images/", views.TopologyImagesView.as_view(), name="images"), path("individualoptions/", views.TopologyIndividualOptionsView.as_view(), name="individualoptions"), + + # Coordinate Group + path("coordinate-groups/", views.CoordinateGroupListView.as_view(), name="coordinategroups_list"), + path("coordinate-groups/add/", views.CoordinateGroupAddView.as_view(), name="coordinategroups_add"), + path("coordinate-groups//", views.CoordinateGroupView.as_view(), name="coordinategroups"), + path("coordinate-groups//edit/", views.CoordinateGroupEditView.as_view(), name="coordinategroups_edit"), + path("coordinate-groups//delete/", views.CoordinateGroupDeleteView.as_view(), name="coordinategroups_delete"), + path("coordinate-groups//changelog/", views.CoordinateGroupChangeLogView.as_view(), name="coordinategroups_changelog", kwargs={'model': models.CoordinateGroups}), + + # Coordinate + path("coordinates/", views.CoordinateListView.as_view(), name="coordinates_list"), + path("coordinates/add/", views.CoordinateAddView.as_view(), name="coordinates_add"), + path("coordinates//", views.CoordinateView.as_view(), name="coordinates"), + path("coordinates//edit/", views.CoordinateEditView.as_view(), name="coordinates_edit"), + path("coordinates//delete/", views.CoordinateDeleteView.as_view(), name="coordinates_delete"), + path("coordinates//changelog/", views.CoordinateChangeLogView.as_view(), name="coordinates_changelog", kwargs={'model': models.Coordinates}), ) diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 890999e..f775cc7 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -29,10 +29,13 @@ from django.shortcuts import render from django.views.generic import View from extras.models import Tag from wireless.models import WirelessLink +from netbox.views.generic import ObjectView, ObjectListView, ObjectEditView, ObjectDeleteView, ObjectChangeLogView -from netbox_topology_views.filters import DeviceFilterSet -from netbox_topology_views.forms import DeviceFilterForm, IndividualOptionsForm -from netbox_topology_views.models import RoleImage, IndividualOptions + +from netbox_topology_views.filters import DeviceFilterSet, CoordinateFilterSet +from netbox_topology_views.forms import DeviceFilterForm, IndividualOptionsForm, CoordinateGroupsForm, CoordinatesForm, CoordinatesFilterForm +from netbox_topology_views.models import RoleImage, CoordinateGroups, Coordinates, IndividualOptions +from netbox_topology_views.tables import CoordinateGroupListTable, CoordinateListTable from netbox_topology_views.utils import ( CONF_IMAGE_DIR, find_image_url, @@ -751,6 +754,80 @@ class TopologyImagesView(PermissionRequiredMixin, View): }, ) +class CoordinateView(PermissionRequiredMixin, ObjectView): + permission_required = 'netbox_topology_views.view_coordinates' + + queryset = Coordinates.objects.all() + +class CoordinateAddView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'netbox_topology_views.add_coordinates' + + queryset = Coordinates.objects.all() + form = CoordinatesForm + template_name = 'netbox_topology_views/coordinates_add.html' + +class CoordinateListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'netbox_topology_views.view_coordinates' + + queryset = Coordinates.objects.all() + table = CoordinateListTable + template_name = 'netbox_topology_views/coordinates_list.html' + filterset = CoordinateFilterSet + filterset_form = CoordinatesFilterForm + +class CoordinateEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'netbox_topology_views.change_coordinates' + + queryset = Coordinates.objects.all() + form = CoordinatesForm + template_name = 'netbox_topology_views/coordinates_edit.html' + +class CoordinateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'netbox_topology_views.delete_coordinates' + + queryset = Coordinates.objects.all() + +class CoordinateChangeLogView(PermissionRequiredMixin, ObjectChangeLogView): + permission_required = 'netbox_topology_views.view_coordinates' + + queryset = Coordinates.objects.all() + +class CoordinateGroupView(PermissionRequiredMixin, ObjectView): + permission_required = 'netbox_topology_views.view_coordinategroups' + + queryset = CoordinateGroups.objects.all() + +class CoordinateGroupAddView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'netbox_topology_views.add_coordinategroups' + + queryset = CoordinateGroups.objects.all() + form = CoordinateGroupsForm + template_name = 'netbox_topology_views/coordinategroups_add.html' + +class CoordinateGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'netbox_topology_views.view_coordinategroups' + + queryset = CoordinateGroups.objects.all() + table = CoordinateGroupListTable + template_name = 'netbox_topology_views/coordinategroups_list.html' + +class CoordinateGroupEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'netbox_topology_views.change_coordinategroups' + + queryset = CoordinateGroups.objects.all() + form = CoordinateGroupsForm + template_name = 'netbox_topology_views/coordinategroups_edit.html' + +class CoordinateGroupDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'netbox_topology_views.delete_coordinategroups' + + queryset = CoordinateGroups.objects.all() + +class CoordinateGroupChangeLogView(PermissionRequiredMixin, ObjectChangeLogView): + permission_required = 'netbox_topology_views.view_coordinategroups' + + queryset = CoordinateGroups.objects.all() + class TopologyIndividualOptionsView(PermissionRequiredMixin, View): permission_required = 'netbox_topology_views.change_individualoptions'