From f489ffa043c8859cce39df69a8397b11825a720b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 7 Sep 2022 22:33:24 +0200 Subject: [PATCH 001/409] Allow running scripts nested in modules/packages --- netbox/extras/api/views.py | 2 +- netbox/extras/scripts.py | 8 +++++++- netbox/extras/urls.py | 4 ++-- netbox/templates/extras/script_list.html | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 82c68c86d..c7c6cc2aa 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -257,7 +257,7 @@ class ScriptViewSet(ViewSet): lookup_value_regex = '[^/]+' # Allow dots def _get_script(self, pk): - module_name, script_name = pk.split('.') + module_name, script_name = pk.split('.', maxsplit=1) script = get_script(module_name, script_name) if script is None: raise Http404 diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 6e4478304..23a778789 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -299,6 +299,10 @@ class BaseScript: def module(cls): return cls.__module__ + @classmethod + def root_module(cls): + return cls.__module__.split(".")[0] + @classproperty def job_timeout(self): return getattr(self.Meta, 'job_timeout', None) @@ -514,7 +518,9 @@ def get_scripts(use_names=False): ordered_scripts = [cls for cls in script_order if is_script(cls)] unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order] for cls in [*ordered_scripts, *unordered_scripts]: - module_scripts[cls.__name__] = cls + # For scripts in submodules use the full import path w/o the root module as the name + script_name = cls.full_name.split(".", maxsplit=1)[1] + module_scripts[script_name] = cls if module_scripts: scripts[module_name] = module_scripts diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 4c23adb0f..6c6156f4a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from extras import models, views from netbox.views.generic import ObjectChangeLogView @@ -105,7 +105,7 @@ urlpatterns = [ # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), - path('scripts/./', views.ScriptView.as_view(), name='script'), path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), + re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 8884ff77c..1f34f4d5e 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -34,7 +34,7 @@ {% for class_name, script in module_scripts.items %} - {{ script.name }} + {{ script.name }} {% include 'extras/inc/job_label.html' with result=script.result %} From b4877e7fac49282a766ebcdd2f886f71e8d61fa5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 15:45:01 -0700 Subject: [PATCH 002/409] #8580 add interface filters for connected --- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/filtersets.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5d92af878..1a9887c31 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1304,6 +1304,9 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class Meta: model = Interface @@ -1359,6 +1362,12 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 173ea5d1e..85fe909c5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1009,6 +1009,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'is_occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1087,6 +1088,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) class FrontPortFilterForm(DeviceComponentFilterForm): From d51e833bf378912c67f60d6eab6f6ba9565a6f09 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:11:17 -0700 Subject: [PATCH 003/409] #8580 changes from code review --- netbox/dcim/filtersets.py | 6 ++--- netbox/dcim/forms/filtersets.py | 41 ++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 1a9887c31..4ccf0dda5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,6 +1144,9 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class PathEndpointFilterSet(django_filters.FilterSet): @@ -1304,9 +1307,6 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' - ) class Meta: model = Interface diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 85fe909c5..fe92350f9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1000,7 +1000,28 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceFilterForm(DeviceComponentFilterForm): +class CabledFilterForm(forms.Form): + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1088,24 +1109,6 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) - cabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - is_occupied = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) class FrontPortFilterForm(DeviceComponentFilterForm): From 27d72746ca4aec377001cfc69fa416bf4bd652dc Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:20:27 -0700 Subject: [PATCH 004/409] #10172 upgrade Django to 4.1.1 --- base_requirements.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 363f97b31..22106587d 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -4,7 +4,7 @@ bleach # The Python web framework on which NetBox is built # https://github.com/django/django -Django<4.1 +Django<4.2 # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers diff --git a/requirements.txt b/requirements.txt index ddbf07b9b..37659c523 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==5.0.1 -Django==4.0.7 +Django==4.1.1 django-cors-headers==3.13.0 django-debug-toolbar==3.6.0 django-filter==22.1 From ce6bf9e5c1bc08edc80f6ea1e55cf1318ae6e14b Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 09:59:37 -0700 Subject: [PATCH 005/409] #10172 fixes for Django 4.1 --- netbox/dcim/models/device_components.py | 13 +++++++------ netbox/dcim/models/devices.py | 2 +- netbox/virtualization/models.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 838336e21..e22913a8b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -953,12 +953,13 @@ class RearPort(ModularComponentModel, CabledObjectModel): super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts - frontport_count = self.frontports.count() - if self.positions < frontport_count: - raise ValidationError({ - "positions": f"The number of positions cannot be less than the number of mapped front ports " - f"({frontport_count})" - }) + if self.pk: + frontport_count = self.frontports.count() + if self.positions < frontport_count: + raise ValidationError({ + "positions": f"The number of positions cannot be less than the number of mapped front ports " + f"({frontport_count})" + }) # diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ccf4613bf..7858960a1 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -268,7 +268,7 @@ class DeviceType(NetBoxModel): if ( self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT - ) and self.devicebaytemplates.count(): + ) and self.pk and self.devicebaytemplates.count(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " "declassifying it as a parent device." diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index abad57f88..4acbe6daf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -367,7 +367,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): }) # Validate primary IP addresses - interfaces = self.interfaces.all() + interfaces = self.interfaces.all() if self.pk else None for family in (4, 6): field = f'primary_ip{family}' ip = getattr(self, field) From 1daa2ff98d07020c864c90b5f0e306956da35479 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:22:05 -0700 Subject: [PATCH 006/409] #8580 add tests --- netbox/dcim/tests/test_filtersets.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1aaf861ef..15e109030 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2741,12 +2741,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'label': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_enabled(self): params = {'enabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -2885,6 +2879,18 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def is_occupied(self): + params = {'is_occupied': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'is_occupied': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) From d24f10ce6e2878a8963412ec271f0dd449862bf7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:52:40 -0700 Subject: [PATCH 007/409] #8580 add tests --- netbox/circuits/tests/test_filtersets.py | 18 ++++++++++++++++-- netbox/dcim/filtersets.py | 12 ++++++------ netbox/dcim/tests/test_filtersets.py | 6 +++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index abcfa8a00..ada3d9bf1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -397,12 +399,24 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_provider_network(self): provider_networks = ProviderNetwork.objects.all()[:2] params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + + def test_is_occupied(self): + params = {'is_occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'is_occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ProviderNetwork.objects.all() diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4ccf0dda5..3af5883ba 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1148,6 +1148,12 @@ class CabledObjectFilterSet(django_filters.FilterSet): method='filter_is_occupied' ) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class PathEndpointFilterSet(django_filters.FilterSet): connected = django_filters.BooleanFilter( @@ -1362,12 +1368,6 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def filter_is_occupied(self, queryset, name, value): - if value: - return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) - else: - return queryset.filter(cable__isnull=True, mark_connected=False) - class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 15e109030..6cbd91122 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def is_occupied(self): - params = {'is_occupied': 'true'} + def test_is_occupied(self): + params = {'is_occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': 'false'} + params = {'is_occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From 57365ef7b9d012b4c5d27361bfe7c0ca3012db15 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 16:42:04 -0400 Subject: [PATCH 008/409] Rename is_occupied to occupied --- netbox/circuits/tests/test_filtersets.py | 6 +++--- netbox/dcim/filtersets.py | 6 +++--- netbox/dcim/forms/filtersets.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index ada3d9bf1..0bc0711c1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -411,10 +411,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3af5883ba..afecf551c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,11 +1144,11 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' + occupied = django_filters.BooleanFilter( + method='filter_occupied' ) - def filter_is_occupied(self, queryset, name, value): + def filter_occupied(self, queryset, name, value): if value: return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) else: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fe92350f9..93e221b19 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1013,7 +1013,7 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - is_occupied = forms.NullBooleanField( + occupied = forms.NullBooleanField( required=False, widget=StaticSelect( choices=BOOLEAN_WITH_BLANK_CHOICES @@ -1030,7 +1030,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'is_occupied')) + ('Connection', ('cabled', 'connected', 'occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6cbd91122..49e68b9a2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From f10460d774db773c759faa4b9b96c59470c12a5a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:03:33 -0400 Subject: [PATCH 009/409] Add relevant tests for all device components --- netbox/circuits/tests/test_filtersets.py | 8 +- netbox/dcim/forms/filtersets.py | 157 ++++++++++++----------- netbox/dcim/tests/test_filtersets.py | 128 +++++++++++------- 3 files changed, 167 insertions(+), 126 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 0bc0711c1..2646de3c2 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -404,12 +404,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_occupied(self): params = {'occupied': True} diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 93e221b19..98be0983e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -936,70 +936,6 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): # Device components # -class ConsolePortFilterForm(DeviceComponentFilterForm): - model = ConsolePort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class ConsoleServerPortFilterForm(DeviceComponentFilterForm): - model = ConsoleServerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerPortFilterForm(DeviceComponentFilterForm): - model = PowerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerPortTypeChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerOutletFilterForm(DeviceComponentFilterForm): - model = PowerOutlet - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerOutletTypeChoices, - required=False - ) - tag = TagFilterField(model) - - class CabledFilterForm(forms.Form): cabled = forms.NullBooleanField( required=False, @@ -1007,12 +943,6 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) occupied = forms.NullBooleanField( required=False, widget=StaticSelect( @@ -1021,7 +951,84 @@ class CabledFilterForm(forms.Form): ) -class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): +class PathEndpointFilterForm(CabledFilterForm): + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsolePort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsoleServerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerPortTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerOutlet + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerOutletTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1030,7 +1037,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'occupied')) + ('Connection', ('cabled', 'connected', 'occupied')), ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1111,11 +1118,12 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortFilterForm(DeviceComponentFilterForm): +class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) model = FrontPort type = MultipleChoiceField( @@ -1128,12 +1136,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortFilterForm(DeviceComponentFilterForm): +class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) type = MultipleChoiceField( choices=PortTypeChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 49e68b9a2..eb4627ac0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1983,12 +1983,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2037,9 +2031,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2144,12 +2150,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2198,9 +2198,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2313,12 +2325,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'allocated_draw': [50, 100]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2367,9 +2373,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2478,12 +2496,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2532,9 +2544,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2874,15 +2898,9 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_occupied(self): @@ -2891,6 +2909,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -3097,9 +3121,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -3261,9 +3291,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4165,9 +4201,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_connected(self): From 211a1394d3671a9ff8a99bc8071296d13e0cbdfb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:17:53 -0400 Subject: [PATCH 010/409] Changelog for #8580, #10333 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 480595d56..f9fe3d494 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes @@ -16,6 +17,7 @@ * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection +* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import --- From ea9d2e3f88c8560141cba95554595c1b9631b196 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Sep 2022 14:14:18 -0400 Subject: [PATCH 011/409] Closes #9577: Add has_front_image and has_rear_image filters for device types --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/filtersets.py | 20 ++++++++++++++++++++ netbox/dcim/forms/filtersets.py | 15 +++++++++++++++ netbox/dcim/tests/test_filtersets.py | 18 +++++++++++++++--- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9fe3d494..c5ca3d5be 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -5,6 +5,7 @@ ### Enhancements * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` +* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index afecf551c..0a4439173 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + has_front_image = django_filters.BooleanFilter( + label='Has a front image', + method='_has_front_image' + ) + has_rear_image = django_filters.BooleanFilter( + label='Has a rear image', + method='_has_rear_image' + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label='Has console ports', @@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): Q(comments__icontains=value) ) + def _has_front_image(self, queryset, name, value): + if value: + return queryset.exclude(front_image='') + else: + return queryset.filter(front_image='') + + def _has_rear_image(self, queryset, name, value): + if value: + return queryset.exclude(rear_image='') + else: + return queryset.filter(rear_image='') + def _console_ports(self, queryset, name, value): return queryset.exclude(consoleporttemplates__isnull=value) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 98be0983e..96b0d1319 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', @@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=add_blank_choice(DeviceAirflowChoices), required=False ) + has_front_image = forms.NullBooleanField( + required=False, + label='Has a front image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + has_rear_image = forms.NullBooleanField( + required=False, + label='Has a rear image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index eb4627ac0..feef4e90c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), ) @@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_is_full_depth(self): - params = {'is_full_depth': 'true'} + params = {'is_full_depth': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'is_full_depth': 'false'} + params = {'is_full_depth': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_subdevice_role(self): @@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_front_image(self): + params = {'has_front_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_front_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_has_rear_image(self): + params = {'has_rear_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_rear_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_console_ports(self): params = {'console_ports': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 6a9274a95f87ad8c3c5dcdb1da47d6781d70a4da Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Sep 2022 14:36:37 -0400 Subject: [PATCH 012/409] Closes #10314: Move clone() method from NetBoxModel to CloningMixin --- docs/plugins/development/models.md | 20 ++------------------ docs/release-notes/version-3.4.md | 5 +++++ mkdocs.yml | 1 + netbox/netbox/models/features.py | 13 +++++++++++-- 4 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 docs/release-notes/version-3.4.md diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index c58621b81..16f5dd0df 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,24 +49,6 @@ class MyModel(NetBoxModel): ... ``` -### The `clone()` Method - -!!! info - This method was introduced in NetBox v3.3. - -The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. - -Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: - -```python -class MyModel(NetBoxModel): - - def clone(self): - attrs = super().clone() - attrs['extra-value'] = 123 - return attrs -``` - ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) @@ -116,6 +98,8 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.ChangeLoggingMixin +::: netbox.models.features.CloningMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md new file mode 100644 index 000000000..88bc7810e --- /dev/null +++ b/docs/release-notes/version-3.4.md @@ -0,0 +1,5 @@ +# NetBox v3.4 + +### Plugins API + +* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin diff --git a/mkdocs.yml b/mkdocs.yml index 530c6d52e..8f6e2930a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -252,6 +252,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.4: 'release-notes/version-3.4.md' - Version 3.3: 'release-notes/version-3.3.md' - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7f30248b4..9fa1c5cef 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -92,8 +92,17 @@ class CloningMixin(models.Model): def clone(self): """ - Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- - populating an object creation form in the UI. + Returns a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. By default, this method will replicate any fields listed in the + model's `clone_fields` list (if defined), but it can be overridden to apply custom logic. + + ```python + class MyModel(NetBoxModel): + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs + ``` """ attrs = {} From ace66eab61f53294a78a8ede48cf73d17518e8f0 Mon Sep 17 00:00:00 2001 From: Zachary Clark Date: Mon, 12 Sep 2022 00:21:20 -0400 Subject: [PATCH 013/409] Fixes #10305: Allows null master in VirtualChassis APIs --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/tests/test_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 79f5339ad..897ee4ca3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer(required=False) + master = NestedDeviceSerializer(required=False, allow_null=True, default=None) member_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index acd52178d..2697c29b2 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2057,6 +2057,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): cls.bulk_update_data = { 'domain': 'newdomain', + 'master': None } From 356ff457be08d5527920c617eb598f24a6edbc3d Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 14 Sep 2022 19:57:37 +0200 Subject: [PATCH 014/409] Allow reports to be nested in submodules --- netbox/extras/api/views.py | 6 ++--- .../extras/management/commands/runreport.py | 4 +-- netbox/extras/reports.py | 27 ++++++++++++------- netbox/extras/urls.py | 2 +- netbox/extras/views.py | 7 ++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index c7c6cc2aa..63003bdf2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -159,7 +159,7 @@ class ReportViewSet(ViewSet): # Read the PK as "." if '.' not in pk: raise Http404 - module_name, report_name = pk.split('.', 1) + module_name, report_name = pk.split('.', maxsplit=1) # Raise a 404 on an invalid Report module/name report = get_report(module_name, report_name) @@ -183,8 +183,8 @@ class ReportViewSet(ViewSet): } # Iterate through all available Reports. - for module_name, reports in get_reports(): - for report in reports: + for module_name, reports in get_reports().items(): + for report in reports.values(): # Attach the relevant JobResult (if any) to each Report. report.result = results.get(report.full_name, None) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index ee166ae6a..38d435613 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -21,8 +21,8 @@ class Command(BaseCommand): reports = get_reports() # Run reports - for module_name, report_list in reports: - for report in report_list: + for module_name, report_list in reports.items(): + for report in report_list.values(): if module_name in options['reports'] or report.full_name in options['reports']: # Run the report and create a new JobResult diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 43d916aff..702ea0338 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -26,20 +26,18 @@ def get_report(module_name, report_name): """ Return a specific report from within a module. """ - file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name) + reports = get_reports() + module = reports.get(module_name) - spec = importlib.util.spec_from_file_location(module_name, file_path) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except FileNotFoundError: + if module is None: return None - report = getattr(module, report_name, None) + report = module.get(report_name) + if report is None: return None - return report() + return report def get_reports(): @@ -52,7 +50,7 @@ def get_reports(): ... ] """ - module_list = [] + module_list = {} # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. @@ -61,7 +59,16 @@ def get_reports(): report_order = getattr(module, "report_order", ()) ordered_reports = [cls() for cls in report_order if is_report(cls)] unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order] - module_list.append((module_name, [*ordered_reports, *unordered_reports])) + + module_reports = {} + + for cls in [*ordered_reports, *unordered_reports]: + # For reports in submodules use the full import path w/o the root module as the name + report_name = cls.full_name.split(".", maxsplit=1)[1] + module_reports[report_name] = cls + + if module_reports: + module_list[module_name] = module_reports return module_list diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 6c6156f4a..ced3bd4b9 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -100,8 +100,8 @@ urlpatterns = [ # Reports path('reports/', views.ReportListView.as_view(), name='report_list'), - path('reports/./', views.ReportView.as_view(), name='report'), path('reports/results//', views.ReportResultView.as_view(), name='report_result'), + re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'), # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 30f48f817..d8a015bb0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -534,9 +534,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): } ret = [] - for module, report_list in reports: + + for module, report_list in reports.items(): module_reports = [] - for report in report_list: + for report in report_list.values(): report.result = results.get(report.full_name, None) module_reports.append(report) ret.append((module, module_reports)) @@ -613,7 +614,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type) # Retrieve the Report and attach the JobResult to it - module, report_name = result.name.split('.') + module, report_name = result.name.split('.', maxsplit=1) report = get_report(module, report_name) report.result = result From c335b76ec69515ea2055a93a5b8cd0f735139dd6 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 14 Sep 2022 20:00:12 +0200 Subject: [PATCH 015/409] PEP8: Fix whitespace on blank line --- netbox/extras/reports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 702ea0338..32e4efc2d 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -59,14 +59,14 @@ def get_reports(): report_order = getattr(module, "report_order", ()) ordered_reports = [cls() for cls in report_order if is_report(cls)] unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order] - + module_reports = {} for cls in [*ordered_reports, *unordered_reports]: # For reports in submodules use the full import path w/o the root module as the name report_name = cls.full_name.split(".", maxsplit=1)[1] module_reports[report_name] = cls - + if module_reports: module_list[module_name] = module_reports From 4208dbd514feb2a68bf89b969246cb03d2fdf3b5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 14 Sep 2022 17:10:14 -0400 Subject: [PATCH 016/409] Closes #10358: Raise minimum required PostgreSQL version from 10 to 11 --- docs/configuration/required-parameters.md | 2 +- docs/installation/1-postgresql.md | 6 +++--- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 9 ++++----- docs/introduction.md | 2 +- docs/release-notes/version-3.4.md | 7 +++++++ 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index a62d14fef..15f743754 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index a6aa27b1b..583a4f3e9 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 10 or later required" - NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 11 or later required" + NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -35,7 +35,7 @@ sudo systemctl start postgresql sudo systemctl enable postgresql ``` -Before continuing, verify that you have installed PostgreSQL 10 or later: +Before continuing, verify that you have installed PostgreSQL 11 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 8b588fccd..49163550d 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 802c13e49..cc49cd30e 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | ## 3. Install the Latest Release @@ -28,16 +28,15 @@ NetBox v3.0 and later require the following: As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. !!! warning - Use the same method as you used to install Netbox originally + Use the same method as you used to install NetBox originally -If you are not sure how Netbox was installed originally, check with this -command: +If you are not sure how NetBox was installed originally, check with this command: ``` ls -ld /opt/netbox /opt/netbox/.git ``` -If Netbox was installed from a release package, then `/opt/netbox` will be a +If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories. diff --git a/docs/introduction.md b/docs/introduction.md index cffcb37dd..fe82e68aa 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -74,6 +74,6 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 10+ | +| Database | PostgreSQL 11+ | | Task queuing | Redis/django-rq | | Live device access | NAPALM (optional) | diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 88bc7810e..39c44f38e 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,12 @@ # NetBox v3.4 +!!! warning "PostgreSQL 11 Required" + NetBox v3.4 requires PostgreSQL 11 or later. + ### Plugins API * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin + +### Other Changes + +* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 From 4d97043e268b29ef7ddfea848b8e768949896a9f Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 14 Sep 2022 15:50:45 -0700 Subject: [PATCH 017/409] #10359 add region column to site table --- netbox/dcim/tables/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c42731b90..a7cdf4b9f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -203,7 +203,7 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', + 'platform', 'serial', 'asset_tag', 'site', 'region', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) From 4e03419e85d30a3f9b4d3503aec57955208a584a Mon Sep 17 00:00:00 2001 From: kvedder Date: Wed, 14 Sep 2022 22:15:12 -0400 Subject: [PATCH 018/409] add custom fields to l2vpntermination edit template --- netbox/templates/ipam/l2vpntermination_edit.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index c66b8a3d1..4379a0899 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -46,4 +46,12 @@ + {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+{% endif %} {% endblock %} From c4b7ab067a914349abd88398dd9bfef9f6c2f806 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2022 10:10:32 -0400 Subject: [PATCH 019/409] Fixes #10247: Allow changing selected device/VM when creating a new component (#10312) * Initial work on #10247 * Continued work on #10247 * Clean up component creation tests * Move valdiation of replicated field to form * Clean up ordering of fields in component creation forms * Omit fieldset header if none * Clean up ordering of fields in component template creation forms * View tests should not move component templates to new device type * Define replication_fields on VMInterfaceCreateForm * Clean up expandable field help texts * Update comments * Update component bulk update forms & views to support new replication fields * Fix ModularDeviceComponentForm parent class * Fix bulk creation of VM interfaces (thanks @kkthxbye-code!) --- netbox/dcim/forms/bulk_create.py | 25 +- netbox/dcim/forms/models.py | 247 ++++++++++------- netbox/dcim/forms/object_create.py | 260 ++++++++++++------ netbox/dcim/models/device_components.py | 24 +- netbox/dcim/tables/template_code.py | 2 +- netbox/dcim/tests/test_forms.py | 18 +- netbox/dcim/tests/test_views.py | 201 +++++++------- netbox/dcim/views.py | 97 +------ netbox/netbox/views/generic/bulk_views.py | 17 +- netbox/netbox/views/generic/object_views.py | 32 +-- .../dcim/component_template_create.html | 38 --- .../templates/dcim/device_component_edit.html | 16 -- .../dcim/frontporttemplate_create.html | 7 - .../templates/dcim/inventoryitem_create.html | 17 -- .../dcim/inventoryitemtemplate_create.html | 17 -- .../dcim/modulebaytemplate_create.html | 7 - netbox/templates/generic/object_edit.html | 8 +- .../virtualization/vminterface_edit.html | 69 ----- netbox/utilities/forms/fields/expandable.py | 2 +- netbox/utilities/testing/views.py | 5 +- netbox/virtualization/forms/bulk_create.py | 4 +- netbox/virtualization/forms/models.py | 12 +- netbox/virtualization/forms/object_create.py | 19 +- netbox/virtualization/tests/test_views.py | 7 +- netbox/virtualization/views.py | 2 - 25 files changed, 523 insertions(+), 630 deletions(-) delete mode 100644 netbox/templates/dcim/component_template_create.html delete mode 100644 netbox/templates/dcim/device_component_edit.html delete mode 100644 netbox/templates/dcim/frontporttemplate_create.html delete mode 100644 netbox/templates/dcim/inventoryitem_create.html delete mode 100644 netbox/templates/dcim/inventoryitemtemplate_create.html delete mode 100644 netbox/templates/dcim/modulebaytemplate_create.html delete mode 100644 netbox/templates/virtualization/vminterface_edit.html diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 43b852928..f6bc27079 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -3,7 +3,7 @@ from django import forms from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag -from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model from .object_create import ComponentCreateForm __all__ = ( @@ -24,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): queryset=Tag.objects.all(), required=False ) + replication_fields = ('name', 'label') class ConsolePortBulkCreateForm( @@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsolePort - field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags') class ConsoleServerPortBulkCreateForm( @@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsoleServerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') + field_order = ('name', 'label', 'type', 'speed', 'description', 'tags') class PowerPortBulkCreateForm( @@ -60,7 +61,7 @@ class PowerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') + field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') class PowerOutletBulkCreateForm( @@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerOutlet - field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') + field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags') class InterfaceBulkCreateForm( @@ -79,7 +80,7 @@ class InterfaceBulkCreateForm( ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mark_connected', 'description', 'tags', ) @@ -96,13 +97,13 @@ class RearPortBulkCreateForm( DeviceBulkAddComponentForm ): model = RearPort - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags') class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags') - + field_order = ('name', 'label', 'position_pattern', 'description', 'tags') + replication_fields = ('name', 'label', 'position') position_pattern = ExpandableNameField( label='Position', required=False, @@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + field_order = ('name', 'label', 'description', 'tags') class InventoryItemBulkCreateForm( @@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm( ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a21265db4..4fa27ae69 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -986,47 +986,74 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): # Device component templates # +class ComponentTemplateForm(BootstrapMixin, forms.ModelForm): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of DeviceType when editing an existing instance + if self.instance.pk: + self.fields['device_type'].disabled = True + + +class ModularComponentTemplateForm(ComponentTemplateForm): + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False + ) + + +class ConsolePortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) -class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) + class Meta: model = ConsoleServerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + )), + ) + class Meta: model = PowerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerOutletTemplateForm(ModularComponentTemplateForm): power_port = DynamicModelChoiceField( queryset=PowerPortTemplate.objects.all(), required=False, @@ -1035,35 +1062,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } ) + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')), + ) + class Meta: model = PowerOutletTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): +class InterfaceTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')), + ('PoE', ('poe_mode', 'poe_type')) + ) + class Meta: model = InterfaceTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'poe_mode': StaticSelect(), 'poe_type': StaticSelect(), } -class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): +class FrontPortTemplateForm(ModularComponentTemplateForm): rear_port = DynamicModelChoiceField( queryset=RearPortTemplate.objects.all(), required=False, @@ -1073,6 +1105,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): } ) + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', + )), + ) + class Meta: model = FrontPortTemplate fields = [ @@ -1080,48 +1119,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): +class RearPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')), + ) + class Meta: model = RearPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): +class ModuleBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'position', 'description')), + ) + class Meta: model = ModuleBayTemplate fields = [ 'device_type', 'name', 'label', 'position', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): +class DeviceBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'description')), + ) + class Meta: model = DeviceBayTemplate fields = [ 'device_type', 'name', 'label', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): +class InventoryItemTemplateForm(ComponentTemplateForm): parent = DynamicModelChoiceField( queryset=InventoryItemTemplate.objects.all(), required=False, @@ -1148,22 +1189,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): widget=forms.HiddenInput ) + fieldsets = ( + (None, ( + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + )), + ) + class Meta: model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'component_type', 'component_id', ] - widgets = { - 'device_type': forms.HiddenInput(), - } # # Device components # -class ConsolePortForm(NetBoxModelForm): +class DeviceComponentForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of Device when editing an existing instance + if self.instance.pk: + self.fields['device'].disabled = True + + +class ModularDeviceComponentForm(DeviceComponentForm): module = DynamicModelChoiceField( queryset=Module.objects.all(), required=False, @@ -1172,25 +1230,31 @@ class ConsolePortForm(NetBoxModelForm): } ) + +class ConsolePortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), + ) + class Meta: model = ConsolePort fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class ConsoleServerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class ConsoleServerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1199,42 +1263,32 @@ class ConsoleServerPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class PowerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class PowerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', + )), ) class Meta: model = PowerPort fields = [ 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', - 'tags', + 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class PowerOutletForm(ModularDeviceComponentForm): power_port = DynamicModelChoiceField( queryset=PowerPort.objects.all(), required=False, @@ -1243,6 +1297,13 @@ class PowerOutletForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + )), + ) + class Meta: model = PowerOutlet fields = [ @@ -1250,20 +1311,12 @@ class PowerOutletForm(NetBoxModelForm): 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1338,7 +1391,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ) fieldsets = ( - ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1358,7 +1411,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), 'poe_mode': StaticSelect(), @@ -1388,14 +1440,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) -class FrontPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class FrontPortForm(ModularDeviceComponentForm): rear_port = DynamicModelChoiceField( queryset=RearPort.objects.all(), query_params={ @@ -1403,6 +1448,13 @@ class FrontPortForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'description', 'tags', + )), + ) + class Meta: model = FrontPort fields = [ @@ -1410,18 +1462,15 @@ class FrontPortForm(NetBoxModelForm): 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class RearPortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1430,33 +1479,32 @@ class RearPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayForm(NetBoxModelForm): +class ModuleBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'position', 'description', 'tags',)), + ) class Meta: model = ModuleBay fields = [ 'device', 'name', 'label', 'position', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } -class DeviceBayForm(NetBoxModelForm): +class DeviceBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'description', 'tags',)), + ) class Meta: model = DeviceBay fields = [ 'device', 'name', 'label', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } class PopulateDeviceBayForm(BootstrapMixin, forms.Form): @@ -1479,10 +1527,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(NetBoxModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) +class InventoryItemForm(DeviceComponentForm): parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d2c941b34..a03597db1 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -2,46 +2,56 @@ from django import forms from dcim.models import * from netbox.forms import NetBoxModelForm -from utilities.forms import ( - BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, -) +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from . import models as model_forms __all__ = ( - 'ComponentTemplateCreateForm', - 'DeviceComponentCreateForm', + 'ComponentCreateForm', + 'ConsolePortCreateForm', + 'ConsolePortTemplateCreateForm', + 'ConsoleServerPortCreateForm', + 'ConsoleServerPortTemplateCreateForm', + 'DeviceBayCreateForm', + 'DeviceBayTemplateCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', + 'InterfaceCreateForm', + 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', - 'ModularComponentTemplateCreateForm', + 'InventoryItemTemplateCreateForm', 'ModuleBayCreateForm', 'ModuleBayTemplateCreateForm', + 'PowerOutletCreateForm', + 'PowerOutletTemplateCreateForm', + 'PowerPortCreateForm', + 'PowerPortTemplateCreateForm', + 'RearPortCreateForm', + 'RearPortTemplateCreateForm', 'VirtualChassisCreateForm', ) -class ComponentCreateForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(forms.Form): """ - Subclass this form when facilitating the creation of one or more device component or component templates based on + Subclass this form when facilitating the creation of one or more component or component template objects based on a name pattern. """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', + name = ExpandableNameField() + label = ExpandableNameField( required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) + # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by + # ComponentCreateView when creating objects. + replication_fields = ('name', 'label') + def clean(self): super().clean() - # Validate that all patterned fields generate an equal number of values - patterned_fields = [ - field_name for field_name in self.fields if field_name.endswith('_pattern') - ] - pattern_count = len(self.cleaned_data['name_pattern']) - for field_name in patterned_fields: + # Validate that all replication fields generate an equal number of values + pattern_count = len(self.cleaned_data[self.replication_fields[0]]) + for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) if self.cleaned_data[field_name] and value_count != pattern_count: raise forms.ValidationError({ @@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') -class ComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned only to a DeviceType. - """ - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - ) - field_order = ('device_type', 'name_pattern', 'label_pattern') +# +# Device component templates +# + +class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm): + + class Meta(model_forms.ConsolePortTemplateForm.Meta): + exclude = ('name', 'label') -class ModularComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType. - """ - name_pattern = ExpandableNameField( - label='Name', - help_text=""" - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9]. {module} is accepted as a substitution for - the module bay position. - """ - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - required=False - ) - module_type = DynamicModelChoiceField( - queryset=ModuleType.objects.all(), - required=False - ) - field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern') +class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm): + + class Meta(model_forms.ConsoleServerPortTemplateForm.Meta): + exclude = ('name', 'label') -class DeviceComponentCreateForm(ComponentCreateForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - field_order = ('device', 'name_pattern', 'label_pattern') +class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm): + + class Meta(model_forms.PowerPortTemplateForm.Meta): + exclude = ('name', 'label') -class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): - rear_port_set = forms.MultipleChoiceField( +class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm): + + class Meta(model_forms.PowerOutletTemplateForm.Meta): + exclude = ('name', 'label') + + +class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm): + + class Meta(model_forms.InterfaceTemplateForm.Meta): + exclude = ('name', 'label') + + +class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortTemplateForm to omit rear_port_position + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')), ) + class Meta(model_forms.FrontPortTemplateForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): } -class FrontPortCreateForm(DeviceComponentCreateForm): - rear_port_set = forms.MultipleChoiceField( +class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm): + + class Meta(model_forms.RearPortTemplateForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm): + + class Meta(model_forms.DeviceBayTemplateForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm): + position = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + ) + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayTemplateForm.Meta): + exclude = ('name', 'label', 'position') + + +class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm): + + class Meta(model_forms.InventoryItemTemplateForm.Meta): + exclude = ('name', 'label') + + +# +# Device components +# + +class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm): + + class Meta(model_forms.ConsolePortForm.Meta): + exclude = ('name', 'label') + + +class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm): + + class Meta(model_forms.ConsoleServerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm): + + class Meta(model_forms.PowerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): + + class Meta(model_forms.PowerOutletForm.Meta): + exclude = ('name', 'label') + + +class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): + + class Meta(model_forms.InterfaceForm.Meta): + exclude = ('name', 'label') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'module' in self.fields: + self.fields['name'].help_text += ' The string {module} will be replaced with the position ' \ + 'of the assigned module, if any' + + +class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortForm to omit rear_port_position + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', + )), ) + class Meta(model_forms.FrontPortForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm): } -class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): - position_pattern = ExpandableNameField( +class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm): + + class Meta(model_forms.RearPortForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm): + + class Meta(model_forms.DeviceBayForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm): + position = ExpandableNameField( label='Position', required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) - field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern') + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayForm.Meta): + exclude = ('name', 'label', 'position') -class ModuleBayCreateForm(DeviceComponentCreateForm): - position_pattern = ExpandableNameField( - label='Position', - required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') +class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm): + + class Meta(model_forms.InventoryItemForm.Meta): + exclude = ('name', 'label') -class InventoryItemCreateForm(ComponentCreateForm): - # Device is assigned by the model form - field_order = ('name_pattern', 'label_pattern') - +# +# Virtual chassis +# class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 838336e21..8f1285901 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel): def clean(self): super().clean() - # Validate rear port assignment - if self.rear_port.device != self.device: - raise ValidationError({ - "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" - }) + if hasattr(self, 'rear_port'): - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " - f"{self.rear_port.name} has only {self.rear_port.positions} positions" - }) + # Validate rear port assignment + if self.rear_port.device != self.device: + raise ValidationError({ + "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" + }) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " + f"{self.rear_port.name} has only {self.rear_port.positions} positions" + }) class RearPort(ModularComponentModel, CabledObjectModel): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index d34003ee5..dfc77b854 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -239,7 +239,7 @@ INTERFACE_BUTTONS = """
  • Inventory Item
  • {% endif %} {% if perms.dcim.add_interface %} -
  • Child Interface
  • +
  • Child Interface
  • {% endif %} {% if perms.ipam.add_l2vpntermination %}
  • L2VPN Termination
  • diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 53474314f..1cd75765a 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices +from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices from dcim.forms import * from dcim.models import * from utilities.testing import create_test_device @@ -129,10 +129,11 @@ class LabelTestCase(TestCase): """ interface_data = { 'device': self.device.pk, - 'name_pattern': 'eth[0-9]', - 'label_pattern': 'Interface[0-9]', + 'name': 'eth[0-9]', + 'label': 'Interface[0-9]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, } - form = DeviceComponentCreateForm(interface_data) + form = InterfaceCreateForm(interface_data) self.assertTrue(form.is_valid()) @@ -142,10 +143,11 @@ class LabelTestCase(TestCase): """ bad_interface_data = { 'device': self.device.pk, - 'name_pattern': 'eth[0-9]', - 'label_pattern': 'Interface[0-1]', + 'name': 'eth[0-9]', + 'label': 'Interface[0-1]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, } - form = DeviceComponentCreateForm(bad_interface_data) + form = InterfaceCreateForm(bad_interface_data) self.assertFalse(form.is_valid()) - self.assertIn('label_pattern', form.errors) + self.assertIn('label', form.errors) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a25267166..50b36e36d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1082,31 +1082,28 @@ front-ports: class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsolePortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ConsolePortTemplate.objects.bulk_create(( - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'), - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'), - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Console Port Template X', 'type': ConsolePortTypeChoices.TYPE_RJ45, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Console Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Console Port Template [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, } @@ -1117,31 +1114,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsoleServerPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ConsoleServerPortTemplate.objects.bulk_create(( - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'), - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'), - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Console Server Port Template X', 'type': ConsolePortTypeChoices.TYPE_RJ45, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Console Server Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Console Server Port Template [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, } @@ -1152,24 +1146,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') PowerPortTemplate.objects.bulk_create(( - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'), - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'), - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Power Port Template X', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, @@ -1177,8 +1168,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Power Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Power Port Template [4-6]', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, 'allocated_draw': 50, @@ -1193,6 +1184,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerOutletTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1220,7 +1212,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC cls.bulk_create_data = { 'device_type': devicetype.pk, - 'name_pattern': 'Power Outlet Template [4-6]', + 'name': 'Power Outlet Template [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'power_port': powerports[0].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, @@ -1234,34 +1226,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InterfaceTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') InterfaceTemplate.objects.bulk_create(( - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'), - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'), - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 1'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 2'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Interface Template X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'mgmt_only': True, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Interface Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Interface Template [4-6]', # Test that a label can be applied to each generated interface templates - 'label_pattern': 'Interface Template Label [3-5]', + 'label': 'Interface Template Label [3-5]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'mgmt_only': True, } @@ -1274,6 +1263,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = FrontPortTemplate + validation_excluded_fields = ('name', 'label', 'rear_port') @classmethod def setUpTestData(cls): @@ -1306,11 +1296,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas cls.bulk_create_data = { 'device_type': devicetype.pk, - 'name_pattern': 'Front Port [4-6]', + 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port_set': [ - '{}:1'.format(rp.pk) for rp in rearports[3:6] - ], + 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], } cls.bulk_edit_data = { @@ -1320,32 +1308,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = RearPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') RearPortTemplate.objects.bulk_create(( - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'), - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'), - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Rear Port Template X', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 2, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Rear Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Rear Port Template [4-6]', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 2, } @@ -1357,30 +1342,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ModuleBayTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ModuleBayTemplate.objects.bulk_create(( - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'), - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'), - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Module Bay Template X', } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Module Bay Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Module Bay Template [4-6]', } cls.bulk_edit_data = { @@ -1390,30 +1372,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) DeviceBayTemplate.objects.bulk_create(( - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'), - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'), - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Device Bay Template X', } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Device Bay Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Device Bay Template [4-6]', } cls.bulk_edit_data = { @@ -1423,6 +1402,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InventoryItemTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1431,30 +1411,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), ) Manufacturer.objects.bulk_create(manufacturers) - - devicetypes = ( - DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1') inventory_item_templates = ( - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]), - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]), - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]), ) for item in inventory_item_templates: item.save() cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Inventory Item Template X', 'manufacturer': manufacturers[1].pk, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Inventory Item Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template [4-6]', 'manufacturer': manufacturers[1].pk, } @@ -1912,6 +1887,7 @@ class ModuleTestCase( class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1935,9 +1911,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Console Port [4-6]', + 'name': 'Console Port [4-6]', # Test that a label can be applied to each generated console ports - 'label_pattern': 'Serial[3-5]', + 'label': 'Serial[3-5]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', 'tags': sorted([t.pk for t in tags]), @@ -1970,6 +1946,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1993,7 +1970,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Console Server Port [4-6]', + 'name': 'Console Server Port [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', 'tags': [t.pk for t in tags], @@ -2026,6 +2003,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2051,7 +2029,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Power Port [4-6]]', + 'name': 'Power Port [4-6]]', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, 'allocated_draw': 50, @@ -2088,6 +2066,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2119,7 +2098,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Power Outlet [4-6]', + 'name': 'Power Outlet [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, @@ -2153,6 +2132,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2217,7 +2197,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Interface [4-6]', + 'name': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, 'bridge': interfaces[4].pk, @@ -2277,6 +2257,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort + validation_excluded_fields = ('name', 'label', 'rear_port') @classmethod def setUpTestData(cls): @@ -2312,11 +2293,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Front Port [4-6]', + 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port_set': [ - '{}:1'.format(rp.pk) for rp in rearports[3:6] - ], + 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], 'description': 'New description', 'tags': [t.pk for t in tags], } @@ -2348,6 +2327,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2372,7 +2352,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Rear Port [4-6]', + 'name': 'Rear Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', @@ -2406,6 +2386,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ModuleBay + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2428,7 +2409,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Module Bay [4-6]', + 'name': 'Module Bay [4-6]', 'description': 'A module bay', 'tags': [t.pk for t in tags], } @@ -2447,6 +2428,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2472,7 +2454,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Device Bay [4-6]', + 'name': 'Device Bay [4-6]', 'description': 'A device bay', 'tags': [t.pk for t in tags], } @@ -2491,6 +2473,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2525,7 +2508,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Inventory Item [4-6]', + 'name': 'Inventory Item [4-6]', 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'parent': None, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6ee74377a..aee0cb384 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1120,9 +1120,8 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ConsolePortTemplateCreateView(generic.ComponentCreateView): queryset = ConsolePortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm - template_name = 'dcim/component_template_create.html' class ConsolePortTemplateEditView(generic.ObjectEditView): @@ -1155,9 +1154,8 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView): class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): queryset = ConsoleServerPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm - template_name = 'dcim/component_template_create.html' class ConsoleServerPortTemplateEditView(generic.ObjectEditView): @@ -1190,9 +1188,8 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerPortTemplateCreateView(generic.ComponentCreateView): queryset = PowerPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm - template_name = 'dcim/component_template_create.html' class PowerPortTemplateEditView(generic.ObjectEditView): @@ -1225,9 +1222,8 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerOutletTemplateCreateView(generic.ComponentCreateView): queryset = PowerOutletTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm - template_name = 'dcim/component_template_create.html' class PowerOutletTemplateEditView(generic.ObjectEditView): @@ -1260,9 +1256,8 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView): class InterfaceTemplateCreateView(generic.ComponentCreateView): queryset = InterfaceTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm - template_name = 'dcim/component_template_create.html' class InterfaceTemplateEditView(generic.ObjectEditView): @@ -1297,15 +1292,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView): queryset = FrontPortTemplate.objects.all() form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm - template_name = 'dcim/frontporttemplate_create.html' - - def initialize_forms(self, request): - form, model_form = super().initialize_forms(request) - - model_form.fields.pop('rear_port') - model_form.fields.pop('rear_port_position') - - return form, model_form class FrontPortTemplateEditView(generic.ObjectEditView): @@ -1338,9 +1324,8 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView): class RearPortTemplateCreateView(generic.ComponentCreateView): queryset = RearPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm - template_name = 'dcim/component_template_create.html' class RearPortTemplateEditView(generic.ObjectEditView): @@ -1375,8 +1360,6 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView): queryset = ModuleBayTemplate.objects.all() form = forms.ModuleBayTemplateCreateForm model_form = forms.ModuleBayTemplateForm - template_name = 'dcim/modulebaytemplate_create.html' - patterned_fields = ('name', 'label', 'position') class ModuleBayTemplateEditView(generic.ObjectEditView): @@ -1409,9 +1392,8 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): class DeviceBayTemplateCreateView(generic.ComponentCreateView): queryset = DeviceBayTemplate.objects.all() - form = forms.ComponentTemplateCreateForm + form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm - template_name = 'dcim/component_template_create.html' class DeviceBayTemplateEditView(generic.ObjectEditView): @@ -1444,9 +1426,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView): class InventoryItemTemplateCreateView(generic.ComponentCreateView): queryset = InventoryItemTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.InventoryItemTemplateCreateForm model_form = forms.InventoryItemTemplateForm - template_name = 'dcim/inventoryitemtemplate_create.html' def alter_object(self, instance, request): # Set component (if any) @@ -1874,14 +1855,13 @@ class ConsolePortView(generic.ObjectView): class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm class ConsolePortEditView(generic.ObjectEditView): queryset = ConsolePort.objects.all() form = forms.ConsolePortForm - template_name = 'dcim/device_component_edit.html' class ConsolePortDeleteView(generic.ObjectDeleteView): @@ -1933,14 +1913,13 @@ class ConsoleServerPortView(generic.ObjectView): class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm class ConsoleServerPortEditView(generic.ObjectEditView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortForm - template_name = 'dcim/device_component_edit.html' class ConsoleServerPortDeleteView(generic.ObjectDeleteView): @@ -1992,14 +1971,13 @@ class PowerPortView(generic.ObjectView): class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.PowerPortCreateForm model_form = forms.PowerPortForm class PowerPortEditView(generic.ObjectEditView): queryset = PowerPort.objects.all() form = forms.PowerPortForm - template_name = 'dcim/device_component_edit.html' class PowerPortDeleteView(generic.ObjectDeleteView): @@ -2051,14 +2029,13 @@ class PowerOutletView(generic.ObjectView): class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm class PowerOutletEditView(generic.ObjectEditView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletForm - template_name = 'dcim/device_component_edit.html' class PowerOutletDeleteView(generic.ObjectDeleteView): @@ -2154,42 +2131,13 @@ class InterfaceView(generic.ObjectView): class InterfaceCreateView(generic.ComponentCreateView): queryset = Interface.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.InterfaceCreateForm model_form = forms.InterfaceForm - # template_name = 'dcim/interface_create.html' - - # TODO: Figure out what to do with this - # def post(self, request): - # """ - # Override inherited post() method to handle request to assign newly created - # interface objects (first object) to an IP Address object. - # """ - # form = self.form(request.POST, initial=request.GET) - # new_objs = self.validate_form(request, form) - # - # if form.is_valid() and not form.errors: - # if '_addanother' in request.POST: - # return redirect(request.get_full_path()) - # elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \ - # request.user.has_perm('ipam.add_ipaddress'): - # first_obj = new_objs[0].pk - # return redirect( - # f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}' - # ) - # else: - # return redirect(self.get_return_url(request)) - # - # return render(request, self.template_name, { - # 'obj_type': self.queryset.model._meta.verbose_name, - # 'form': form, - # 'return_url': self.get_return_url(request), - # }) class InterfaceEditView(generic.ObjectEditView): queryset = Interface.objects.all() form = forms.InterfaceForm - template_name = 'dcim/interface_edit.html' class InterfaceDeleteView(generic.ObjectDeleteView): @@ -2244,19 +2192,10 @@ class FrontPortCreateView(generic.ComponentCreateView): form = forms.FrontPortCreateForm model_form = forms.FrontPortForm - def initialize_forms(self, request): - form, model_form = super().initialize_forms(request) - - model_form.fields.pop('rear_port') - model_form.fields.pop('rear_port_position') - - return form, model_form - class FrontPortEditView(generic.ObjectEditView): queryset = FrontPort.objects.all() form = forms.FrontPortForm - template_name = 'dcim/device_component_edit.html' class FrontPortDeleteView(generic.ObjectDeleteView): @@ -2308,14 +2247,13 @@ class RearPortView(generic.ObjectView): class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.RearPortCreateForm model_form = forms.RearPortForm class RearPortEditView(generic.ObjectEditView): queryset = RearPort.objects.all() form = forms.RearPortForm - template_name = 'dcim/device_component_edit.html' class RearPortDeleteView(generic.ObjectDeleteView): @@ -2369,13 +2307,11 @@ class ModuleBayCreateView(generic.ComponentCreateView): queryset = ModuleBay.objects.all() form = forms.ModuleBayCreateForm model_form = forms.ModuleBayForm - patterned_fields = ('name', 'label', 'position') class ModuleBayEditView(generic.ObjectEditView): queryset = ModuleBay.objects.all() form = forms.ModuleBayForm - template_name = 'dcim/device_component_edit.html' class ModuleBayDeleteView(generic.ObjectDeleteView): @@ -2423,14 +2359,13 @@ class DeviceBayView(generic.ObjectView): class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm class DeviceBayEditView(generic.ObjectEditView): queryset = DeviceBay.objects.all() form = forms.DeviceBayForm - template_name = 'dcim/device_component_edit.html' class DeviceBayDeleteView(generic.ObjectDeleteView): @@ -2552,7 +2487,6 @@ class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_create.html' def alter_object(self, instance, request): # Set component (if any) @@ -2736,7 +2670,6 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView): filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' - patterned_fields = ('name', 'label', 'position') class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 7340ea2a0..f0741af2c 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -774,7 +774,6 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): model_form = None filterset = None table = None - patterned_fields = ('name', 'label') def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' @@ -804,23 +803,25 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): new_components = [] data = deepcopy(form.cleaned_data) + replication_data = { + field: data.pop(field) for field in form.replication_fields + } try: with transaction.atomic(): for obj in data['pk']: - pattern_count = len(data[f'{self.patterned_fields[0]}_pattern']) + pattern_count = len(replication_data[form.replication_fields[0]]) for i in range(pattern_count): component_data = { self.parent_field: obj.pk } - - for field_name in self.patterned_fields: - if data.get(f'{field_name}_pattern'): - component_data[field_name] = data[f'{field_name}_pattern'][i] - component_data.update(data) + for field, values in replication_data.items(): + if values: + component_data[field] = values[i] + component_form = self.model_form(component_data) if component_form.is_valid(): instance = component_form.save() @@ -829,7 +830,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): else: for field, errors in component_form.errors.as_data().items(): for e in errors: - form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + form.add_error(field, '{}: {}'.format(obj, ', '.join(e))) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 7617e0402..a56a832b6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -538,10 +538,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - template_name = 'dcim/component_create.html' + template_name = 'generic/object_edit.html' form = None model_form = None - patterned_fields = ('name', 'label') def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -549,44 +548,38 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): def alter_object(self, instance, request): return instance - def initialize_forms(self, request): + def initialize_form(self, request): data = request.POST if request.method == 'POST' else None initial_data = normalize_querydict(request.GET) - form = self.form(data=data, initial=request.GET) - model_form = self.model_form(data=data, initial=initial_data) + form = self.form(data=data, initial=initial_data) - # These fields will be set from the pattern values - for field_name in self.patterned_fields: - model_form.fields[field_name].widget = HiddenInput() - - return form, model_form + return form def get(self, request): - form, model_form = self.initialize_forms(request) + form = self.initialize_form(request) instance = self.alter_object(self.queryset.model(), request) return render(request, self.template_name, { 'object': instance, - 'replication_form': form, - 'form': model_form, + 'form': form, 'return_url': self.get_return_url(request), }) def post(self, request): logger = logging.getLogger('netbox.views.ComponentCreateView') - form, model_form = self.initialize_forms(request) + form = self.initialize_form(request) instance = self.alter_object(self.queryset.model(), request) if form.is_valid(): new_components = [] data = deepcopy(request.POST) - pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern']) + pattern_count = len(form.cleaned_data[self.form.replication_fields[0]]) for i in range(pattern_count): - for field_name in self.patterned_fields: - if form.cleaned_data.get(f'{field_name}_pattern'): - data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i] + for field_name in self.form.replication_fields: + if form.cleaned_data.get(field_name): + data[field_name] = form.cleaned_data[field_name][i] if hasattr(form, 'get_iterative_data'): data.update(form.get_iterative_data(i)) @@ -626,7 +619,6 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): return render(request, self.template_name, { 'object': instance, - 'replication_form': form, - 'form': model_form, + 'form': form, 'return_url': self.get_return_url(request), }) diff --git a/netbox/templates/dcim/component_template_create.html b/netbox/templates/dcim/component_template_create.html deleted file mode 100644 index d164db872..000000000 --- a/netbox/templates/dcim/component_template_create.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} - {% if form.module_type %} -
    -
    - -
    -
    -
    -
    - {% render_field replication_form.device_type %} -
    -
    - {% render_field replication_form.module_type %} -
    -
    - {% else %} - {% render_field replication_form.device_type %} - {% endif %} - {% block replication_fields %} - {% render_field replication_form.name_pattern %} - {% render_field replication_form.label_pattern %} - {% endblock replication_fields %} - {{ block.super }} -{% endblock form %} diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html deleted file mode 100644 index 44b93d870..000000000 --- a/netbox/templates/dcim/device_component_edit.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    - {% if form.instance.device %} -
    - -
    - -
    -
    - {% endif %} - {% render_form form %} -
    -{% endblock form %} diff --git a/netbox/templates/dcim/frontporttemplate_create.html b/netbox/templates/dcim/frontporttemplate_create.html deleted file mode 100644 index 50e9d355c..000000000 --- a/netbox/templates/dcim/frontporttemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.rear_port_set %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html deleted file mode 100644 index be910f143..000000000 --- a/netbox/templates/dcim/inventoryitem_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
    - -
    - -
    -
    - {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitemtemplate_create.html b/netbox/templates/dcim/inventoryitemtemplate_create.html deleted file mode 100644 index 9180cf6ab..000000000 --- a/netbox/templates/dcim/inventoryitemtemplate_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
    - -
    - -
    -
    - {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/modulebaytemplate_create.html b/netbox/templates/dcim/modulebaytemplate_create.html deleted file mode 100644 index 74323ac4b..000000000 --- a/netbox/templates/dcim/modulebaytemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.position_pattern %} -{% endblock replication_fields %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 4ce270b30..56e4f5a32 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -59,9 +59,11 @@ Context: {# Render grouped fields according to Form #} {% for group, fields in form.fieldsets %}
    -
    -
    {{ group }}
    -
    + {% if group %} +
    +
    {{ group }}
    +
    + {% endif %} {% for name in fields %} {% with field=form|getfield:name %} {% if not field.field.widget.is_hidden %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html deleted file mode 100644 index efb138954..000000000 --- a/netbox/templates/virtualization/vminterface_edit.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} - {# Render hidden fields #} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - -
    -
    -
    Interface
    -
    - {% if form.instance.virtual_machine %} -
    - -
    - -
    -
    - {% endif %} - {% render_field form.name %} - {% render_field form.description %} - {% render_field form.tags %} -
    - -
    -
    -
    Addressing
    -
    - {% render_field form.vrf %} - {% render_field form.mac_address %} -
    - -
    -
    -
    Operation
    -
    - {% render_field form.mtu %} - {% render_field form.enabled %} -
    - -
    -
    -
    Related Interfaces
    -
    - {% render_field form.parent %} - {% render_field form.bridge %} -
    - -
    -
    -
    802.1Q Switching
    -
    - {% render_field form.mode %} - {% render_field form.vlan_group %} - {% render_field form.untagged_vlan %} - {% render_field form.tagged_vlans %} -
    - - {% if form.custom_fields %} -
    -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    - {% endif %} -{% endblock %} diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py index 214775f03..fca370c26 100644 --- a/netbox/utilities/forms/fields/expandable.py +++ b/netbox/utilities/forms/fields/expandable.py @@ -22,7 +22,7 @@ class ExpandableNameField(forms.CharField): if not self.help_text: self.help_text = """ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9] + are not supported (example: [ge,xe]-0/0/[0-9]). """ def to_python(self, value): diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 7fa9f66bc..93cb88088 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -466,6 +466,7 @@ class ViewTestCases: """ bulk_create_count = 3 bulk_create_data = {} + validation_excluded_fields = [] @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_without_permission(self): @@ -500,7 +501,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_with_constrained_permission(self): @@ -532,7 +533,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) class BulkImportObjectsViewTestCase(ModelViewTestCase): """ diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py index 6cf7c0d7c..03997f88d 100644 --- a/netbox/virtualization/forms/bulk_create.py +++ b/netbox/virtualization/forms/bulk_create.py @@ -13,7 +13,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput() ) - name_pattern = ExpandableNameField( + name = ExpandableNameField( label='Name' ) @@ -27,4 +27,4 @@ class VMInterfaceBulkCreateForm( form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - pass + replication_fields = ('name',) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index fca9c6b56..268afb9bb 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -5,7 +5,6 @@ from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.models import Tag from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -278,6 +277,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() + ) parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, @@ -338,7 +340,6 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'virtual_machine': forms.HiddenInput(), 'mode': StaticSelect() } labels = { @@ -347,3 +348,10 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of VirtualMachine when editing an existing instance + if self.instance.pk: + self.fields['virtual_machine'].disabled = True diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index feab3bb3a..79457a56e 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,17 +1,14 @@ -from django import forms - -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField -from .models import VirtualMachine +from utilities.forms import ExpandableNameField +from .models import VMInterfaceForm __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, forms.Form): - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) - name_pattern = ExpandableNameField( - label='Name' - ) +class VMInterfaceCreateForm(VMInterfaceForm): + name = ExpandableNameField() + replication_fields = ('name',) + + class Meta(VMInterfaceForm.Meta): + exclude = ('name',) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 01d4394f3..d00ceb5a2 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -251,6 +251,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface + validation_excluded_fields = ('name',) @classmethod def setUpTestData(cls): @@ -290,10 +291,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'virtual_machine': virtualmachines[1].pk, + 'virtual_machine': virtualmachines[0].pk, 'name': 'Interface X', 'enabled': False, - 'bridge': interfaces[3].pk, + 'bridge': interfaces[1].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -306,7 +307,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, - 'name_pattern': 'Interface [4-6]', + 'name': 'Interface [4-6]', 'enabled': False, 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 5b26f8503..611725d62 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -451,13 +451,11 @@ class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm - patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView): queryset = VMInterface.objects.all() form = forms.VMInterfaceForm - template_name = 'virtualization/vminterface_edit.html' class VMInterfaceDeleteView(generic.ObjectDeleteView): From f97eb99950819789d10866981a8aead250905126 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 10:14:16 -0400 Subject: [PATCH 020/409] Changelog for #10247, #10258, #10305, #10362 --- docs/release-notes/version-3.3.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c5ca3d5be..c249d9874 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -11,14 +11,18 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components * [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables +* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules * [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links +* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection * [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import +* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination --- From e9a91455e8827fffaab7f10e9938bce3022b9ba3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 12:55:21 -0400 Subject: [PATCH 021/409] #10359: Add region and site group columns to the devices table --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/tables/devices.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c5ca3d5be..6c7d5d291 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -7,6 +7,7 @@ * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` * [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI +* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table ### Bug Fixes diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a7cdf4b9f..142c7ef67 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -143,6 +143,15 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): template_code=DEVICE_LINK ) status = columns.ChoiceFieldColumn() + region = tables.Column( + accessor=Accessor('site__region'), + linkify=True + ) + site_group = tables.Column( + accessor=Accessor('site__group'), + linkify=True, + verbose_name='Site Group' + ) site = tables.Column( linkify=True ) @@ -203,9 +212,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'site', 'region', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', - 'contacts', 'tags', 'created', 'last_updated', + 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', + 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', + 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', From e05696dfcc8f3c711f1435795ea9676638fc12b8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 13:17:04 -0400 Subject: [PATCH 022/409] Fixes #10337: Display SSO links when local authentication fails --- docs/release-notes/version-3.3.md | 1 + netbox/templates/login.html | 20 ++++++++++---------- netbox/users/views.py | 27 +++++++++++++++------------ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ba6e4a06e..3f4272f95 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -23,6 +23,7 @@ * [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection * [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import +* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails * [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination --- diff --git a/netbox/templates/login.html b/netbox/templates/login.html index ea5cfc3e5..66b519671 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -13,6 +13,16 @@
    {% endif %} + {# Login form errors #} + {% if form.non_field_errors %} + + {% endif %} + {# Login form #}
    @@ -64,6 +65,7 @@
    + {% plugin_right_page object %}
    From d8c07abd6817771b4f5491df19550d9f58261165 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 25 Oct 2022 09:07:39 -0700 Subject: [PATCH 174/409] 10610 interface_id query on lag return vc interfaces --- netbox/dcim/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a999383c7..917f57923 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1363,7 +1363,7 @@ class InterfaceFilterSet( try: devices = Device.objects.filter(pk__in=id_list) for device in devices: - vc_interface_ids += device.vc_interfaces().values_list('id', flat=True) + vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True) return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() From 2a62b628cf8c3648468e14bf67ec5a9b24278704 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 08:23:50 -0400 Subject: [PATCH 175/409] Fixes #10723: Distinguish between inside/outside NAT assignments for device/VM primary IPs --- docs/release-notes/version-3.3.md | 3 +++ netbox/templates/dcim/device.html | 4 ++-- netbox/templates/virtualization/virtualmachine.html | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e66ee1a28..fe37cce3e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -15,10 +15,13 @@ * [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication * [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth * [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link +* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ +* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view +* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs * [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list * [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d800658a5..b0cd76de4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -178,7 +178,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -193,7 +193,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5756d939a..c0e2ebd07 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -46,7 +46,7 @@ {% if object.primary_ip4.nat_inside %} (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) {% elif object.primary_ip4.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} @@ -61,7 +61,7 @@ {% if object.primary_ip6.nat_inside %} (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) {% elif object.primary_ip6.nat_outside.exists %} - (NAT for {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + (NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) {% endif %} {% else %} {{ ''|placeholder }} From 7b3ef2ade5266ec0738c4e210cf1c6f9b85fbee7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 08:44:20 -0400 Subject: [PATCH 176/409] Fixes #10719: Prevent user without sufficient permission from creating an IP address via FHRP group creation --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/forms/models.py | 3 ++- netbox/ipam/views.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fe37cce3e..0015dc2df 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -21,6 +21,7 @@ * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view +* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation * [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs * [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list * [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index f66b7efba..1986b1590 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -552,6 +552,7 @@ class FHRPGroupForm(NetBoxModelForm): def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) + user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object() # Check if we need to create a new IPAddress for the group if self.cleaned_data.get('ip_address'): @@ -565,7 +566,7 @@ class FHRPGroupForm(NetBoxModelForm): ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions - if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first(): raise PermissionsViolation() return instance diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 04d07e356..72483d40f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -930,6 +930,12 @@ class FHRPGroupEditView(generic.ObjectEditView): return return_url + def alter_object(self, obj, request, url_args, url_kwargs): + # Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that + # we can evaluate permissions during the creation of a new IPAddress within the form. + obj._user = request.user + return obj + class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() From 658c9347f39c7376866f02c917df8dcb0224e079 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 09:32:29 -0400 Subject: [PATCH 177/409] Fixes #10682: Correct home view links to connection lists --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/views/__init__.py | 114 ++++++++++++++---------------- netbox/templates/home.html | 4 +- 3 files changed, 56 insertions(+), 63 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 0015dc2df..1e2c4a90e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables +* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view * [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index bc1f0e2ca..18b64344f 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -1,5 +1,6 @@ import platform import sys +from collections import namedtuple from django.conf import settings from django.core.cache import cache @@ -8,6 +9,7 @@ from django.shortcuts import redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View @@ -24,100 +26,90 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from netbox.constants import SEARCH_MAX_RESULTS from netbox.forms import SearchForm from netbox.search import SEARCH_TYPES -from tenancy.models import Tenant +from tenancy.models import Contact, Tenant from virtualization.models import Cluster, VirtualMachine from wireless.models import WirelessLAN, WirelessLink +Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) + + class HomeView(View): template_name = 'home.html' def get(self, request): if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - return redirect("login") + return redirect('login') - connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).count + power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) - connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + ).count + interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( _path__is_complete=True - ) + ).count + + def get_count_queryset(model): + return model.objects.restrict(request.user, 'view').count def build_stats(): org = ( - ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), - ("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count), + Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), + Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), + Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), ) dcim = ( - ("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count), - ("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count), - ("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count), + Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), + Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), + Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), ) ipam = ( - ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count), - ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count), - ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count), - ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count), - ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count), - ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count) - + Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), + Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), + Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), + Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), + Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), + Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), ) circuits = ( - ("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count), - ("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count), + Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), + Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) ) virtualization = ( - ("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count), - ("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count), - + Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', + get_count_queryset(Cluster)), + Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', + get_count_queryset(VirtualMachine)), ) connections = ( - ("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count), - ("dcim.view_consoleport", "Console", connected_consoleports.count), - ("dcim.view_interface", "Interfaces", connected_interfaces.count), - ("dcim.view_powerport", "Power Connections", connected_powerports.count), + Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), + Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), + Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), + Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), ) power = ( - ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), - ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), + Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), + Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), ) wireless = ( - ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), - ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', + get_count_queryset(WirelessLAN)), + Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', + get_count_queryset(WirelessLink)), ) - sections = ( - ("Organization", org, "domain"), - ("IPAM", ipam, "counter"), - ("Virtualization", virtualization, "monitor"), - ("Inventory", dcim, "server"), - ("Circuits", circuits, "transit-connection-variant"), - ("Connections", connections, "cable-data"), - ("Power", power, "flash"), - ("Wireless", wireless, "wifi"), + stats = ( + (_('Organization'), org, 'domain'), + (_('IPAM'), ipam, 'counter'), + (_('Virtualization'), virtualization, 'monitor'), + (_('Inventory'), dcim, 'server'), + (_('Circuits'), circuits, 'transit-connection-variant'), + (_('Connections'), connections, 'cable-data'), + (_('Power'), power, 'flash'), + (_('Wireless'), wireless, 'wifi'), ) - stats = [] - for section_label, section_items, icon_class in sections: - items = [] - for perm, item_label, get_count in section_items: - app, scope = perm.split(".") - url = ":".join((app, scope.replace("view_", "") + "_list")) - item = { - "label": item_label, - "count": None, - "url": url, - "disabled": True, - "icon": icon_class, - } - if request.user.has_perm(perm): - item["count"] = get_count() - item["disabled"] = False - items.append(item) - stats.append((section_label, items, icon_class)) - return stats # Compile changelog table diff --git a/netbox/templates/home.html b/netbox/templates/home.html index a12ec9277..f98d0ccf3 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -36,8 +36,8 @@
    {% for item in items %} - {% if not item.disabled %} - + {% if item.permission in perms %} +
    {{ item.label }}

    {{ item.count }}

    From 174ba6cf0f9d2c04f5d7ed533654f9c30bd3f75c Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Fri, 14 Oct 2022 17:00:20 +0200 Subject: [PATCH 178/409] Fix LDAP auth: user never updated if inactive --- netbox/netbox/api/authentication.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index b8607a0bb..814ca1ed6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication): if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - + user = token.user # When LDAP authentication is active try to load user data from LDAP directory if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() # Load from LDAP if FIND_GROUP_PERMS is active - if ldap_backend.settings.FIND_GROUP_PERMS: - user = ldap_backend.populate_user(token.user.username) + # Always query LDAP when user is not active, otherwise it is never activated again + if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active: + ldap_user = ldap_backend.populate_user(token.user.username) # If the user is found in the LDAP directory use it, if not fallback to the local user - if user: - return user, token + if ldap_user: + user = ldap_user - return token.user, token + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return user, token class TokenPermissions(DjangoObjectPermissions): From eac2ace80b2ee333700c59dc1493cde42eb2e1b6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 09:58:31 -0400 Subject: [PATCH 179/409] Release v3.3.6 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 907ad6cf7..56c14e966 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.5 + placeholder: v3.3.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3cd9bc4ee..bef1ce587 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.5 + placeholder: v3.3.6 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1e2c4a90e..ffb831e9d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.6 (FUTURE) +## v3.3.6 (2022-10-26) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 14b66b2dd..cb26652b9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.6-dev' +VERSION = '3.3.6' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 9afcaea03..bce015110 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,18 +19,18 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==8.5.6 +mkdocs-material==8.5.7 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.2.0 -psycopg2-binary==2.9.3 +psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.9.10 +sentry-sdk==1.10.1 social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.4 +tzdata==2022.5 # Workaround for #7401 jsonschema==3.2.0 From 18332bdbf10d8d79d2110a6a18cc80efbb8dc67d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 10:23:50 -0400 Subject: [PATCH 180/409] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ffb831e9d..8b8bd0060 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.7 (FUTURE) + +--- + ## v3.3.6 (2022-10-26) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cb26652b9..02e80b6cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.6' +VERSION = '3.3.7-dev' # Hostname HOSTNAME = platform.node() From 9e8234bb4528a1f1960fa5c7f8a7e9d53575954d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 11:33:11 -0400 Subject: [PATCH 181/409] Closes #8274: Enable associating a custom link with multiple object types --- docs/release-notes/version-3.4.md | 4 +++ netbox/extras/api/serializers.py | 7 ++-- netbox/extras/filtersets.py | 6 +++- netbox/extras/forms/bulk_edit.py | 5 --- netbox/extras/forms/bulk_import.py | 6 ++-- netbox/extras/forms/filtersets.py | 4 +-- netbox/extras/forms/model_forms.py | 4 +-- netbox/extras/graphql/types.py | 2 +- .../0081_customlink_content_types.py | 32 +++++++++++++++++++ netbox/extras/models/models.py | 8 ++--- netbox/extras/templatetags/custom_links.py | 3 +- netbox/extras/tests/test_api.py | 11 +++---- netbox/extras/tests/test_filtersets.py | 11 ++++--- netbox/extras/tests/test_views.py | 20 ++++++------ netbox/netbox/tables/tables.py | 2 +- netbox/templates/extras/customlink.html | 20 ++++++++---- 16 files changed, 94 insertions(+), 51 deletions(-) create mode 100644 netbox/extras/migrations/0081_customlink_content_types.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index ba5947364..873967456 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,6 +8,7 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. +* The `content_type` field on the CustomLink model has been renamed to `content_types` and now supports the assignment of multiple content types. ### New Features @@ -22,6 +23,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects +* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types @@ -57,6 +59,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * dcim.Rack * Added optional `weight` and `weight_unit` fields +* extras.CustomLink + * Renamed `content_type` field to `content_types` * ipam.FHRPGroup * Added optional `name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 99f4dd02b..f8a5862a3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -117,14 +117,15 @@ class CustomFieldSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') - content_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) + content_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + many=True ) class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 1b1b049c7..c0114bb58 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -93,11 +93,15 @@ class CustomLinkFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = CustomLink fields = [ - 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + 'id', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b1d8a6c21..26c6a195d 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -53,11 +53,6 @@ class CustomLinkBulkEditForm(BulkEditForm): queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), - required=False - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 0303dae30..bcc392805 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -53,16 +53,16 @@ class CustomFieldCSVForm(CSVModelForm): class CustomLinkCSVForm(CSVModelForm): - content_type = CSVContentTypeField( + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), - help_text="Assigned object type" + help_text="One or more assigned object types" ) class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'name', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 059f0d9f2..2e8d4862d 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -121,9 +121,9 @@ class JobResultFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), + ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index eca93849b..8b00c2779 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -63,13 +63,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links') ) fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), + ('Custom Link', ('name', 'content_types', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 41a6103d3..c9f897715 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -35,7 +35,7 @@ class CustomLinkType(ObjectType): class Meta: model = models.CustomLink - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.CustomLinkFilterSet diff --git a/netbox/extras/migrations/0081_customlink_content_types.py b/netbox/extras/migrations/0081_customlink_content_types.py new file mode 100644 index 000000000..2f0f23509 --- /dev/null +++ b/netbox/extras/migrations/0081_customlink_content_types.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + CustomLink = apps.get_model('extras', 'CustomLink') + + for customlink in CustomLink.objects.all(): + customlink.content_types.set([customlink.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0080_search'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='content_types', + field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='customlink', + name='content_type', + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 6d7d2ae04..5c07c360c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -197,10 +197,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. """ - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('custom_links') + related_name='custom_links', + help_text='The object type(s) to which this link applies.' ) name = models.CharField( max_length=100, @@ -236,7 +236,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged ) clone_fields = ( - 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index a73eb3fb4..b7d8d1448 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from extras.models import CustomLink -from utilities.utils import render_jinja2 register = template.Library() @@ -34,7 +33,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7a9ee487d..c26b95c08 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -137,21 +137,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 4', 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 5', 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { - 'content_type': 'dcim.site', + 'content_types': ['dcim.site'], 'name': 'Custom Link 6', 'enabled': False, 'link_text': 'Link 6', @@ -169,21 +169,18 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): custom_links = ( CustomLink( - content_type=site_ct, name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( - content_type=site_ct, name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( - content_type=site_ct, name='Custom Link 3', enabled=False, link_text='Link 3', @@ -191,6 +188,8 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) class ExportTemplateTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9f9483bbb..3d4dd4cf1 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -168,7 +168,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): custom_links = ( CustomLink( name='Custom Link 1', - content_type=content_types[0], enabled=True, weight=100, new_window=False, @@ -177,7 +176,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 2', - content_type=content_types[1], enabled=True, weight=200, new_window=False, @@ -186,7 +184,6 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), CustomLink( name='Custom Link 3', - content_type=content_types[2], enabled=False, weight=300, new_window=True, @@ -195,13 +192,17 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): ), ) CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Custom Link 1', 'Custom Link 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_weight(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9634038c1..cfde58782 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,17 +59,19 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) - CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), - )) + custom_links = ( + CustomLink(name='Custom Link 1', enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', enabled=False, link_text='Link 3', link_url='http://example.com/?3'), + ) + CustomLink.objects.bulk_create(custom_links) + for i, custom_link in enumerate(custom_links): + custom_link.content_types.set([site_ct]) cls.form_data = { 'name': 'Custom Link X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, @@ -78,7 +80,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,enabled,weight,button_class,link_text,link_url", + "name,content_types,enabled,weight,button_class,link_text,link_url", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", @@ -327,13 +329,13 @@ class CustomLinkTest(TestCase): def test_view_object_with_custom_link(self): customlink = CustomLink( - content_type=ContentType.objects.get_for_model(Site), name='Test', link_text='FOO {{ obj.name }} BAR', link_url='http://example.com/?site={{ obj.slug }}', new_window=False ) customlink.save() + customlink.content_types.set([ContentType.objects.get_for_model(Site)]) site = Site(name='Test Site', slug='test-site') site.save() diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 9b86b2ed3..50c109be8 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -191,7 +191,7 @@ class NetBoxTable(BaseTable): extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + custom_links = CustomLink.objects.filter(content_types=content_type, enabled=True) extra_columns.extend([ (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links ]) diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index 1f3866182..ff0f7423e 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -6,19 +6,13 @@
    -
    - Custom Link -
    +
    Custom Link
    - - - - @@ -42,6 +36,18 @@
    Name {{ object.name }}
    Content Type{{ object.content_type }}
    Enabled {% checkmark object.enabled %}
    +
    +
    Assigned Models
    +
    + + {% for ct in object.content_types.all %} + + + + {% endfor %} +
    {{ ct }}
    +
    +
    {% plugin_left_page object %}
    From 16919cc1d9484635e5fb9d6c1da9dd9344db2747 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 13:30:45 -0400 Subject: [PATCH 182/409] Closes #10761: Enable associating an export template with multiple object types --- docs/release-notes/version-3.4.md | 5 ++- netbox/extras/api/serializers.py | 5 ++- netbox/extras/filtersets.py | 6 ++- netbox/extras/forms/bulk_edit.py | 5 --- netbox/extras/forms/bulk_import.py | 6 +-- netbox/extras/forms/filtersets.py | 4 +- netbox/extras/forms/model_forms.py | 4 +- netbox/extras/graphql/types.py | 2 +- .../0082_exporttemplate_content_types.py | 40 +++++++++++++++++++ netbox/extras/models/models.py | 16 +++----- netbox/extras/tests/test_api.py | 13 +++--- netbox/extras/tests/test_filtersets.py | 15 ++++--- netbox/extras/tests/test_views.py | 19 +++++---- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/templates/extras/exporttemplate.html | 16 ++++++-- netbox/utilities/templatetags/buttons.py | 2 +- 16 files changed, 104 insertions(+), 56 deletions(-) create mode 100644 netbox/extras/migrations/0082_exporttemplate_content_types.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 873967456..93e2c8841 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -8,7 +8,7 @@ * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. -* The `content_type` field on the CustomLink model has been renamed to `content_types` and now supports the assignment of multiple content types. +* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. ### New Features @@ -32,6 +32,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types ### Plugins API @@ -61,6 +62,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * extras.CustomLink * Renamed `content_type` field to `content_types` +* extras.ExportTemplate + * Renamed `content_type` field to `content_types` * ipam.FHRPGroup * Added optional `name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f8a5862a3..ac025ff16 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -136,14 +136,15 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_type = ContentTypeField( + content_types = ContentTypeField( queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + many=True ) class Meta: model = ExportTemplate fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index c0114bb58..22fe6537e 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -120,10 +120,14 @@ class ExportTemplateFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'description'] + fields = ['id', 'content_types', 'name', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 26c6a195d..df17324ec 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -76,11 +76,6 @@ class ExportTemplateBulkEditForm(BulkEditForm): queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), - required=False - ) description = forms.CharField( max_length=200, required=False diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index bcc392805..ee638015b 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -68,16 +68,16 @@ class CustomLinkCSVForm(CSVModelForm): class ExportTemplateCSVForm(CSVModelForm): - content_type = CSVContentTypeField( + content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), - help_text="Assigned object type" + help_text="One or more assigned object types" ) class Meta: model = ExportTemplate fields = ( - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 2e8d4862d..a164a3d95 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -148,9 +148,9 @@ class CustomLinkFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), + ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 8b00c2779..7ff4f3e27 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -89,13 +89,13 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( + content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates') ) fieldsets = ( - ('Export Template', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_types', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index c9f897715..3be7b371e 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -43,7 +43,7 @@ class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate - fields = '__all__' + exclude = ('content_types', ) filterset_class = filtersets.ExportTemplateFilterSet diff --git a/netbox/extras/migrations/0082_exporttemplate_content_types.py b/netbox/extras/migrations/0082_exporttemplate_content_types.py new file mode 100644 index 000000000..34a9c77e6 --- /dev/null +++ b/netbox/extras/migrations/0082_exporttemplate_content_types.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +def copy_content_types(apps, schema_editor): + ExportTemplate = apps.get_model('extras', 'ExportTemplate') + + for et in ExportTemplate.objects.all(): + et.content_types.set([et.content_type]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0081_customlink_content_types'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='content_types', + field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=copy_content_types, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveConstraint( + model_name='exporttemplate', + name='extras_exporttemplate_unique_content_type_name', + ), + migrations.RemoveField( + model_name='exporttemplate', + name='content_type', + ), + migrations.AlterModelOptions( + name='exporttemplate', + options={'ordering': ('name',)}, + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 5c07c360c..a8b2f2647 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -268,10 +268,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): - content_type = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('export_templates') + related_name='export_templates', + help_text='The object type(s) to which this template applies.' ) name = models.CharField( max_length=100 @@ -301,16 +301,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) class Meta: - ordering = ['content_type', 'name'] - constraints = ( - models.UniqueConstraint( - fields=('content_type', 'name'), - name='%(app_label)s_%(class)s_unique_content_type_name' - ), - ) + ordering = ('name',) def __str__(self): - return f"{self.content_type}: {self.name}" + return self.name def get_absolute_url(self): return reverse('extras:exporttemplate', args=[self.pk]) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index c26b95c08..42246b651 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -197,17 +197,17 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 4', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 5', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, { - 'content_type': 'dcim.device', + 'content_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, @@ -218,26 +218,23 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - ct = ContentType.objects.get_for_model(Device) - export_templates = ( ExportTemplate( - content_type=ct, name='Export Template 1', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 2', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ExportTemplate( - content_type=ct, name='Export Template 3', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' ), ) ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([ContentType.objects.get_for_model(Device)]) class TagTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 3d4dd4cf1..dd1fdb6b3 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -228,22 +228,25 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( - ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), + ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), + ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), + ExportTemplate(name='Export Template 3', template_code='TESTING'), ) ExportTemplate.objects.bulk_create(export_templates) + for i, et in enumerate(export_templates): + et.content_types.set([content_types[i]]) def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type(self): - params = {'content_type': ContentType.objects.get(model='site').pk} + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_description(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index cfde58782..da11d42ad 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -98,23 +98,26 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - site_ct = ContentType.objects.get_for_model(Site) TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}""" - ExportTemplate.objects.bulk_create(( - ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE), - )) + + export_templates = ( + ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ) + ExportTemplate.objects.bulk_create(export_templates) + for et in export_templates: + et.content_types.set([site_ct]) cls.form_data = { 'name': 'Export Template X', - 'content_type': site_ct.pk, + 'content_types': [site_ct.pk], 'template_code': TEMPLATE_CODE, } cls.csv_data = ( - "name,content_type,template_code", + "name,content_types,template_code", f"Export Template 4,dcim.site,{TEMPLATE_CODE}", f"Export Template 5,dcim.site,{TEMPLATE_CODE}", f"Export Template 6,dcim.site,{TEMPLATE_CODE}", diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index f0741af2c..69f9842ca 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -142,7 +142,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Render an ExportTemplate elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + template = get_object_or_404(ExportTemplate, content_types=content_type, name=request.GET['export']) return self.export_template(template, request) # Check for YAML export support on the model diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 912702b86..d14294355 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -18,10 +18,6 @@
    - - - - @@ -45,6 +41,18 @@
    Content Type{{ object.content_type }}
    Name {{ object.name }}
    +
    +
    Assigned Models
    +
    + + {% for ct in object.content_types.all %} + + + + {% endfor %} +
    {{ ct }}
    +
    +
    {% plugin_left_page object %}
    diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 4b8178405..bcdb099d8 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -83,7 +83,7 @@ def export_button(context, model): data_format = 'YAML' if hasattr(content_type.model_class(), 'to_yaml') else 'CSV' # Retrieve all export templates for this model - export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_types=content_type) return { 'perms': context['perms'], From edb522022848b7a00a08dbe4206e16526a1e585a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 15:11:44 -0400 Subject: [PATCH 183/409] Changelog for #10666 (missed in v3.3.6) --- docs/release-notes/version-3.3.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 8b8bd0060..6af0586b4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -23,6 +23,7 @@ * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables +* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests * [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view From 7e7f68923d7fec2a98d6f979b80aa5634259db76 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 17:05:09 -0400 Subject: [PATCH 184/409] #10761: Fix ExportTemplate resolution for REST API requests --- netbox/netbox/api/viewsets/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c50ad9ca6..e5e842696 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError -from django.shortcuts import get_object_or_404 +from django.http import Http404 from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -142,7 +142,9 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali """ if 'export' in request.GET: content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() + if et is None: + raise Http404 queryset = self.filter_queryset(self.get_queryset()) return et.render_to_response(queryset) From ec4e2a8e1692b1821998f05b64dbf3309b9a20bc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 17:07:16 -0400 Subject: [PATCH 185/409] #8274, #10761: Fix content types display in object lists --- netbox/extras/tables/tables.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 73d3e98b2..4b4acb235 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -69,17 +69,17 @@ class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = columns.ContentTypeColumn() + content_types = columns.ContentTypesColumn() enabled = columns.BooleanColumn() new_window = columns.BooleanColumn() class Meta(NetBoxTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'content_types', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') # @@ -90,17 +90,17 @@ class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = columns.ContentTypeColumn() + content_types = columns.ContentTypesColumn() as_attachment = columns.BooleanColumn() class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( - 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', ) From dbe66596f90803034b5dd1eb1d4a0e070f584c36 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Oct 2022 09:44:09 -0400 Subject: [PATCH 186/409] Closes #9887: Inspect docs_url property to determine link to model documentation --- docs/plugins/development/models.md | 6 ++++++ docs/release-notes/version-3.4.md | 1 + netbox/netbox/models/__init__.py | 5 +++++ netbox/templates/generic/object_edit.html | 4 ++-- netbox/utilities/templatetags/helpers.py | 8 -------- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 16f5dd0df..b3bcb292a 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,6 +49,12 @@ class MyModel(NetBoxModel): ... ``` +### NetBoxModel Properties + +#### `docs_url` + +This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models///`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/). + ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 93e2c8841..eefb0ee21 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -40,6 +40,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter +* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin * [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 1385dd585..2f2dc1c9f 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -26,6 +27,10 @@ class NetBoxFeatureSet( class Meta: abstract = True + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' + @classmethod def get_prerequisite_models(cls): """ diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 56e4f5a32..c61fb723f 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -37,9 +37,9 @@ Context: {% endif %} {# Link to model documentation #} - {% if object and settings.DOCS_ROOT %} + {% if settings.DOCS_ROOT and object.docs_url %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 462b37feb..9789724ee 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -141,14 +141,6 @@ def percentage(x, y): return round(x / y * 100) -@register.filter() -def get_docs_url(model): - """ - Return the documentation URL for the specified model. - """ - return f'{settings.STATIC_URL}docs/models/{model._meta.app_label}/{model._meta.model_name}/' - - @register.filter() def has_perms(user, permissions_list): """ From d773f4e62a92b7d941beca1212133420bf219998 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Oct 2022 10:17:20 -0400 Subject: [PATCH 187/409] Closes #9832: Add mounting_depth field to rack model --- docs/models/dcim/rack.md | 4 ++++ docs/release-notes/version-3.4.md | 3 +++ netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 11 +++++++++-- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 2 +- .../migrations/0164_rack_mounting_depth.py | 18 ++++++++++++++++++ netbox/dcim/models/racks.py | 10 +++++++++- netbox/dcim/tables/racks.py | 5 +++-- netbox/templates/dcim/rack.html | 14 ++++++++++++-- netbox/templates/dcim/rack_edit.html | 1 + 12 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 netbox/dcim/migrations/0164_rack_mounting_depth.py diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index e88c36fad..505160d3e 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -65,6 +65,10 @@ The height of the rack, measured in units. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +### Mounting Depth + +The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) + ### Weight The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index eefb0ee21..5ca84c996 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations +* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types @@ -81,6 +82,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Add `component` field * dcim.InventoryItemTemplate * Add `component` field +* dcim.Rack + * Add `mounting_depth` field * ipam.FHRPGroupAssignment * Add `interface` field * ipam.IPAddress diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 22d56565e..1cf9369ae 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -210,8 +210,8 @@ class RackSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'powerfeed_count', + 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', 'device_count', 'powerfeed_count', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a200b1ece..78afd816c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = [ 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'weight', 'weight_unit' + 'outer_unit', 'mounting_depth', 'weight', 'weight_unit' ] def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d033d3a67..e3b69dc81 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -281,6 +281,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + mounting_depth = forms.IntegerField( + required=False, + min_value=1 + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -300,11 +304,14 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Location', ('region', 'site_group', 'site', 'location')), - ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), + ('Hardware', ( + 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', + )), ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit' + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'weight', 'weight_unit' ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f0fd9bf86..add66ee96 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -196,7 +196,7 @@ class RackCSVForm(NetBoxModelCSVForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 1df9e143c..0da2f3430 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -278,7 +278,7 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags', + 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", diff --git a/netbox/dcim/migrations/0164_rack_mounting_depth.py b/netbox/dcim/migrations/0164_rack_mounting_depth.py new file mode 100644 index 000000000..5bd087beb --- /dev/null +++ b/netbox/dcim/migrations/0164_rack_mounting_depth.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.1 on 2022-10-27 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0163_rack_devicetype_moduletype_weights'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='mounting_depth', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 6da48b65c..6fcd65a19 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -167,6 +167,14 @@ class Rack(NetBoxModel, WeightMixin): choices=RackDimensionUnitChoices, blank=True, ) + mounting_depth = models.PositiveSmallIntegerField( + blank=True, + null=True, + help_text=( + 'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the ' + 'distance between the front and rear rails.' + ) + ) comments = models.TextField( blank=True ) @@ -187,7 +195,7 @@ class Rack(NetBoxModel, WeightMixin): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'weight', 'weight_unit', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', ) class Meta: diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 9c7b28983..1a355cc2a 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -89,8 +89,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', - 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments', - 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', + 'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight', + 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index e30ce7a62..7118f09ef 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -129,7 +129,7 @@ Outer Width {% if object.outer_width %} - {{ object.outer_width }} {{ object.get_outer_unit_display }} + {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} {{ ''|placeholder }} {% endif %} @@ -139,7 +139,17 @@ Outer Depth {% if object.outer_depth %} - {{ object.outer_depth }} {{ object.get_outer_unit_display }} + {{ object.outer_depth }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + + + Mounting Depth + + {% if object.mounting_depth %} + {{ object.mounting_depth }} Millimeters {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4a340c147..a0af20c68 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -55,6 +55,7 @@
    Unit
    + {% render_field form.mounting_depth %} {% render_field form.desc_units %}
    From cb815ede60ab298ca13907d523126380f50a8023 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 27 Oct 2022 10:10:18 -0700 Subject: [PATCH 188/409] 7961 CSV bulk update (#10715) * 7961 add csv bulk update * temp checkin - blocked * 7961 bugfix and cleanup * 7961 change to id, add docs * 7961 add tests cases * 7961 fix does not exist validation error * 7961 fix does not exist validation error * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 update tests * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 make test cases more explicit * 7961 optimize loading csv test data * 7961 update tests remove redundant code * 7961 avoid MPTT issue in test cases --- docs/getting-started/populating-data.md | 4 +- netbox/circuits/tests/test_views.py | 46 +++- netbox/dcim/forms/bulk_import.py | 6 +- netbox/dcim/tests/test_views.py | 294 ++++++++++++++++++---- netbox/extras/tests/test_views.py | 40 ++- netbox/ipam/forms/bulk_import.py | 4 +- netbox/ipam/tests/test_views.py | 187 +++++++++++--- netbox/netbox/views/generic/bulk_views.py | 53 +++- netbox/tenancy/tests/test_views.py | 50 +++- netbox/utilities/forms/utils.py | 13 +- netbox/utilities/testing/views.py | 43 ++++ netbox/virtualization/tests/test_views.py | 55 +++- netbox/wireless/tests/test_views.py | 37 ++- 13 files changed, 715 insertions(+), 117 deletions(-) diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md index bb0e8e17f..9a2386d71 100644 --- a/docs/getting-started/populating-data.md +++ b/docs/getting-started/populating-data.md @@ -20,12 +20,14 @@ To create a new object in NetBox, find the object type in the navigation menu an ## Bulk Import (CSV/YAML) -NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. +NetBox supports the bulk import of new objects, and updating of existing objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.) +If an "id" field is added the data will be used to update existing records instead of importing new objects. + Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. ## Scripting diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 9644c0b02..54d001c8d 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -50,6 +50,13 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Provider 6,provider-6", ) + cls.csv_update_data = ( + "id,name,comments", + f"{providers[0].pk},Provider 7,New comment7", + f"{providers[1].pk},Provider 8,New comment8", + f"{providers[2].pk},Provider 9,New comment9", + ) + cls.bulk_edit_data = { 'account': '5678', 'comments': 'New comments', @@ -62,11 +69,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - CircuitType.objects.bulk_create([ + circuit_types = ( CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 2', slug='circuit-type-2'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'), - ]) + ) + + CircuitType.objects.bulk_create(circuit_types) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -84,6 +93,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Circuit Type 6,circuit-type-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{circuit_types[0].pk},Circuit Type 7,New description7", + f"{circuit_types[1].pk},Circuit Type 8,New description8", + f"{circuit_types[2].pk},Circuit Type 9,New description9", + ) + cls.bulk_edit_data = { 'description': 'Foo', } @@ -107,11 +123,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) CircuitType.objects.bulk_create(circuittypes) - Circuit.objects.bulk_create([ + circuits = ( Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), - ]) + ) + + Circuit.objects.bulk_create(circuits) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -136,6 +154,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Circuit 6,Provider 1,Circuit Type 1,active", ) + cls.csv_update_data = ( + f"id,cid,description,status", + f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", + ) + cls.bulk_edit_data = { 'provider': providers[1].pk, 'type': circuittypes[1].pk, @@ -159,11 +184,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Provider.objects.bulk_create(providers) - ProviderNetwork.objects.bulk_create([ + provider_networks = ( ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 2', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[0]), - ]) + ) + + ProviderNetwork.objects.bulk_create(provider_networks) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -182,6 +209,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Provider Network 6,Provider 1,Baz", ) + cls.csv_update_data = ( + "id,name,description", + f"{provider_networks[0].pk},Provider Network 7,New description7", + f"{provider_networks[1].pk},Provider Network 8,New description8", + f"{provider_networks[2].pk},Provider Network 9,New description9", + ) + cls.bulk_edit_data = { 'provider': providers[1].pk, 'description': 'New description', diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index add66ee96..13e788e75 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -576,7 +576,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): super().__init__(*args, **kwargs) # Limit PowerPort choices to those belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -711,7 +711,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): super().__init__(*args, **kwargs) # Limit RearPort choices to those belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: @@ -782,7 +782,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): super().__init__(*args, **kwargs) # Limit installed device choices to devices of the correct type and location - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index db3495521..8bf1c1948 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -50,6 +50,13 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Region 6,region-6,Sixth region", ) + cls.csv_update_data = ( + "id,name,description", + f"{regions[0].pk},Region 7,Fourth region7", + f"{regions[1].pk},Region 8,Fifth region8", + f"{regions[2].pk},Region 0,Sixth region9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -87,6 +94,13 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Site Group 6,site-group-6,Sixth site group", ) + cls.csv_update_data = ( + "id,name,description", + f"{sitegroups[0].pk},Site Group 7,Fourth site group7", + f"{sitegroups[1].pk},Site Group 8,Fifth site group8", + f"{sitegroups[2].pk},Site Group 0,Sixth site group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -156,6 +170,13 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 6,site-6,staging", ) + cls.csv_update_data = ( + "id,name,status", + f"{sites[0].pk},Site 7,staging", + f"{sites[1].pk},Site 8,planned", + f"{sites[2].pk},Site 9,active", + ) + cls.bulk_edit_data = { 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, @@ -202,6 +223,13 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) + cls.csv_update_data = ( + "id,name,description", + f"{locations[0].pk},Location 7,Fourth location7", + f"{locations[1].pk},Location 8,Fifth location8", + f"{locations[2].pk},Location 0,Sixth location9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -213,11 +241,12 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - RackRole.objects.bulk_create([ + rack_roles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), RackRole(name='Rack Role 2', slug='rack-role-2'), RackRole(name='Rack Role 3', slug='rack-role-3'), - ]) + ) + RackRole.objects.bulk_create(rack_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -236,6 +265,13 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Rack Role 6,rack-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{rack_roles[0].pk},Rack Role 7,New description7", + f"{rack_roles[1].pk},Rack Role 8,New description8", + f"{rack_roles[2].pk},Rack Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -259,11 +295,12 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): rack = Rack(name='Rack 1', site=site, location=location) rack.save() - RackReservation.objects.bulk_create([ + rack_reservations = ( RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'), RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'), RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), - ]) + ) + RackReservation.objects.bulk_create(rack_reservations) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -283,6 +320,13 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', ) + cls.csv_update_data = ( + 'id,description', + f'{rack_reservations[0].pk},New description1', + f'{rack_reservations[1].pk},New description2', + f'{rack_reservations[2].pk},New description3', + ) + cls.bulk_edit_data = { 'user': user3.pk, 'tenant': None, @@ -315,11 +359,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RackRole.objects.bulk_create(rackroles) - Rack.objects.bulk_create(( + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[0]), Rack(name='Rack 3', site=sites[0]), - )) + ) + Rack.objects.bulk_create(racks) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -351,6 +396,13 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 2,Location 2,Rack 6,active,19,42", ) + cls.csv_update_data = ( + "id,name,status", + f"{racks[0].pk},Rack 7,{RackStatusChoices.STATUS_DEPRECATED}", + f"{racks[1].pk},Rack 8,{RackStatusChoices.STATUS_DEPRECATED}", + f"{racks[2].pk},Rack 9,{RackStatusChoices.STATUS_DEPRECATED}", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'location': locations[1].pk, @@ -383,11 +435,12 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Manufacturer.objects.bulk_create([ + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), - ]) + ) + Manufacturer.objects.bulk_create(manufacturers) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -405,6 +458,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) + cls.csv_update_data = ( + "id,name,description", + f"{manufacturers[0].pk},Manufacturer 7,Fourth manufacturer7", + f"{manufacturers[1].pk},Manufacturer 8,Fifth manufacturer8", + f"{manufacturers[2].pk},Manufacturer 9,Sixth manufacturer9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -1444,11 +1504,12 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - DeviceRole.objects.bulk_create([ + device_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), - ]) + ) + DeviceRole.objects.bulk_create(device_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1468,6 +1529,13 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Device Role 6,device-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{device_roles[0].pk},Device Role 7,New description7", + f"{device_roles[1].pk},Device Role 8,New description8", + f"{device_roles[2].pk},Device Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -1482,11 +1550,12 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - Platform.objects.bulk_create([ + platforms = ( Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer), Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), - ]) + ) + Platform.objects.bulk_create(platforms) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1507,6 +1576,13 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Platform 6,platform-6,Sixth platform", ) + cls.csv_update_data = ( + "id,name,description", + f"{platforms[0].pk},Platform 7,Fourth platform7", + f"{platforms[1].pk},Platform 8,Fifth platform8", + f"{platforms[2].pk},Platform 9,Sixth platform9", + ) + cls.bulk_edit_data = { 'napalm_driver': 'ios', 'description': 'New description', @@ -1554,11 +1630,12 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) - Device.objects.bulk_create([ + devices = ( Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), - ]) + ) + Device.objects.bulk_create(devices) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1595,6 +1672,13 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30", ) + cls.csv_update_data = ( + "id,status", + f"{devices[0].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + f"{devices[1].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + f"{devices[2].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}", + ) + cls.bulk_edit_data = { 'device_type': devicetypes[1].pk, 'device_role': deviceroles[1].pk, @@ -1815,6 +1899,13 @@ class ModuleTestCase( "Device 2,Module Bay 3,Module Type 3,C,C", ) + cls.csv_update_data = ( + "id,serial", + f"{modules[0].pk},Serial 2", + f"{modules[1].pk},Serial 3", + f"{modules[2].pk},Serial 1", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_module_component_replication(self): self.add_permissions('dcim.add_module') @@ -1894,11 +1985,12 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ConsolePort.objects.bulk_create([ + console_ports = ( ConsolePort(device=device, name='Console Port 1'), ConsolePort(device=device, name='Console Port 2'), ConsolePort(device=device, name='Console Port 3'), - ]) + ) + ConsolePort.objects.bulk_create(console_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1932,6 +2024,13 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{console_ports[0].pk},Console Port 7,New description7", + f"{console_ports[1].pk},Console Port 8,New description8", + f"{console_ports[2].pk},Console Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleport = ConsolePort.objects.first() @@ -1953,11 +2052,12 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ConsoleServerPort.objects.bulk_create([ + console_server_ports = ( ConsoleServerPort(device=device, name='Console Server Port 1'), ConsoleServerPort(device=device, name='Console Server Port 2'), ConsoleServerPort(device=device, name='Console Server Port 3'), - ]) + ) + ConsoleServerPort.objects.bulk_create(console_server_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -1989,6 +2089,13 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Console Server Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{console_server_ports[0].pk},Console Server Port 7,New description 7", + f"{console_server_ports[1].pk},Console Server Port 8,New description 8", + f"{console_server_ports[2].pk},Console Server Port 9,New description 9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): consoleserverport = ConsoleServerPort.objects.first() @@ -2010,11 +2117,12 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - PowerPort.objects.bulk_create([ + power_ports = ( PowerPort(device=device, name='Power Port 1'), PowerPort(device=device, name='Power Port 2'), PowerPort(device=device, name='Power Port 3'), - ]) + ) + PowerPort.objects.bulk_create(power_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2052,6 +2160,13 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Port 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{power_ports[0].pk},Power Port 7,New description7", + f"{power_ports[1].pk},Power Port 8,New description8", + f"{power_ports[2].pk},Power Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): powerport = PowerPort.objects.first() @@ -2079,11 +2194,12 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): ) PowerPort.objects.bulk_create(powerports) - PowerOutlet.objects.bulk_create([ + power_outlets = ( PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), - ]) + ) + PowerOutlet.objects.bulk_create(power_outlets) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2121,6 +2237,13 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Power Outlet 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{power_outlets[0].pk},Power Outlet 7,New description7", + f"{power_outlets[1].pk},Power Outlet 8,New description8", + f"{power_outlets[2].pk},Power Outlet 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): poweroutlet = PowerOutlet.objects.first() @@ -2247,6 +2370,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) + cls.csv_update_data = ( + "id,name,description", + f"{interfaces[0].pk},Interface 7,New description7", + f"{interfaces[1].pk},Interface 8,New description8", + f"{interfaces[2].pk},Interface 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): interface1, interface2 = Interface.objects.all()[:2] @@ -2274,11 +2404,12 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ) RearPort.objects.bulk_create(rearports) - FrontPort.objects.bulk_create([ + front_ports = ( FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]), FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]), FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), - ]) + ) + FrontPort.objects.bulk_create(front_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2313,6 +2444,13 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) + cls.csv_update_data = ( + "id,name,description", + f"{front_ports[0].pk},Front Port 7,New description7", + f"{front_ports[1].pk},Front Port 8,New description8", + f"{front_ports[2].pk},Front Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): frontport = FrontPort.objects.first() @@ -2334,11 +2472,12 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - RearPort.objects.bulk_create([ + rear_ports = ( RearPort(device=device, name='Rear Port 1'), RearPort(device=device, name='Rear Port 2'), RearPort(device=device, name='Rear Port 3'), - ]) + ) + RearPort.objects.bulk_create(rear_ports) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2372,6 +2511,13 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Rear Port 6,8p8c,1", ) + cls.csv_update_data = ( + "id,name,description", + f"{rear_ports[0].pk},Rear Port 7,New description7", + f"{rear_ports[1].pk},Rear Port 8,New description8", + f"{rear_ports[2].pk},Rear Port 9,New description9", + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): rearport = RearPort.objects.first() @@ -2393,11 +2539,12 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - ModuleBay.objects.bulk_create([ + module_bays = ( ModuleBay(device=device, name='Module Bay 1'), ModuleBay(device=device, name='Module Bay 2'), ModuleBay(device=device, name='Module Bay 3'), - ]) + ) + ModuleBay.objects.bulk_create(module_bays) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2426,6 +2573,13 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Module Bay 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{module_bays[0].pk},Module Bay 7,New description7", + f"{module_bays[1].pk},Module Bay 8,New description8", + f"{module_bays[2].pk},Module Bay 9,New description9", + ) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay @@ -2438,11 +2592,12 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): # Update the DeviceType subdevice role to allow adding DeviceBays DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) - DeviceBay.objects.bulk_create([ + device_bays = ( DeviceBay(device=device, name='Device Bay 1'), DeviceBay(device=device, name='Device Bay 2'), DeviceBay(device=device, name='Device Bay 3'), - ]) + ) + DeviceBay.objects.bulk_create(device_bays) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2471,6 +2626,13 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Device Bay 6", ) + cls.csv_update_data = ( + "id,name,description", + f"{device_bays[0].pk},Device Bay 7,New description7", + f"{device_bays[1].pk},Device Bay 8,New description8", + f"{device_bays[2].pk},Device Bay 9,New description9", + ) + class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem @@ -2487,9 +2649,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): ) InventoryItemRole.objects.bulk_create(roles) - InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) + inventory_item1 = InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) + inventory_item2 = InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) + inventory_item3 = InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2533,6 +2695,13 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): "Device 1,Inventory Item 6,Inventory Item 3", ) + cls.csv_update_data = ( + "id,name,description", + f"{inventory_item1.pk},Inventory Item 7,New description7", + f"{inventory_item2.pk},Inventory Item 8,New description8", + f"{inventory_item3.pk},Inventory Item 9,New description9", + ) + class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = InventoryItemRole @@ -2540,11 +2709,12 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - InventoryItemRole.objects.bulk_create([ + inventory_item_roles = ( InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), - ]) + ) + InventoryItemRole.objects.bulk_create(inventory_item_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2563,6 +2733,13 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Inventory Item Role 6,inventory-item-role-6,0000ff", ) + cls.csv_update_data = ( + "id,name,description", + f"{inventory_item_roles[0].pk},Inventory Item Role 7,New description7", + f"{inventory_item_roles[1].pk},Inventory Item Role 8,New description8", + f"{inventory_item_roles[2].pk},Inventory Item Role 9,New description9", + ) + cls.bulk_edit_data = { 'color': '00ff00', 'description': 'New description', @@ -2615,9 +2792,12 @@ class CableTestCase( ) Interface.objects.bulk_create(interfaces) - Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save() - Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save() - Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save() + cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6) + cable1.save() + cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6) + cable2.save() + cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6) + cable3.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2643,6 +2823,13 @@ class CableTestCase( "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", ) + cls.csv_update_data = ( + "id,label,color", + f"{cable1.pk},New label7,00ff00", + f"{cable2.pk},New label8,00ff00", + f"{cable3.pk},New label9,00ff00", + ) + cls.bulk_edit_data = { 'type': CableTypeChoices.TYPE_CAT5E, 'status': LinkStatusChoices.STATUS_CONNECTED, @@ -2726,6 +2913,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VC6,Domain 6,Device 12", ) + cls.csv_update_data = ( + "id,name,domain", + f"{vc1.pk},VC7,Domain 7", + f"{vc2.pk},VC8,Domain 8", + f"{vc3.pk},VC9,Domain 9", + ) + cls.bulk_edit_data = { 'domain': 'domain-x', } @@ -2750,11 +2944,12 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): for location in locations: location.save() - PowerPanel.objects.bulk_create(( + power_panels = ( PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'), PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), - )) + ) + PowerPanel.objects.bulk_create(power_panels) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2772,6 +2967,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 1,Location 1,Power Panel 6", ) + cls.csv_update_data = ( + "id,name", + f"{power_panels[0].pk},Power Panel 7", + f"{power_panels[1].pk},Power Panel 8", + f"{power_panels[2].pk},Power Panel 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'location': locations[1].pk, @@ -2798,11 +3000,12 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Rack.objects.bulk_create(racks) - PowerFeed.objects.bulk_create(( + power_feeds = ( PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), - )) + ) + PowerFeed.objects.bulk_create(power_feeds) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -2828,6 +3031,13 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80", ) + cls.csv_update_data = ( + "id,name,status", + f"{power_feeds[0].pk},Power Feed 7,{PowerFeedStatusChoices.STATUS_PLANNED}", + f"{power_feeds[1].pk},Power Feed 8,{PowerFeedStatusChoices.STATUS_PLANNED}", + f"{power_feeds[2].pk},Power Feed 9,{PowerFeedStatusChoices.STATUS_PLANNED}", + ) + cls.bulk_edit_data = { 'power_panel': powerpanels[1].pk, 'rack': racks[1].pk, diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index da11d42ad..85e5aea5e 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -48,6 +48,13 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', ) + cls.csv_update_data = ( + 'id,label', + f'{custom_fields[0].pk},New label 1', + f'{custom_fields[1].pk},New label 2', + f'{custom_fields[2].pk},New label 3', + ) + cls.bulk_edit_data = { 'required': True, 'weight': 200, @@ -86,6 +93,13 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", ) + cls.csv_update_data = ( + "id,name", + f"{custom_links[0].pk},Custom Link 7", + f"{custom_links[1].pk},Custom Link 8", + f"{custom_links[2].pk},Custom Link 9", + ) + cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, 'enabled': False, @@ -123,6 +137,13 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"Export Template 6,dcim.site,{TEMPLATE_CODE}", ) + cls.csv_update_data = ( + "id,name", + f"{export_templates[0].pk},Export Template 7", + f"{export_templates[1].pk},Export Template 8", + f"{export_templates[2].pk},Export Template 9", + ) + cls.bulk_edit_data = { 'mime_type': 'text/html', 'file_extension': 'html', @@ -165,6 +186,13 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json", ) + cls.csv_update_data = ( + "id,name", + f"{webhooks[0].pk},Webhook 7", + f"{webhooks[1].pk},Webhook 8", + f"{webhooks[2].pk},Webhook 9", + ) + cls.bulk_edit_data = { 'enabled': False, 'type_create': False, @@ -180,11 +208,12 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Tag.objects.bulk_create(( + tags = ( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 3', slug='tag-3'), - )) + ) + Tag.objects.bulk_create(tags) cls.form_data = { 'name': 'Tag X', @@ -200,6 +229,13 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tag 6,tag-6,0000ff,Sixth tag", ) + cls.csv_update_data = ( + "id,name,description", + f"{tags[0].pk},Tag 7,Fourth tag7", + f"{tags[1].pk},Tag 8,Fifth tag8", + f"{tags[2].pk},Tag 9,Sixth tag9", + ) + cls.bulk_edit_data = { 'color': '00ff00', } diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 6a9dd91ac..3aead6151 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -298,13 +298,13 @@ class IPAddressCSVForm(NetBoxModelCSVForm): def save(self, *args, **kwargs): # Set interface assignment - if self.cleaned_data['interface']: + if self.cleaned_data.get('interface'): self.instance.assigned_object = self.cleaned_data['interface'] ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM - if self.cleaned_data['is_primary']: + if self.cleaned_data.get('is_primary'): parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] if self.instance.address.version == 4: parent.primary_ip4 = ipaddress diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 5cc8fad24..25b8af9ae 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -60,6 +60,13 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): "4200000002,RFC 6996", ) + cls.csv_update_data = ( + "id,description", + f"{asns[0].pk},New description1", + f"{asns[1].pk},New description2", + f"{asns[2].pk},New description3", + ) + cls.bulk_edit_data = { 'rir': rirs[1].pk, 'description': 'Next description', @@ -78,11 +85,12 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Tenant.objects.bulk_create(tenants) - VRF.objects.bulk_create([ + vrfs = ( VRF(name='VRF 1', rd='65000:1'), VRF(name='VRF 2', rd='65000:2'), VRF(name='VRF 3', rd='65000:3'), - ]) + ) + VRF.objects.bulk_create(vrfs) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -102,6 +110,13 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 6", ) + cls.csv_update_data = ( + "id,name", + f"{vrfs[0].pk},VRF 7", + f"{vrfs[1].pk},VRF 8", + f"{vrfs[2].pk},VRF 9", + ) + cls.bulk_edit_data = { 'tenant': tenants[1].pk, 'enforce_unique': False, @@ -143,6 +158,13 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase): "65000:1006,,No tenant", ) + cls.csv_update_data = ( + "id,name,description", + f"{route_targets[0].pk},65000:1007,New description1", + f"{route_targets[1].pk},65000:1008,New description2", + f"{route_targets[2].pk},65000:1009,New description3", + ) + cls.bulk_edit_data = { 'tenant': tenants[1].pk, 'description': 'New description', @@ -155,11 +177,12 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - RIR.objects.bulk_create([ + rirs = ( RIR(name='RIR 1', slug='rir-1'), RIR(name='RIR 2', slug='rir-2'), RIR(name='RIR 3', slug='rir-3'), - ]) + ) + RIR.objects.bulk_create(rirs) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -178,6 +201,13 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "RIR 6,rir-6,Sixth RIR", ) + cls.csv_update_data = ( + "id,name,description", + f"{rirs[0].pk},RIR 7,Fourth RIR7", + f"{rirs[1].pk},RIR 8,Fifth RIR8", + f"{rirs[2].pk},RIR 9,Sixth RIR9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -195,11 +225,12 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RIR.objects.bulk_create(rirs) - Aggregate.objects.bulk_create([ + aggregates = ( Aggregate(prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), - ]) + ) + Aggregate.objects.bulk_create(aggregates) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -218,6 +249,13 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): "10.6.0.0/16,RIR 1", ) + cls.csv_update_data = ( + "id,description", + f"{aggregates[0].pk},New description1", + f"{aggregates[1].pk},New description2", + f"{aggregates[2].pk},New description3", + ) + cls.bulk_edit_data = { 'rir': rirs[1].pk, 'date_added': datetime.date(2020, 1, 1), @@ -246,11 +284,12 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - Role.objects.bulk_create([ + roles = ( Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), Role(name='Role 3', slug='role-3'), - ]) + ) + Role.objects.bulk_create(roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -269,6 +308,13 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Role 6,role-6,1000", ) + cls.csv_update_data = ( + "id,name,description", + f"{roles[0].pk},Role 7,New description7", + f"{roles[1].pk},Role 8,New description8", + f"{roles[2].pk},Role 9,New description9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -298,11 +344,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Role.objects.bulk_create(roles) - Prefix.objects.bulk_create([ + prefixes = ( Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), - ]) + ) + Prefix.objects.bulk_create(prefixes) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -326,6 +373,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,10.6.0.0/16,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}", + f"{prefixes[1].pk},New description 8,{PrefixStatusChoices.STATUS_RESERVED}", + f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'vrf': vrfs[1].pk, @@ -428,6 +482,13 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,10.3.0.1/16,10.3.9.254/16,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}", + f"{ip_ranges[1].pk},New description 8,{IPRangeStatusChoices.STATUS_RESERVED}", + f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -467,11 +528,12 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) VRF.objects.bulk_create(vrfs) - IPAddress.objects.bulk_create([ + ipaddresses = ( IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), - ]) + ) + IPAddress.objects.bulk_create(ipaddresses) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -494,6 +556,13 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): "VRF 1,192.0.2.6/24,active", ) + cls.csv_update_data = ( + "id,description,status", + f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}", + f"{ipaddresses[1].pk},New description 8,{IPAddressStatusChoices.STATUS_RESERVED}", + f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}", + ) + cls.bulk_edit_data = { 'vrf': vrfs[1].pk, 'tenant': None, @@ -510,11 +579,12 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - FHRPGroup.objects.bulk_create(( + fhrp_groups = ( FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), - )) + ) + FHRPGroup.objects.bulk_create(fhrp_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -535,6 +605,13 @@ class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): "hsrp,60,,,", ) + cls.csv_update_data = ( + "id,name,description", + f"{fhrp_groups[0].pk},FHRP Group 1,New description 1", + f"{fhrp_groups[1].pk},FHRP Group 2,New description 2", + f"{fhrp_groups[2].pk},FHRP Group 3,New description 3", + ) + cls.bulk_edit_data = { 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, } @@ -552,11 +629,12 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) Site.objects.bulk_create(sites) - VLANGroup.objects.bulk_create([ + vlan_groups = ( VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]), VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), - ]) + ) + VLANGroup.objects.bulk_create(vlan_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -576,6 +654,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", ) + cls.csv_update_data = ( + f"id,name,description", + f"{vlan_groups[0].pk},VLAN Group 7,Fourth VLAN group7", + f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8", + f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -605,11 +690,12 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Role.objects.bulk_create(roles) - VLAN.objects.bulk_create([ + vlans = ( VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), - ]) + ) + VLAN.objects.bulk_create(vlans) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -632,6 +718,13 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): "106,VLAN106,active", ) + cls.csv_update_data = ( + "id,name,description", + f"{vlans[0].pk},VLAN107,New description 7", + f"{vlans[1].pk},VLAN108,New description 8", + f"{vlans[2].pk},VLAN109,New description 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'group': vlangroups[1].pk, @@ -647,11 +740,12 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - ServiceTemplate.objects.bulk_create([ + service_templates = ( ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), - ]) + ) + ServiceTemplate.objects.bulk_create(service_templates) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -670,6 +764,13 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Service Template 6,tcp,3,Third service template", ) + cls.csv_update_data = ( + "id,name,description", + f"{service_templates[0].pk},Service Template 7,First service template7", + f"{service_templates[1].pk},Service Template 8,Second service template8", + f"{service_templates[2].pk},Service Template 9,Third service template9", + ) + cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'ports': '106,107', @@ -689,11 +790,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - Service.objects.bulk_create([ + services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), - ]) + ) + Service.objects.bulk_create(services) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -715,6 +817,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Device 1,Service 3,udp,3,Third service", ) + cls.csv_update_data = ( + "id,name,description", + f"{services[0].pk},Service 7,First service7", + f"{services[1].pk},Service 8,Second service8", + f"{services[2].pk},Service 9,Third service9", + ) + cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'ports': '106,107', @@ -751,14 +860,6 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = L2VPN - csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', - ) - bulk_edit_data = { - 'description': 'New Description', - } @classmethod def setUpTestData(cls): @@ -773,9 +874,24 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') ) - L2VPN.objects.bulk_create(l2vpns) + cls.csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + + cls.csv_update_data = ( + 'id,name,description', + f'{l2vpns[0].pk},L2VPN 7,New description 7', + f'{l2vpns[1].pk},L2VPN 8,New description 8', + ) + + cls.bulk_edit_data = { + 'description': 'New Description', + } + cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', @@ -804,7 +920,7 @@ class L2VPNTerminationTestCase( def setUpTestData(cls): device = create_test_device('Device 1') interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') - l2vpn = L2VPN.objects.create(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) + l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) vlans = ( VLAN(name='Vlan 1', vid=1001), @@ -836,6 +952,13 @@ class L2VPNTerminationTestCase( "L2VPN 1,Vlan 6", ) + cls.csv_update_data = ( + "id,l2vpn", + f"{terminations[0].pk},L2VPN 2", + f"{terminations[1].pk},L2VPN 2", + f"{terminations[2].pk},L2VPN 2", + ) + cls.bulk_edit_data = {} # diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 69f9842ca..5d7b4eff0 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,11 +4,11 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport @@ -321,13 +321,52 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return ImportForm(*args, **kwargs) - def _create_objects(self, form, request): - new_objs = [] + def _get_records(self, form, request): if request.FILES: headers, records = form.cleaned_data['csv_file'] else: headers, records = form.cleaned_data['csv'] + return headers, records + + def _update_objects(self, form, request, headers, records): + from utilities.forms import CSVModelChoiceField + updated_objs = [] + + ids = [int(record["id"]) for record in records] + qs = self.queryset.model.objects.filter(id__in=ids) + objs = {} + for obj in qs: + objs[obj.id] = obj + + for row, data in enumerate(records, start=1): + if int(data["id"]) not in objs: + form.add_error('csv', f'Row {row} id: {data["id"]} Does not exist') + raise ValidationError("") + + obj = objs[int(data["id"])] + obj_form = self.model_form(data, headers=headers, instance=obj) + + # The form should only contain fields that are in the CSV + for name, field in list(obj_form.fields.items()): + if name not in headers: + del obj_form.fields[name] + + restrict_form_fields(obj_form, request.user) + + if obj_form.is_valid(): + obj = self._save_obj(obj_form, request) + updated_objs.append(obj) + else: + for field, err in obj_form.errors.items(): + form.add_error('csv', f'Row {row} {field}: {err[0]}') + raise ValidationError("") + + return updated_objs + + def _create_objects(self, form, request, headers, records): + new_objs = [] + for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) restrict_form_fields(obj_form, request.user) @@ -375,7 +414,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - new_objs = self._create_objects(form, request) + headers, records = self._get_records(form, request) + if "id" in headers: + new_objs = self._update_objects(form, request, headers, records) + else: + new_objs = self._create_objects(form, request, headers, records) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 881802a7b..0ac5b16d4 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -32,6 +32,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tenant Group 6,tenant-group-6,Sixth tenant group", ) + cls.csv_update_data = ( + "id,name,description", + f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", + f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", + f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -50,11 +57,12 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): for tenanantgroup in tenant_groups: tenanantgroup.save() - Tenant.objects.bulk_create([ + tenants = ( Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]), Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), - ]) + ) + Tenant.objects.bulk_create(tenants) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -74,6 +82,13 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Tenant 6,tenant-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{tenants[0].pk},Tenant 7,New description 7", + f"{tenants[1].pk},Tenant 8,New description 8", + f"{tenants[2].pk},Tenant 9,New description 9", + ) + cls.bulk_edit_data = { 'group': tenant_groups[1].pk, } @@ -109,6 +124,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Contact Group 6,contact-group-6,Sixth contact group", ) + cls.csv_update_data = ( + "id,name,description", + f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", + f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", + f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -120,11 +142,12 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ContactRole.objects.bulk_create([ + contact_roles = ( ContactRole(name='Contact Role 1', slug='contact-role-1'), ContactRole(name='Contact Role 2', slug='contact-role-2'), ContactRole(name='Contact Role 3', slug='contact-role-3'), - ]) + ) + ContactRole.objects.bulk_create(contact_roles) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -142,6 +165,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Contact Role 6,contact-role-6", ) + cls.csv_update_data = ( + "id,name,description", + f"{contact_roles[0].pk},Contact Role 7,New description 7", + f"{contact_roles[1].pk},Contact Role 8,New description 8", + f"{contact_roles[2].pk},Contact Role 9,New description 9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -160,11 +190,12 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): for contactgroup in contact_groups: contactgroup.save() - Contact.objects.bulk_create([ + contacts = ( Contact(name='Contact 1', group=contact_groups[0]), Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 3', group=contact_groups[0]), - ]) + ) + Contact.objects.bulk_create(contacts) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -182,6 +213,13 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Contact Group 1,Contact 6", ) + cls.csv_update_data = ( + "id,name,comments", + f"{contacts[0].pk},Contact Group 7,New comments 7", + f"{contacts[1].pk},Contact Group 8,New comments 8", + f"{contacts[2].pk},Contact Group 9,New comments 9", + ) + cls.bulk_edit_data = { 'group': contact_groups[1].pk, } diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index a6f037e0b..1a2f62b2e 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -220,7 +220,11 @@ def validate_csv(headers, fields, required_fields): if parsed csv data contains invalid headers or does not contain required headers. """ # Validate provided column headers + is_update = False for field, to_field in headers.items(): + if field == "id": + is_update = True + continue if field not in fields: raise forms.ValidationError(f'Unexpected column header "{field}" found.') if to_field and not hasattr(fields[field], 'to_field_name'): @@ -228,7 +232,8 @@ def validate_csv(headers, fields, required_fields): if to_field and not hasattr(fields[field].queryset.model, to_field): raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - # Validate required fields - for f in required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') + # Validate required fields (if not an update) + if not is_update: + for f in required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 93cb88088..f51893f74 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,5 +1,8 @@ +import csv + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from django.db.models import ForeignKey from django.test import override_settings from django.urls import reverse @@ -19,6 +22,7 @@ __all__ = ( # UI Tests # + class ModelViewTestCase(ModelTestCase): """ Base TestCase for model views. Subclass to test individual views. @@ -546,6 +550,9 @@ class ViewTestCases: def _get_csv_data(self): return '\n'.join(self.csv_data) + def _get_update_csv_data(self): + return self.csv_update_data, '\n'.join(self.csv_update_data) + def test_bulk_import_objects_without_permission(self): data = { 'csv': self._get_csv_data(), @@ -583,6 +590,42 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_update_objects_with_permission(self): + if not hasattr(self, 'csv_update_data'): + raise NotImplementedError("The test must define csv_update_data.") + + initial_count = self._get_queryset().count() + array, csv_data = self._get_update_csv_data() + data = { + 'csv': csv_data, + } + + # Assign model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(initial_count, self._get_queryset().count()) + + reader = csv.DictReader(array, delimiter=',') + check_data = list(reader) + for line in check_data: + obj = self.model.objects.get(id=line["id"]) + for attr, value in line.items(): + if attr != "id": + field = self.model._meta.get_field(attr) + value = getattr(obj, attr) + # cannot verify FK fields as don't know what name the CSV maps to + if value is not None and not isinstance(field, ForeignKey): + self.assertEqual(value, value) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): initial_count = self._get_queryset().count() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index d00ceb5a2..32382ee3b 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -16,11 +16,12 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ClusterGroup.objects.bulk_create([ + cluster_groups = ( ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), - ]) + ) + ClusterGroup.objects.bulk_create(cluster_groups) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -38,6 +39,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Group 6,cluster-group-6,Sixth cluster group", ) + cls.csv_update_data = ( + "id,name,description", + f"{cluster_groups[0].pk},Cluster Group 7,Fourth cluster group7", + f"{cluster_groups[1].pk},Cluster Group 8,Fifth cluster group8", + f"{cluster_groups[2].pk},Cluster Group 9,Sixth cluster group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -49,11 +57,12 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - ClusterType.objects.bulk_create([ + cluster_types = ( ClusterType(name='Cluster Type 1', slug='cluster-type-1'), ClusterType(name='Cluster Type 2', slug='cluster-type-2'), ClusterType(name='Cluster Type 3', slug='cluster-type-3'), - ]) + ) + ClusterType.objects.bulk_create(cluster_types) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -71,6 +80,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Type 6,cluster-type-6,Sixth cluster type", ) + cls.csv_update_data = ( + "id,name,description", + f"{cluster_types[0].pk},Cluster Type 7,Fourth cluster type7", + f"{cluster_types[1].pk},Cluster Type 8,Fifth cluster type8", + f"{cluster_types[2].pk},Cluster Type 9,Sixth cluster type9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -100,11 +116,12 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) ClusterType.objects.bulk_create(clustertypes) - Cluster.objects.bulk_create([ + clusters = ( Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), - ]) + ) + Cluster.objects.bulk_create(clusters) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -126,6 +143,13 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Cluster 6,Cluster Type 1,active", ) + cls.csv_update_data = ( + "id,name,comments", + f"{clusters[0].pk},Cluster 7,New comments 7", + f"{clusters[1].pk},Cluster 8,New comments 8", + f"{clusters[2].pk},Cluster 9,New comments 9", + ) + cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, @@ -187,11 +211,12 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): create_test_device('device2', site=sites[1], cluster=clusters[1]), ) - VirtualMachine.objects.bulk_create([ + virtual_machines = ( VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - ]) + ) + VirtualMachine.objects.bulk_create(virtual_machines) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -221,6 +246,13 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): "Virtual Machine 6,active,Site 1,Cluster 1,", ) + cls.csv_update_data = ( + "id,name,comments", + f"{virtual_machines[0].pk},Virtual Machine 7,New comments 7", + f"{virtual_machines[1].pk},Virtual Machine 8,New comments 8", + f"{virtual_machines[2].pk},Virtual Machine 9,New comments 9", + ) + cls.bulk_edit_data = { 'site': sites[1].pk, 'cluster': clusters[1].pk, @@ -327,6 +359,13 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): f"Virtual Machine 2,Interface 6,{vrfs[0].pk}", ) + cls.csv_update_data = ( + f"id,name,description", + f"{interfaces[0].pk},Interface 7,New description 7", + f"{interfaces[1].pk},Interface 8,New description 8", + f"{interfaces[2].pk},Interface 9,New description 9", + ) + cls.bulk_edit_data = { 'enabled': False, 'mtu': 2000, diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 7dea17d15..615678a62 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -32,11 +32,18 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.csv_data = ( "name,slug,description", - "Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", + "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", ) + cls.csv_update_data = ( + "id,name,description", + f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", + f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", + f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -62,11 +69,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - WirelessLAN.objects.bulk_create([ + wireless_lans = ( WirelessLAN(group=groups[0], ssid='WLAN1', tenant=tenants[0]), WirelessLAN(group=groups[0], ssid='WLAN2', tenant=tenants[0]), WirelessLAN(group=groups[0], ssid='WLAN3', tenant=tenants[0]), - ]) + ) + WirelessLAN.objects.bulk_create(wireless_lans) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -84,6 +92,13 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"Wireless LAN Group 2,WLAN6,{tenants[2].name}", ) + cls.csv_update_data = ( + f"id,ssid", + f"{wireless_lans[0].pk},WLAN7", + f"{wireless_lans[1].pk},WLAN8", + f"{wireless_lans[2].pk},WLAN9", + ) + cls.bulk_edit_data = { 'description': 'New description', } @@ -115,9 +130,12 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] Interface.objects.bulk_create(interfaces) - WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]).save() - WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]).save() - WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]).save() + wirelesslink1 = WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]) + wirelesslink1.save() + wirelesslink2 = WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]) + wirelesslink2.save() + wirelesslink3 = WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]) + wirelesslink3.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -136,6 +154,13 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): f"{interfaces[10].pk},{interfaces[11].pk},connected,{tenants[2].name}", ) + cls.csv_update_data = ( + "id,ssid,description", + f"{wirelesslink1.pk},LINK7,New decription 7", + f"{wirelesslink2.pk},LINK8,New decription 8", + f"{wirelesslink3.pk},LINK9,New decription 9", + ) + cls.bulk_edit_data = { 'status': LinkStatusChoices.STATUS_PLANNED, } From a176e9452f13e61135fc0536e3ff77ee98716bea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Oct 2022 13:24:20 -0400 Subject: [PATCH 189/409] Changelog for #7961 --- docs/release-notes/version-3.4.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 5ca84c996..6e3219770 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -16,6 +16,10 @@ NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. +#### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961)) + +NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects. + #### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071)) A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. From 8f4fa065f90b94f1a840331289677384fe4d6b3a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 31 Oct 2022 09:18:50 -0700 Subject: [PATCH 190/409] 10770 fix social auth --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 02e80b6cd..84c1944af 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -501,7 +501,7 @@ for param in dir(configuration): # Force usage of PostgreSQL's JSONB field for extra data SOCIAL_AUTH_JSONFIELD_ENABLED = True -SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username' +SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username' # # Django Prometheus From 867af61875538c8d305b26b622a7555be354c50a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 28 Oct 2022 14:22:31 -0700 Subject: [PATCH 191/409] 10282 fix race condition in API IP creation --- netbox/ipam/api/views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9db3d7953..9ea38758d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet): serializer_class = serializers.IPAddressSerializer filterset_class = filtersets.IPAddressFilterSet + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + class FHRPGroupViewSet(NetBoxModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') From 13c27b00d384b2980dc5f24fc04990cdd6bfefc5 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 31 Oct 2022 12:05:34 -0700 Subject: [PATCH 192/409] 10781 add python 3.11 support (#10782) * 10781 add python 3.11 support * 10781 update Django to latest --- .github/ISSUE_TEMPLATE/bug_report.yaml | 1 + .github/workflows/ci.yml | 2 +- docs/installation/3-netbox.md | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 56c14e966..ae562fda6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -25,6 +25,7 @@ body: - "3.8" - "3.9" - "3.10" + - "3.11" validations: required: true - type: textarea diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75f98fbc..1d9692194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] node-version: ['14.x'] services: redis: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index f42e28deb..353b0ddab 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.8 or later required" - NetBox requires Python 3.8, 3.9, or 3.10. + NetBox requires Python 3.8, 3.9, 3.10 or 3.11. === "Ubuntu" diff --git a/requirements.txt b/requirements.txt index c8fdd410a..f7c57588b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==5.0.1 -Django==4.1.1 +Django==4.1.2 django-cors-headers==3.13.0 django-debug-toolbar==3.7.0 django-filter==22.1 From a25ee66150884f122c3c5e58ff97b355ca1119c2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Oct 2022 15:15:45 -0400 Subject: [PATCH 193/409] Changelog for #10282, #10770 --- docs/release-notes/version-3.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6af0586b4..28fd9367d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,11 @@ ## v3.3.7 (FUTURE) +### Bug Fixes + +* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions +* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users + --- ## v3.3.6 (2022-10-26) From 675a5f868713fd70b60b7f5eaac37afac2b2402e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Oct 2022 15:17:24 -0400 Subject: [PATCH 194/409] Changelog for #10781 --- docs/release-notes/version-3.4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 6e3219770..fb4a6ed32 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -38,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields * [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types +* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 ### Plugins API From 7990cfb078db1800d557bd06785239cfc84d8db0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 15:27:35 -0400 Subject: [PATCH 195/409] Fixes #10803: Fix exception when ordering contacts by number of assignments --- docs/release-notes/version-3.3.md | 1 + netbox/tenancy/views.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 28fd9367d..3a75166c3 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users +* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments --- diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index e582c15d1..d8b810ad9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -188,6 +188,8 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance + ).annotate( + assignment_count=count_related(ContactAssignment, 'contact') ) contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) @@ -338,14 +340,18 @@ class ContactBulkImportView(generic.BulkImportView): class ContactBulkEditView(generic.BulkEditView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable form = forms.ContactBulkEditForm class ContactBulkDeleteView(generic.BulkDeleteView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable From aaf1ea52b769571991292211bb2ca477d557006c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 15:38:10 -0400 Subject: [PATCH 196/409] Fixes #10791: Permit nullifying VLAN group scope_type via REST API --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/api/serializers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3a75166c3..23c797dbf 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users +* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API * [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments --- diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index fa8b563e9..eff39a418 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -175,6 +175,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): queryset=ContentType.objects.filter( model__in=VLANGROUP_SCOPE_TYPES ), + allow_null=True, required=False, default=None ) From aa7f04bf1b8200ef9c5b261cd642862229444d8f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 16:45:32 -0400 Subject: [PATCH 197/409] Fixes #10809: Permit nullifying site time_zone via REST API --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/serializers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 23c797dbf..754efcddf 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -8,6 +8,7 @@ * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users * [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API * [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments +* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca3..cb1edfe1f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneSerializerField(required=False) + time_zone = TimeZoneSerializerField(required=False, allow_null=True) asns = SerializedPKRelatedField( queryset=ASN.objects.all(), serializer=NestedASNSerializer, From 4f5caa5ed27692fac07eda11f0d7bb6295fed08a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 16:48:40 -0400 Subject: [PATCH 198/409] Release v3.3.7 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 56c14e966..4de82d4e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.6 + placeholder: v3.3.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bef1ce587..5f0a17aa7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.6 + placeholder: v3.3.7 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 754efcddf..fe02827d6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.7 (FUTURE) +## v3.3.7 (2022-11-01) ### Bug Fixes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 84c1944af..524173722 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.7-dev' +VERSION = '3.3.7' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index bce015110..73abfa259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ Markdown==3.3.7 mkdocs-material==8.5.7 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.2.0 +Pillow==9.3.0 psycopg2-binary==2.9.5 PyYAML==6.0 sentry-sdk==1.10.1 @@ -30,7 +30,7 @@ social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.5 +tzdata==2022.6 # Workaround for #7401 jsonschema==3.2.0 From 44814f759cd1956bca533bea5b18fa155dc9deed Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 10:23:50 -0400 Subject: [PATCH 199/409] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ffb831e9d..8b8bd0060 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.7 (FUTURE) + +--- + ## v3.3.6 (2022-10-26) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cb26652b9..02e80b6cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.6' +VERSION = '3.3.7-dev' # Hostname HOSTNAME = platform.node() From f3fdf03661705087c7472f2207182b6abdd43b3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Oct 2022 15:11:44 -0400 Subject: [PATCH 200/409] Changelog for #10666 (missed in v3.3.6) --- docs/release-notes/version-3.3.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 8b8bd0060..6af0586b4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -23,6 +23,7 @@ * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables +* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests * [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ * [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view From 10e258739f7541fc0ef8eb2d344624be59ecffdc Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 31 Oct 2022 09:18:50 -0700 Subject: [PATCH 201/409] 10770 fix social auth --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 02e80b6cd..84c1944af 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -501,7 +501,7 @@ for param in dir(configuration): # Force usage of PostgreSQL's JSONB field for extra data SOCIAL_AUTH_JSONFIELD_ENABLED = True -SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username' +SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username' # # Django Prometheus From 8001694a4c373862a1c5d8a8efe1c008b71949f2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 28 Oct 2022 14:22:31 -0700 Subject: [PATCH 202/409] 10282 fix race condition in API IP creation --- netbox/ipam/api/views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9db3d7953..9ea38758d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet): serializer_class = serializers.IPAddressSerializer filterset_class = filtersets.IPAddressFilterSet + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + class FHRPGroupViewSet(NetBoxModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') From d1970ca85bd9e487fc520dd09e2acac217ee573b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Oct 2022 15:15:45 -0400 Subject: [PATCH 203/409] Changelog for #10282, #10770 --- docs/release-notes/version-3.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6af0586b4..28fd9367d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,11 @@ ## v3.3.7 (FUTURE) +### Bug Fixes + +* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions +* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users + --- ## v3.3.6 (2022-10-26) From 816214361d7015534696d261a53219e169ac01b2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 15:27:35 -0400 Subject: [PATCH 204/409] Fixes #10803: Fix exception when ordering contacts by number of assignments --- docs/release-notes/version-3.3.md | 1 + netbox/tenancy/views.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 28fd9367d..3a75166c3 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users +* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments --- diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index e582c15d1..d8b810ad9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -188,6 +188,8 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance + ).annotate( + assignment_count=count_related(ContactAssignment, 'contact') ) contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) @@ -338,14 +340,18 @@ class ContactBulkImportView(generic.BulkImportView): class ContactBulkEditView(generic.BulkEditView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable form = forms.ContactBulkEditForm class ContactBulkDeleteView(generic.BulkDeleteView): - queryset = Contact.objects.all() + queryset = Contact.objects.annotate( + assignment_count=count_related(ContactAssignment, 'contact') + ) filterset = filtersets.ContactFilterSet table = tables.ContactTable From c8be4ef8e25dfef6f4da3833c0bc8e4d24a1ecf6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 15:38:10 -0400 Subject: [PATCH 205/409] Fixes #10791: Permit nullifying VLAN group scope_type via REST API --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/api/serializers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3a75166c3..23c797dbf 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users +* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API * [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments --- diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index fa8b563e9..eff39a418 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -175,6 +175,7 @@ class VLANGroupSerializer(NetBoxModelSerializer): queryset=ContentType.objects.filter( model__in=VLANGROUP_SCOPE_TYPES ), + allow_null=True, required=False, default=None ) From ade307bc032e73d0cf16073f4db9d47d733feef2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 16:45:32 -0400 Subject: [PATCH 206/409] Fixes #10809: Permit nullifying site time_zone via REST API --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/serializers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 23c797dbf..754efcddf 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -8,6 +8,7 @@ * [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users * [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API * [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments +* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca3..cb1edfe1f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneSerializerField(required=False) + time_zone = TimeZoneSerializerField(required=False, allow_null=True) asns = SerializedPKRelatedField( queryset=ASN.objects.all(), serializer=NestedASNSerializer, From 2cd5fce62d1bf6016d3eec978a8b1ab584ce4c79 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 16:48:40 -0400 Subject: [PATCH 207/409] Release v3.3.7 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 56c14e966..4de82d4e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.6 + placeholder: v3.3.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bef1ce587..5f0a17aa7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.6 + placeholder: v3.3.7 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 754efcddf..fe02827d6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.7 (FUTURE) +## v3.3.7 (2022-11-01) ### Bug Fixes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 84c1944af..524173722 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.7-dev' +VERSION = '3.3.7' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index bce015110..73abfa259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ Markdown==3.3.7 mkdocs-material==8.5.7 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.2.0 +Pillow==9.3.0 psycopg2-binary==2.9.5 PyYAML==6.0 sentry-sdk==1.10.1 @@ -30,7 +30,7 @@ social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.5 +tzdata==2022.6 # Workaround for #7401 jsonschema==3.2.0 From 2af8891f70a02cc8991028348a4764c32cdfc2a2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Nov 2022 17:11:55 -0400 Subject: [PATCH 208/409] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fe02827d6..a693ec1e0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.8 (FUTURE) + +--- + ## v3.3.7 (2022-11-01) ### Bug Fixes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 524173722..e5a8b7dbd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.7' +VERSION = '3.3.8-dev' # Hostname HOSTNAME = platform.node() From ea61a540cdaf7f479e3709ad895aff50f671bdcb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 11:00:09 -0400 Subject: [PATCH 209/409] Closes #10816: Pass the current request when instantiating a FilterSet within UI views --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/views/generic/bulk_views.py | 9 +++++---- netbox/netbox/views/generic/object_views.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index fb4a6ed32..3783cc967 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -56,6 +56,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function +* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views ### REST API Changes diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5d7b4eff0..df7cfdf67 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -126,7 +126,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): content_type = ContentType.objects.get_for_model(model) if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs + self.queryset = self.filterset(request.GET, self.queryset, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user) @@ -544,7 +544,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: - pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs + pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs else: pk_list = request.POST.getlist('pk') @@ -741,7 +741,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if request.POST.get('_all'): qs = model.objects.all() if self.filterset is not None: - qs = self.filterset(request.GET, qs).qs + qs = self.filterset(request.GET, qs, request=request).qs pk_list = qs.only('pk').values_list('pk', flat=True) else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -828,7 +828,8 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filterset is not None: - pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs] + queryset = self.filterset(request.GET, self.parent_model.objects.only('pk'), request=request).qs + pk_list = [obj.pk for obj in queryset] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 3f5a9f614..0d122a41a 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -126,7 +126,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): child_objects = self.get_children(request, instance) if self.filterset: - child_objects = self.filterset(request.GET, child_objects).qs + child_objects = self.filterset(request.GET, child_objects, request=request).qs # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) From 484efdaf75f267a43f9321b938fda1bc967b9e53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 2 Nov 2022 12:27:53 -0400 Subject: [PATCH 210/409] Closes #9623: Implement saved filters (#10801) * Initial work on saved filters * Return only enabled/shared filters * Add tests * Clean up filtering of usable SavedFilters --- netbox/circuits/forms/filtersets.py | 6 +- netbox/dcim/forms/filtersets.py | 50 +++++------ netbox/extras/api/nested_serializers.py | 9 ++ netbox/extras/api/serializers.py | 20 +++++ netbox/extras/api/urls.py | 26 +----- netbox/extras/api/views.py | 12 +++ netbox/extras/filtersets.py | 50 +++++++++++ netbox/extras/forms/__init__.py | 2 +- netbox/extras/forms/bulk_edit.py | 29 ++++++- netbox/extras/forms/bulk_import.py | 14 +++ netbox/extras/forms/filtersets.py | 61 +++++++++---- .../forms/{customfields.py => mixins.py} | 14 +++ netbox/extras/forms/model_forms.py | 30 +++++++ netbox/extras/graphql/schema.py | 3 + netbox/extras/graphql/types.py | 9 ++ netbox/extras/migrations/0083_savedfilter.py | 36 ++++++++ netbox/extras/models/__init__.py | 1 + netbox/extras/models/models.py | 66 +++++++++++++- netbox/extras/tables/tables.py | 42 ++++----- netbox/extras/tests/test_api.py | 69 ++++++++++++++- netbox/extras/tests/test_filtersets.py | 86 +++++++++++++++++++ netbox/extras/tests/test_views.py | 52 +++++++++++ netbox/extras/urls.py | 8 ++ netbox/extras/views.py | 69 ++++++++++++++- netbox/ipam/forms/filtersets.py | 33 +++---- netbox/netbox/filtersets.py | 23 ++++- netbox/netbox/forms/base.py | 13 ++- netbox/netbox/navigation/menu.py | 1 + netbox/netbox/views/generic/bulk_views.py | 9 +- netbox/templates/extras/savedfilter.html | 70 +++++++++++++++ netbox/templates/generic/object_list.html | 2 +- netbox/tenancy/forms/filtersets.py | 2 +- .../templates/helpers/applied_filters.html | 5 ++ netbox/utilities/templatetags/helpers.py | 17 +++- netbox/utilities/testing/base.py | 8 +- netbox/virtualization/forms/filtersets.py | 8 +- netbox/wireless/forms/filtersets.py | 4 +- 37 files changed, 821 insertions(+), 138 deletions(-) rename netbox/extras/forms/{customfields.py => mixins.py} (84%) create mode 100644 netbox/extras/migrations/0083_savedfilter.py create mode 100644 netbox/templates/extras/savedfilter.html diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 29410ffdf..9ad825299 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -20,7 +20,7 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( @@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 818da83e1..905a898df 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -116,7 +116,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'tag', 'parent_id')), + (None, ('q', 'filter', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -222,7 +222,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), @@ -306,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('User', ('user_id',)), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -362,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -371,7 +371,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( @@ -486,7 +486,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'part_number')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -578,7 +578,7 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), @@ -731,7 +731,7 @@ class DeviceFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -761,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -790,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -862,7 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -900,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ) @@ -1002,7 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1021,7 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1040,7 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1055,7 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1070,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), @@ -1159,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1178,7 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1196,7 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'position')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1209,7 +1209,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1219,7 +1219,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 44dfe7cbc..dce062b84 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -13,6 +13,7 @@ __all__ = [ 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedJournalEntrySerializer', + 'NestedSavedFilterSerializer', 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedSavedFilterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + + class Meta: + model = models.SavedFilter + fields = ['id', 'url', 'display', 'name'] + + class NestedImageAttachmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ac025ff16..1afb8fa8f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -39,6 +39,7 @@ __all__ = ( 'ReportDetailSerializer', 'ReportSerializer', 'ReportInputSerializer', + 'SavedFilterSerializer', 'ScriptDetailSerializer', 'ScriptInputSerializer', 'ScriptLogMessageSerializer', @@ -149,6 +150,25 @@ class ExportTemplateSerializer(ValidatedModelSerializer): ] +# +# Saved filters +# + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight', + 'enabled', 'shared', 'parameters', 'created', 'last_updated', + ] + + # # Tags # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bcad6b77c..91067d40d 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,43 +5,19 @@ from . import views router = NetBoxRouter() router.APIRootView = views.ExtrasRootView -# Webhooks router.register('webhooks', views.WebhookViewSet) - -# Custom fields router.register('custom-fields', views.CustomFieldViewSet) - -# Custom links router.register('custom-links', views.CustomLinkViewSet) - -# Export templates router.register('export-templates', views.ExportTemplateViewSet) - -# Tags +router.register('saved-filters', views.SavedFilterViewSet) router.register('tags', views.TagViewSet) - -# Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) - -# Journal entries router.register('journal-entries', views.JournalEntryViewSet) - -# Config contexts router.register('config-contexts', views.ConfigContextViewSet) - -# Reports router.register('reports', views.ReportViewSet, basename='report') - -# Scripts router.register('scripts', views.ScriptViewSet, basename='script') - -# Change logging router.register('object-changes', views.ObjectChangeViewSet) - -# Job Results router.register('job-results', views.JobResultViewSet) - -# ContentTypes router.register('content-types', views.ContentTypeViewSet) app_name = 'extras-api' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 62a011530..ab111b0ec 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.http import Http404 from django_rq.queues import get_connection from rest_framework import status @@ -98,6 +99,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet): filterset_class = filtersets.ExportTemplateFilterSet +# +# Saved filters +# + +class SavedFilterViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = SavedFilter.objects.all() + serializer_class = serializers.SavedFilterSerializer + filterset_class = filtersets.SavedFilterFilterSet + + # # Tags # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 22fe6537e..6010c733a 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -23,6 +23,7 @@ __all__ = ( 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'SavedFilterFilterSet', 'TagFilterSet', 'WebhookFilterSet', ) @@ -138,6 +139,55 @@ class ExportTemplateFilterSet(BaseFilterSet): ) +class SavedFilterFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) + content_types = ContentTypeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) + usable = django_filters.BooleanFilter( + method='_usable' + ) + + class Meta: + model = SavedFilter + fields = ['id', 'content_types', 'name', 'description', 'enabled', 'shared', 'weight'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + def _usable(self, queryset, name, value): + """ + Return only SavedFilters that are both enabled and are shared (or belong to the current user). + """ + user = self.request.user if self.request else None + if not user or user.is_anonymous: + if value: + return queryset.filter(enabled=True, shared=True) + return queryset.filter(Q(enabled=False) | Q(shared=False)) + if value: + return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user)) + return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) + + class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index d2f2fb015..af0f7cf43 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -2,6 +2,6 @@ from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * -from .customfields import * +from .mixins import * from .config import * from .scripts import * diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index df17324ec..a061d9784 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,11 +1,9 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from utilities.forms import ( - add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, + add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect, ) __all__ = ( @@ -14,6 +12,7 @@ __all__ = ( 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_extension') +class SavedFilterBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + weight = forms.IntegerField( + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + shared = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + nullable_fields = ('description',) + + class WebhookBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Webhook.objects.all(), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index ee638015b..0f5974698 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -12,6 +12,7 @@ __all__ = ( 'CustomFieldCSVForm', 'CustomLinkCSVForm', 'ExportTemplateCSVForm', + 'SavedFilterCSVForm', 'TagCSVForm', 'WebhookCSVForm', ) @@ -81,6 +82,19 @@ class ExportTemplateCSVForm(CSVModelForm): ) +class SavedFilterCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + help_text="One or more assigned object types" + ) + + class Meta: + model = SavedFilter + fields = ( + 'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + ) + + class WebhookCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index a164a3d95..479367ff0 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -15,6 +15,7 @@ from utilities.forms import ( StaticSelect, TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType +from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', @@ -25,14 +26,15 @@ __all__ = ( 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', + 'SavedFilterFilterForm', 'TagFilterForm', 'WebhookFilterForm', ) -class CustomFieldFilterForm(FilterForm): +class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( @@ -66,9 +68,9 @@ class CustomFieldFilterForm(FilterForm): ) -class JobResultFilterForm(FilterForm): +class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('obj_type', 'status')), ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', 'scheduled_time__before', 'scheduled_time__after', 'user')), @@ -118,9 +120,9 @@ class JobResultFilterForm(FilterForm): ) -class CustomLinkFilterForm(FilterForm): +class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ) content_types = ContentTypeMultipleChoiceField( @@ -145,9 +147,9 @@ class CustomLinkFilterForm(FilterForm): ) -class ExportTemplateFilterForm(FilterForm): +class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) content_types = ContentTypeMultipleChoiceField( @@ -170,9 +172,36 @@ class ExportTemplateFilterForm(FilterForm): ) -class WebhookFilterForm(FilterForm): +class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), + ('Attributes', ('content_types', 'enabled', 'shared', 'weight')), + ) + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('export_templates'), + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + shared = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + weight = forms.IntegerField( + required=False + ) + + +class WebhookFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) @@ -213,7 +242,7 @@ class WebhookFilterForm(FilterForm): ) -class TagFilterForm(FilterForm): +class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), @@ -222,9 +251,9 @@ class TagFilterForm(FilterForm): ) -class ConfigContextFilterForm(FilterForm): +class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'tag_id')), + (None, ('q', 'filter', 'tag_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), @@ -311,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -349,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ObjectChangeFilterForm(FilterForm): +class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q',)), + (None, ('q', 'filter')), ('Time', ('time_before', 'time_after')), ('Attributes', ('action', 'user_id', 'changed_object_type_id')), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/mixins.py similarity index 84% rename from netbox/extras/forms/customfields.py rename to netbox/extras/forms/mixins.py index 40d068450..2b64d1a74 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/mixins.py @@ -1,10 +1,13 @@ from django.contrib.contenttypes.models import ContentType +from django import forms from extras.models import * from extras.choices import CustomFieldVisibilityChoices +from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', + 'SavedFiltersMixin', ) @@ -57,3 +60,14 @@ class CustomFieldsMixin: if customfield.group_name not in self.custom_field_groups: self.custom_field_groups[customfield.group_name] = [] self.custom_field_groups[customfield.group_name].append(field_name) + + +class SavedFiltersMixin(forms.Form): + filter = DynamicModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + required=False, + label='Saved Filter', + query_params={ + 'usable': True, + } + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7ff4f3e27..97e80100a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -20,6 +21,7 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'SavedFilterForm', 'TagForm', 'WebhookForm', ) @@ -108,6 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): } +class SavedFilterForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + + fieldsets = ( + ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Parameters', ('parameters',)), + ) + + class Meta: + model = SavedFilter + exclude = ('user',) + widgets = { + 'parameters': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + def __init__(self, *args, initial=None, **kwargs): + + # Convert any parameters delivered via initial data to a dictionary + if initial and 'parameters' in initial: + if type(initial['parameters']) is str: + # TODO: Make a utility function for this + initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + + super().__init__(*args, initial=initial, **kwargs) + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 3073976e8..0c3113879 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType): image_attachment = ObjectField(ImageAttachmentType) image_attachment_list = ObjectListField(ImageAttachmentType) + saved_filter = ObjectField(SavedFilterType) + saved_filter_list = ObjectListField(SavedFilterType) + journal_entry = ObjectField(JournalEntryType) journal_entry_list = ObjectListField(JournalEntryType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 3be7b371e..b5d4dffce 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -10,6 +10,7 @@ __all__ = ( 'ImageAttachmentType', 'JournalEntryType', 'ObjectChangeType', + 'SavedFilterType', 'TagType', 'WebhookType', ) @@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType): filterset_class = filtersets.ObjectChangeFilterSet +class SavedFilterType(ObjectType): + + class Meta: + model = models.SavedFilter + exclude = ('content_types', ) + filterset_class = filtersets.SavedFilterFilterSet + + class TagType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0083_savedfilter.py b/netbox/extras/migrations/0083_savedfilter.py new file mode 100644 index 000000000..6bae7ccde --- /dev/null +++ b/netbox/extras/migrations/0083_savedfilter.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.1 on 2022-10-27 18:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0082_exporttemplate_content_types'), + ] + + operations = [ + migrations.CreateModel( + name='SavedFilter', + 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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('weight', models.PositiveSmallIntegerField(default=100)), + ('enabled', models.BooleanField(default=True)), + ('shared', models.BooleanField(default=True)), + ('parameters', models.JSONField()), + ('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('weight', 'name'), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e3a4be3fe..6d2bf288c 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -18,6 +18,7 @@ __all__ = ( 'JournalEntry', 'ObjectChange', 'Report', + 'SavedFilter', 'Script', 'Tag', 'TaggedItem', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index a8b2f2647..4b4e7c0cf 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse +from django.http import HttpResponse, QueryDict from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -34,6 +34,7 @@ __all__ = ( 'JobResult', 'JournalEntry', 'Report', + 'SavedFilter', 'Script', 'Webhook', ) @@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return response +class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): + """ + A set of predefined keyword parameters that can be reused to filter for specific objects. + """ + content_types = models.ManyToManyField( + to=ContentType, + related_name='saved_filters', + help_text='The object type(s) to which this filter applies.' + ) + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True + ) + weight = models.PositiveSmallIntegerField( + default=100 + ) + enabled = models.BooleanField( + default=True + ) + shared = models.BooleanField( + default=True + ) + parameters = models.JSONField() + + clone_fields = ( + 'enabled', 'weight', + ) + + class Meta: + ordering = ('weight', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:savedfilter', args=[self.pk]) + + def clean(self): + super().clean() + + # Verify that `parameters` is a JSON object + if type(self.parameters) is not dict: + raise ValidationError( + {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'} + ) + + @property + def url_params(self): + qd = QueryDict(mutable=True) + qd.update(self.parameters) + return qd.urlencode() + + class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 4b4acb235..da4241e69 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -13,16 +13,13 @@ __all__ = ( 'ExportTemplateTable', 'JournalEntryTable', 'ObjectChangeTable', + 'SavedFilterTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -# -# Custom fields -# - class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True @@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') -# -# Custom fields -# - class JobResultTable(NetBoxTable): name = tables.Column( linkify=True @@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable): default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',) -# -# Custom links -# - class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True @@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') -# -# Export templates -# - class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True @@ -104,9 +89,24 @@ class ExportTemplateTable(NetBoxTable): ) -# -# Webhooks -# +class SavedFilterTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + shared = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = SavedFilter + fields = ( + 'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared', + ) + class WebhookTable(NetBoxTable): name = tables.Column( @@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable): ) -# -# Tags -# - class TagTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 42246b651..045391ea8 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,7 +3,6 @@ from unittest import skipIf from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from django.utils.timezone import make_aware from django_rq.queues import get_connection @@ -17,7 +16,6 @@ from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases - rq_worker_running = Worker.count(get_connection('default')) @@ -192,6 +190,73 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): custom_link.content_types.set([site_ct]) +class SavedFilterTest(APIViewTestCases.APIViewTestCase): + model = SavedFilter + brief_fields = ['display', 'id', 'name', 'url'] + create_data = [ + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 4', + 'weight': 100, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['active']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 5', + 'weight': 200, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['planned']}, + }, + { + 'content_types': ['dcim.site'], + 'name': 'Saved Filter 6', + 'weight': 300, + 'enabled': True, + 'shared': True, + 'parameters': {'status': ['retired']}, + }, + ] + bulk_update_data = { + 'weight': 1000, + 'enabled': False, + 'shared': False, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + weight=300, + enabled=True, + shared=True, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) + + class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index dd1fdb6b3..140f05906 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -222,6 +222,92 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class SavedFilterTestCase(TestCase, BaseFilterSetTests): + queryset = SavedFilter.objects.all() + filterset = SavedFilterFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + saved_filters = ( + SavedFilter( + name='Saved Filter 1', + user=users[0], + weight=100, + enabled=True, + shared=True, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + user=users[1], + weight=200, + enabled=True, + shared=True, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + user=users[2], + weight=300, + enabled=False, + shared=False, + parameters={'status': ['retired']} + ), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([content_types[i]]) + + def test_name(self): + params = {'name': ['Saved Filter 1', 'Saved Filter 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_user(self): + users = User.objects.filter(username__startswith='User') + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_shared(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_usable(self): + # Filtering for an anonymous user + params = {'usable': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'usable': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ExportTemplateTestCase(TestCase, BaseFilterSetTests): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 85e5aea5e..175ffb9ca 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -107,6 +107,58 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = SavedFilter + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + saved_filters = ( + SavedFilter(name='Saved Filter 1', user=users[0], weight=100, parameters={'status': ['active']}), + SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}), + SavedFilter(name='Saved Filter 3', user=users[2], weight=300, parameters={'status': ['retired']}), + ) + SavedFilter.objects.bulk_create(saved_filters) + for i, savedfilter in enumerate(saved_filters): + savedfilter.content_types.set([site_ct]) + + cls.form_data = { + 'name': 'Saved Filter X', + 'content_types': [site_ct.pk], + 'description': 'Foo', + 'weight': 1000, + 'enabled': True, + 'shared': True, + 'parameters': '{"foo": 123}', + } + + cls.csv_data = ( + 'name,content_types,weight,enabled,shared,parameters', + 'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}', + 'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}', + 'Saved Filter 6,dcim.device,600,True,True,{"foo": "c"}', + ) + + cls.csv_update_data = ( + "id,name", + f"{saved_filters[0].pk},Saved Filter 7", + f"{saved_filters[1].pk},Saved Filter 8", + f"{saved_filters[2].pk},Saved Filter 9", + ) + + cls.bulk_edit_data = { + 'weight': 999, + } + + class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ExportTemplate diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 0640904f2..f41a45f5a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,14 @@ urlpatterns = [ path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), + # Saved filters + path('saved-filters/', views.SavedFilterListView.as_view(), name='savedfilter_list'), + path('saved-filters/add/', views.SavedFilterEditView.as_view(), name='savedfilter_add'), + path('saved-filters/import/', views.SavedFilterBulkImportView.as_view(), name='savedfilter_import'), + path('saved-filters/edit/', views.SavedFilterBulkEditView.as_view(), name='savedfilter_bulk_edit'), + path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), + path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))), + # Webhooks path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index c042c248a..4a1350bde 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -9,7 +9,6 @@ from django_rq.queues import get_connection from rq import Worker from netbox.views import generic -from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view @@ -159,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +# +# Saved filters +# + +class SavedFilterMixin: + + def get_queryset(self, request): + """ + Return only shared SavedFilters, or those owned by the current user, unless + this is a superuser. + """ + queryset = SavedFilter.objects.all() + user = request.user + if user.is_superuser: + return queryset + if user.is_anonymous: + return queryset.filter(shared=True) + return queryset.filter( + Q(shared=True) | Q(user=user) + ) + + +class SavedFilterListView(SavedFilterMixin, generic.ObjectListView): + filterset = filtersets.SavedFilterFilterSet + filterset_form = forms.SavedFilterFilterForm + table = tables.SavedFilterTable + + +@register_model_view(SavedFilter) +class SavedFilterView(SavedFilterMixin, generic.ObjectView): + queryset = SavedFilter.objects.all() + + +@register_model_view(SavedFilter, 'edit') +class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView): + queryset = SavedFilter.objects.all() + form = forms.SavedFilterForm + + def alter_object(self, obj, request, url_args, url_kwargs): + if not obj.pk: + obj.user = request.user + return obj + + +@register_model_view(SavedFilter, 'delete') +class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): + queryset = SavedFilter.objects.all() + + +class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): + queryset = SavedFilter.objects.all() + model_form = forms.SavedFilterCSVForm + table = tables.SavedFilterTable + + +class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + form = forms.SavedFilterBulkEditForm + + +class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): + queryset = SavedFilter.objects.all() + filterset = filtersets.SavedFilterFilterSet + table = tables.SavedFilterTable + + # # Webhooks # diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a2ff7085b..7d277b33b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device @@ -11,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine @@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Route Targets', ('import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('VRF', ('importing_vrf_id', 'exporting_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('family', 'rir_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Assignment', ('rir_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), @@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -334,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) @@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('VLAN ID', ('min_vid', 'max_vid')), ) @@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( @@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -511,8 +510,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn_id', )), - ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), + (None, ('filter', 'l2vpn_id',)), + ('Assigned Object', ( + 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', + )), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 6a8f5d0d3..02ccdca50 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field +from django.shortcuts import get_object_or_404 from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter -from extras.models import CustomField +from extras.models import CustomField, SavedFilter from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP @@ -80,12 +81,28 @@ class BaseFilterSet(django_filters.FilterSet): }, }) - def __init__(self, *args, **kwargs): + def __init__(self, data=None, *args, **kwargs): # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # however FilterSet Factory is setup before this which creates the # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() - super().__init__(*args, **kwargs) + + # Apply any referenced SavedFilters + if data and 'filter' in data: + data = data.copy() # Get a mutable copy + saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter')) + for sf in saved_filters: + for key, value in sf.parameters.items(): + # QueryDicts are... fun + if type(value) not in (list, tuple): + value = [value] + if key in data: + for v in value: + data.appendlist(key, v) + else: + data.setlist(key, value) + + super().__init__(data, *args, **kwargs) @staticmethod def _get_filter_lookup_dict(existing_filter): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2cbc67971..564e254a3 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices -from extras.forms.customfields import CustomFieldsMixin +from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -114,7 +114,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) -class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): +class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form): """ Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the corresponding FilterSet *must* provide a `q` filter. @@ -129,6 +129,15 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): label='Search' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit saved filters to those applicable to the form's model + content_type = ContentType.objects.get_for_model(self.model) + self.fields['filter'].widget.add_query_params({ + 'content_type_id': content_type.pk, + }) + def _get_custom_fields(self, content_type): return super()._get_custom_fields(content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 65c2ec7fc..68551827c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -278,6 +278,7 @@ OTHER_MENU = Menu( get_model_item('extras', 'customfield', 'Custom Fields'), get_model_item('extras', 'customlink', 'Custom Links'), get_model_item('extras', 'exporttemplate', 'Export Templates'), + get_model_item('extras', 'savedfilter', 'Saved Filters'), ), ), MenuGroup( diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index df7cfdf67..5ab9e6da0 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,17 +4,17 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django_tables2.export import TableExport from django.utils.safestring import mark_safe +from django_tables2.export import TableExport -from extras.models import ExportTemplate +from extras.models import ExportTemplate, SavedFilter from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation @@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return headers, records def _update_objects(self, form, request, headers, records): - from utilities.forms import CSVModelChoiceField updated_objs = [] ids = [int(record["id"]) for record in records] diff --git a/netbox/templates/extras/savedfilter.html b/netbox/templates/extras/savedfilter.html new file mode 100644 index 000000000..4372481aa --- /dev/null +++ b/netbox/templates/extras/savedfilter.html @@ -0,0 +1,70 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    Saved Filter
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    User{{ object.user|placeholder }}
    Enabled{% checkmark object.enabled %}
    Shared{% checkmark object.shared %}
    Weight{{ object.weight }}
    +
    +
    +
    +
    Assigned Models
    +
    + + {% for ct in object.content_types.all %} + + + + {% endfor %} +
    {{ ct }}
    +
    +
    + {% plugin_left_page object %} +
    +
    +
    +
    + Parameters +
    +
    +
    {{ object.parameters }}
    +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 60eba6097..c58565c31 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -64,7 +64,7 @@ Context: {# Applied filters #} {% if filter_form %} - {% applied_filters filter_form request.GET %} + {% applied_filters model filter_form request.GET %} {% endif %} {# "Select all" form #} diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 02589d733..f840a2177 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( - (None, ('q', 'tag', 'group_id')), + (None, ('q', 'filter', 'tag', 'group_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( diff --git a/netbox/utilities/templates/helpers/applied_filters.html b/netbox/utilities/templates/helpers/applied_filters.html index 4f22a7c9a..3cf8fe425 100644 --- a/netbox/utilities/templates/helpers/applied_filters.html +++ b/netbox/utilities/templates/helpers/applied_filters.html @@ -10,5 +10,10 @@ Clear all {% endif %} + {% if save_link %} + + Save + + {% endif %}
    {% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9789724ee..ed2e39041 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,9 +1,11 @@ import datetime import decimal +from urllib.parse import quote from typing import Dict, Any from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -278,12 +280,13 @@ def table_config_form(table, table_name=None): } -@register.inclusion_tag('helpers/applied_filters.html') -def applied_filters(form, query_params): +@register.inclusion_tag('helpers/applied_filters.html', takes_context=True) +def applied_filters(context, model, form, query_params): """ Display the active filters for a given filter form. """ - form.is_valid() + user = context['request'].user + form.is_valid() # Ensure cleaned_data has been set applied_filters = [] for filter_name in form.changed_data: @@ -305,6 +308,14 @@ def applied_filters(form, query_params): 'link_text': f'{bound_field.label}: {display_value}', }) + save_link = None + if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET: + content_type = ContentType.objects.get_for_model(model).pk + parameters = context['request'].GET.urlencode() + url = reverse('extras:savedfilter_add') + save_link = f"{url}?content_types={content_type}¶meters={quote(parameters)}" + return { 'applied_filters': applied_filters, + 'save_link': save_link, } diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 499a5e2e7..04ceca1e2 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,8 +1,10 @@ +import json + from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist -from django.db.models import ManyToManyField +from django.db.models import ManyToManyField, JSONField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase from netaddr import IPNetwork @@ -132,6 +134,10 @@ class ModelTestCase(TestCase): if type(instance._meta.get_field(key)) is ArrayField: model_dict[key] = ','.join([str(v) for v in value]) + # JSON + if type(instance._meta.get_field(key)) is JSONField and value is not None: + model_dict[key] = json.dumps(value) + return model_dict # diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 4b8ff6d21..62fa4002e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -90,7 +90,7 @@ class VirtualMachineFilterForm( ): model = VirtualMachine fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), @@ -175,7 +175,7 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id')), ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 9e8808e17..d7a6aac6e 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -28,7 +28,7 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('ssid', 'group_id',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), @@ -62,7 +62,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( - (None, ('q', 'tag')), + (None, ('q', 'filter', 'tag')), ('Attributes', ('ssid', 'status',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), From 816fedb78dccf49c311a1bec1926d19f127a7091 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 2 Nov 2022 09:45:00 -0700 Subject: [PATCH 211/409] 8853 Prevent the retrieval of API tokens after creation (#10645) * 8853 hide api token * 8853 hide key on edit * 8853 add key display * 8853 cleanup html * 8853 make token view accessible only once on POST * Clean up display of tokens in views * Honor ALLOW_TOKEN_RETRIEVAL in API serializer * Add docs & tweak default setting * Include token key when provisioning with user credentials Co-authored-by: jeremystretch --- docs/configuration/security.md | 8 ++++ docs/integrations/rest-api.md | 3 ++ docs/release-notes/version-3.4.md | 1 + netbox/netbox/configuration_example.py | 3 ++ netbox/netbox/settings.py | 1 + netbox/templates/users/api_token.html | 60 ++++++++++++++++++++++++++ netbox/users/api/serializers.py | 9 +++- netbox/users/api/views.py | 2 + netbox/users/forms.py | 8 ++++ netbox/users/models.py | 11 ++--- netbox/users/tables.py | 12 +++--- netbox/users/views.py | 10 ++++- 12 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 netbox/templates/users/api_token.html diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 6aa363b1a..b8c2b1e11 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -1,5 +1,13 @@ # Security & Authentication Parameters +## ALLOW_TOKEN_RETRIEVAL + +Default: True + +If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. + +--- + ## ALLOWED_URL_SCHEMES !!! tip "Dynamic Configuration Parameter" diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 3a5aed055..6f54a8cb0 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -579,6 +579,9 @@ By default, a token can be used to perform all actions via the API that a user w Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +!!! warning "Restricting Token Retrieval" + The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. + #### Client IP Restriction !!! note diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 3783cc967..b6e30f2a8 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types +* [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index ad0dcc7c3..b3b6fbb6c 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -72,6 +72,9 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] +# Permit the retrieval of API tokens after their creation. +ALLOW_TOKEN_RETRIEVAL = False + # Enable any desired validators for local account passwords below. For a list of included validators, please see the # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation. AUTH_PASSWORD_VALIDATORS = [ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2898fbd75..4e93eb149 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -71,6 +71,7 @@ DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/api_token.html new file mode 100644 index 000000000..1a9296704 --- /dev/null +++ b/netbox/templates/users/api_token.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load form_helpers %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    + {% if not settings.ALLOW_TOKEN_RETRIEVAL %} + + {% endif %} +
    +
    Token
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Key +
    + + + +
    +
    {{ key }}
    +
    Description{{ object.description|placeholder }}
    User{{ object.user }}
    Created{{ object.created|annotated_date }}
    Expires + {% if object.expires %} + {{ object.expires|annotated_date }} + {% else %} + Never + {% endif %} +
    +
    +
    + +
    +
    +{% endblock %} diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1ec3528f7..f1f1fc975 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers @@ -63,7 +64,13 @@ class GroupSerializer(ValidatedModelSerializer): class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') - key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) + key = serializers.CharField( + min_length=40, + max_length=40, + allow_blank=True, + required=False, + write_only=not settings.ALLOW_TOKEN_RETRIEVAL + ) user = NestedUserSerializer() allowed_ips = serializers.ListField( child=IPNetworkSerializer(), diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 66ef92ab7..86a66a01f 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -88,6 +88,8 @@ class TokenProvisionView(APIView): token = Token(user=user) token.save() data = serializers.TokenSerializer(token, context={'request': request}).data + # Manually append the token key, which is normally write-only + data['key'] = token.key return Response(data, status=HTTP_201_CREATED) diff --git a/netbox/users/forms.py b/netbox/users/forms.py index b4e86461d..048005f13 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe @@ -117,3 +118,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm): widgets = { 'expires': DateTimePicker(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Omit the key field if token retrieval is not permitted + if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: + del self.fields['key'] diff --git a/netbox/users/models.py b/netbox/users/models.py index 4ee4dce6b..441ed2eee 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,6 +1,7 @@ import binascii import os +from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField @@ -230,12 +231,12 @@ class Token(models.Model): 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) - class Meta: - pass - def __str__(self): - # Only display the last 24 bits of the token to avoid accidental exposure. - return f"{self.key[-6:]} ({self.user})" + return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + + @property + def partial(self): + return f'**********************************{self.key[-6:]}' if self.key else '' def save(self, *args, **kwargs): if not self.key: diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 27547b955..8fbe9e8b3 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -6,14 +6,16 @@ __all__ = ( ) -TOKEN = """{{ value }}""" +TOKEN = """{{ record }}""" ALLOWED_IPS = """{{ value|join:", " }}""" COPY_BUTTON = """ - - - +{% if settings.ALLOW_TOKEN_RETRIEVAL %} + + + +{% endif %} """ @@ -38,5 +40,5 @@ class TokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description', + 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) diff --git a/netbox/users/views.py b/netbox/users/views.py index 33ef3fadd..fe1181fc1 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -273,6 +273,7 @@ class TokenEditView(LoginRequiredMixin, View): form = TokenForm(request.POST) if form.is_valid(): + token = form.save(commit=False) token.user = request.user token.save() @@ -280,7 +281,13 @@ class TokenEditView(LoginRequiredMixin, View): msg = f"Modified token {token}" if pk else f"Created token {token}" messages.success(request, msg) - if '_addanother' in request.POST: + if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: + return render(request, 'users/api_token.html', { + 'object': token, + 'key': token.key, + 'return_url': reverse('users:token_list'), + }) + elif '_addanother' in request.POST: return redirect(request.path) else: return redirect('users:token_list') @@ -289,6 +296,7 @@ class TokenEditView(LoginRequiredMixin, View): 'object': token, 'form': form, 'return_url': reverse('users:token_list'), + 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL }) From 81c0dce5a3abf47bb6e749be60f5ea5524cf45d6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:18:07 -0400 Subject: [PATCH 212/409] Closes #10697: Move application registry into core app --- docs/release-notes/version-3.4.md | 1 + netbox/extras/management/commands/reindex.py | 2 +- netbox/extras/plugins/__init__.py | 2 +- netbox/extras/templatetags/plugins.py | 2 +- netbox/extras/tests/test_plugins.py | 2 +- netbox/extras/utils.py | 2 +- netbox/extras/webhooks.py | 2 +- netbox/netbox/context_processors.py | 2 +- netbox/netbox/denormalized.py | 2 +- netbox/netbox/graphql/schema.py | 2 +- netbox/netbox/navigation/menu.py | 2 +- netbox/netbox/preferences.py | 2 +- netbox/{extras => netbox}/registry.py | 0 netbox/netbox/search/__init__.py | 2 +- netbox/netbox/search/backends.py | 2 +- netbox/{extras => netbox}/tests/test_registry.py | 2 +- netbox/utilities/templatetags/tabs.py | 2 +- netbox/utilities/urls.py | 2 +- netbox/utilities/views.py | 2 +- 19 files changed, 18 insertions(+), 17 deletions(-) rename netbox/{extras => netbox}/registry.py (100%) rename netbox/{extras => netbox}/tests/test_registry.py (94%) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index b6e30f2a8..43e649f7b 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -56,6 +56,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 +* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py index 6dc9bbb2d..f519688f8 100644 --- a/netbox/extras/management/commands/reindex.py +++ b/netbox/extras/management/commands/reindex.py @@ -1,7 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError -from extras.registry import registry +from netbox.registry import registry from netbox.search.backends import search_backend diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f855027e2..681c5bc29 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -7,8 +7,8 @@ from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template from django.utils.module_loading import import_string -from extras.registry import registry from netbox.navigation import MenuGroup +from netbox.registry import registry from netbox.search import register_search from utilities.choices import ButtonColorChoices diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py index df3024a16..b2f4ec0a7 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/extras/templatetags/plugins.py @@ -3,7 +3,7 @@ from django.conf import settings from django.utils.safestring import mark_safe from extras.plugins import PluginTemplateExtension -from extras.registry import registry +from netbox.registry import registry register = template_.Library() diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 2eca3a3f7..b65d32702 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -6,9 +6,9 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from extras.plugins import PluginMenu -from extras.registry import registry from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query +from netbox.registry import registry @skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index e16807821..268bf9e80 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -3,7 +3,7 @@ from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from extras.constants import EXTRAS_FEATURES -from extras.registry import registry +from netbox.registry import registry def is_taggable(obj): diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index bef90a245..a93be7934 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -5,11 +5,11 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django_rq import get_queue +from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.utils import serialize_object from .choices import * from .models import Webhook -from .registry import registry def serialize_for_webhook(instance): diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index 74178ceb4..024ca85b5 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings -from extras.registry import registry from netbox.config import get_config +from netbox.registry import registry def settings_and_registry(request): diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py index cd4a869d2..a94f83e18 100644 --- a/netbox/netbox/denormalized.py +++ b/netbox/netbox/denormalized.py @@ -3,7 +3,7 @@ import logging from django.db.models.signals import post_save from django.dispatch import receiver -from extras.registry import registry +from netbox.registry import registry logger = logging.getLogger('netbox.denormalized') diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 084ac3607..82abfb4d5 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -3,8 +3,8 @@ import graphene from circuits.graphql.schema import CircuitsQuery from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery -from extras.registry import registry from ipam.graphql.schema import IPAMQuery +from netbox.registry import registry from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 68551827c..60c0657ae 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from . import * diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 6bf56b562..95fd101c3 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,4 +1,4 @@ -from extras.registry import registry +from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator diff --git a/netbox/extras/registry.py b/netbox/netbox/registry.py similarity index 100% rename from netbox/extras/registry.py rename to netbox/netbox/registry.py diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 568bf8652..c05a2492b 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -2,7 +2,7 @@ from collections import namedtuple from django.db import models -from extras.registry import registry +from netbox.registry import registry ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value')) diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index f1e00b86b..3aa6c4f47 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -9,7 +9,7 @@ from django.db.models.signals import post_delete, post_save from django.utils.module_loading import import_string from extras.models import CachedValue, CustomField -from extras.registry import registry +from netbox.registry import registry from utilities.querysets import RestrictedPrefetch from utilities.templatetags.builtins.filters import bettertitle from . import FieldTypes, LookupTypes, get_indexer diff --git a/netbox/extras/tests/test_registry.py b/netbox/netbox/tests/test_registry.py similarity index 94% rename from netbox/extras/tests/test_registry.py rename to netbox/netbox/tests/test_registry.py index 38a6b9f83..25f9e43ec 100644 --- a/netbox/extras/tests/test_registry.py +++ b/netbox/netbox/tests/test_registry.py @@ -1,6 +1,6 @@ from django.test import TestCase -from extras.registry import Registry +from netbox.registry import Registry class RegistryTest(TestCase): diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py index 6f245eff3..70f40d742 100644 --- a/netbox/utilities/templatetags/tabs.py +++ b/netbox/utilities/templatetags/tabs.py @@ -2,7 +2,7 @@ from django import template from django.urls import reverse from django.utils.module_loading import import_string -from extras.registry import registry +from netbox.registry import registry register = template.Library() diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py index 16642f589..f344b9b61 100644 --- a/netbox/utilities/urls.py +++ b/netbox/utilities/urls.py @@ -2,7 +2,7 @@ from django.urls import path from django.utils.module_loading import import_string from django.views.generic import View -from extras.registry import registry +from netbox.registry import registry def get_model_urls(app_label, model_name): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index edad7c1b2..400f127fc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -3,7 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import reverse from django.urls.exceptions import NoReverseMatch -from extras.registry import registry +from netbox.registry import registry from .permissions import resolve_permission __all__ = ( From 3b0a84969bf5275a5de9486f31921ef8c1b9b67c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:38:17 -0400 Subject: [PATCH 213/409] Closes #10820: Switch timezone library from pytz to zoneinfo --- docs/models/dcim/site.md | 2 +- docs/release-notes/version-3.4.md | 1 + netbox/dcim/tests/test_views.py | 7 +++---- netbox/netbox/settings.py | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md index c74c209e1..2e35ab11f 100644 --- a/docs/models/dcim/site.md +++ b/docs/models/dcim/site.md @@ -33,7 +33,7 @@ Each site can have multiple [AS numbers](../ipam/asn.md) assigned to it. ### Time Zone -The site's local time zone. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) +The site's local time zone. (Time zones are provided by the [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) library.) ### Physical Address diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 43e649f7b..e337c8642 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -59,6 +59,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views +* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo ### REST API Changes diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8bf1c1948..4c111be52 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,7 +1,7 @@ from decimal import Decimal -import pytz import yaml +from backports.zoneinfo import ZoneInfo from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import override_settings @@ -12,7 +12,6 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF -from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -153,7 +152,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'facility': 'Facility X', 'asns': [asns[6].pk, asns[7].pk], - 'time_zone': pytz.UTC, + 'time_zone': ZoneInfo('UTC'), 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', 'shipping_address': '742 Evergreen Terrace, Springfield, USA', @@ -182,7 +181,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'time_zone': pytz.timezone('US/Eastern'), + 'time_zone': ZoneInfo('US/Eastern'), 'description': 'New description', } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4e93eb149..2a9d43df0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -389,7 +389,6 @@ LANGUAGE_CODE = 'en-us' USE_I18N = True USE_L10N = False USE_TZ = True -USE_DEPRECATED_PYTZ = True # WSGI WSGI_APPLICATION = 'netbox.wsgi.application' From 8fb91a1f8c53801ac9c3ba0b9eb00d594e04cf17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 15:55:39 -0400 Subject: [PATCH 214/409] Closes #10821: Enable data localization --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/settings.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index e337c8642..a50686158 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -60,6 +60,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views * [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo +* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2a9d43df0..1046812e8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -386,8 +386,8 @@ AUTHENTICATION_BACKENDS = [ # Internationalization LANGUAGE_CODE = 'en-us' -USE_I18N = True -USE_L10N = False + +# Time zones USE_TZ = True # WSGI From 0ad7ae28377f44ff7d1ed119a6ff7a8f43bf8e91 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 16:26:26 -0400 Subject: [PATCH 215/409] Closes #10698: Omit app label from content type in table columns --- docs/release-notes/version-3.4.md | 1 + netbox/netbox/search/backends.py | 5 ++--- netbox/netbox/tables/columns.py | 4 ++-- netbox/netbox/tables/tables.py | 5 ++--- netbox/utilities/templatetags/builtins/filters.py | 4 ++-- netbox/utilities/utils.py | 15 +++++++++++++-- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index a50686158..b15eb0262 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -38,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields +* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns * [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types * [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 3aa6c4f47..dfc251aa9 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -11,7 +11,7 @@ from django.utils.module_loading import import_string from extras.models import CachedValue, CustomField from netbox.registry import registry from utilities.querysets import RestrictedPrefetch -from utilities.templatetags.builtins.filters import bettertitle +from utilities.utils import title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -34,8 +34,7 @@ class SearchBackend: # Organize choices by category categories = defaultdict(dict) for label, idx in registry['search'].items(): - title = bettertitle(idx.model._meta.verbose_name) - categories[idx.get_category()][label] = title + categories[idx.get_category()][label] = title(idx.model._meta.verbose_name) # Compile a nested tuple of choices for form rendering results = ( diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c7545192a..5e92196e5 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -300,7 +300,7 @@ class ContentTypeColumn(tables.Column): def render(self, value): if value is None: return None - return content_type_name(value) + return content_type_name(value, include_app=False) def value(self, value): if value is None: @@ -319,7 +319,7 @@ class ContentTypesColumn(tables.ManyToManyColumn): super().__init__(separator=separator, *args, **kwargs) def transform(self, obj): - return content_type_name(obj) + return content_type_name(obj, include_app=False) def value(self, value): return ','.join([ diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 50c109be8..3a2e71084 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -12,8 +12,7 @@ from extras.models import CustomField, CustomLink from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.templatetags.builtins.filters import bettertitle -from utilities.utils import highlight_string +from utilities.utils import highlight_string, title __all__ = ( 'BaseTable', @@ -223,7 +222,7 @@ class SearchTable(tables.Table): def render_field(self, value, record): if hasattr(record.object, value): - return bettertitle(record.object._meta.get_field(value).verbose_name) + return title(record.object._meta.get_field(value).verbose_name) return value def render_value(self, value): diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 6b548a89d..8c9315ffe 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import clean_html, foreground_color +from utilities.utils import clean_html, foreground_color, title register = template.Library() @@ -46,7 +46,7 @@ def bettertitle(value): Alternative to the builtin title(). Ensures that the first letter of each word is uppercase but retains the original case of all others. """ - return ' '.join([w[0].upper() + w[1:] for w in value.split()]) + return title(value) @register.filter() diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index e1fbbfe84..a5bccfbea 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -21,6 +21,13 @@ from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY +def title(value): + """ + Improved implementation of str.title(); retains all existing uppercase letters. + """ + return ' '.join([w[0].upper() + w[1:] for w in str(value).split()]) + + def get_viewname(model, action=None, rest_api=False): """ Return the view name for the given model and action, if valid. @@ -393,13 +400,17 @@ def array_to_string(array): return ', '.join(ret) -def content_type_name(ct): +def content_type_name(ct, include_app=True): """ Return a human-friendly ContentType name (e.g. "DCIM > Site"). """ try: meta = ct.model_class()._meta - return f'{meta.app_config.verbose_name} > {meta.verbose_name}' + app_label = title(meta.app_config.verbose_name) + model_name = title(meta.verbose_name) + if include_app: + return f'{app_label} > {model_name}' + return model_name except AttributeError: # Model no longer exists return f'{ct.app_label} > {ct.model}' From 07730ccd33080d5160604d8867b0a7015f7de0dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 16:29:42 -0400 Subject: [PATCH 216/409] #10820: Fix zoneinfo import for py3.9+ --- netbox/dcim/tests/test_views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4c111be52..d563dcfd6 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,7 +1,11 @@ from decimal import Decimal +try: + from zoneinfo import ZoneInfo +except ImportError: + # Python 3.8 + from backports.zoneinfo import ZoneInfo import yaml -from backports.zoneinfo import ZoneInfo from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import override_settings From 4ebcdd2b8f740acb74d6de28793986928f216cdf Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 3 Nov 2022 06:29:45 -0700 Subject: [PATCH 217/409] 8072 move js code from template to static file (#10824) --- netbox/netbox/settings.py | 1 + netbox/project-static/js/setmode.js | 72 +++++++++++++++++++++++++++ netbox/templates/base/base.html | 77 +++-------------------------- 3 files changed, 80 insertions(+), 70 deletions(-) create mode 100644 netbox/project-static/js/setmode.js diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e5a8b7dbd..c941f0672 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -407,6 +407,7 @@ STATIC_URL = f'/{BASE_PATH}static/' STATICFILES_DIRS = ( os.path.join(BASE_DIR, 'project-static', 'dist'), os.path.join(BASE_DIR, 'project-static', 'img'), + os.path.join(BASE_DIR, 'project-static', 'js'), ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs ) diff --git a/netbox/project-static/js/setmode.js b/netbox/project-static/js/setmode.js new file mode 100644 index 000000000..8441a542f --- /dev/null +++ b/netbox/project-static/js/setmode.js @@ -0,0 +1,72 @@ +/** + * Set the color mode on the `` element and in local storage. + * + * @param mode {"dark" | "light"} NetBox Color Mode. + * @param inferred {boolean} Value is inferred from browser/system preference. + */ +function setMode(mode, inferred) { + document.documentElement.setAttribute("data-netbox-color-mode", mode); + localStorage.setItem("netbox-color-mode", mode); + localStorage.setItem("netbox-color-mode-inferred", inferred); +} +/** + * Determine the best initial color mode to use prior to rendering. + */ +function initMode() { + try { + // Browser prefers dark color scheme. + var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + // Browser prefers light color scheme. + var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches; + // Client NetBox color-mode override. + var clientMode = localStorage.getItem("netbox-color-mode"); + // NetBox server-rendered value. + var serverMode = document.documentElement.getAttribute("data-netbox-color-mode"); + // Color mode is inferred from browser/system preference and not deterministically set by + // the client or server. + var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred")); + + if (inferred === true && (serverMode === "light" || serverMode === "dark")) { + // The color mode was previously inferred from browser/system preference, but + // the server now has a value, so we should use the server's value. + return setMode(serverMode, false); + } + if (clientMode === null && (serverMode === "light" || serverMode === "dark")) { + // If the client mode is not set but the server mode is, use the server mode. + return setMode(serverMode, false); + } + if (clientMode !== null && serverMode === "unset") { + // The color mode has been set, deterministically or otherwise, and the server + // has no preference or has not been set. Use the client mode, but allow it to + /// be overridden by the server if/when a server value exists. + return setMode(clientMode, true); + } + if ( + clientMode !== null && + (serverMode === "light" || serverMode === "dark") && + clientMode !== serverMode + ) { + // If the client mode is set and is different than the server mode (which is also set), + // use the client mode over the server mode, as it should be more recent. + return setMode(clientMode, false); + } + if (clientMode === serverMode) { + // If the client and server modes match, use that value. + return setMode(clientMode, false); + } + if (preferDark && serverMode === "unset") { + // If the server mode is not set but the browser prefers dark mode, use dark mode, but + // allow it to be overridden by an explicit preference. + return setMode("dark", true); + } + if (preferLight && serverMode === "unset") { + // If the server mode is not set but the browser prefers light mode, use light mode, + // but allow it to be overridden by an explicit preference. + return setMode("light", true); + } + } catch (error) { + // In the event of an error, log it to the console and set the mode to light mode. + console.error(error); + } + return setMode("light", true); +}; diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 8ba47dde3..138a92b6d 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -26,78 +26,15 @@ {# Page title #} {% block title %}Home{% endblock %} | NetBox + + From e7f54c5867cf49126bbf95e28633e4283c2bbcb2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 12:59:01 -0400 Subject: [PATCH 218/409] Reorganize plugin resources --- netbox/extras/plugins/__init__.py | 195 +------------------------- netbox/extras/plugins/navigation.py | 66 +++++++++ netbox/extras/plugins/registration.py | 64 +++++++++ netbox/extras/plugins/templates.py | 65 +++++++++ 4 files changed, 199 insertions(+), 191 deletions(-) create mode 100644 netbox/extras/plugins/navigation.py create mode 100644 netbox/extras/plugins/registration.py create mode 100644 netbox/extras/plugins/templates.py diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 681c5bc29..6fa22c4a3 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,17 +1,15 @@ import collections -import inspect -from packaging import version from django.apps import AppConfig from django.core.exceptions import ImproperlyConfigured -from django.template.loader import get_template from django.utils.module_loading import import_string +from packaging import version -from netbox.navigation import MenuGroup from netbox.registry import registry from netbox.search import register_search -from utilities.choices import ButtonColorChoices - +from .navigation import * +from .registration import * +from .templates import * # Initialize plugin registry registry['plugins'] = { @@ -142,188 +140,3 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value - - -# -# Template content injection -# - -class PluginTemplateExtension: - """ - This class is used to register plugin content to be injected into core NetBox templates. It contains methods - that are overridden by plugin authors to return template content. - - The `model` attribute on the class defines the which model detail page this class renders content for. It - should be set as a string in the form '.'. render() provides the following context data: - - * object - The object being viewed - * request - The current request - * settings - Global NetBox settings - * config - Plugin-specific configuration parameters - """ - model = None - - def __init__(self, context): - self.context = context - - def render(self, template_name, extra_context=None): - """ - Convenience method for rendering the specified Django template using the default context data. An additional - context dictionary may be passed as `extra_context`. - """ - if extra_context is None: - extra_context = {} - elif not isinstance(extra_context, dict): - raise TypeError("extra_context must be a dictionary") - - return get_template(template_name).render({**self.context, **extra_context}) - - def left_page(self): - """ - Content that will be rendered on the left of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def right_page(self): - """ - Content that will be rendered on the right of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def full_width_page(self): - """ - Content that will be rendered within the full width of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError - - -def register_template_extensions(class_list): - """ - Register a list of PluginTemplateExtension classes - """ - # Validation - for template_extension in class_list: - if not inspect.isclass(template_extension): - raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") - if not issubclass(template_extension, PluginTemplateExtension): - raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") - if template_extension.model is None: - raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") - - registry['plugins']['template_extensions'][template_extension.model].append(template_extension) - - -# -# Navigation menu links -# - -class PluginMenu: - icon_class = 'mdi mdi-puzzle' - - def __init__(self, label, groups, icon_class=None): - self.label = label - self.groups = [ - MenuGroup(label, items) for label, items in groups - ] - if icon_class is not None: - self.icon_class = icon_class - - -class PluginMenuItem: - """ - This class represents a navigation menu item. This constitutes primary link and its text, but also allows for - specifying additional link buttons that appear to the right of the item in the van menu. - - Links are specified as Django reverse URL strings. - Buttons are each specified as a list of PluginMenuButton instances. - """ - permissions = [] - buttons = [] - - def __init__(self, link, link_text, permissions=None, buttons=None): - self.link = link - self.link_text = link_text - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if buttons is not None: - if type(buttons) not in (list, tuple): - raise TypeError("Buttons must be passed as a tuple or list.") - self.buttons = buttons - - -class PluginMenuButton: - """ - This class represents a button within a PluginMenuItem. Note that button colors should come from - ButtonColorChoices. - """ - color = ButtonColorChoices.DEFAULT - permissions = [] - - def __init__(self, link, title, icon_class, color=None, permissions=None): - self.link = link - self.title = title - self.icon_class = icon_class - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if color is not None: - if color not in ButtonColorChoices.values(): - raise ValueError("Button color must be a choice within ButtonColorChoices.") - self.color = color - - -def register_menu(menu): - if not isinstance(menu, PluginMenu): - raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") - registry['plugins']['menus'].append(menu) - - -def register_menu_items(section_name, class_list): - """ - Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) - """ - # Validation - for menu_link in class_list: - if not isinstance(menu_link, PluginMenuItem): - raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") - for button in menu_link.buttons: - if not isinstance(button, PluginMenuButton): - raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - - registry['plugins']['menu_items'][section_name] = class_list - - -# -# GraphQL schemas -# - -def register_graphql_schema(graphql_schema): - """ - Register a GraphQL schema class for inclusion in NetBox's GraphQL API. - """ - registry['plugins']['graphql_schemas'].append(graphql_schema) - - -# -# User preferences -# - -def register_user_preferences(plugin_name, preferences): - """ - Register a list of user preferences defined by a plugin. - """ - registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py new file mode 100644 index 000000000..193be6cfb --- /dev/null +++ b/netbox/extras/plugins/navigation.py @@ -0,0 +1,66 @@ +from netbox.navigation import MenuGroup +from utilities.choices import ButtonColorChoices + +__all__ = ( + 'PluginMenu', + 'PluginMenuButton', + 'PluginMenuItem', +) + + +class PluginMenu: + icon_class = 'mdi mdi-puzzle' + + def __init__(self, label, groups, icon_class=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon_class is not None: + self.icon_class = icon_class + + +class PluginMenuItem: + """ + This class represents a navigation menu item. This constitutes primary link and its text, but also allows for + specifying additional link buttons that appear to the right of the item in the van menu. + + Links are specified as Django reverse URL strings. + Buttons are each specified as a list of PluginMenuButton instances. + """ + permissions = [] + buttons = [] + + def __init__(self, link, link_text, permissions=None, buttons=None): + self.link = link + self.link_text = link_text + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if buttons is not None: + if type(buttons) not in (list, tuple): + raise TypeError("Buttons must be passed as a tuple or list.") + self.buttons = buttons + + +class PluginMenuButton: + """ + This class represents a button within a PluginMenuItem. Note that button colors should come from + ButtonColorChoices. + """ + color = ButtonColorChoices.DEFAULT + permissions = [] + + def __init__(self, link, title, icon_class, color=None, permissions=None): + self.link = link + self.title = title + self.icon_class = icon_class + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if color is not None: + if color not in ButtonColorChoices.values(): + raise ValueError("Button color must be a choice within ButtonColorChoices.") + self.color = color diff --git a/netbox/extras/plugins/registration.py b/netbox/extras/plugins/registration.py new file mode 100644 index 000000000..5b7e58172 --- /dev/null +++ b/netbox/extras/plugins/registration.py @@ -0,0 +1,64 @@ +import inspect + +from netbox.registry import registry +from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem +from .templates import PluginTemplateExtension + +__all__ = ( + 'register_graphql_schema', + 'register_menu', + 'register_menu_items', + 'register_template_extensions', + 'register_user_preferences', +) + + +def register_template_extensions(class_list): + """ + Register a list of PluginTemplateExtension classes + """ + # Validation + for template_extension in class_list: + if not inspect.isclass(template_extension): + raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") + if not issubclass(template_extension, PluginTemplateExtension): + raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") + if template_extension.model is None: + raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") + + registry['plugins']['template_extensions'][template_extension.model].append(template_extension) + + +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): + """ + Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) + """ + # Validation + for menu_link in class_list: + if not isinstance(menu_link, PluginMenuItem): + raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") + for button in menu_link.buttons: + if not isinstance(button, PluginMenuButton): + raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") + + registry['plugins']['menu_items'][section_name] = class_list + + +def register_graphql_schema(graphql_schema): + """ + Register a GraphQL schema class for inclusion in NetBox's GraphQL API. + """ + registry['plugins']['graphql_schemas'].append(graphql_schema) + + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/extras/plugins/templates.py b/netbox/extras/plugins/templates.py new file mode 100644 index 000000000..5f3d038c6 --- /dev/null +++ b/netbox/extras/plugins/templates.py @@ -0,0 +1,65 @@ +from django.template.loader import get_template + +__all__ = ( + 'PluginTemplateExtension', +) + + +class PluginTemplateExtension: + """ + This class is used to register plugin content to be injected into core NetBox templates. It contains methods + that are overridden by plugin authors to return template content. + + The `model` attribute on the class defines the which model detail page this class renders content for. It + should be set as a string in the form '.'. render() provides the following context data: + + * object - The object being viewed + * request - The current request + * settings - Global NetBox settings + * config - Plugin-specific configuration parameters + """ + model = None + + def __init__(self, context): + self.context = context + + def render(self, template_name, extra_context=None): + """ + Convenience method for rendering the specified Django template using the default context data. An additional + context dictionary may be passed as `extra_context`. + """ + if extra_context is None: + extra_context = {} + elif not isinstance(extra_context, dict): + raise TypeError("extra_context must be a dictionary") + + return get_template(template_name).render({**self.context, **extra_context}) + + def left_page(self): + """ + Content that will be rendered on the left of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def right_page(self): + """ + Content that will be rendered on the right of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def full_width_page(self): + """ + Content that will be rendered within the full width of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError From 13afc526172af8bbaacbdc7346507d41cd2ce052 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:18:58 -0400 Subject: [PATCH 219/409] Closes #10543: Introduce get_plugin_config() utility function --- docs/plugins/development/index.md | 6 +++--- netbox/extras/plugins/__init__.py | 21 +++++++++++++++++++++ netbox/extras/tests/test_plugins.py | 12 +++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index cad77c7fe..dcbad9d8d 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -117,11 +117,11 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. !!! tip "Accessing Config Parameters" - Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example: + Plugin configuration parameters can be accessed using the `get_plugin_config()` function. For example: ```python - from django.conf import settings - settings.PLUGINS_CONFIG['myplugin']['verbose_name'] + from extras.plugins import get_plugin_config + get_plugin_config('my_plugin', 'verbose_name') ``` #### Important Notes About `django_apps` diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 6fa22c4a3..7694a1fbe 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,6 +1,7 @@ import collections from django.apps import AppConfig +from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version @@ -140,3 +141,23 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value + + +# +# Utilities +# + +def get_plugin_config(plugin_name, parameter, default=None): + """ + Return the value of the specified plugin configuration parameter. + + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) + """ + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index b65d32702..e20dccbd9 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu +from extras.plugins import PluginMenu, get_plugin_config from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query from netbox.registry import registry @@ -173,3 +173,13 @@ class PluginTest(TestCase): self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertTrue(issubclass(Query, DummyQuery)) + + @override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}}) + def test_get_plugin_config(self): + """ + Validate that get_plugin_config() returns config parameters correctly. + """ + plugin = 'extras.tests.dummy_plugin' + self.assertEqual(get_plugin_config(plugin, 'foo'), 123) + self.assertEqual(get_plugin_config(plugin, 'bar'), None) + self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) From 6b2deaeced3d088a80ea910b2155ba048fa76414 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:29:24 -0400 Subject: [PATCH 220/409] Closes #8485: Enable journaling for all organizational models --- docs/features/journaling.md | 2 +- docs/release-notes/version-3.4.md | 2 ++ netbox/netbox/models/__init__.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/features/journaling.md b/docs/features/journaling.md index ce126bf27..8aebdb446 100644 --- a/docs/features/journaling.md +++ b/docs/features/journaling.md @@ -1,5 +1,5 @@ # Journaling -All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. +All primary and organizational models in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index b15eb0262..cc9fc90d2 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types +* [#8485](https://github.com/netbox-community/netbox/issues/8485) - Enable journaling for all organizational models * [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects @@ -50,6 +51,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin +* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function * [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views ### Other Changes diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2f2dc1c9f..f4f28030d 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -21,6 +21,7 @@ class NetBoxFeatureSet( CustomLinksMixin, CustomValidationMixin, ExportTemplatesMixin, + JournalingMixin, TagsMixin, WebhooksMixin ): @@ -55,7 +56,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model): +class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ From e2f5ee661a384c380bfd81e043b65b2eea9f4d12 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 13:59:44 -0400 Subject: [PATCH 221/409] Clean up redundant NestedGroupModel, OrganizationalModel fields --- netbox/circuits/migrations/0001_squashed.py | 2 +- netbox/circuits/models/circuits.py | 19 ------- netbox/dcim/migrations/0001_squashed.py | 8 +-- .../dcim/migrations/0147_inventoryitemrole.py | 2 +- netbox/dcim/models/device_components.py | 18 ------ netbox/dcim/models/devices.py | 55 ------------------ netbox/dcim/models/racks.py | 18 ------ netbox/dcim/models/sites.py | 57 ------------------- netbox/ipam/migrations/0001_squashed.py | 4 +- netbox/ipam/models/ip.py | 31 +--------- netbox/ipam/models/vlans.py | 3 - netbox/netbox/models/__init__.py | 6 ++ netbox/tenancy/migrations/0003_contacts.py | 2 +- netbox/tenancy/models/contacts.py | 38 ------------- netbox/tenancy/models/tenants.py | 12 ---- .../migrations/0001_squashed_0022.py | 4 +- netbox/virtualization/models.py | 38 ------------- netbox/wireless/models.py | 15 ----- 18 files changed, 19 insertions(+), 313 deletions(-) diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 971233162..656eb35a1 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -65,7 +65,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index ea74eeb40..7100c9796 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -23,25 +23,6 @@ class CircuitType(OrganizationalModel): Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('circuits:circuittype', args=[self.pk]) diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index fca7d8eb9..3d7156e17 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -195,7 +195,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -352,7 +352,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -369,7 +369,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -538,7 +538,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index cbdd36c08..4b6c27450 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.AddField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 59d63ef7b..8855107b3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1025,27 +1025,9 @@ class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:inventoryitemrole', args=[self.pk]) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d4646762f..3710bf7f4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -45,30 +45,11 @@ class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations contacts = GenericRelation( to='tenancy.ContactAssignment' ) - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('dcim:manufacturer', args=[self.pk]) @@ -418,14 +399,6 @@ class DeviceRole(OrganizationalModel): color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to virtual machines as well. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -434,16 +407,6 @@ class DeviceRole(OrganizationalModel): verbose_name='VM Role', help_text='Virtual machines may be assigned to this role' ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:devicerole', args=[self.pk]) @@ -455,14 +418,6 @@ class Platform(OrganizationalModel): NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -483,16 +438,6 @@ class Platform(OrganizationalModel): verbose_name='NAPALM arguments', help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:platform', args=[self.pk]) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 6fcd65a19..e61765e69 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -38,27 +38,9 @@ class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) color = ColorField( default=ColorChoices.COLOR_GREY ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name def get_absolute_url(self): return reverse('dcim:rackrole', args=[self.pk]) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 9ddadace2..c352b69de 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey from timezone_field import TimeZoneField from dcim.choices import * @@ -28,25 +27,6 @@ class Region(NestedGroupModel): states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are also considered to be members of its parent and ancestor region(s). """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -102,25 +82,6 @@ class SiteGroup(NestedGroupModel): within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be nested recursively to form a hierarchy. """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -298,25 +259,11 @@ class Location(NestedGroupModel): A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a site, or a room within a building, for example. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, related_name='locations' ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) status = models.CharField( max_length=50, choices=LocationStatusChoices, @@ -329,10 +276,6 @@ class Location(NestedGroupModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Generic relations vlan_groups = GenericRelation( diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index b5d68439a..bef36e698 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -91,7 +91,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'RIR', 'verbose_name_plural': 'RIRs', - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -107,7 +107,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['weight', 'name'], + 'ordering': ('weight', 'name'), }, ), migrations.CreateModel( diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 456bab4f0..75f90ff54 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -61,32 +61,17 @@ class RIR(OrganizationalModel): A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) is_private = models.BooleanField( default=False, verbose_name='Private', help_text='IP space managed by this RIR is considered private' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: - ordering = ['name'] + ordering = ('name',) verbose_name = 'RIR' verbose_name_plural = 'RIRs' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:rir', args=[self.pk]) @@ -265,24 +250,12 @@ class Role(OrganizationalModel): A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) weight = models.PositiveSmallIntegerField( default=1000 ) - description = models.CharField( - max_length=200, - blank=True, - ) class Meta: - ordering = ['weight', 'name'] + ordering = ('weight', 'name') def __str__(self): return self.name diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index c8c401e1c..e3a4b973b 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -83,9 +83,6 @@ class VLANGroup(OrganizationalModel): verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('ipam:vlangroup', args=[self.pk]) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index f4f28030d..38a6fcc9f 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -82,6 +82,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): name = models.CharField( max_length=100 ) + slug = models.SlugField( + max_length=100 + ) description = models.CharField( max_length=200, blank=True @@ -135,3 +138,6 @@ class OrganizationalModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True ordering = ('name',) + + def __str__(self): + return self.name diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index ba9bef50f..eb247ee29 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index f2fd09de7..ba937c167 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -20,25 +20,6 @@ class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - class Meta: ordering = ['name'] constraints = ( @@ -56,25 +37,6 @@ class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True, - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('tenancy:contactrole', args=[self.pk]) diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index b0ccd1cb2..b76efcbf9 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -23,18 +23,6 @@ class TenantGroup(NestedGroupModel): max_length=100, unique=True ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ['name'] diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index 29eda8a50..2a7894737 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -72,7 +72,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( @@ -87,7 +87,7 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ], options={ - 'ordering': ['name'], + 'ordering': ('name',), }, ), migrations.CreateModel( diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4e8645707..b859d25fe 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -33,25 +33,6 @@ class ClusterType(OrganizationalModel): """ A type of Cluster. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('virtualization:clustertype', args=[self.pk]) @@ -64,19 +45,6 @@ class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - # Generic relations vlan_groups = GenericRelation( to='ipam.VLANGroup', @@ -88,12 +56,6 @@ class ClusterGroup(OrganizationalModel): to='tenancy.ContactAssignment' ) - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('virtualization:clustergroup', args=[self.pk]) diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 29fe33f4b..ee2744e40 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -54,18 +54,6 @@ class WirelessLANGroup(NestedGroupModel): max_length=100, unique=True ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('name', 'pk') @@ -77,9 +65,6 @@ class WirelessLANGroup(NestedGroupModel): ) verbose_name = 'Wireless LAN Group' - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('wireless:wirelesslangroup', args=[self.pk]) From bc6b5bc4be52e3310a1e1d7ad4bdd40db9ae290a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Nov 2022 08:28:09 -0400 Subject: [PATCH 222/409] Closes #10545: Standardize description & comment fields on primary models (#10834) * Standardize description & comments fields on primary models * Update REST API serializers * Update forms * Update tables * Update templates --- docs/release-notes/version-3.4.md | 51 +++++++ netbox/circuits/api/serializers.py | 4 +- netbox/circuits/forms/bulk_edit.py | 6 +- netbox/circuits/forms/bulk_import.py | 2 +- netbox/circuits/forms/model_forms.py | 9 +- .../0041_standardize_description_comments.py | 18 +++ netbox/circuits/models/circuits.py | 11 +- netbox/circuits/models/providers.py | 17 +-- netbox/circuits/tables/providers.py | 4 +- netbox/dcim/api/serializers.py | 41 +++--- netbox/dcim/forms/bulk_edit.py | 131 ++++++++++++++---- netbox/dcim/forms/bulk_import.py | 17 +-- netbox/dcim/forms/model_forms.py | 40 +++--- netbox/dcim/forms/object_import.py | 4 +- .../0165_standardize_description_comments.py | 78 +++++++++++ netbox/dcim/models/cables.py | 6 +- netbox/dcim/models/devices.py | 24 +--- netbox/dcim/models/power.py | 10 +- netbox/dcim/models/racks.py | 9 +- netbox/dcim/models/sites.py | 11 +- netbox/dcim/tables/cables.py | 3 +- netbox/dcim/tables/devices.py | 78 +++++------ netbox/dcim/tables/devicetypes.py | 44 ++---- netbox/dcim/tables/modules.py | 6 +- netbox/dcim/tables/power.py | 6 +- netbox/dcim/tables/racks.py | 9 +- netbox/ipam/api/serializers.py | 39 +++--- netbox/ipam/forms/bulk_edit.py | 90 +++++++++--- netbox/ipam/forms/bulk_import.py | 24 ++-- netbox/ipam/forms/model_forms.py | 50 ++++--- .../0063_standardize_description_comments.py | 73 ++++++++++ netbox/ipam/models/fhrp.py | 8 +- netbox/ipam/models/ip.py | 32 +---- netbox/ipam/models/l2vpn.py | 8 +- netbox/ipam/models/services.py | 10 +- netbox/ipam/models/vlans.py | 14 +- netbox/ipam/models/vrfs.py | 14 +- netbox/ipam/tables/fhrp.py | 4 +- netbox/ipam/tables/ip.py | 26 ++-- netbox/ipam/tables/l2vpn.py | 8 +- netbox/ipam/tables/services.py | 10 +- netbox/ipam/tables/vlans.py | 3 +- netbox/ipam/tables/vrfs.py | 10 +- netbox/netbox/models/__init__.py | 21 ++- netbox/templates/circuits/provider.html | 4 + netbox/templates/dcim/cable.html | 5 + netbox/templates/dcim/cable_edit.html | 23 +-- netbox/templates/dcim/device.html | 6 +- netbox/templates/dcim/device_edit.html | 1 + netbox/templates/dcim/devicetype.html | 4 + netbox/templates/dcim/module.html | 4 + netbox/templates/dcim/moduletype.html | 4 + netbox/templates/dcim/powerfeed.html | 4 + netbox/templates/dcim/powerpanel.html | 41 +++--- netbox/templates/dcim/rack.html | 4 + netbox/templates/dcim/rack_edit.html | 1 + netbox/templates/dcim/rackreservation.html | 1 + netbox/templates/dcim/virtualchassis.html | 7 +- .../templates/dcim/virtualchassis_edit.html | 8 +- netbox/templates/ipam/aggregate.html | 1 + netbox/templates/ipam/asn.html | 1 + netbox/templates/ipam/fhrpgroup.html | 1 + netbox/templates/ipam/fhrpgroup_edit.html | 11 +- netbox/templates/ipam/ipaddress.html | 1 + netbox/templates/ipam/ipaddress_edit.html | 7 + netbox/templates/ipam/iprange.html | 7 +- netbox/templates/ipam/l2vpn.html | 1 + netbox/templates/ipam/prefix.html | 1 + netbox/templates/ipam/routetarget.html | 1 + netbox/templates/ipam/service.html | 7 +- netbox/templates/ipam/service_create.html | 7 + netbox/templates/ipam/service_edit.html | 7 + netbox/templates/ipam/servicetemplate.html | 13 +- netbox/templates/ipam/vlan.html | 7 +- netbox/templates/ipam/vlan_edit.html | 7 + netbox/templates/ipam/vrf.html | 1 + netbox/templates/tenancy/contact.html | 4 + netbox/templates/virtualization/cluster.html | 4 + .../virtualization/virtualmachine.html | 4 + netbox/templates/wireless/wirelesslan.html | 1 + netbox/templates/wireless/wirelesslink.html | 1 + .../templates/wireless/wirelesslink_edit.html | 6 + netbox/tenancy/api/serializers.py | 4 +- netbox/tenancy/forms/bulk_edit.py | 14 +- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 4 +- .../0009_standardize_description_comments.py | 18 +++ netbox/tenancy/models/contacts.py | 8 +- netbox/tenancy/models/tenants.py | 12 +- netbox/tenancy/tables/contacts.py | 4 +- netbox/virtualization/api/serializers.py | 8 +- netbox/virtualization/forms/bulk_edit.py | 18 ++- netbox/virtualization/forms/bulk_import.py | 4 +- netbox/virtualization/forms/model_forms.py | 10 +- .../0034_standardize_description_comments.py | 23 +++ netbox/virtualization/models.py | 12 +- netbox/virtualization/tables/clusters.py | 4 +- .../virtualization/tables/virtualmachines.py | 4 +- netbox/wireless/api/serializers.py | 4 +- netbox/wireless/forms/bulk_edit.py | 28 ++-- netbox/wireless/forms/bulk_import.py | 7 +- netbox/wireless/forms/model_forms.py | 11 +- .../0007_standardize_description_comments.py | 23 +++ netbox/wireless/models.py | 16 +-- netbox/wireless/tables/wirelesslan.py | 9 +- 105 files changed, 1014 insertions(+), 534 deletions(-) create mode 100644 netbox/circuits/migrations/0041_standardize_description_comments.py create mode 100644 netbox/dcim/migrations/0165_standardize_description_comments.py create mode 100644 netbox/ipam/migrations/0063_standardize_description_comments.py create mode 100644 netbox/tenancy/migrations/0009_standardize_description_comments.py create mode 100644 netbox/virtualization/migrations/0034_standardize_description_comments.py create mode 100644 netbox/wireless/migrations/0007_standardize_description_comments.py diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index cc9fc90d2..158e7a77f 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -69,18 +69,69 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * circuits.provider * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields + * Added a `description` field +* dcim.Cable + * Added `description` and `comments` fields +* dcim.Device + * Added a `description` field * dcim.DeviceType + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.Module + * Added a `description` field * dcim.ModuleType + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.PowerFeed + * Added a `description` field +* dcim.PowerPanel + * Added `description` and `comments` fields * dcim.Rack + * Added a `description` field * Added optional `weight` and `weight_unit` fields +* dcim.RackReservation + * Added a `comments` field +* dcim.VirtualChassis + * Added `description` and `comments` fields * extras.CustomLink * Renamed `content_type` field to `content_types` * extras.ExportTemplate * Renamed `content_type` field to `content_types` +* ipam.Aggregate + * Added a `comments` field +* ipam.ASN + * Added a `comments` field * ipam.FHRPGroup + * Added a `comments` field * Added optional `name` field +* ipam.IPAddress + * Added a `comments` field +* ipam.IPRange + * Added a `comments` field +* ipam.L2VPN + * Added a `comments` field +* ipam.Prefix + * Added a `comments` field +* ipam.RouteTarget + * Added a `comments` field +* ipam.Service + * Added a `comments` field +* ipam.ServiceTemplate + * Added a `comments` field +* ipam.VLAN + * Added a `comments` field +* ipam.VRF + * Added a `comments` field +* tenancy.Contact + * Added a `description` field +* virtualization.Cluster + * Added a `description` field +* virtualization.VirtualMachine + * Added a `description` field +* wireless.WirelessLAN + * Added a `comments` field +* wireless.WirelessLink + * Added a `comments` field ### GraphQL API Changes diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 4a8e2bd28..2bcb0895a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,8 +31,8 @@ class ProviderSerializer(NetBoxModelSerializer): class Meta: model = Provider fields = [ - 'id', 'url', 'display', 'name', 'slug', 'account', - 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'account', 'description', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 12975b5d6..6e9ae516c 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -30,6 +30,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Account number' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -40,7 +44,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): (None, ('asns', 'account', )), ) nullable_fields = ( - 'asns', 'account', 'comments', + 'asns', 'account', 'description', 'comments', ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 77ebb3de9..d0bdb09a7 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'account', 'comments', + 'name', 'slug', 'account', 'description', 'comments', ) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 03c473d62..ab1b6bca2 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -1,4 +1,3 @@ -from django import forms from django.utils.translation import gettext as _ from circuits.models import * @@ -7,8 +6,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( - BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect, + CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField, + StaticSelect, ) __all__ = ( @@ -30,14 +29,14 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asns', 'tags')), + ('Provider', ('name', 'slug', 'asns', 'description', 'tags')), ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'account', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags', ] help_texts = { 'name': "Full name of the provider", diff --git a/netbox/circuits/migrations/0041_standardize_description_comments.py b/netbox/circuits/migrations/0041_standardize_description_comments.py new file mode 100644 index 000000000..49cdefcba --- /dev/null +++ b/netbox/circuits/migrations/0041_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0040_provider_remove_deprecated_fields'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 7100c9796..9d302bb8e 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -7,7 +7,7 @@ from django.urls import reverse from circuits.choices import * from dcim.models import CabledObjectModel from netbox.models import ( - ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin, ) from netbox.models.features import WebhooksMixin @@ -27,7 +27,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -class Circuit(NetBoxModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -73,13 +73,6 @@ class Circuit(NetBoxModel): blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index bd63ff0c6..18a81dcef 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -2,8 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from dcim.fields import ASNField -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( 'ProviderNetwork', @@ -11,7 +10,7 @@ __all__ = ( ) -class Provider(NetBoxModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -34,9 +33,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -57,7 +53,7 @@ class Provider(NetBoxModel): return reverse('circuits:provider', args=[self.pk]) -class ProviderNetwork(NetBoxModel): +class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. @@ -75,13 +71,6 @@ class ProviderNetwork(NetBoxModel): blank=True, verbose_name='Service ID' ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) class Meta: ordering = ('provider', 'name') diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index a117274ff..9de8d25b2 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -39,8 +39,8 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asns', 'account', 'asn_count', - 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'description', 'comments', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'account', 'circuit_count') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 19de84791..9317d7c51 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -210,8 +210,8 @@ class RackSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', 'powerfeed_count', + 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'powerfeed_count', ] @@ -243,8 +243,8 @@ class RackReservationSerializer(NetBoxModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', - 'custom_fields', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', + 'comments', 'tags', 'custom_fields', ] @@ -324,8 +324,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer): model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', + 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] @@ -333,13 +333,12 @@ class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) - # module_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -656,8 +655,8 @@ class DeviceSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) @@ -681,8 +680,8 @@ class ModuleSerializer(NetBoxModelSerializer): class Meta: model = Module fields = [ - 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1020,7 +1019,7 @@ class CableSerializer(NetBoxModelSerializer): model = Cable fields = [ 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated', + 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -1086,8 +1085,8 @@ class VirtualChassisSerializer(NetBoxModelSerializer): class Meta: model = VirtualChassis fields = [ - 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', + 'member_count', 'created', 'last_updated', ] @@ -1108,8 +1107,8 @@ class PowerPanelSerializer(NetBoxModelSerializer): class Meta: model = PowerPanel fields = [ - 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', - 'created', 'last_updated', + 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', + 'powerfeed_count', 'created', 'last_updated', ] @@ -1142,7 +1141,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', - 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e3b69dc81..1e58dd2f7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -127,22 +127,26 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Contact E-mail' ) - description = forms.CharField( - max_length=100, - required=False - ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), required=False, widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Site fieldsets = ( (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), ) nullable_fields = ( - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments', ) @@ -285,10 +289,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) weight = forms.DecimalField( min_value=0, required=False @@ -299,10 +299,18 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Rack fieldsets = ( - ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), + ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')), ('Location', ('region', 'site_group', 'site', 'location')), ('Hardware', ( 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', @@ -310,8 +318,8 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - 'weight', 'weight_unit' + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'weight_unit', 'description', 'comments', ) @@ -328,14 +336,19 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RackReservation fieldsets = ( (None, ('user', 'tenant', 'description')), ) + nullable_fields = ('comments',) class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): @@ -383,13 +396,21 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = DeviceType fieldsets = ( - ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit') + nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -410,13 +431,21 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ModuleType fieldsets = ( - ('Module Type', ('manufacturer', 'part_number')), + ('Module Type', ('manufacturer', 'part_number', 'description')), ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'weight', 'weight_unit') + nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -512,15 +541,23 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Serial Number' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Device fieldsets = ( - ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')), ('Location', ('site', 'location')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) nullable_fields = ( - 'location', 'tenant', 'platform', 'serial', 'airflow', + 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', ) @@ -541,12 +578,20 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Serial Number' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Module fieldsets = ( - (None, ('manufacturer', 'module_type', 'serial')), + (None, ('manufacturer', 'module_type', 'serial', 'description')), ) - nullable_fields = ('serial',) + nullable_fields = ('serial', 'description', 'comments') class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -583,14 +628,22 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect() ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Cable fieldsets = ( - (None, ('type', 'status', 'tenant', 'label')), + (None, ('type', 'status', 'tenant', 'label', 'description')), ('Attributes', ('color', 'length', 'length_unit')), ) nullable_fields = ( - 'type', 'status', 'tenant', 'label', 'color', 'length', + 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', ) @@ -599,12 +652,20 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): max_length=30, required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VirtualChassis fieldsets = ( - (None, ('domain',)), + (None, ('domain', 'description')), ) - nullable_fields = ('domain',) + nullable_fields = ('domain', 'description', 'comments') class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): @@ -637,12 +698,20 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = PowerPanel fieldsets = ( - (None, ('region', 'site_group', 'site', 'location')), + (None, ('region', 'site_group', 'site', 'location', 'description')), ) - nullable_fields = ('location',) + nullable_fields = ('location', 'description', 'comments') class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): @@ -691,6 +760,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=BulkEditNullBooleanSelect ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -698,10 +771,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')), ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) ) - nullable_fields = ('location', 'comments') + nullable_fields = ('location', 'description', 'comments') # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 13e788e75..4c90c9c02 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -196,7 +196,8 @@ class RackCSVForm(NetBoxModelCSVForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'comments', + 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', + 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -240,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm): class Meta: model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -387,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'comments', + 'cluster', 'description', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -424,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -927,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm): model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', ] help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), @@ -984,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master') + fields = ('name', 'domain', 'master', 'description') # @@ -1005,7 +1006,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPanel - fields = ('site', 'location', 'name') + fields = ('site', 'location', 'name', 'description', 'comments') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1061,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'comments', + 'voltage', 'amperage', 'max_utilization', 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 0da2f3430..539c48709 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -278,7 +278,7 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'comments', 'tags', + 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -342,6 +342,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ), widget=StaticSelect() ) + comments = CommentField() fieldsets = ( ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), @@ -352,7 +353,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): model = RackReservation fields = [ 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'tags', + 'description', 'comments', 'tags', ] @@ -383,10 +384,10 @@ class DeviceTypeForm(NetBoxModelForm): fieldsets = ( ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', + 'manufacturer', 'model', 'slug', 'description', 'tags', )), ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', )), ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), @@ -396,7 +397,7 @@ class DeviceTypeForm(NetBoxModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -418,15 +419,14 @@ class ModuleTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Module Type', ( - 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit' - )), + ('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')), + ('Weight', ('weight', 'weight_unit')) ) class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', + 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] widgets = { @@ -591,7 +591,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', - 'comments', 'tags', 'local_context_data' + 'description', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -705,7 +705,7 @@ class ModuleForm(NetBoxModelForm): fieldsets = ( ('Module', ( - 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + 'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags', )), ('Hardware', ( 'serial', 'asset_tag', 'replicate_components', 'adopt_components', @@ -716,7 +716,7 @@ class ModuleForm(NetBoxModelForm): model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'adopt_components', 'comments', + 'replicate_components', 'adopt_components', 'description', 'comments', ] def __init__(self, *args, **kwargs): @@ -793,11 +793,13 @@ class ModuleForm(NetBoxModelForm): class CableForm(TenancyForm, NetBoxModelForm): + comments = CommentField() class Meta: model = Cable fields = [ - 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', + 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, @@ -840,15 +842,16 @@ class PowerPanelForm(NetBoxModelForm): 'site_id': '$site' } ) + comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')), ) class Meta: model = PowerPanel fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags', ] @@ -894,7 +897,7 @@ class PowerFeedForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Panel', ('region', 'site', 'power_panel', 'description')), ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) @@ -903,7 +906,7 @@ class PowerFeedForm(NetBoxModelForm): model = PowerFeed fields = [ 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', - 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -922,11 +925,12 @@ class VirtualChassisForm(NetBoxModelForm): queryset=Device.objects.all(), required=False, ) + comments = CommentField() class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'master', 'tags', + 'name', 'domain', 'master', 'description', 'comments', 'tags', ] widgets = { 'master': SelectWithPK(), diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 023aba8f1..82ee093dd 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -30,7 +30,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'comments', + 'description', 'comments', ] @@ -42,7 +42,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] # diff --git a/netbox/dcim/migrations/0165_standardize_description_comments.py b/netbox/dcim/migrations/0165_standardize_description_comments.py new file mode 100644 index 000000000..f17f1d321 --- /dev/null +++ b/netbox/dcim/migrations/0165_standardize_description_comments.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0164_rack_mounting_depth'), + ] + + operations = [ + migrations.AddField( + model_name='cable', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='cable', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='device', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicetype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='module', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='moduletype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerfeed', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerpanel', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='powerpanel', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rack', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rackreservation', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fad3e8bd6..c51b59f94 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,8 +12,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.fields import PathField -from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import NetBoxModel +from dcim.utils import decompile_path_node, object_to_path_node +from netbox.models import PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -34,7 +34,7 @@ trace_paths = Signal() # Cables # -class Cable(NetBoxModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3710bf7f4..78282f893 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,7 +18,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -54,7 +54,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(NetBoxModel, WeightMixin): +class DeviceType(PrimaryModel, WeightMixin): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -117,9 +117,6 @@ class DeviceType(NetBoxModel, WeightMixin): upload_to='devicetype-images', blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', @@ -298,7 +295,7 @@ class DeviceType(NetBoxModel, WeightMixin): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(NetBoxModel, WeightMixin): +class ModuleType(PrimaryModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -318,9 +315,6 @@ class ModuleType(NetBoxModel, WeightMixin): blank=True, help_text='Discrete part number (optional)' ) - comments = models.TextField( - blank=True - ) # Generic relations images = GenericRelation( @@ -443,7 +437,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -class Device(NetBoxModel, ConfigContextModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -587,9 +581,6 @@ class Device(NetBoxModel, ConfigContextModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( @@ -906,7 +897,7 @@ class Device(NetBoxModel, ConfigContextModel): return round(total_weight / 1000, 2) -class Module(NetBoxModel, ConfigContextModel): +class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. @@ -939,9 +930,6 @@ class Module(NetBoxModel, ConfigContextModel): verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - comments = models.TextField( - blank=True - ) clone_fields = ('device', 'module_type') @@ -1019,7 +1007,7 @@ class Module(NetBoxModel, ConfigContextModel): # Virtual chassis # -class VirtualChassis(NetBoxModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 39f0f37ef..e79cf4c44 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,9 +6,8 @@ from django.db import models from django.urls import reverse from dcim.choices import * -from dcim.constants import * from netbox.config import ConfigItem -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import CabledObjectModel, PathEndpoint @@ -22,7 +21,7 @@ __all__ = ( # Power # -class PowerPanel(NetBoxModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -77,7 +76,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): +class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -132,9 +131,6 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): default=0, editable=False ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e61765e69..e37fc8dc3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,7 +14,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange @@ -46,7 +46,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(NetBoxModel, WeightMixin): +class Rack(PrimaryModel, WeightMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -157,9 +157,6 @@ class Rack(NetBoxModel, WeightMixin): 'distance between the front and rear rails.' ) ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -463,7 +460,7 @@ class Rack(NetBoxModel, WeightMixin): return round(total_weight / 1000, 2) -class RackReservation(NetBoxModel): +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index c352b69de..c760119fb 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -131,7 +131,7 @@ class SiteGroup(NestedGroupModel): # Sites # -class Site(NetBoxModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -188,10 +188,6 @@ class Site(NetBoxModel): time_zone = TimeZoneField( blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) physical_address = models.CharField( max_length=200, blank=True @@ -214,9 +210,6 @@ class Site(NetBoxModel): null=True, help_text='GPS coordinate (longitude)' ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index e5410e42a..6e9d49719 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -111,6 +111,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): order_by=('_abs_length', 'length_unit') ) color = columns.ColorColumn() + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:cable_list' ) @@ -120,7 +121,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'length', 'tags', 'created', 'last_updated', + 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b129c963..45a210080 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,21 +1,5 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePort, - ConsoleServerPort, - Device, - DeviceBay, - DeviceRole, - FrontPort, - Interface, - InventoryItem, - InventoryItemRole, - ModuleBay, - Platform, - PowerOutlet, - PowerPort, - RearPort, - VirtualChassis, -) +from dcim import models from django_tables2.utils import Accessor from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin @@ -106,7 +90,7 @@ class DeviceRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = DeviceRole + model = models.DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', @@ -137,7 +121,7 @@ class PlatformTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Platform + model = models.Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', @@ -220,12 +204,12 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -252,7 +236,7 @@ class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Device + model = models.Device fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -326,7 +310,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -345,7 +329,7 @@ class DeviceConsolePortTable(ConsolePortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsolePort + model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' @@ -368,7 +352,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', @@ -388,7 +372,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -411,7 +395,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -432,7 +416,7 @@ class DevicePowerPortTable(PowerPortTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerPort + model = models.PowerPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -460,7 +444,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', @@ -480,7 +464,7 @@ class DevicePowerOutletTable(PowerOutletTable): ) class Meta(DeviceComponentTable.Meta): - model = PowerOutlet + model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', @@ -544,7 +528,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', @@ -578,7 +562,7 @@ class DeviceInterfaceTable(InterfaceTable): ) class Meta(DeviceComponentTable.Meta): - model = Interface + model = models.Interface fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', @@ -617,7 +601,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', @@ -640,7 +624,7 @@ class DeviceFrontPortTable(FrontPortTable): ) class Meta(DeviceComponentTable.Meta): - model = FrontPort + model = models.FrontPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -666,7 +650,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', @@ -686,7 +670,7 @@ class DeviceRearPortTable(RearPortTable): ) class Meta(DeviceComponentTable.Meta): - model = RearPort + model = models.RearPort fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', @@ -727,7 +711,7 @@ class DeviceBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags', 'created', 'last_updated', @@ -748,7 +732,7 @@ class DeviceDeviceBayTable(DeviceBayTable): ) class Meta(DeviceComponentTable.Meta): - model = DeviceBay + model = models.DeviceBay fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) @@ -777,7 +761,7 @@ class ModuleBayTable(DeviceComponentTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', @@ -791,7 +775,7 @@ class DeviceModuleBayTable(ModuleBayTable): ) class Meta(DeviceComponentTable.Meta): - model = ModuleBay + model = models.ModuleBay fields = ( 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', 'description', 'tags', 'actions', @@ -821,7 +805,7 @@ class InventoryItemTable(DeviceComponentTable): cable = None # Override DeviceComponentTable class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', @@ -840,7 +824,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItem + model = models.InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'description', 'discovered', 'tags', 'actions', @@ -865,7 +849,7 @@ class InventoryItemRoleTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = InventoryItemRole + model = models.InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) @@ -888,11 +872,15 @@ class VirtualChassisTable(NetBoxTable): url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) class Meta(NetBoxTable.Meta): - model = VirtualChassis - fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) + model = models.VirtualChassis + fields = ( + 'pk', 'id', 'name', 'domain', 'master', 'member_count', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 19b04c70d..a52d41b70 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,19 +1,6 @@ import django_tables2 as tables -from dcim.models import ( - ConsolePortTemplate, - ConsoleServerPortTemplate, - DeviceBayTemplate, - DeviceType, - FrontPortTemplate, - InterfaceTemplate, - InventoryItemTemplate, - Manufacturer, - ModuleBayTemplate, - PowerOutletTemplate, - PowerPortTemplate, - RearPortTemplate, -) +from dcim import models from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT @@ -59,7 +46,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = Manufacturer + model = models.Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'contacts', 'actions', 'created', 'last_updated', @@ -100,15 +87,12 @@ class DeviceTypeTable(NetBoxTable): template_code=DEVICE_WEIGHT, order_by=('_abs_weight', 'weight_unit') ) - u_height = columns.TemplateColumn( - template_code='{{ value|floatformat }}' - ) class Meta(NetBoxTable.Meta): - model = DeviceType + model = models.DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', @@ -138,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsolePortTemplate + model = models.ConsolePortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -150,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ConsoleServerPortTemplate + model = models.ConsoleServerPortTemplate fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -162,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerPortTemplate + model = models.PowerPortTemplate fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') empty_text = "None" @@ -174,7 +158,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = PowerOutletTemplate + model = models.PowerOutletTemplate fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') empty_text = "None" @@ -189,7 +173,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InterfaceTemplate + model = models.InterfaceTemplate fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') empty_text = "None" @@ -205,7 +189,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = FrontPortTemplate + model = models.FrontPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions') empty_text = "None" @@ -218,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = RearPortTemplate + model = models.RearPortTemplate fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions') empty_text = "None" @@ -229,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = ModuleBayTemplate + model = models.ModuleBayTemplate fields = ('pk', 'name', 'label', 'position', 'description', 'actions') empty_text = "None" @@ -240,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = DeviceBayTemplate + model = models.DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" @@ -260,7 +244,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable): ) class Meta(ComponentTemplateTable.Meta): - model = InventoryItemTemplate + model = models.InventoryItemTemplate fields = ( 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index b644e6ba6..9df26eb73 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -35,7 +35,7 @@ class ModuleTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags', + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'description', 'comments', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', @@ -64,8 +64,8 @@ class ModuleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Module fields = ( - 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments', - 'tags', + 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description', + 'comments', 'tags', ) default_columns = ( 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 04012ea4a..feff29e12 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -31,6 +31,7 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -38,7 +39,8 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = PowerPanel fields = ( - 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -77,7 +79,7 @@ class PowerFeedTable(CableTerminationTable): fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', 'created', 'last_updated', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1a355cc2a..b360002d2 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -90,8 +90,8 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight', - 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', - 'last_updated', + 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -123,6 +123,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Units' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) @@ -130,7 +131,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = RackReservation fields = ( - 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags', - 'actions', 'created', 'last_updated', + 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e04849c13..6ec062aee 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -31,8 +31,8 @@ class ASNSerializer(NetBoxModelSerializer): class Meta: model = ASN fields = [ - 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'site_count', 'provider_count', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'site_count', 'provider_count', ] @@ -61,8 +61,9 @@ class VRFSerializer(NetBoxModelSerializer): class Meta: model = VRF fields = [ - 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', - 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', + 'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', + 'prefix_count', ] @@ -77,7 +78,8 @@ class RouteTargetSerializer(NetBoxModelSerializer): class Meta: model = RouteTarget fields = [ - 'id', 'url', 'display', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] @@ -106,8 +108,8 @@ class AggregateSerializer(NetBoxModelSerializer): class Meta: model = Aggregate fields = [ - 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -123,8 +125,8 @@ class FHRPGroupSerializer(NetBoxModelSerializer): class Meta: model = FHRPGroup fields = [ - 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses', ] @@ -215,7 +217,7 @@ class VLANSerializer(NetBoxModelSerializer): model = VLAN fields = [ 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', - 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] @@ -273,7 +275,8 @@ class PrefixSerializer(NetBoxModelSerializer): model = Prefix fields = [ 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', - 'mark_utilized', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + '_depth', ] read_only_fields = ['family'] @@ -342,7 +345,7 @@ class IPRangeSerializer(NetBoxModelSerializer): model = IPRange fields = [ 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', ] read_only_fields = ['family'] @@ -371,8 +374,8 @@ class IPAddressSerializer(NetBoxModelSerializer): model = IPAddress fields = [ 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', - 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.JSONField) @@ -415,8 +418,8 @@ class ServiceTemplateSerializer(NetBoxModelSerializer): class Meta: model = ServiceTemplate fields = [ - 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -436,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): model = Service fields = [ 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] # @@ -465,7 +468,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 67bcf83fb..ed1d1d9e9 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -8,8 +8,8 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, DynamicModelChoiceField, NumericArrayField, StaticSelect, - DynamicModelMultipleChoiceField, + add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, + SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( @@ -43,15 +43,19 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): label='Enforce unique space' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VRF fieldsets = ( (None, ('tenant', 'enforce_unique', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): @@ -63,12 +67,16 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = RouteTarget fieldsets = ( (None, ('tenant', 'description')), ) - nullable_fields = ('tenant', 'description') + nullable_fields = ('tenant', 'description', 'comments') class RIRBulkEditForm(NetBoxModelBulkEditForm): @@ -103,15 +111,19 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ASN fieldsets = ( (None, ('sites', 'rir', 'tenant', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class AggregateBulkEditForm(NetBoxModelBulkEditForm): @@ -128,15 +140,19 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Aggregate fieldsets = ( (None, ('rir', 'tenant', 'date_added', 'description')), ) - nullable_fields = ('date_added', 'description') + nullable_fields = ('date_added', 'description', 'comments') class RoleBulkEditForm(NetBoxModelBulkEditForm): @@ -206,9 +222,13 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): label='Treat as 100% utilized' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Prefix fieldsets = ( @@ -217,7 +237,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), ) nullable_fields = ( - 'site', 'vrf', 'tenant', 'role', 'description', + 'site', 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -241,16 +261,20 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPRange fieldsets = ( (None, ('status', 'role', 'vrf', 'tenant', 'description')), ) nullable_fields = ( - 'vrf', 'tenant', 'role', 'description', + 'vrf', 'tenant', 'role', 'description', 'comments', ) @@ -285,9 +309,13 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): label='DNS name' ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = IPAddress fieldsets = ( @@ -295,7 +323,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ('Addressing', ('vrf', 'mask_length', 'dns_name')), ) nullable_fields = ( - 'vrf', 'role', 'tenant', 'dns_name', 'description', + 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments', ) @@ -329,13 +357,17 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = FHRPGroup fieldsets = ( (None, ('protocol', 'group_id', 'name', 'description')), ('Authentication', ('auth_type', 'auth_key')), ) - nullable_fields = ('auth_type', 'auth_key', 'name', 'description') + nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -405,9 +437,13 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = VLAN fieldsets = ( @@ -415,7 +451,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ('Site & Group', ('region', 'site_group', 'site', 'group')), ) nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', + 'site', 'group', 'tenant', 'role', 'description', 'comments', ) @@ -433,15 +469,19 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = ServiceTemplate fieldsets = ( (None, ('protocol', 'ports', 'description')), ) - nullable_fields = ('description',) + nullable_fields = ('description', 'comments') class ServiceBulkEditForm(ServiceTemplateBulkEditForm): @@ -459,15 +499,19 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) description = forms.CharField( - max_length=100, + max_length=200, required=False ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = L2VPN fieldsets = ( - (None, ('type', 'description', 'tenant')), + (None, ('type', 'tenant', 'description')), ) - nullable_fields = ('tenant', 'description',) + nullable_fields = ('tenant', 'description', 'comments') class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3aead6151..3a31b6757 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm): class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments') class RouteTargetCSVForm(NetBoxModelCSVForm): @@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): class Meta: model = RouteTarget - fields = ('name', 'description', 'tenant') + fields = ('name', 'tenant', 'description', 'comments') class RIRCSVForm(NetBoxModelCSVForm): @@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments') class ASNCSVForm(NetBoxModelCSVForm): @@ -101,7 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description') + fields = ('asn', 'rir', 'tenant', 'description', 'comments') help_texts = {} @@ -159,7 +159,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): model = Prefix fields = ( 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', + 'description', 'comments', ) def __init__(self, data=None, *args, **kwargs): @@ -204,7 +204,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): class Meta: model = IPRange fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', ) @@ -257,7 +257,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', + 'dns_name', 'description', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -326,7 +326,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments') class VLANGroupCSVForm(NetBoxModelCSVForm): @@ -389,7 +389,7 @@ class VLANCSVForm(NetBoxModelCSVForm): class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description') + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments') help_texts = { 'vid': 'Numeric VLAN ID (1-4094)', 'name': 'VLAN name', @@ -404,7 +404,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description') + fields = ('name', 'protocol', 'ports', 'description', 'comments') class ServiceCSVForm(NetBoxModelCSVForm): @@ -427,7 +427,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments') class L2VPNCSVForm(NetBoxModelCSVForm): @@ -443,7 +443,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments') class L2VPNTerminationCSVForm(NetBoxModelCSVForm): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 061462e71..9a5abc082 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation from utilities.forms import ( - add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface @@ -49,6 +49,7 @@ class VRFForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), @@ -59,8 +60,8 @@ class VRFForm(TenancyForm, NetBoxModelForm): class Meta: model = VRF fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', - 'tags', + 'name', 'rd', 'enforce_unique', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] labels = { 'rd': "RD", @@ -75,11 +76,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm): ('Route Target', ('name', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) + comments = CommentField() class Meta: model = RouteTarget fields = [ - 'name', 'description', 'tenant_group', 'tenant', 'tags', + 'name', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] @@ -104,6 +106,7 @@ class AggregateForm(TenancyForm, NetBoxModelForm): queryset=RIR.objects.all(), label='RIR' ) + comments = CommentField() fieldsets = ( ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), @@ -113,7 +116,7 @@ class AggregateForm(TenancyForm, NetBoxModelForm): class Meta: model = Aggregate fields = [ - 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', + 'prefix', 'rir', 'date_added', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] help_texts = { 'prefix': "IPv4 or IPv6 network", @@ -134,6 +137,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): label='Sites', required=False ) + comments = CommentField() fieldsets = ( ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), @@ -143,7 +147,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'comments', 'tags' ] help_texts = { 'asn': "AS number", @@ -235,6 +239,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), @@ -245,8 +250,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', - 'tenant_group', 'tenant', 'tags', + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', + 'description', 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -263,6 +268,7 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), @@ -272,7 +278,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): class Meta: model = IPRange fields = [ - 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -394,13 +401,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, label='Make this the primary IP for the device/VM' ) + comments = CommentField() class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', - 'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', - 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device', + 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect(), @@ -535,6 +543,7 @@ class FHRPGroupForm(NetBoxModelForm): required=False, label='Status' ) + comments = CommentField() fieldsets = ( ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')), @@ -545,7 +554,8 @@ class FHRPGroupForm(NetBoxModelForm): class Meta: model = FHRPGroup fields = ( - 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description', + 'comments', 'tags', ) def save(self, *args, **kwargs): @@ -767,11 +777,13 @@ class VLANForm(TenancyForm, NetBoxModelForm): queryset=Role.objects.all(), required=False ) + comments = CommentField() class Meta: model = VLAN fields = [ - 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', + 'tags', ] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", @@ -794,6 +806,7 @@ class ServiceTemplateForm(NetBoxModelForm): ), help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." ) + comments = CommentField() fieldsets = ( ('Service Template', ( @@ -803,7 +816,7 @@ class ServiceTemplateForm(NetBoxModelForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'tags') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') widgets = { 'protocol': StaticSelect(), } @@ -834,11 +847,12 @@ class ServiceForm(NetBoxModelForm): 'virtual_machine_id': '$virtual_machine', } ) + comments = CommentField() class Meta: model = Service fields = [ - 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', + 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " @@ -899,6 +913,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) + comments = CommentField() fieldsets = ( ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), @@ -909,7 +924,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', + 'comments', 'tags' ) widgets = { 'type': StaticSelect(), diff --git a/netbox/ipam/migrations/0063_standardize_description_comments.py b/netbox/ipam/migrations/0063_standardize_description_comments.py new file mode 100644 index 000000000..3a4959d14 --- /dev/null +++ b/netbox/ipam/migrations/0063_standardize_description_comments.py @@ -0,0 +1,73 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0062_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='asn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='fhrpgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='ipaddress', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='iprange', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='l2vpn', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='prefix', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='routetarget', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='service', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='servicetemplate', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vlan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='vrf', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 633affa41..759a6e1d3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from netbox.models import ChangeLoggedModel, NetBoxModel +from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,7 @@ __all__ = ( ) -class FHRPGroup(NetBoxModel): +class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ @@ -41,10 +41,6 @@ class FHRPGroup(NetBoxModel): blank=True, verbose_name='Authentication key' ) - description = models.CharField( - max_length=200, - blank=True - ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 75f90ff54..bf9bd6d7f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -76,7 +76,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -class ASN(NetBoxModel): +class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -86,10 +86,6 @@ class ASN(NetBoxModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) - description = models.CharField( - max_length=200, - blank=True - ) rir = models.ForeignKey( to='ipam.RIR', on_delete=models.PROTECT, @@ -139,7 +135,7 @@ class ASN(NetBoxModel): return self.asn -class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): +class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -162,10 +158,6 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'rir', 'tenant', 'date_added', 'description', @@ -264,7 +256,7 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -class Prefix(GetAvailablePrefixesMixin, NetBoxModel): +class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -327,10 +319,6 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): default=False, help_text="Treat as 100% utilized" ) - description = models.CharField( - max_length=200, - blank=True - ) # Cached depth & child counts _depth = models.PositiveSmallIntegerField( @@ -545,7 +533,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): return min(utilization, 100) -class IPRange(NetBoxModel): +class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. """ @@ -587,10 +575,6 @@ class IPRange(NetBoxModel): null=True, help_text='The primary function of this range' ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ( 'vrf', 'tenant', 'status', 'role', 'description', @@ -740,7 +724,7 @@ class IPRange(NetBoxModel): return int(float(child_count) / self.size * 100) -class IPAddress(NetBoxModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -813,10 +797,6 @@ class IPAddress(NetBoxModel): verbose_name='DNS Name', help_text='Hostname or FQDN (not case-sensitive)' ) - description = models.CharField( - max_length=200, - blank=True - ) objects = IPAddressManager() diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index a457f334b..f3f7a1d55 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -8,7 +8,7 @@ from django.utils.functional import cached_property from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS -from netbox.models import NetBoxModel +from netbox.models import NetBoxModel, PrimaryModel __all__ = ( 'L2VPN', @@ -16,7 +16,7 @@ __all__ = ( ) -class L2VPN(NetBoxModel): +class L2VPN(PrimaryModel): name = models.CharField( max_length=100, unique=True @@ -43,10 +43,6 @@ class L2VPN(NetBoxModel): related_name='exporting_l2vpns', blank=True ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index b566db375..690abf045 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -6,7 +6,7 @@ from django.urls import reverse from ipam.choices import * from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel from utilities.utils import array_to_string @@ -30,10 +30,6 @@ class ServiceBase(models.Model): ), verbose_name='Port numbers' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: abstract = True @@ -46,7 +42,7 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -class ServiceTemplate(ServiceBase, NetBoxModel): +class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. """ @@ -62,7 +58,7 @@ class ServiceTemplate(ServiceBase, NetBoxModel): return reverse('ipam:servicetemplate', args=[self.pk]) -class Service(ServiceBase, NetBoxModel): +class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index e3a4b973b..4f5d513cf 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -8,12 +8,10 @@ from django.urls import reverse from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface - __all__ = ( 'VLAN', 'VLANGroup', @@ -63,10 +61,6 @@ class VLANGroup(OrganizationalModel): ), help_text='Highest permissible ID of a child VLAN' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('name', 'pk') # Name may be non-unique @@ -120,7 +114,7 @@ class VLANGroup(OrganizationalModel): return None -class VLAN(NetBoxModel): +class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, @@ -172,10 +166,6 @@ class VLAN(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index a926bec3e..0f3c9793c 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -2,7 +2,7 @@ from django.db import models from django.urls import reverse from ipam.constants import * -from netbox.models import NetBoxModel +from netbox.models import PrimaryModel __all__ = ( @@ -11,7 +11,7 @@ __all__ = ( ) -class VRF(NetBoxModel): +class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -40,10 +40,6 @@ class VRF(NetBoxModel): verbose_name='Enforce unique space', help_text='Prevent duplicate prefixes/IP addresses within this VRF' ) - description = models.CharField( - max_length=200, - blank=True - ) import_targets = models.ManyToManyField( to='ipam.RouteTarget', related_name='importing_vrfs', @@ -73,7 +69,7 @@ class VRF(NetBoxModel): return reverse('ipam:vrf', args=[self.pk]) -class RouteTarget(NetBoxModel): +class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ @@ -82,10 +78,6 @@ class RouteTarget(NetBoxModel): unique=True, help_text='Route target value (formatted in accordance with RFC 4360)' ) - description = models.CharField( - max_length=200, - blank=True - ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index beffdd232..89aa16e65 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -20,7 +20,6 @@ class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) - comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -29,6 +28,7 @@ class FHRPGroupTable(NetBoxTable): member_count = tables.Column( verbose_name='Members' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) @@ -36,7 +36,7 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'comments', 'ip_addresses', 'member_count', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 44f40b8a1..f83831d2d 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -120,6 +120,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): linkify_item=True, verbose_name='Sites' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:asn_list' ) @@ -127,8 +128,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = ASN fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags', - 'created', 'last_updated', 'actions', + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', + 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') @@ -153,6 +154,7 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:aggregate_list' ) @@ -160,8 +162,8 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Aggregate fields = ( - 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -278,6 +280,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): accessor='get_utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:prefix_list' ) @@ -285,8 +288,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site', - 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', + 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -317,6 +321,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): accessor='utilization', orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:iprange_list' ) @@ -324,8 +329,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPRange fields = ( - 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description', - 'utilization', 'tags', 'created', 'last_updated', + 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', + 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -378,6 +383,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): linkify=lambda record: record.assigned_object.get_absolute_url(), verbose_name='Assigned' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) @@ -385,8 +391,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = IPAddress fields = ( - 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', + 'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 4a6af7c9b..2ece2c434 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -29,12 +29,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): template_code=L2VPN_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='ipam:prefix_list' + ) class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'actions', + 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'description', 'comments', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions') diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 58d0a9aff..826ac98d5 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -17,13 +17,16 @@ class ServiceTemplateTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:servicetemplate_list' ) class Meta(NetBoxTable.Meta): model = ServiceTemplate - fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'protocol', 'ports', 'description') @@ -39,6 +42,7 @@ class ServiceTable(NetBoxTable): accessor=tables.A('port_list'), order_by=tables.A('ports'), ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:service_list' ) @@ -46,7 +50,7 @@ class ServiceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Service fields = ( - 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index f183f8a7b..6fa2cd2da 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -121,6 +121,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): orderable=False, verbose_name='Prefixes' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vlan_list' ) @@ -129,7 +130,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', - 'description', 'tags', 'l2vpn', 'created', 'last_updated', + 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 69807410b..635af48d0 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -38,6 +38,7 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): template_code=VRF_TARGETS, orderable=False ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) @@ -45,8 +46,8 @@ class VRFTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'import_targets', 'export_targets', + 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -59,11 +60,14 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( linkify=True ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='ipam:vrf_list' ) class Meta(NetBoxTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',) + fields = ( + 'pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 38a6fcc9f..661470ee0 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -10,8 +10,9 @@ from netbox.models.features import * __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', - 'OrganizationalModel', 'NetBoxModel', + 'OrganizationalModel', + 'PrimaryModel', ) @@ -58,7 +59,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ - Primary models represent real objects within the infrastructure being modeled. + Base model for most object types. Suitable for use by plugins. """ objects = RestrictedQuerySet.as_manager() @@ -66,6 +67,22 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): abstract = True +class PrimaryModel(NetBoxModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + abstract = True + + class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 0fc18a368..51f911350 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -33,6 +33,10 @@ Account {{ object.account|placeholder }} + + Description + {{ object.description|placeholder }} + Circuits diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e032d7034..bd0f27106 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -32,6 +32,10 @@ Label {{ object.label|placeholder }} + + Description + {{ object.description|placeholder }} + Color @@ -57,6 +61,7 @@
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 29bb60d70..1c747b44b 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -80,6 +80,7 @@ {% render_field form.tenant_group %} {% render_field form.tenant %} {% render_field form.label %} + {% render_field form.description %} {% render_field form.color %}
    @@ -92,16 +93,22 @@
    {% render_field form.tags %} - {% if form.custom_fields %} -
    -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    - {% endif %}
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %}
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0cd76de4..046600d08 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -94,7 +94,11 @@ - Airflow + Description + {{ object.description|placeholder }} + + + Airflow {{ object.get_airflow_display|placeholder }} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 38125e83c..b814e65ef 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -10,6 +10,7 @@
    {% render_field form.name %} {% render_field form.device_role %} + {% render_field form.description %} {% render_field form.tags %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 458c74ac1..930390a56 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -27,6 +27,10 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Height (U) {{ object.u_height|floatformat }} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f2dac38f2..139ac2eb8 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -62,6 +62,10 @@ Module Type {{ object.module_type|linkify }} + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 8128e64be..fd0148c2f 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,10 @@ Part Number {{ object.part_number|placeholder }} + + Description + {{ object.description|placeholder }} + Weight diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 54ac96bab..6387c111d 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -38,6 +38,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Connected Device diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b7fe8eb39..16bd82cc0 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -14,26 +14,29 @@ {% block content %}
    -
    -
    - Power Panel -
    -
    - - - - - - - - - -
    Site{{ object.site|linkify }}
    Location{{ object.location|linkify|placeholder }}
    -
    -
    - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Power Panel
    +
    + + + + + + + + + + + + + +
    Site{{ object.site|linkify }}
    Location{{ object.location|linkify|placeholder }}
    Description{{ object.description|placeholder }}
    +
    + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 7118f09ef..185634e8a 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -78,6 +78,10 @@ Role {{ object.role|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Serial Number {{ object.serial|placeholder }} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index a0af20c68..d214bbee8 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -13,6 +13,7 @@ {% render_field form.name %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.description %} {% render_field form.tags %}
    diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ebdd1d845..52472e297 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -73,6 +73,7 @@
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 1ff9f2e9a..d0fba3ca2 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -27,11 +27,15 @@ Master {{ object.master|linkify }} + + Description + {{ object.description|placeholder }} +
    - {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
    @@ -73,6 +77,7 @@
    {% endif %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 87917f2a2..f98a9fe64 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -17,12 +17,18 @@ {% render_field vc_form.name %} {% render_field vc_form.domain %} + {% render_field vc_form.description %} {% render_field vc_form.master %} {% render_field vc_form.tags %} +
    +
    Comments
    + {% render_field vc_form.comments %} +
    + {% if vc_form.custom_fields %} -
    +
    Custom Fields
    diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index f3eff9df1..b95341b16 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -51,6 +51,7 @@
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 7afe981e6..3af5177cc 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -67,6 +67,7 @@
    {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 89fc7083c..a74ddac70 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -42,6 +42,7 @@ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index 02816b440..bf86e6c41 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -13,7 +13,7 @@ {% render_field form.tags %}
    -
    +
    Authentication
    @@ -22,7 +22,7 @@
    {% if not form.instance.pk %} -
    +
    Virtual IP Address
    @@ -32,6 +32,13 @@
    {% endif %} +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    + {% if form.custom_fields %}
    Custom Fields
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7f77e8137..4a110c2e6 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -108,6 +108,7 @@
    {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index f4b21397a..b9a988009 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -138,6 +138,13 @@
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    + {% if form.custom_fields %}
    diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index c78b5a132..6ba9e4bea 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -70,9 +70,10 @@ {% plugin_left_page object %}
    - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index c19363d33..4ffda2c98 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -39,6 +39,7 @@
    {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index b15aa60bb..a0baf3325 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -155,6 +155,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index e093aee61..ea7a98c97 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -26,6 +26,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 47ae70dc9..fdc4be342 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -58,9 +58,10 @@ {% plugin_left_page object %}
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html index 022821bcf..5c47dd2f8 100644 --- a/netbox/templates/ipam/service_create.html +++ b/netbox/templates/ipam/service_create.html @@ -65,6 +65,13 @@ {% render_field form.tags %}
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    + {% if form.custom_fields %}
    Custom Fields
    diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index f3e34a7d1..709d816c1 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -52,6 +52,13 @@ {% render_field form.tags %}
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    + {% if form.custom_fields %}
    Custom Fields
    diff --git a/netbox/templates/ipam/servicetemplate.html b/netbox/templates/ipam/servicetemplate.html index 6e2aacb34..afb4163b9 100644 --- a/netbox/templates/ipam/servicetemplate.html +++ b/netbox/templates/ipam/servicetemplate.html @@ -31,12 +31,13 @@
    {% plugin_left_page object %} - -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} -
    + +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
    diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 53bb75b8f..c0f68bae2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -74,9 +74,10 @@ {% plugin_left_page object %}
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
    diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 5aa577942..f4432efe3 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -55,6 +55,13 @@ {% endwith %}
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    + {% if form.custom_fields %}
    diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 831338600..b53862f9e 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -55,6 +55,7 @@
    {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8e71628e9..d92226137 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -63,6 +63,10 @@ {% endif %} + + Description + {{ object.description|placeholder }} + Assignments {{ assignment_count }} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index bc02424cc..510c5a48e 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -23,6 +23,10 @@ Status {% badge object.get_status_display bg_color=object.get_status_color %} + + Description + {{ object.description|placeholder }} + Group {{ object.group|linkify|placeholder }} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index c0e2ebd07..9d95b02ea 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -29,6 +29,10 @@ Platform {{ object.platform|linkify|placeholder }} + + Description + {{ object.description|placeholder }} + Tenant diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 9250ef7ef..19e8b930d 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -39,6 +39,7 @@
    {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index d1a93e40d..be98979c1 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -40,6 +40,7 @@
    {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
    diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html index 034d147de..462ae5148 100644 --- a/netbox/templates/wireless/wirelesslink_edit.html +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -22,6 +22,12 @@
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    {% if form.custom_fields %}
    diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index d2c6801c6..c8ef77117 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -85,8 +85,8 @@ class ContactSerializer(NetBoxModelSerializer): class Meta: model = Contact fields = [ - 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 4c1f03757..183a8e851 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import DynamicModelChoiceField +from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea __all__ = ( 'ContactBulkEditForm', @@ -101,9 +101,17 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): link = forms.URLField( required=False ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = Contact fieldsets = ( - (None, ('group', 'title', 'phone', 'email', 'address', 'link')), + (None, ('group', 'title', 'phone', 'email', 'address', 'link', 'description')), ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'comments') + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index d617a27b5..a465230c5 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments') diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 80af04928..b466c94b2 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -103,13 +103,13 @@ class ContactForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'tags')), + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags')), ) class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/migrations/0009_standardize_description_comments.py b/netbox/tenancy/migrations/0009_standardize_description_comments.py new file mode 100644 index 000000000..af93b055c --- /dev/null +++ b/netbox/tenancy/migrations/0009_standardize_description_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0008_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index ba937c167..4fa8d87cb 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -2,9 +2,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import WebhooksMixin from tenancy.choices import * @@ -41,7 +40,7 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -class Contact(NetBoxModel): +class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ @@ -73,9 +72,6 @@ class Contact(NetBoxModel): link = models.URLField( blank=True ) - comments = models.TextField( - blank=True - ) clone_fields = ( 'group', 'name', 'title', 'phone', 'email', 'address', 'link', diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index b76efcbf9..4c0c11e2a 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -1,9 +1,8 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from mptt.models import TreeForeignKey -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( 'Tenant', @@ -31,7 +30,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -class Tenant(NetBoxModel): +class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. @@ -51,13 +50,6 @@ class Tenant(NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) # Generic relations contacts = GenericRelation( diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 234dc2ad7..b66a1182f 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -65,8 +65,8 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'comments', 'assignment_count', 'tags', - 'created', 'last_updated', + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'assignment_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b88bc7712..bb4418b43 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -58,8 +58,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -84,8 +84,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer): model = VirtualMachine fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', - 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', + 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index b2429744b..a94b2da1c 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -84,6 +84,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): 'group_id': '$site_group', } ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -91,11 +95,11 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'status', 'tenant',)), - ('Site', ('region', 'site_group', 'site',)), + (None, ('type', 'group', 'status', 'tenant', 'description')), + ('Site', ('region', 'site_group', 'site')), ) nullable_fields = ( - 'group', 'site', 'comments', 'tenant', + 'group', 'site', 'tenant', 'description', 'comments', ) @@ -153,6 +157,10 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Disk (GB)' ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -160,11 +168,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 2d7ee52e2..d140197dd 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'comments', + 'description', 'comments', ) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 5438002b4..3f598d061 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -90,7 +90,7 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'description', 'tags')), ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -98,7 +98,8 @@ class ClusterForm(TenancyForm, NetBoxModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'description', 'comments', + 'tags', ) widgets = { 'status': StaticSelect(), @@ -220,9 +221,10 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + comments = CommentField() fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Virtual Machine', ('name', 'role', 'status', 'description', 'tags')), ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), @@ -234,7 +236,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): model = VirtualMachine fields = [ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', - 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data', ] help_texts = { diff --git a/netbox/virtualization/migrations/0034_standardize_description_comments.py b/netbox/virtualization/migrations/0034_standardize_description_comments.py new file mode 100644 index 000000000..8517adeca --- /dev/null +++ b/netbox/virtualization/migrations/0034_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0033_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='virtualmachine', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b859d25fe..b5129d581 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -10,7 +10,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import OrganizationalModel, NetBoxModel +from netbox.models import NetBoxModel, OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -64,7 +64,7 @@ class ClusterGroup(OrganizationalModel): # Clusters # -class Cluster(NetBoxModel): +class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -102,9 +102,6 @@ class Cluster(NetBoxModel): blank=True, null=True ) - comments = models.TextField( - blank=True - ) # Generic relations vlan_groups = GenericRelation( @@ -165,7 +162,7 @@ class Cluster(NetBoxModel): # Virtual machines # -class VirtualMachine(NetBoxModel, ConfigContextModel): +class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -262,9 +259,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): null=True, verbose_name='Disk (GB)' ) - comments = models.TextField( - blank=True - ) # Generic relation contacts = GenericRelation( diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index ae4c610d7..a3e67373d 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -86,7 +86,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', - 'vm_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments', + 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 29baff4cb..b1d44ad02 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -75,8 +75,8 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', - 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index d65511765..109c3a341 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -42,7 +42,7 @@ class WirelessLANSerializer(NetBoxModelSerializer): model = WirelessLAN fields = [ 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', - 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -59,5 +59,5 @@ class WirelessLinkSerializer(NetBoxModelSerializer): model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', - 'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 639a1ed1b..543e7e0b3 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, DynamicModelChoiceField +from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -52,9 +52,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -67,6 +64,14 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLAN fieldsets = ( @@ -74,7 +79,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) @@ -92,9 +97,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - required=False - ) auth_type = forms.ChoiceField( choices=add_blank_choice(WirelessAuthTypeChoices), required=False @@ -107,6 +109,14 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Pre-shared key' ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) model = WirelessLink fieldsets = ( @@ -114,5 +124,5 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) ) nullable_fields = ( - 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 6a1ca4f36..03ac997a3 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -60,7 +60,9 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ( + 'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', + ) class WirelessLinkCSVForm(NetBoxModelCSVForm): @@ -94,5 +96,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLink fields = ( - 'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', ) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 386484193..d57c74575 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -2,7 +2,7 @@ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect +from utilities.forms import CommentField, DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * __all__ = ( @@ -82,6 +82,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): 'group_id': '$vlan_group', } ) + comments = CommentField() fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), @@ -93,8 +94,8 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', - 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', 'tenant', + 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', ] widgets = { 'auth_type': StaticSelect, @@ -183,6 +184,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): disabled_indicator='_occupied', label='Interface' ) + comments = CommentField() fieldsets = ( ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), @@ -196,7 +198,8 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/migrations/0007_standardize_description_comments.py b/netbox/wireless/migrations/0007_standardize_description_comments.py new file mode 100644 index 000000000..e6e1ce8dd --- /dev/null +++ b/netbox/wireless/migrations/0007_standardize_description_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-11-03 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0006_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='wirelesslink', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index ee2744e40..96764b53c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,11 +2,11 @@ from django.apps import apps from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import NestedGroupModel, NetBoxModel +from netbox.models import NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -69,7 +69,7 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -98,10 +98,6 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) clone_fields = ('ssid', 'group', 'tenant', 'description') @@ -122,7 +118,7 @@ def get_wireless_interface_types(): return {'type__in': WIRELESS_IFACE_TYPES} -class WirelessLink(WirelessAuthenticationBase, NetBoxModel): +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ @@ -157,10 +153,6 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): blank=True, null=True ) - description = models.CharField( - max_length=200, - blank=True - ) # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their # associated Devices. diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index af0cdae88..4aa5cc1fd 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -21,6 +21,7 @@ class WirelessLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Wireless LANs' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) @@ -28,7 +29,8 @@ class WirelessLANGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLANGroup fields = ( - 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated', + 'actions', ) default_columns = ('pk', 'name', 'wirelesslan_count', 'description') @@ -43,6 +45,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): interface_count = tables.Column( verbose_name='Interfaces' ) + comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) @@ -50,8 +53,8 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'description', 'vlan', 'interface_count', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', + 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') From 271d524687728f803f8a55c918f77f87a93fed8f Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 2 Nov 2022 16:52:46 -0700 Subject: [PATCH 223/409] 10709 add AzureAD Tenant Oauth2 --- netbox/netbox/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a7e56e279..89d71b815 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -24,6 +24,7 @@ AUTH_BACKEND_ATTRS = { 'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'), 'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'), 'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), + 'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), 'bitbucket': ('BitBucket', 'bitbucket'), 'bitbucket-oauth2': ('BitBucket', 'bitbucket'), 'digitalocean': ('DigitalOcean', 'digital-ocean'), From cdeb65e2fb18fe473cffd493133ff13848fef996 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 4 Nov 2022 07:50:43 -0700 Subject: [PATCH 224/409] 7376 csv tags (#10802) * 7376 add tags to CSV import * 7376 change help text * 7376 validate tags * 7376 fix tests * 7376 add tag validation tests * Introduce CSVModelMultipleChoiceField for CSV import tag assignment * Clean up CSVImportTestCase Co-authored-by: jeremystretch --- netbox/circuits/forms/bulk_import.py | 8 +-- netbox/dcim/forms/bulk_import.py | 52 +++++++------- netbox/ipam/forms/bulk_import.py | 33 +++++---- netbox/netbox/forms/base.py | 10 ++- netbox/netbox/tests/test_import.py | 84 ++++++++++++++++++++++ netbox/tenancy/forms/bulk_import.py | 8 +-- netbox/utilities/forms/fields/csv.py | 16 ++++- netbox/virtualization/forms/bulk_import.py | 10 +-- netbox/wireless/forms/bulk_import.py | 5 +- 9 files changed, 165 insertions(+), 61 deletions(-) create mode 100644 netbox/netbox/tests/test_import.py diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d0bdb09a7..4976e2d9b 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'account', 'description', 'comments', + 'name', 'slug', 'account', 'description', 'comments', 'tags', ) @@ -32,7 +32,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'service_id', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags' ] @@ -41,7 +41,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm): class Meta: model = CircuitType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') help_texts = { 'name': 'Name of circuit type', } @@ -73,5 +73,5 @@ class CircuitCSVForm(NetBoxModelCSVForm): model = Circuit fields = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', - 'description', 'comments', + 'description', 'comments', 'tags' ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4c90c9c02..2b77ef5a9 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -56,7 +56,7 @@ class RegionCSVForm(NetBoxModelCSVForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class SiteGroupCSVForm(NetBoxModelCSVForm): @@ -100,7 +100,7 @@ class SiteCSVForm(NetBoxModelCSVForm): model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags' ) help_texts = { 'time_zone': mark_safe( @@ -137,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -145,7 +145,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm): class Meta: model = RackRole - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'description', 'tags') help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -197,7 +197,7 @@ class RackCSVForm(NetBoxModelCSVForm): fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - 'description', 'comments', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -241,7 +241,7 @@ class RackReservationCSVForm(NetBoxModelCSVForm): class Meta: model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -264,7 +264,7 @@ class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class DeviceRoleCSVForm(NetBoxModelCSVForm): @@ -272,7 +272,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm): class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'description') + fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -289,7 +289,7 @@ class PlatformCSVForm(NetBoxModelCSVForm): class Meta: model = Platform - fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') + fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') class BaseDeviceCSVForm(NetBoxModelCSVForm): @@ -388,7 +388,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'description', 'comments', + 'cluster', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -425,7 +425,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): class Meta: model = Module fields = ( - 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -452,7 +452,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags' ] def __init__(self, data=None, *args, **kwargs): @@ -503,7 +503,7 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): class Meta: model = ConsolePort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsoleServerPortCSVForm(NetBoxModelCSVForm): @@ -526,7 +526,7 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): class Meta: model = ConsoleServerPort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') class PowerPortCSVForm(NetBoxModelCSVForm): @@ -543,7 +543,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPort fields = ( - 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', 'tags' ) @@ -571,7 +571,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): class Meta: model = PowerOutlet - fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description') + fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -659,7 +659,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm): fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', - 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -702,7 +702,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): model = FrontPort fields = ( 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', - 'description', + 'description', 'tags' ) help_texts = { 'rear_port_position': 'Mapped position on corresponding rear port', @@ -743,7 +743,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): class Meta: model = RearPort - fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description') + fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags') help_texts = { 'positions': 'Number of front ports which may be mapped' } @@ -757,7 +757,7 @@ class ModuleBayCSVForm(NetBoxModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'position', 'description') + fields = ('device', 'name', 'label', 'position', 'description', 'tags') class DeviceBayCSVForm(NetBoxModelCSVForm): @@ -777,7 +777,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): class Meta: model = DeviceBay - fields = ('device', 'name', 'label', 'installed_device', 'description') + fields = ('device', 'name', 'label', 'installed_device', 'description', 'tags') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -832,7 +832,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): model = InventoryItem fields = ( 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags' ) def __init__(self, *args, **kwargs): @@ -928,7 +928,7 @@ class CableCSVForm(NetBoxModelCSVForm): model = Cable fields = [ 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', + 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), @@ -985,7 +985,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualChassis - fields = ('name', 'domain', 'master', 'description') + fields = ('name', 'domain', 'master', 'description', 'comments', 'tags') # @@ -1006,7 +1006,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): class Meta: model = PowerPanel - fields = ('site', 'location', 'name', 'description', 'comments') + fields = ('site', 'location', 'name', 'description', 'comments', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1062,7 +1062,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'description', 'comments', + 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3a31b6757..4cd0bb69f 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -41,7 +41,7 @@ class VRFCSVForm(NetBoxModelCSVForm): class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments') + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') class RouteTargetCSVForm(NetBoxModelCSVForm): @@ -54,7 +54,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): class Meta: model = RouteTarget - fields = ('name', 'tenant', 'description', 'comments') + fields = ('name', 'tenant', 'description', 'comments', 'tags') class RIRCSVForm(NetBoxModelCSVForm): @@ -62,7 +62,7 @@ class RIRCSVForm(NetBoxModelCSVForm): class Meta: model = RIR - fields = ('name', 'slug', 'is_private', 'description') + fields = ('name', 'slug', 'is_private', 'description', 'tags') help_texts = { 'name': 'RIR name', } @@ -83,7 +83,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): class Meta: model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments') + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') class ASNCSVForm(NetBoxModelCSVForm): @@ -101,8 +101,7 @@ class ASNCSVForm(NetBoxModelCSVForm): class Meta: model = ASN - fields = ('asn', 'rir', 'tenant', 'description', 'comments') - help_texts = {} + fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags') class RoleCSVForm(NetBoxModelCSVForm): @@ -110,7 +109,7 @@ class RoleCSVForm(NetBoxModelCSVForm): class Meta: model = Role - fields = ('name', 'slug', 'weight', 'description') + fields = ('name', 'slug', 'weight', 'description', 'tags') class PrefixCSVForm(NetBoxModelCSVForm): @@ -159,7 +158,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): model = Prefix fields = ( 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', 'comments', + 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -204,7 +203,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): class Meta: model = IPRange fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', 'comments', 'tags', ) @@ -257,7 +256,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', 'comments', + 'dns_name', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -326,7 +325,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments', 'tags') class VLANGroupCSVForm(NetBoxModelCSVForm): @@ -351,7 +350,7 @@ class VLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags') labels = { 'scope_id': 'Scope ID', } @@ -389,7 +388,7 @@ class VLANCSVForm(NetBoxModelCSVForm): class Meta: model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments') + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') help_texts = { 'vid': 'Numeric VLAN ID (1-4094)', 'name': 'VLAN name', @@ -404,7 +403,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'comments') + fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') class ServiceCSVForm(NetBoxModelCSVForm): @@ -427,7 +426,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments') + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') class L2VPNCSVForm(NetBoxModelCSVForm): @@ -443,7 +442,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments') + fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') class L2VPNTerminationCSVForm(NetBoxModelCSVForm): @@ -480,7 +479,7 @@ class L2VPNTerminationCSVForm(NetBoxModelCSVForm): class Meta: model = L2VPNTermination - fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan') + fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 564e254a3..4a4368a65 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -1,12 +1,13 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin from extras.models import CustomField, Tag from utilities.forms import BootstrapMixin, CSVModelForm -from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField __all__ = ( 'NetBoxModelForm', @@ -61,7 +62,12 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ - tags = None # Temporary fix in lieu of tag import support (see #9158) + tags = CSVModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + to_field_name='slug', + help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")' + ) def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).filter( diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py new file mode 100644 index 000000000..73f2e0e27 --- /dev/null +++ b/netbox/netbox/tests/test_import.py @@ -0,0 +1,84 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings + +from dcim.models import * +from users.models import ObjectPermission +from utilities.testing import ModelViewTestCase, create_tags + + +class CSVImportTestCase(ModelViewTestCase): + model = Region + + @classmethod + def setUpTestData(cls): + create_tags('Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo') + + def _get_csv_data(self, csv_data): + return '\n'.join(csv_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_valid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"alpha,bravo"', + 'Region 2,region-2,"charlie,delta"', + 'Region 3,region-3,echo', + 'Region 4,region-4,', + ) + + data = { + 'csv': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + regions = Region.objects.all() + self.assertEqual(regions.count(), 4) + region = Region.objects.get(slug="region-4") + self.assertEqual( + list(regions[0].tags.values_list('name', flat=True)), + ['Alpha', 'Bravo'] + ) + self.assertEqual( + list(regions[1].tags.values_list('name', flat=True)), + ['Charlie', 'Delta'] + ) + self.assertEqual( + list(regions[2].tags.values_list('name', flat=True)), + ['Echo'] + ) + self.assertEqual(regions[3].tags.count(), 0) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_invalid_tags(self): + csv_data = ( + 'name,slug,tags', + 'Region 1,region-1,"Alpha,Bravo"', # Valid + 'Region 2,region-2,"Alpha,Tango"', # Invalid + ) + + data = { + 'csv': self._get_csv_data(csv_data), + } + + # Assign model-level permission + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(Region.objects.count(), 0) diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index a465230c5..137f79d42 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -26,7 +26,7 @@ class TenantGroupCSVForm(NetBoxModelCSVForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class TenantCSVForm(NetBoxModelCSVForm): @@ -40,7 +40,7 @@ class TenantCSVForm(NetBoxModelCSVForm): class Meta: model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments') + fields = ('name', 'slug', 'group', 'description', 'comments', 'tags') # @@ -58,7 +58,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class ContactRoleCSVForm(NetBoxModelCSVForm): @@ -79,4 +79,4 @@ class ContactCSVForm(NetBoxModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index 275c8084c..59765cae8 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -16,6 +16,7 @@ __all__ = ( 'CSVDataField', 'CSVFileField', 'CSVModelChoiceField', + 'CSVModelMultipleChoiceField', 'CSVMultipleChoiceField', 'CSVMultipleContentTypeField', 'CSVTypedChoiceField', @@ -142,7 +143,7 @@ class CSVModelChoiceField(forms.ModelChoiceField): Extends Django's `ModelChoiceField` to provide additional validation for CSV values. """ default_error_messages = { - 'invalid_choice': 'Object not found.', + 'invalid_choice': 'Object not found: %(value)s', } def to_python(self, value): @@ -154,6 +155,19 @@ class CSVModelChoiceField(forms.ModelChoiceField): ) +class CSVModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + Extends Django's `ModelMultipleChoiceField` to support comma-separated values. + """ + default_error_messages = { + 'invalid_choice': 'Object not found: %(value)s', + } + + def clean(self, value): + value = value.split(',') if value else [] + return super().clean(value) + + class CSVContentTypeField(CSVModelChoiceField): """ CSV field for referencing a single content type, in the form `.`. diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d140197dd..6fc704ae4 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -21,7 +21,7 @@ class ClusterTypeCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterType - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterGroupCSVForm(NetBoxModelCSVForm): @@ -29,7 +29,7 @@ class ClusterGroupCSVForm(NetBoxModelCSVForm): class Meta: model = ClusterGroup - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'tags') class ClusterCSVForm(NetBoxModelCSVForm): @@ -63,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -114,7 +114,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'comments', + 'description', 'comments', 'tags', ) @@ -151,7 +151,7 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vrf', + 'vrf', 'tags' ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 03ac997a3..00078c8eb 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -25,7 +25,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'tags') class WirelessLANCSVForm(NetBoxModelCSVForm): @@ -62,6 +62,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): model = WirelessLAN fields = ( 'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', + 'tags', ) @@ -97,5 +98,5 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): model = WirelessLink fields = ( 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', - 'comments', + 'comments', 'tags', ) From ad40d42dc467940b27021104a8beaee3cce1afaa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 13:40:39 -0400 Subject: [PATCH 225/409] Closes #10710: Add status field to WirelessLAN --- docs/models/wireless/wirelesslan.md | 7 +++ netbox/templates/wireless/wirelesslan.html | 4 ++ netbox/wireless/api/serializers.py | 5 +- netbox/wireless/choices.py | 18 +++++++ netbox/wireless/filtersets.py | 3 ++ netbox/wireless/forms/bulk_edit.py | 6 ++- netbox/wireless/forms/bulk_import.py | 8 ++- netbox/wireless/forms/filtersets.py | 7 ++- netbox/wireless/forms/model_forms.py | 6 +-- .../migrations/0008_wirelesslan_status.py | 18 +++++++ netbox/wireless/models.py | 8 +++ netbox/wireless/tables/wirelesslan.py | 7 +-- netbox/wireless/tests/test_api.py | 10 ++-- netbox/wireless/tests/test_filtersets.py | 52 ++++++++++++++++--- netbox/wireless/tests/test_views.py | 31 ++++++++--- 15 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 netbox/wireless/migrations/0008_wirelesslan_status.py diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 5bb3dbd65..0f50fa75f 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -12,6 +12,13 @@ The service set identifier (SSID) for the wireless network. The [wireless LAN group](./wirelesslangroup.md) to which this wireless LAN is assigned (if any). +### Status + +The operational status of the wireless network. + +!!! tip + Additional statuses may be defined by setting `WirelessLAN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + ### VLAN Each wireless LAN can optionally be mapped to a [VLAN](../ipam/vlan.md), to model a bridge between wired and wireless segments. diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 19e8b930d..ad76f9c07 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -18,6 +18,10 @@ Group {{ object.group|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Description {{ object.description|placeholder }} diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 109c3a341..cc2c8701c 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -33,6 +33,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + status = ChoiceField(choices=WirelessLANStatusChoices, required=False, allow_blank=True) vlan = NestedVLANSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) @@ -41,8 +42,8 @@ class WirelessLANSerializer(NetBoxModelSerializer): class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', - 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant', 'auth_type', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 135fa1b0c..b1f283620 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -1,3 +1,5 @@ +from django.utils.translation import gettext as _ + from utilities.choices import ChoiceSet @@ -11,6 +13,22 @@ class WirelessRoleChoices(ChoiceSet): ) +class WirelessLANStatusChoices(ChoiceSet): + key = 'WirelessLANS.status' + + STATUS_ACTIVE = 'active' + STATUS_RESERVED = 'reserved' + STATUS_DISABLED = 'disabled' + STATUS_DEPRECATED = 'deprecated' + + CHOICES = [ + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_RESERVED, _('Reserved'), 'cyan'), + (STATUS_DISABLED, _('Disabled'), 'orange'), + (STATUS_DEPRECATED, _('Deprecated'), 'red'), + ] + + class WirelessChannelChoices(ChoiceSet): # 2.4 GHz diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 60c4f935b..6ffb9cb91 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -43,6 +43,9 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): lookup_expr='in', to_field_name='slug' ) + status = django_filters.MultipleChoiceFilter( + choices=WirelessLANStatusChoices + ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all() ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 543e7e0b3..7544327a5 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -34,6 +34,10 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + choices=add_blank_choice(WirelessLANStatusChoices), + required=False + ) group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -75,7 +79,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( - (None, ('group', 'ssid', 'vlan', 'tenant', 'description')), + (None, ('group', 'ssid', 'status', 'vlan', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 00078c8eb..4d96f60ad 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -35,6 +35,10 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Assigned group' ) + status = CSVChoiceField( + choices=WirelessLANStatusChoices, + help_text='Operational status' + ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -61,8 +65,8 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN fields = ( - 'ssid', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', - 'tags', + 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', + 'comments', 'tags', ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index d7a6aac6e..c3e63687d 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -29,7 +29,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( (None, ('q', 'filter', 'tag')), - ('Attributes', ('ssid', 'group_id',)), + ('Attributes', ('ssid', 'group_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) @@ -43,6 +43,11 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): null_option='None', label=_('Group') ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessLANStatusChoices), + widget=StaticSelect() + ) auth_type = forms.ChoiceField( required=False, choices=add_blank_choice(WirelessAuthTypeChoices), diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index d57c74575..e59c36696 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -37,7 +37,6 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): queryset=WirelessLANGroup.objects.all(), required=False ) - region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -85,7 +84,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'group', 'status', 'description', 'tags')), ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), @@ -94,10 +93,11 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', 'tenant', + 'ssid', 'group', 'region', 'site_group', 'site', 'status', 'vlan_group', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', ] widgets = { + 'status': StaticSelect, 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, } diff --git a/netbox/wireless/migrations/0008_wirelesslan_status.py b/netbox/wireless/migrations/0008_wirelesslan_status.py new file mode 100644 index 000000000..e7832aba2 --- /dev/null +++ b/netbox/wireless/migrations/0008_wirelesslan_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-04 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0007_standardize_description_comments'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 96764b53c..5858e641c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -84,6 +84,11 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=WirelessLANStatusChoices, + default=WirelessLANStatusChoices.STATUS_ACTIVE + ) vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.PROTECT, @@ -111,6 +116,9 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): def get_absolute_url(self): return reverse('wireless:wirelesslan', args=[self.pk]) + def get_status_color(self): + return WirelessLANStatusChoices.colors.get(self.status) + def get_wireless_interface_types(): # Wrap choices in a callable to avoid generating dummy migrations diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 4aa5cc1fd..5d17465f0 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -42,6 +42,7 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() interface_count = tables.Column( verbose_name='Interfaces' ) @@ -53,10 +54,10 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', - 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', + 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') + default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count') class WirelessLANInterfacesTable(NetBoxTable): diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 9ef552eb7..cfc17c660 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -68,9 +68,9 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): group.save() wireless_lans = ( - WirelessLAN(ssid='WLAN1'), - WirelessLAN(ssid='WLAN2'), - WirelessLAN(ssid='WLAN3'), + WirelessLAN(ssid='WLAN1', status=WirelessLANStatusChoices.STATUS_ACTIVE), + WirelessLAN(ssid='WLAN2', status=WirelessLANStatusChoices.STATUS_ACTIVE), + WirelessLAN(ssid='WLAN3', status=WirelessLANStatusChoices.STATUS_ACTIVE), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -78,23 +78,27 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): { 'ssid': 'WLAN4', 'group': groups[0].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, }, { 'ssid': 'WLAN5', 'group': groups[1].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, }, { 'ssid': 'WLAN6', + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, }, ] cls.bulk_update_data = { + 'status': WirelessLANStatusChoices.STATUS_DEPRECATED, 'group': groups[2].pk, 'tenant': tenants[1].pk, 'description': 'New description', diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index ffe919c32..0629fea07 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -64,9 +64,18 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): groups = ( - WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), - WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), - WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + WirelessLANGroup( + name='Wireless LAN Group 1', + slug='wireless-lan-group-1' + ), + WirelessLANGroup( + name='Wireless LAN Group 2', + slug='wireless-lan-group-2' + ), + WirelessLANGroup( + name='Wireless LAN Group 3', + slug='wireless-lan-group-3' + ), ) for group in groups: group.save() @@ -86,9 +95,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) wireless_lans = ( - WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], tenant=tenants[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), - WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], tenant=tenants[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), - WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], tenant=tenants[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + WirelessLAN( + ssid='WLAN1', + group=groups[0], + status=WirelessLANStatusChoices.STATUS_ACTIVE, + vlan=vlans[0], + tenant=tenants[0], + auth_type=WirelessAuthTypeChoices.TYPE_OPEN, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, + auth_psk='PSK1' + ), + WirelessLAN( + ssid='WLAN2', + group=groups[1], + status=WirelessLANStatusChoices.STATUS_DISABLED, + vlan=vlans[1], + tenant=tenants[1], + auth_type=WirelessAuthTypeChoices.TYPE_WEP, + auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, + auth_psk='PSK2' + ), + WirelessLAN( + ssid='WLAN3', + group=groups[2], + status=WirelessLANStatusChoices.STATUS_RESERVED, + vlan=vlans[2], + tenant=tenants[2], + auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, + auth_psk='PSK3' + ), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -103,6 +139,10 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [WirelessLANStatusChoices.STATUS_ACTIVE, WirelessLANStatusChoices.STATUS_DISABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vlan(self): vlans = VLAN.objects.all()[:2] params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 615678a62..62c3b451f 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -70,9 +70,24 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): group.save() wireless_lans = ( - WirelessLAN(group=groups[0], ssid='WLAN1', tenant=tenants[0]), - WirelessLAN(group=groups[0], ssid='WLAN2', tenant=tenants[0]), - WirelessLAN(group=groups[0], ssid='WLAN3', tenant=tenants[0]), + WirelessLAN( + group=groups[0], + ssid='WLAN1', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), + WirelessLAN( + group=groups[0], + ssid='WLAN2', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), + WirelessLAN( + group=groups[0], + ssid='WLAN3', + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0] + ), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -81,15 +96,16 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'ssid': 'WLAN2', 'group': groups[1].pk, + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - f"group,ssid,tenant", - f"Wireless LAN Group 2,WLAN4,{tenants[0].name}", - f"Wireless LAN Group 2,WLAN5,{tenants[1].name}", - f"Wireless LAN Group 2,WLAN6,{tenants[2].name}", + f"group,ssid,status,tenant", + f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}", + f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}", + f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}", ) cls.csv_update_data = ( @@ -100,6 +116,7 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { + 'status': WirelessLANStatusChoices.STATUS_DISABLED, 'description': 'New description', } From f68c7fb18830dce03df92aa7f36a42230f9565dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 13:46:15 -0400 Subject: [PATCH 226/409] Changelog for #7376, #10710 --- docs/release-notes/version-3.4.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 158e7a77f..69009ea8d 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -26,6 +26,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements +* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types * [#8485](https://github.com/netbox-community/netbox/issues/8485) - Enable journaling for all organizational models @@ -40,6 +41,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields * [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns +* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN * [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types * [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11 @@ -129,6 +131,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * virtualization.VirtualMachine * Added a `description` field * wireless.WirelessLAN + * Added a required `status` choice field * Added a `comments` field * wireless.WirelessLink * Added a `comments` field From fe73e90b7b19637be39e5001c35d38099a8dbc6a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 13:49:31 -0400 Subject: [PATCH 227/409] Reorganize virtualization models --- netbox/virtualization/models/__init__.py | 2 + netbox/virtualization/models/clusters.py | 135 ++++++++++++++++ .../{models.py => models/virtualmachines.py} | 151 +----------------- 3 files changed, 141 insertions(+), 147 deletions(-) create mode 100644 netbox/virtualization/models/__init__.py create mode 100644 netbox/virtualization/models/clusters.py rename netbox/virtualization/{models.py => models/virtualmachines.py} (75%) diff --git a/netbox/virtualization/models/__init__.py b/netbox/virtualization/models/__init__.py new file mode 100644 index 000000000..dc1e7eb20 --- /dev/null +++ b/netbox/virtualization/models/__init__.py @@ -0,0 +1,2 @@ +from .clusters import * +from .virtualmachines import * diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py new file mode 100644 index 000000000..e7c1294c2 --- /dev/null +++ b/netbox/virtualization/models/clusters.py @@ -0,0 +1,135 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse + +from dcim.models import Device +from netbox.models import OrganizationalModel, PrimaryModel +from virtualization.choices import * + +__all__ = ( + 'Cluster', + 'ClusterGroup', + 'ClusterType', +) + + +class ClusterType(OrganizationalModel): + """ + A type of Cluster. + """ + def get_absolute_url(self): + return reverse('virtualization:clustertype', args=[self.pk]) + + +class ClusterGroup(OrganizationalModel): + """ + An organizational group of Clusters. + """ + # Generic relations + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster_group' + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + def get_absolute_url(self): + return reverse('virtualization:clustergroup', args=[self.pk]) + + +class Cluster(PrimaryModel): + """ + A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. + """ + name = models.CharField( + max_length=100 + ) + type = models.ForeignKey( + to=ClusterType, + on_delete=models.PROTECT, + related_name='clusters' + ) + group = models.ForeignKey( + to=ClusterGroup, + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + + # Generic relations + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster' + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + clone_fields = ( + 'type', 'group', 'status', 'tenant', 'site', + ) + + class Meta: + ordering = ['name'] + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), + ) + + def __str__(self): + return self.name + + @classmethod + def get_prerequisite_models(cls): + return [ClusterType, ] + + def get_absolute_url(self): + return reverse('virtualization:cluster', args=[self.pk]) + + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + + # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. + if self.pk and self.site: + nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + if nonsite_devices: + raise ValidationError({ + 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( + nonsite_devices, self.site + ) + }) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models/virtualmachines.py similarity index 75% rename from netbox/virtualization/models.py rename to netbox/virtualization/models/virtualmachines.py index b5129d581..d64289eb2 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -6,162 +6,23 @@ from django.db.models import Q from django.db.models.functions import Lower from django.urls import reverse -from dcim.models import BaseInterface, Device +from dcim.models import BaseInterface from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import NetBoxModel, OrganizationalModel, PrimaryModel +from netbox.models import NetBoxModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar -from .choices import * +from virtualization.choices import * +from .clusters import Cluster __all__ = ( - 'Cluster', - 'ClusterGroup', - 'ClusterType', 'VirtualMachine', 'VMInterface', ) -# -# Cluster types -# - -class ClusterType(OrganizationalModel): - """ - A type of Cluster. - """ - def get_absolute_url(self): - return reverse('virtualization:clustertype', args=[self.pk]) - - -# -# Cluster groups -# - -class ClusterGroup(OrganizationalModel): - """ - An organizational group of Clusters. - """ - # Generic relations - vlan_groups = GenericRelation( - to='ipam.VLANGroup', - content_type_field='scope_type', - object_id_field='scope_id', - related_query_name='cluster_group' - ) - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - def get_absolute_url(self): - return reverse('virtualization:clustergroup', args=[self.pk]) - - -# -# Clusters -# - -class Cluster(PrimaryModel): - """ - A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. - """ - name = models.CharField( - max_length=100 - ) - type = models.ForeignKey( - to=ClusterType, - on_delete=models.PROTECT, - related_name='clusters' - ) - group = models.ForeignKey( - to=ClusterGroup, - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - status = models.CharField( - max_length=50, - choices=ClusterStatusChoices, - default=ClusterStatusChoices.STATUS_ACTIVE - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='clusters', - blank=True, - null=True - ) - - # Generic relations - vlan_groups = GenericRelation( - to='ipam.VLANGroup', - content_type_field='scope_type', - object_id_field='scope_id', - related_query_name='cluster' - ) - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - clone_fields = ( - 'type', 'group', 'status', 'tenant', 'site', - ) - - class Meta: - ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - models.UniqueConstraint( - fields=('site', 'name'), - name='%(app_label)s_%(class)s_unique_site_name' - ), - ) - - def __str__(self): - return self.name - - @classmethod - def get_prerequisite_models(cls): - return [ClusterType, ] - - def get_absolute_url(self): - return reverse('virtualization:cluster', args=[self.pk]) - - def get_status_color(self): - return ClusterStatusChoices.colors.get(self.status) - - def clean(self): - super().clean() - - # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. - if self.pk and self.site: - nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() - if nonsite_devices: - raise ValidationError({ - 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( - nonsite_devices, self.site - ) - }) - - -# -# Virtual machines -# - class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. @@ -357,10 +218,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): return None -# -# Interfaces -# - class VMInterface(NetBoxModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', From ea6d86e6c4bb6037465410db6205a7471bc81a6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 14:53:18 -0400 Subject: [PATCH 228/409] Closes #10052: The cf attribute now returns deserialized custom field data --- docs/release-notes/version-3.4.md | 2 + netbox/extras/tests/test_customfields.py | 12 ++--- netbox/netbox/models/features.py | 57 +++++++++++++++++++----- netbox/netbox/search/__init__.py | 2 +- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 69009ea8d..ce3edc07c 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -9,6 +9,7 @@ * The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. * The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. +* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead. ### New Features @@ -37,6 +38,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations * [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups +* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c6ba96a82..7e7eaeda0 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1022,7 +1022,7 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Check custom field data on new instance - site.cf['foo'] = 'abc' + site.custom_field_data['foo'] = 'abc' self.assertEqual(site.cf['foo'], 'abc') # Check custom field data from database @@ -1037,12 +1037,12 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Set custom field data - site.cf['foo'] = 'abc' - site.cf['bar'] = 'def' + site.custom_field_data['foo'] = 'abc' + site.custom_field_data['bar'] = 'def' with self.assertRaises(ValidationError): site.clean() - del site.cf['bar'] + del site.custom_field_data['bar'] site.clean() def test_missing_required_field(self): @@ -1056,11 +1056,11 @@ class CustomFieldModelTest(TestCase): site = Site(name='Test Site', slug='test-site') # Set custom field data with a required field omitted - site.cf['foo'] = 'abc' + site.custom_field_data['foo'] = 'abc' with self.assertRaises(ValidationError): site.clean() - site.cf['baz'] = 'def' + site.custom_field_data['baz'] = 'def' site.clean() diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index f59e72c14..8e5af0ab5 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,4 +1,5 @@ from collections import defaultdict +from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared @@ -133,18 +134,35 @@ class CustomFieldsMixin(models.Model): class Meta: abstract = True - @property + @cached_property def cf(self): """ - A pass-through convenience alias for accessing `custom_field_data` (read-only). + Return a dictionary mapping each custom field for this instance to its deserialized value. ```python >>> tenant = Tenant.objects.first() >>> tenant.cf - {'cust_id': 'CYB01'} + {'primary_site': , 'cust_id': 'DMI01', 'is_active': True} ``` """ - return self.custom_field_data + return { + cf.name: cf.deserialize(self.custom_field_data.get(cf.name)) + for cf in self.custom_fields + } + + @cached_property + def custom_fields(self): + """ + Return the QuerySet of CustomFields assigned to this model. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.custom_fields + , , ]> + ``` + """ + from extras.models import CustomField + return CustomField.objects.get_for_model(self) def get_custom_fields(self, omit_hidden=False): """ @@ -155,10 +173,13 @@ class CustomFieldsMixin(models.Model): >>> tenant.get_custom_fields() {: 'CYB01'} ``` + + Args: + omit_hidden: If True, custom fields with no UI visibility will be omitted. """ from extras.models import CustomField - data = {} + for field in CustomField.objects.get_for_model(self): # Skip fields that are hidden if 'omit_hidden' is set if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: @@ -172,12 +193,28 @@ class CustomFieldsMixin(models.Model): def get_custom_fields_by_group(self): """ Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. - """ - grouped_custom_fields = defaultdict(dict) - for cf, value in self.get_custom_fields(omit_hidden=True).items(): - grouped_custom_fields[cf.group_name][cf] = value - return dict(grouped_custom_fields) + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields_by_group() + { + '': {: }, + 'Billing': {: 'DMI01', : True} + } + ``` + """ + from extras.models import CustomField + groups = defaultdict(dict) + visible_custom_fields = CustomField.objects.get_for_model(self).exclude( + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ) + + for cf in visible_custom_fields: + value = self.custom_field_data.get(cf.name) + value = cf.deserialize(value) + groups[cf.group_name][cf] = value + + return dict(groups) def clean(self): super().clean() diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index c05a2492b..82fff68c6 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -82,7 +82,7 @@ class SearchIndex: # Capture custom fields if getattr(instance, 'custom_field_data', None): if custom_fields is None: - custom_fields = instance.get_custom_fields().keys() + custom_fields = instance.custom_fields for cf in custom_fields: type_ = cf.search_type value = instance.custom_field_data.get(cf.name) From 43da786016f5bfb1f58026d620106fc24aa38ecb Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 4 Nov 2022 11:00:35 -0700 Subject: [PATCH 229/409] 10829 fix top edit selected button --- netbox/templates/generic/object_list.html | 99 ++++++++++++----------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 60eba6097..272c1598e 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -67,64 +67,65 @@ Context: {% applied_filters filter_form request.GET %} {% endif %} - {# "Select all" form #} - {% if table.paginator.num_pages > 1 %} -
    -
    - {% csrf_token %} -
    -
    + + {% csrf_token %} + {# "Select all" form #} + {% if table.paginator.num_pages > 1 %} +
    +
    +
    +
    + {% if 'bulk_edit' in actions %} + {% bulk_edit_button model query_params=request.GET %} + {% endif %} + {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
    +
    + + +
    +
    +
    +
    + {% endif %} + + {# Object table controls #} + {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %} + +
    + {% csrf_token %} + + + {# Object table #} + + {% if prerequisite_model %} + {% include 'inc/missing_prerequisites.html' %} + {% endif %} + +
    +
    + {% include 'htmx/table.html' %} +
    +
    + + {# Form buttons #} +
    +
    + {% block bulk_buttons %} {% if 'bulk_edit' in actions %} {% bulk_edit_button model query_params=request.GET %} {% endif %} {% if 'bulk_delete' in actions %} {% bulk_delete_button model query_params=request.GET %} {% endif %} -
    -
    - - -
    + {% endblock %}
    - -
    - {% endif %} - - {# Object table controls #} - {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %} - -
    - {% csrf_token %} - - - {# Object table #} - - {% if prerequisite_model %} - {% include 'inc/missing_prerequisites.html' %} - {% endif %} - -
    -
    - {% include 'htmx/table.html' %}
    - - {# Form buttons #} -
    -
    - {% block bulk_buttons %} - {% if 'bulk_edit' in actions %} - {% bulk_edit_button model query_params=request.GET %} - {% endif %} - {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} - {% endblock %} -
    -
    -
    From 93e241e8f3e1b3002eaf14615863eda16500dca1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Nov 2022 16:56:52 -0400 Subject: [PATCH 230/409] Changelog for #10709, #10829 --- docs/release-notes/version-3.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a693ec1e0..4b058c6ca 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,11 @@ ## v3.3.8 (FUTURE) +### Bug Fixes + +* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend +* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists + --- ## v3.3.7 (2022-11-01) From 93e7457e0d84ad24cba22cc5c0811777ddebf94e Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 10 Nov 2022 08:01:52 -0800 Subject: [PATCH 231/409] 4347 Add JSON/YAML import support for all objects (#10367) * 4347 initial code for json import * 4347 initial code for json import * Clean up form processing logic * Consolidate import forms * Consolidate object import/update logic * Clean up bulk import view Co-authored-by: jeremystretch --- netbox/dcim/views.py | 11 +- netbox/extras/tests/test_customfields.py | 2 +- netbox/ipam/tests/test_views.py | 22 +- netbox/netbox/tests/test_import.py | 7 +- netbox/netbox/views/generic/bulk_views.py | 207 ++++++++------- netbox/netbox/views/generic/object_views.py | 146 +---------- netbox/templates/generic/bulk_import.html | 268 +++++++++++--------- netbox/utilities/forms/choices.py | 17 ++ netbox/utilities/forms/forms.py | 117 ++++++--- netbox/utilities/testing/views.py | 13 +- 10 files changed, 386 insertions(+), 424 deletions(-) create mode 100644 netbox/utilities/forms/choices.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 13e8354aa..437162bce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -807,7 +807,7 @@ class RackReservationImportView(generic.BulkImportView): model_form = forms.RackReservationCSVForm table = tables.RackReservationTable - def _save_obj(self, obj_form, request): + def save_object(self, obj_form, request): """ Assign the currently authenticated user to the RackReservation. """ @@ -1082,7 +1082,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): ) -class DeviceTypeImportView(generic.ObjectImportView): +class DeviceTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', @@ -1098,6 +1098,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm + table = tables.DeviceTypeTable related_object_forms = { 'console-ports': forms.ConsolePortTemplateImportForm, 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, @@ -1267,7 +1268,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView): ) -class ModuleTypeImportView(generic.ObjectImportView): +class ModuleTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_moduletype', 'dcim.add_consoleporttemplate', @@ -1280,6 +1281,7 @@ class ModuleTypeImportView(generic.ObjectImportView): ] queryset = ModuleType.objects.all() model_form = forms.ModuleTypeImportForm + table = tables.ModuleTypeTable related_object_forms = { 'console-ports': forms.ConsolePortTemplateImportForm, 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, @@ -2026,8 +2028,7 @@ class ChildDeviceBulkImportView(generic.BulkImportView): table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' - def _save_obj(self, obj_form, request): - + def save_object(self, obj_form, request): obj = obj_form.save() # Save the reverse relation to the parent device bay diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7e7eaeda0..2f3c7932a 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -935,7 +935,7 @@ class CustomFieldImportTest(TestCase): ) csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) + response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'}) self.assertEqual(response.status_code, 200) self.assertEqual(Site.objects.count(), 3) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 25b8af9ae..8bf19ebfa 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -920,7 +920,11 @@ class L2VPNTerminationTestCase( def setUpTestData(cls): device = create_test_device('Device 1') interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') - l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002), + ) + L2VPN.objects.bulk_create(l2vpns) vlans = ( VLAN(name='Vlan 1', vid=1001), @@ -933,14 +937,14 @@ class L2VPNTerminationTestCase( VLAN.objects.bulk_create(vlans) terminations = ( - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) L2VPNTermination.objects.bulk_create(terminations) cls.form_data = { - 'l2vpn': l2vpn.pk, + 'l2vpn': l2vpns[0].pk, 'device': device.pk, 'interface': interface.pk, } @@ -953,10 +957,10 @@ class L2VPNTerminationTestCase( ) cls.csv_update_data = ( - "id,l2vpn", - f"{terminations[0].pk},L2VPN 2", - f"{terminations[1].pk},L2VPN 2", - f"{terminations[2].pk},L2VPN 2", + f"id,l2vpn", + f"{terminations[0].pk},{l2vpns[0].name}", + f"{terminations[1].pk},{l2vpns[0].name}", + f"{terminations[2].pk},{l2vpns[0].name}", ) cls.bulk_edit_data = {} diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index 73f2e0e27..b6f732bfe 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -3,6 +3,7 @@ from django.test import override_settings from dcim.models import * from users.models import ObjectPermission +from utilities.forms.choices import ImportFormatChoices from utilities.testing import ModelViewTestCase, create_tags @@ -27,7 +28,8 @@ class CSVImportTestCase(ModelViewTestCase): ) data = { - 'csv': self._get_csv_data(csv_data), + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), } # Assign model-level permission @@ -67,7 +69,8 @@ class CSVImportTestCase(ModelViewTestCase): ) data = { - 'csv': self._get_csv_data(csv_data), + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), } # Assign model-level permission diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5ab9e6da0..1a83c9de2 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,23 +4,22 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput +from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe from django_tables2.export import TableExport -from extras.models import ExportTemplate, SavedFilter +from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, PermissionsViolation -from utilities.forms import ( - BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, -) +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields +from utilities.forms.choices import ImportFormatChoices from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin @@ -295,109 +294,136 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ template_name = 'generic/bulk_import.html' model_form = None + related_object_forms = dict() - def _import_form(self, *args, **kwargs): + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') - class ImportForm(BootstrapMixin, Form): - csv = CSVDataField( - from_form=self.model_form - ) - csv_file = CSVFileField( - label="CSV file", - from_form=self.model_form, - required=False - ) + def prep_related_object_data(self, parent, data): + """ + Hook to modify the data for related objects before it's passed to the related object form (for example, to + assign a parent object). + """ + return data - def clean(self): - csv_rows = self.cleaned_data['csv'][1] if 'csv' in self.cleaned_data else None - csv_file = self.files.get('csv_file') + def _save_object(self, model_form, request): - # Check that the user has not submitted both text data and a file - if csv_rows and csv_file: - raise ValidationError( - "Cannot process CSV text and file attachment simultaneously. Please choose only one import " - "method." - ) + # Save the primary object + obj = self.save_object(model_form, request) - return ImportForm(*args, **kwargs) + # Enforce object-level permissions + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() - def _get_records(self, form, request): - if request.FILES: - headers, records = form.cleaned_data['csv_file'] - else: - headers, records = form.cleaned_data['csv'] + # Iterate through the related object forms (if any), validating and saving each instance. + for field_name, related_object_form in self.related_object_forms.items(): - return headers, records + related_obj_pks = [] + for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): + rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) + f = related_object_form(rel_obj_data) - def _update_objects(self, form, request, headers, records): - updated_objs = [] + for subfield_name, field in f.fields.items(): + if subfield_name not in rel_obj_data and hasattr(field, 'initial'): + f.data[subfield_name] = field.initial - ids = [int(record["id"]) for record in records] - qs = self.queryset.model.objects.filter(id__in=ids) - objs = {} - for obj in qs: - objs[obj.id] = obj + if f.is_valid(): + related_obj = f.save() + related_obj_pks.append(related_obj.pk) + else: + # Replicate errors on the related object form to the primary form for display + for subfield_name, errors in f.errors.items(): + for err in errors: + err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) + model_form.add_error(None, err_msg) + raise AbortTransaction() - for row, data in enumerate(records, start=1): - if int(data["id"]) not in objs: - form.add_error('csv', f'Row {row} id: {data["id"]} Does not exist') - raise ValidationError("") + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist - obj = objs[int(data["id"])] - obj_form = self.model_form(data, headers=headers, instance=obj) + return obj - # The form should only contain fields that are in the CSV - for name, field in list(obj_form.fields.items()): - if name not in headers: - del obj_form.fields[name] - - restrict_form_fields(obj_form, request.user) - - if obj_form.is_valid(): - obj = self._save_obj(obj_form, request) - updated_objs.append(obj) - else: - for field, err in obj_form.errors.items(): - form.add_error('csv', f'Row {row} {field}: {err[0]}') - raise ValidationError("") - - return updated_objs - - def _create_objects(self, form, request, headers, records): - new_objs = [] - - for row, data in enumerate(records, start=1): - obj_form = self.model_form(data, headers=headers) - restrict_form_fields(obj_form, request.user) - - if obj_form.is_valid(): - obj = self._save_obj(obj_form, request) - new_objs.append(obj) - else: - for field, err in obj_form.errors.items(): - form.add_error('csv', f'Row {row} {field}: {err[0]}') - raise ValidationError("") - - return new_objs - - def _save_obj(self, obj_form, request): + def save_object(self, obj_form, request): """ Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). """ return obj_form.save() - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') + def create_and_update_objects(self, form, request): + saved_objects = [] + + records = list(form.cleaned_data['data']) + + # Prefetch objects to be updated, if any + prefetch_ids = [int(record['id']) for record in records if record.get('id')] + prefetched_objects = { + obj.pk: obj + for obj in self.queryset.model.objects.filter(id__in=prefetch_ids) + } if prefetch_ids else {} + + for i, record in enumerate(records, start=1): + instance = None + object_id = int(record.pop('id')) if record.get('id') else None + + # Determine whether this object is being created or updated + if object_id: + try: + instance = prefetched_objects[object_id] + except KeyError: + form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist") + raise ValidationError('') + + if form.cleaned_data['format'] == ImportFormatChoices.CSV: + model_form = self.model_form(record, instance=instance, headers=form._csv_headers) + else: + model_form = self.model_form(record, instance=instance) + # Assign default values for any fields which were not specified. + # We have to do this manually because passing 'initial=' to the form + # on initialization merely sets default values for the widgets. + # Since widgets are not used for YAML/JSON import, we first bind the + # imported data normally, then update the form's data with the applicable + # field defaults as needed prior to form validation. + for field_name, field in model_form.fields.items(): + if field_name not in record and hasattr(field, 'initial'): + model_form.data[field_name] = field.initial + + # When updating, omit all form fields other than those specified in the record. (No + # fields are required when modifying an existing object.) + if object_id: + unused_fields = [f for f in model_form.fields if f not in record] + for field_name in unused_fields: + del model_form.fields[field_name] + + restrict_form_fields(model_form, request.user) + + if model_form.is_valid(): + obj = self._save_object(model_form, request) + saved_objects.append(obj) + else: + # Replicate model form errors for display + for field, errors in model_form.errors.items(): + for err in errors: + if field == '__all__': + form.add_error(None, f'Record {i}: {err}') + else: + form.add_error(None, f'Record {i} {field}: {err}') + + raise ValidationError("") + + return saved_objects # # Request handlers # def get(self, request): + form = ImportForm() return render(request, self.template_name, { 'model': self.model_form._meta.model, - 'form': self._import_form(), + 'form': form, 'fields': self.model_form().fields, 'return_url': self.get_return_url(request), **self.get_extra_context(request), @@ -405,19 +431,16 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): def post(self, request): logger = logging.getLogger('netbox.views.BulkImportView') - form = self._import_form(request.POST, request.FILES) + + form = ImportForm(request.POST, request.FILES) if form.is_valid(): - logger.debug("Form validation was successful") + logger.debug("Import form validation was successful") try: - # Iterate through CSV data and bind each row to a new model form instance. + # Iterate through data and bind each record to a new model form instance. with transaction.atomic(): - headers, records = self._get_records(form, request) - if "id" in headers: - new_objs = self._update_objects(form, request, headers, records) - else: - new_objs = self._create_objects(form, request, headers, records) + new_objs = self.create_and_update_objects(form, request) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0d122a41a..738d70786 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -2,7 +2,6 @@ import logging from copy import deepcopy from django.contrib import messages -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.shortcuts import redirect, render @@ -12,8 +11,8 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation -from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields +from utilities.exceptions import AbortRequest, PermissionsViolation +from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields @@ -27,7 +26,6 @@ __all__ = ( 'ObjectChildrenView', 'ObjectDeleteView', 'ObjectEditView', - 'ObjectImportView', 'ObjectView', ) @@ -151,146 +149,6 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): }) -class ObjectImportView(GetReturnURLMixin, BaseObjectView): - """ - Import a single object (YAML or JSON format). - - Attributes: - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - """ - template_name = 'generic/object_import.html' - model_form = None - related_object_forms = dict() - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - - def prep_related_object_data(self, parent, data): - """ - Hook to modify the data for related objects before it's passed to the related object form (for example, to - assign a parent object). - """ - return data - - def _create_object(self, model_form): - - # Save the primary object - obj = model_form.save() - - # Enforce object-level permissions - if not self.queryset.filter(pk=obj.pk).exists(): - raise PermissionsViolation() - - # Iterate through the related object forms (if any), validating and saving each instance. - for field_name, related_object_form in self.related_object_forms.items(): - - related_obj_pks = [] - for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): - rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) - f = related_object_form(rel_obj_data) - - for subfield_name, field in f.fields.items(): - if subfield_name not in rel_obj_data and hasattr(field, 'initial'): - f.data[subfield_name] = field.initial - - if f.is_valid(): - related_obj = f.save() - related_obj_pks.append(related_obj.pk) - else: - # Replicate errors on the related object form to the primary form for display - for subfield_name, errors in f.errors.items(): - for err in errors: - err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) - model_form.add_error(None, err_msg) - raise AbortTransaction() - - # Enforce object-level permissions on related objects - model = related_object_form.Meta.model - if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): - raise ObjectDoesNotExist - - return obj - - # - # Request handlers - # - - def get(self, request): - form = ImportForm() - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - def post(self, request): - logger = logging.getLogger('netbox.views.ObjectImportView') - form = ImportForm(request.POST) - - if form.is_valid(): - logger.debug("Import form validation was successful") - - # Initialize model form - data = form.cleaned_data['data'] - model_form = self.model_form(data) - restrict_form_fields(model_form, request.user) - - # Assign default values for any fields which were not specified. We have to do this manually because passing - # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not - # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the - # applicable field defaults as needed prior to form validation. - for field_name, field in model_form.fields.items(): - if field_name not in data and hasattr(field, 'initial'): - model_form.data[field_name] = field.initial - - if model_form.is_valid(): - - try: - with transaction.atomic(): - obj = self._create_object(model_form) - - except AbortTransaction: - clear_webhooks.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) - clear_webhooks.send(sender=self) - - if not model_form.errors: - logger.info(f"Import object {obj} (PK: {obj.pk})") - msg = f'Imported object: {obj}' - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - - self.get_return_url(request, obj) - return redirect(self.get_return_url(request, obj)) - - else: - logger.debug("Model form validation failed") - - # Replicate model form errors for display - for field, errors in model_form.errors.items(): - for err in errors: - if field == '__all__': - form.add_error(None, err) - else: - form.add_error(None, "{}: {}".format(field, err)) - - else: - logger.debug("Import form validation failed") - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ Create or edit a single object. diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 1d638cb2c..4ddfb884c 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -15,142 +15,160 @@ Context: {% block tabs %} {% endblock tabs %} {% block content-wrapper %}
    - {% block content %} -
    -
    - -
    - {% csrf_token %} -
    -
    - {% render_field form.csv %} + + {# Data Import Form #} +
    + {% block content %} +
    +
    + + {% csrf_token %} + {% render_field form.data %} + {% render_field form.format %} +
    +
    + + {% if return_url %} + Cancel + {% endif %} +
    -
    - {% render_field form.csv_file %} + +
    +
    + {% endblock content %} +
    + + {# File Upload Form #} +
    +
    +
    + {% csrf_token %} + {% render_field form.data_file %} + {% render_field form.format %} +
    +
    + + {% if return_url %} + Cancel + {% endif %} +
    -
    -
    -
    - - {% if return_url %} - Cancel - {% endif %} -
    -
    - - {% if fields %} -
    -
    -
    -
    - CSV Field Options -
    -
    - - - - - - - - {% for name, field in fields.items %} - - - - - + + {% endfor %} +
    FieldRequiredAccessorDescription
    - {{ name }} - - {% if field.required %} - {% checkmark True true="Required" %} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if field.to_field_name %} - {{ field.to_field_name }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% if field.STATIC_CHOICES %} - - + + + {% if fields %} +
    +
    +
    +
    + Field Options +
    +
    + + + + + + + + {% for name, field in fields.items %} + + + + + - - {% endfor %} -
    FieldRequiredAccessorDescription
    + {% if field.required %}{% endif %}{{ name }}{% if field.required %}{% endif %} + + {% if field.required %} + {% checkmark True true="Required" %} + {% else %} + {{ ''|placeholder }} + {% endif %} + + {% if field.to_field_name %} + {{ field.to_field_name }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + {% if field.STATIC_CHOICES %} + +
    -
    -
    -
    + +
    + + + {% endif %} + {% if field.help_text %} + {{ field.help_text }}
    + {% elif field.label %} + {{ field.label }}
    + {% endif %} + {% if field|widget_type == 'dateinput' %} + Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} + Specify "true" or "false" + {% endif %} +
    -

    - Required fields must be specified for all - objects. -

    -

    - Related objects may be referenced by any unique attribute. - For example, vrf.rd would identify a VRF by its route distinguisher. -

    - {% endif %}
    - {% endblock content %} +
    +

    + Required fields must be specified for all + objects. +

    +

    + Related objects may be referenced by any unique attribute. + For example, vrf.rd would identify a VRF by its route distinguisher. +

    + {% endif %} +
    {% endblock content-wrapper %} diff --git a/netbox/utilities/forms/choices.py b/netbox/utilities/forms/choices.py new file mode 100644 index 000000000..bf0ea5f94 --- /dev/null +++ b/netbox/utilities/forms/choices.py @@ -0,0 +1,17 @@ +from utilities.choices import ChoiceSet + + +# +# Import Choices +# + +class ImportFormatChoices(ChoiceSet): + CSV = 'csv' + JSON = 'json' + YAML = 'yaml' + + CHOICES = [ + (CSV, 'CSV'), + (JSON, 'JSON'), + (YAML, 'YAML'), + ] diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 8ad6f103b..0569853b8 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -1,12 +1,15 @@ +import csv import json import re +from io import StringIO import yaml from django import forms +from utilities.forms.utils import parse_csv +from .choices import ImportFormatChoices from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect - __all__ = ( 'BootstrapMixin', 'BulkEditForm', @@ -120,64 +123,94 @@ class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. """ - - def __init__(self, *args, headers=None, **kwargs): + def __init__(self, *args, headers=None, fields=None, **kwargs): + headers = headers or {} + fields = fields or [] super().__init__(*args, **kwargs) # Modify the model form to accommodate any customized to_field_name properties - if headers: - for field, to_field in headers.items(): - if to_field is not None: - self.fields[field].to_field_name = to_field + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + # Omit any fields not specified (e.g. because the form is being used to + # updated rather than create objects) + if fields: + for field in list(self.fields.keys()): + if field not in fields: + del self.fields[field] class ImportForm(BootstrapMixin, forms.Form): - """ - Generic form for creating an object from JSON/YAML data - """ data = forms.CharField( + required=False, widget=forms.Textarea(attrs={'class': 'font-monospace'}), - help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." + help_text="Enter object data in CSV, JSON or YAML format." ) + data_file = forms.FileField( + label="Data file", + required=False + ) + # TODO: Enable auto-detection of format format = forms.ChoiceField( - choices=( - ('json', 'JSON'), - ('yaml', 'YAML') - ), - initial='yaml' + choices=ImportFormatChoices, + initial=ImportFormatChoices.CSV, + widget=StaticSelect() ) + data_field = 'data' + def clean(self): super().clean() - - data = self.cleaned_data['data'] format = self.cleaned_data['format'] - # Process JSON/YAML data - if format == 'json': - try: - self.cleaned_data['data'] = json.loads(data) - # Check for multiple JSON objects - if type(self.cleaned_data['data']) is not dict: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - except json.decoder.JSONDecodeError as err: - raise forms.ValidationError({ - 'data': "Invalid JSON data: {}".format(err) - }) + # Determine whether we're reading from form data or an uploaded file + if self.cleaned_data['data'] and self.cleaned_data['data_file']: + raise forms.ValidationError("Form data must be empty when uploading a file.") + if 'data_file' in self.files: + self.data_field = 'data_file' + file = self.files.get('data_file') + data = file.read().decode('utf-8') else: - # Check for multiple YAML documents - if '\n---' in data: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - try: - self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) - except yaml.error.YAMLError as err: - raise forms.ValidationError({ - 'data': "Invalid YAML data: {}".format(err) - }) + data = self.cleaned_data['data'] + + # Process data according to the selected format + if format == ImportFormatChoices.CSV: + self.cleaned_data['data'] = self._clean_csv(data) + elif format == ImportFormatChoices.JSON: + self.cleaned_data['data'] = self._clean_json(data) + elif format == ImportFormatChoices.YAML: + self.cleaned_data['data'] = self._clean_yaml(data) + + def _clean_csv(self, data): + stream = StringIO(data.strip()) + reader = csv.reader(stream) + headers, records = parse_csv(reader) + + # Set CSV headers for reference by the model form + self._csv_headers = headers + + return records + + def _clean_json(self, data): + try: + data = json.loads(data) + # Accommodate for users entering single objects + if type(data) is not list: + data = [data] + return data + except json.decoder.JSONDecodeError as err: + raise forms.ValidationError({ + self.data_field: f"Invalid JSON data: {err}" + }) + + def _clean_yaml(self, data): + try: + return yaml.load_all(data, Loader=yaml.SafeLoader) + except yaml.error.YAMLError as err: + raise forms.ValidationError({ + self.data_field: f"Invalid YAML data: {err}" + }) class FilterForm(BootstrapMixin, forms.Form): diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index f51893f74..5e1e207cc 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -9,6 +9,7 @@ from django.urls import reverse from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange from users.models import ObjectPermission +from utilities.forms.choices import ImportFormatChoices from .base import ModelTestCase from .utils import disable_warnings, post_data @@ -555,7 +556,8 @@ class ViewTestCases: def test_bulk_import_objects_without_permission(self): data = { - 'csv': self._get_csv_data(), + 'data': self._get_csv_data(), + 'format': 'csv', } # Test GET without permission @@ -571,7 +573,8 @@ class ViewTestCases: def test_bulk_import_objects_with_permission(self): initial_count = self._get_queryset().count() data = { - 'csv': self._get_csv_data(), + 'data': self._get_csv_data(), + 'format': 'csv', } # Assign model-level permission @@ -598,7 +601,8 @@ class ViewTestCases: initial_count = self._get_queryset().count() array, csv_data = self._get_update_csv_data() data = { - 'csv': csv_data, + 'format': ImportFormatChoices.CSV, + 'data': csv_data, } # Assign model-level permission @@ -630,7 +634,8 @@ class ViewTestCases: def test_bulk_import_objects_with_constrained_permission(self): initial_count = self._get_queryset().count() data = { - 'csv': self._get_csv_data(), + 'data': self._get_csv_data(), + 'format': 'csv', } # Assign constrained permission From 653acbf62cc487a32cff4b432ac0176f26163697 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 10 Nov 2022 11:05:44 -0500 Subject: [PATCH 232/409] #4347: Changelog & cleanup --- docs/release-notes/version-3.4.md | 4 ++++ netbox/netbox/views/generic/bulk_views.py | 19 +++++++------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index ce3edc07c..d6b26c4eb 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -17,6 +17,10 @@ NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. +### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347)) + +NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well. + #### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961)) NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects. diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 1a83c9de2..86ffbf224 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -375,19 +375,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist") raise ValidationError('') + # Instantiate the model form for the object + model_form_kwargs = { + 'data': record, + 'instance': instance, + } if form.cleaned_data['format'] == ImportFormatChoices.CSV: - model_form = self.model_form(record, instance=instance, headers=form._csv_headers) - else: - model_form = self.model_form(record, instance=instance) - # Assign default values for any fields which were not specified. - # We have to do this manually because passing 'initial=' to the form - # on initialization merely sets default values for the widgets. - # Since widgets are not used for YAML/JSON import, we first bind the - # imported data normally, then update the form's data with the applicable - # field defaults as needed prior to form validation. - for field_name, field in model_form.fields.items(): - if field_name not in record and hasattr(field, 'initial'): - model_form.data[field_name] = field.initial + model_form_kwargs['headers'] = form._csv_headers + model_form = self.model_form(**model_form_kwargs) # When updating, omit all form fields other than those specified in the record. (No # fields are required when modifying an existing object.) From 33d8f8e5e782d789aa35b329229115b94d818611 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 10 Nov 2022 13:19:43 -0800 Subject: [PATCH 233/409] 10874 remove link to contact roles (#10879) --- netbox/templates/tenancy/contactrole.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 37ef5e936..85b78578a 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -25,7 +25,7 @@ Assignments - {{ assignment_count }} + {{ assignment_count }} From c854c290160e0a02181d0e36f7bbc941ddbb8ded Mon Sep 17 00:00:00 2001 From: Brian Candler Date: Thu, 10 Nov 2022 21:23:05 +0000 Subject: [PATCH 234/409] Fix broken cookie paths when BASE_PATH is set (introduced in #10706) (#10856) Fixes #10837 --- netbox/netbox/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c941f0672..4ff440c46 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -81,11 +81,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [] BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') -CSRF_COOKIE_PATH = BASE_PATH or '/' CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') @@ -130,8 +130,6 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') -SESSION_COOKIE_PATH = BASE_PATH or '/' -LANGUAGE_COOKIE_PATH = BASE_PATH or '/' SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') From 3d1501e8fdd8e051a4574fe890c504122aa0e6f1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 10 Nov 2022 16:33:34 -0500 Subject: [PATCH 235/409] Changelog for #10837, #10874 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4b058c6ca..2df69c32c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,8 @@ * [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend * [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists +* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set +* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count --- From d59d23e3083b0c8c8e10b20553042f450798c639 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 10 Nov 2022 16:47:17 -0500 Subject: [PATCH 236/409] Fixes #10881: Fix dark mode coloring for data on device status page --- docs/release-notes/version-3.3.md | 1 + netbox/templates/dcim/device/status.html | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 2df69c32c..f23b03a25 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -8,6 +8,7 @@ * [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists * [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set * [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count +* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page --- diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html index a668ebf1e..51dd7d27e 100644 --- a/netbox/templates/dcim/device/status.html +++ b/netbox/templates/dcim/device/status.html @@ -64,19 +64,19 @@
    Environment
    - + - + - + - + - + From b374351154e26ac10721cd0a816bd591735522ac Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 11 Nov 2022 06:55:49 -0600 Subject: [PATCH 237/409] Closes: #7854 - Add VDC/Instances/etc (#10787) * Work on #7854 * Move to new URL scheme. * Fix PEP8 errors * Fix PEP8 errors * Add GraphQL and fix primary_ip missing * Fix PEP8 on GQL Type * Fix missing NestedSerializer. * Fix missing NestedSerializer & rename VDC to VDCs * Fix migration * Change Validation for identifier * Fix missing migration * Rebase to feature * Post-review changes * Remove VDC Type * Remove M2M Enforcement logic * Interface related changes * Add filter fields to filterset for Interface filter * Add form field to filterset form for Interface filter * Add VDC display to interface detail template * Remove VirtualDeviceContextTypeChoices * Accommodate recent changes in feature branch * Add tests Add missing search() * Update tests, and fix model form * Update test_api * Update test_api.InterfaceTest create_data * Fix issue with tests * Update interface serializer * Update serializer and tests * Update status to be required * Remove error message for constraint * Remove extraneous import * Re-ordered devices menu to place VDC below virtual chassis * Add helptext for `identifier` field * Fix breadcrumb link * Remove add interface link * Add missing tenant and status fields * Changes to tests as per Jeremy * Change for #9623 Co-authored-by: Jeremy Stretch * Update filterset form for status field * Remove Rename View * Change tabs to spaces * Update netbox/dcim/tables/devices.py Co-authored-by: Jeremy Stretch * Update netbox/dcim/tables/devices.py Co-authored-by: Jeremy Stretch * Fix tenant in bulk_edit * Apply suggestions from code review Co-authored-by: Jeremy Stretch * Add status field to table. * Re-order table fields. Co-authored-by: Jeremy Stretch --- netbox/dcim/api/nested_serializers.py | 10 ++ netbox/dcim/api/serializers.py | 36 ++++-- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 8 ++ netbox/dcim/choices.py | 17 +++ netbox/dcim/filtersets.py | 58 +++++++++- netbox/dcim/forms/bulk_edit.py | 22 ++++ netbox/dcim/forms/bulk_import.py | 23 ++++ netbox/dcim/forms/filtersets.py | 43 ++++++- netbox/dcim/forms/model_forms.py | 84 +++++++++++++- netbox/dcim/graphql/schema.py | 4 + netbox/dcim/graphql/types.py | 8 ++ .../migrations/0166_virtualdevicecontext.py | 54 +++++++++ netbox/dcim/models/device_components.py | 4 + netbox/dcim/models/devices.py | 81 ++++++++++++- netbox/dcim/tables/devices.py | 41 +++++++ netbox/dcim/tests/test_api.py | 63 ++++++++++ netbox/dcim/tests/test_filtersets.py | 109 +++++++++++++++++- netbox/dcim/tests/test_models.py | 47 ++++++++ netbox/dcim/tests/test_views.py | 45 ++++++++ netbox/dcim/urls.py | 8 ++ netbox/dcim/views.py | 61 ++++++++++ netbox/netbox/navigation/menu.py | 1 + netbox/templates/dcim/interface.html | 1 + .../templates/dcim/virtualdevicecontext.html | 68 +++++++++++ netbox/templates/tenancy/tenant.html | 4 + netbox/tenancy/views.py | 3 +- 27 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 netbox/dcim/migrations/0166_virtualdevicecontext.py create mode 100644 netbox/templates/dcim/virtualdevicecontext.html diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index f5e06e155..29881a548 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -45,6 +45,7 @@ __all__ = [ 'NestedSiteSerializer', 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', + 'NestedVirtualDeviceContextSerializer', ] @@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): class Meta: model = models.PowerFeed fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] + + +class NestedVirtualDeviceContextSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') + device = NestedDeviceSerializer() + + class Meta: + model = models.VirtualDeviceContext + fields = ['id', 'url', 'display', 'name', 'identifier', 'device'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9317d7c51..33d79612a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -671,6 +671,22 @@ class DeviceSerializer(NetBoxModelSerializer): return data +class VirtualDeviceContextSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + device = NestedDeviceSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + class ModuleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer() @@ -823,6 +839,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + vdcs = SerializedPKRelatedField( + queryset=VirtualDeviceContext.objects.all(), + serializer=NestedVirtualDeviceContextSerializer, + required=False, + many=True + ) module = ComponentNestedModuleSerializer( required=False, allow_null=True @@ -859,13 +881,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', - 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', - 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', + 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', + 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', + 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 47bbfd525..2e16e2786 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('vdcs', views.VirtualDeviceContextViewSet) router.register('modules', views.ModuleViewSet) # Device components diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c18eab01f..3c5a3171f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -538,6 +538,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return Response(response) +class VirtualDeviceContextViewSet(NetBoxModelViewSet): + queryset = VirtualDeviceContext.objects.prefetch_related( + 'device__device_type', 'device', 'tenant', 'tags', + ) + serializer_class = serializers.VirtualDeviceContextSerializer + filterset_class = filtersets.VirtualDeviceContextFilterSet + + class ModuleViewSet(NetBoxModelViewSet): queryset = Module.objects.prefetch_related( 'device', 'module_bay', 'module_type__manufacturer', 'tags', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8466d4861..ce637fb3d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1399,3 +1399,20 @@ class PowerFeedPhaseChoices(ChoiceSet): (PHASE_SINGLE, 'Single phase'), (PHASE_3PHASE, 'Three-phase'), ) + + +# +# VDC +# +class VirtualDeviceContextStatusChoices(ChoiceSet): + key = 'VirtualDeviceContext.status' + + STATUS_PLANNED = 'planned' + STATUS_ACTIVE = 'active' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 78afd816c..88d84a7ab 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -65,6 +65,7 @@ __all__ = ( 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', + 'VirtualDeviceContextFilterSet', ) @@ -482,7 +483,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', ] def search(self, queryset, name, value): @@ -1009,6 +1010,44 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.exclude(devicebays__isnull=value) +class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='VDC (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='Device model', + ) + status = django_filters.MultipleChoiceFilter( + choices=VirtualDeviceContextStatusChoices + ) + has_primary_ip = django_filters.BooleanFilter( + method='_has_primary_ip', + label='Has a primary IP', + ) + + class Meta: + model = VirtualDeviceContext + fields = ['id', 'device', 'name', ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(identifier=value.strip()) + ).distinct() + + def _has_primary_ip(self, queryset, name, value): + params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) + if value: + return queryset.filter(params) + return queryset.exclude(params) + + class ModuleFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', @@ -1342,6 +1381,23 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) + vdc_id = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs', + queryset=VirtualDeviceContext.objects.all(), + label='Virtual Device Context', + ) + vdc_identifier = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__identifier', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='identifier', + label='Virtual Device Context (Identifier)', + ) + vdc = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__name', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='name', + label='Virtual Device Context', + ) class Meta: model = Interface diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 1e58dd2f7..7a81ae7fb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -54,6 +54,7 @@ __all__ = ( 'SiteBulkEditForm', 'SiteGroupBulkEditForm', 'VirtualChassisBulkEditForm', + 'VirtualDeviceContextBulkEditForm' ) @@ -1398,3 +1399,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): (None, ('color', 'description')), ) nullable_fields = ('color', 'description') + + +class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices), + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + model = VirtualDeviceContext + fieldsets = ( + (None, ('device', 'status', 'tenant')), + ) + nullable_fields = ('device', 'tenant', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 2b77ef5a9..6073ee6fc 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -43,6 +43,7 @@ __all__ = ( 'SiteCSVForm', 'SiteGroupCSVForm', 'VirtualChassisCSVForm', + 'VirtualDeviceContextCSVForm' ) @@ -1084,3 +1085,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + + +class VirtualDeviceContextCSVForm(NetBoxModelCSVForm): + + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Assigned role' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + fields = [ + 'name', 'device', 'status', 'tenant', 'identifier', 'comments', + ] + model = VirtualDeviceContext + help_texts = {} diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 905a898df..cc4dd635c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -50,6 +50,7 @@ __all__ = ( 'SiteFilterForm', 'SiteGroupFilterForm', 'VirtualChassisFilterForm', + 'VirtualDeviceContextFilterForm' ) @@ -728,6 +729,37 @@ class DeviceFilterForm( tag = TagFilterField(model) +class VirtualDeviceContextFilterForm( + TenancyFilterForm, + NetBoxModelFilterSetForm +): + model = VirtualDeviceContext + fieldsets = ( + (None, ('q', 'filter', 'tag')), + ('Hardware', ('device', 'status', )), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Miscellaneous', ('has_primary_ip',)) + ) + device = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + fetch_trigger='open' + ) + status = MultipleChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices) + ) + has_primary_ip = forms.NullBooleanField( + required=False, + label='Has a primary IP', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( @@ -1075,9 +1107,18 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', + 'device_id', 'vdc_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) + vdc_id = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + query_params={ + 'device_id': '$device_id', + }, + label=_('Virtual Device Context') + ) kind = MultipleChoiceField( choices=InterfaceKindChoices, required=False diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 539c48709..da0148784 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -62,6 +62,7 @@ __all__ = ( 'SiteGroupForm', 'VCMemberSelectForm', 'VirtualChassisForm', + 'VirtualDeviceContextForm' ) INTERFACE_MODE_HELP_TEXT = """ @@ -1378,6 +1379,14 @@ class PowerOutletForm(ModularDeviceComponentForm): class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): + vdcs = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1452,7 +1461,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): ) fieldsets = ( - ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), + ('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1466,7 +1475,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class Meta: model = Interface fields = [ - 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', @@ -1636,3 +1645,74 @@ class InventoryItemRoleForm(NetBoxModelForm): fields = [ 'name', 'slug', 'color', 'description', 'tags', ] + + +class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site', + 'location_id': '$location', + 'rack_id': '$rack', + } + ) + + fieldsets = ( + ('Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), + ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', + 'tenant')), + (None, ('tags', )) + ) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'region', 'site_group', 'site', 'location', 'rack', + 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', + 'comments', 'tags' + ] + help_texts = {} + widgets = { + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 1d5b6a580..eba311420 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -2,6 +2,7 @@ import graphene from netbox.graphql.fields import ObjectField, ObjectListField from .types import * +from .types import VirtualDeviceContextType class DCIMQuery(graphene.ObjectType): @@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType): virtual_chassis = ObjectField(VirtualChassisType) virtual_chassis_list = ObjectListField(VirtualChassisType) + + virtual_device_context = ObjectField(VirtualDeviceContextType) + virtual_device_context_list = ObjectListField(VirtualDeviceContextType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index bb414ed00..41f0092f9 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -500,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType): model = models.VirtualChassis fields = '__all__' filterset_class = filtersets.VirtualChassisFilterSet + + +class VirtualDeviceContextType(NetBoxObjectType): + + class Meta: + model = models.VirtualDeviceContext + fields = '__all__' + filterset_class = filtersets.VirtualDeviceContextFilterSet diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py new file mode 100644 index 000000000..76627307a --- /dev/null +++ b/netbox/dcim/migrations/0166_virtualdevicecontext.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.2 on 2022-11-10 16:56 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0063_standardize_description_comments'), + ('extras', '0083_savedfilter'), + ('tenancy', '0009_standardize_description_comments'), + ('dcim', '0165_standardize_description_comments'), + ] + + operations = [ + migrations.CreateModel( + name='VirtualDeviceContext', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=64)), + ('status', models.CharField(max_length=50)), + ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')), + ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='interface', + name='vdcs', + field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifiers'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_name'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8855107b3..23c820571 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -531,6 +531,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd max_length=100, blank=True ) + vdcs = models.ManyToManyField( + to='dcim.VirtualDeviceContext', + related_name='interfaces' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 78282f893..3a14af059 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -34,6 +34,7 @@ __all__ = ( 'ModuleType', 'Platform', 'VirtualChassis', + 'VirtualDeviceContext', ) @@ -119,7 +120,7 @@ class DeviceType(PrimaryModel, WeightMixin): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) class Meta: @@ -1062,3 +1063,81 @@ class VirtualChassis(PrimaryModel): ) return super().delete(*args, **kwargs) + + +class VirtualDeviceContext(PrimaryModel): + device = models.ForeignKey( + to='Device', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + name = models.CharField( + max_length=64 + ) + status = models.CharField( + max_length=50, + choices=VirtualDeviceContextStatusChoices, + ) + identifier = models.PositiveSmallIntegerField( + help_text='Unique identifier provided by the platform being virtualized (Example: Nexus VDC Identifier)', + blank=True, + null=True, + ) + primary_ip4 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv4' + ) + primary_ip6 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv6' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + ordering = ['name'] + constraints = ( + models.UniqueConstraint( + fields=('device', 'identifier',), + name='%(app_label)s_%(class)s_device_identifiers' + ), + models.UniqueConstraint( + fields=('device', 'name',), + name='%(app_label)s_%(class)s_name' + ), + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk}) + + @property + def primary_ip(self): + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: + return self.primary_ip4 + elif self.primary_ip6: + return self.primary_ip6 + elif self.primary_ip4: + return self.primary_ip4 + else: + return None diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 45a210080..3a089ae93 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -36,6 +36,7 @@ __all__ = ( 'PowerPortTable', 'RearPortTable', 'VirtualChassisTable', + 'VirtualDeviceContextTable' ) @@ -884,3 +885,43 @@ class VirtualChassisTable(NetBoxTable): 'last_updated', ) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') + + +class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + linkify=True + ) + device = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) + status = columns.ChoiceFieldColumn() + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + + comments = columns.MarkdownColumn() + + tags = columns.TagColumn( + url_name='dcim:vdc_list' + ) + + class Meta(NetBoxTable.Meta): + model = models.VirtualDeviceContext + fields = ( + 'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip', + ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2697c29b2..bd3cb3f01 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1485,6 +1485,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) Interface.objects.bulk_create(interfaces) + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=device), + VirtualDeviceContext(name='VDC 2', identifier=2, device=device) + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + vlans = ( VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), @@ -1533,6 +1539,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[0].pk], 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -1543,6 +1550,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 7', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -1551,6 +1559,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 8', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -2163,3 +2172,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): 'type': REDUNDANT, }, ] + + +class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): + model = VirtualDeviceContext + brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type') + devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000') + + devices = ( + Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + cls.create_data = [ + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 1', + 'identifier': 1, + }, + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 2', + 'identifier': 2, + }, + { + 'device': devices[1].pk, + 'status': 'active', + 'name': 'VDC 3', + 'identifier': 3, + }, + ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 92298bd73..f3dff428c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2681,6 +2681,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VRF.objects.bulk_create(vrfs) + # Virtual Device Context Creation + vdcs = ( + VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + # VirtualChassis assignment for filtering virtual_chassis = VirtualChassis.objects.create(master=devices[0]) Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) @@ -2793,6 +2800,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Interface.objects.bulk_create(interfaces) + interfaces[3].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[4].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[5].vdcs.set([vdcs[0]]) + interfaces[6].vdcs.set([vdcs[0]]) + interfaces[7].vdcs.set([vdcs[1]]) + # Cables Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save() @@ -2997,6 +3010,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vdc(self): + params = {'vdc': ['VDC 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_id': vdc.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_vdc_identifier(self): + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() @@ -4254,4 +4282,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -# TODO: Connection filters +class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VirtualDeviceContext.objects.all() + filterset = VirtualDeviceContextFilterSet + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE), + VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type='virtual'), + Interface(device=devices[0], name='Interface 2', type='virtual'), + ) + Interface.objects.bulk_create(interfaces) + + interfaces[0].vdcs.set([vdcs[0]]) + interfaces[1].vdcs.set([vdcs[1]]) + + addresses = ( + IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), + IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + ) + IPAddress.objects.bulk_create(addresses) + + vdcs[0].primary_ip4 = addresses[0] + vdcs[0].save() + vdcs[1].primary_ip4 = addresses[1] + vdcs[1].save() + + def test_device(self): + params = {'device': ['Device 1', 'Device 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_status(self): + params = {'status': ['active']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device_id(self): + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_has_primary_ip(self): + params = {'has_primary_ip': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'has_primary_ip': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 460a5e252..a64c6d56b 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -588,3 +588,50 @@ class CableTestCase(TestCase): cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() + + +class VirtualDeviceContextTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + ) + + def test_vdc_and_interface_creation(self): + + vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc.full_clean() + vdc.save() + + interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t') + interface.full_clean() + interface.save() + + interface.vdcs.set([vdc]) + + def test_vdc_duplicate_name(self): + vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() + + def test_vdc_duplicate_identifier(self): + vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index d563dcfd6..300228601 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -3076,3 +3076,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) self.assertHttpStatus(response, 200) + + +class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VirtualDeviceContext + + @classmethod + def setUpTestData(cls): + devices = [create_test_device(name='Device 1')] + + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 4', + 'identifier': 4, + 'primary_ip4': None, + 'primary_ip6': None, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "device,status,name,identifier", + "Device 1,active,VDC 5,5", + "Device 1,active,VDC 6,6", + "Device 1,active,VDC 7,7", + ) + + cls.csv_update_data = ( + "id,status", + f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + ) + + cls.bulk_edit_data = { + 'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE, + } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ecd2d46c5..33b61309e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -183,6 +183,14 @@ urlpatterns = [ path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), path('devices//', include(get_model_urls('dcim', 'device'))), + # Virtual Device Context + path('vdcs/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'), + path('vdcs/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'), + path('vdcs/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'), + path('vdcs/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'), + path('vdcs/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'), + path('vdcs//', include(get_model_urls('dcim', 'virtualdevicecontext'))), + # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), path('modules/add/', views.ModuleEditView.as_view(), name='module_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 437162bce..ae621008a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2442,6 +2442,14 @@ class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() def get_extra_context(self, request, instance): + # Get assigned VDC's + vdc_table = tables.VirtualDeviceContextTable( + data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), + exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', + 'created', 'last_updated', 'actions', ), + orderable=False + ) + # Get assigned IP addresses ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), @@ -2479,6 +2487,7 @@ class InterfaceView(generic.ObjectView): ) return { + 'vdc_table': vdc_table, 'ipaddress_table': ipaddress_table, 'bridge_interfaces_table': bridge_interfaces_tables, 'child_interfaces_table': child_interfaces_tables, @@ -3562,3 +3571,55 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView): # Trace view register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView) + + +# VDC +class VirtualDeviceContextListView(generic.ObjectListView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + filterset_form = forms.VirtualDeviceContextFilterForm + table = tables.VirtualDeviceContextTable + + +@register_model_view(VirtualDeviceContext) +class VirtualDeviceContextView(generic.ObjectView): + queryset = VirtualDeviceContext.objects.all() + + def get_extra_context(self, request, instance): + interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user) + interfaces_table.configure(request) + + return { + 'interfaces_table': interfaces_table, + 'interface_count': instance.interfaces.count(), + } + + +@register_model_view(VirtualDeviceContext, 'edit') +class VirtualDeviceContextEditView(generic.ObjectEditView): + queryset = VirtualDeviceContext.objects.all() + form = forms.VirtualDeviceContextForm + + +@register_model_view(VirtualDeviceContext, 'delete') +class VirtualDeviceContextDeleteView(generic.ObjectDeleteView): + queryset = VirtualDeviceContext.objects.all() + + +class VirtualDeviceContextBulkImportView(generic.BulkImportView): + queryset = VirtualDeviceContext.objects.all() + model_form = forms.VirtualDeviceContextCSVForm + table = tables.VirtualDeviceContextTable + + +class VirtualDeviceContextBulkEditView(generic.BulkEditView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable + form = forms.VirtualDeviceContextBulkEditForm + + +class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 60c0657ae..dcec76d91 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -62,6 +62,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), + get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'), ), ), MenuGroup( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 887433d7b..73d590d3d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -116,6 +116,7 @@ {% plugin_left_page object %}
    + {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
    Addressing
    diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html new file mode 100644 index 000000000..f5eb820f0 --- /dev/null +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -0,0 +1,68 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Virtual Device Context +
    +
    +
    CPU
    Memory
    Temperature
    Fans
    Power
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Device{{ object.device|linkify }}
    Identifier{{ object.identifier|placeholder }}
    Primary IPv4 + {{ object.primary_ip4|placeholder }} +
    Primary IPv6 + {{ object.primary_ip6|placeholder }} +
    +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    Interfaces
    +
    + {% render_table interfaces_table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e8dc4b23a..6fb454391 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -61,6 +61,10 @@

    {{ stats.device_count }}

    Devices

    +
    +

    {{ stats.vdc_count }}

    +

    Virtual Device Contexts

    +

    {{ stats.cable_count }}

    Cables

    diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index d95568a22..228e8cd3f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from circuits.models import Circuit -from dcim.models import Cable, Device, Location, Rack, RackReservation, Site +from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related @@ -109,6 +109,7 @@ class TenantView(generic.ObjectView): 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), From 2cc2d2cc3745e241b9b6c6f279989481ffa6af89 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 11 Nov 2022 08:25:44 -0500 Subject: [PATCH 238/409] Changelog & documentation for #7854 --- docs/models/dcim/virtualdevicecontext.md | 33 +++++++++++++++++++ docs/release-notes/version-3.4.md | 7 ++++ .../migrations/0166_virtualdevicecontext.py | 4 +-- netbox/dcim/models/devices.py | 4 +-- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 docs/models/dcim/virtualdevicecontext.md diff --git a/docs/models/dcim/virtualdevicecontext.md b/docs/models/dcim/virtualdevicecontext.md new file mode 100644 index 000000000..fe2e61356 --- /dev/null +++ b/docs/models/dcim/virtualdevicecontext.md @@ -0,0 +1,33 @@ +# Virtual Device Context + +A virtual device context (VDC) represents a logical partition within a physical device, to which interfaces from the parent device can be allocated. Each VDC effectively provides an isolated control plane, but relies on shared resources of the parent device. A VDC is somewhat similar to a virtual machine in that it effects isolation between various components, but stops short of delivering a fully virtualized environment. + +Each VDC must be assigned to a device upon creation, after which interfaces belonging to that device can be assigned to one or more of its VDCs. A VDC can have any number of interfaces assigned to it, and an interface can belong to any number of VDCs. + +!!! info "A VDC by Any Other Name" + Network vendors use differing names for this concept. Cisco uses the term VDC, whereas Juniper refers to it as a _Virtual Routing Instance_, and Fortinet uses _Virtual Domain_, for instance. While there may be some nuance among the vendors' unique implementations, the general concept remains the same for each. + +## Fields + +### Device + +The device to which this VDC belongs. + +### Name + +The VDC's configured name. Must be unique to the assigned device. + +### Status + +The operational status of the VDC. + +### Identifier + +A vendor-prescribed unique identifier for the VDC (optional). Must be unique to the assigned device if defined. + +### Primary IPv4 & IPv6 Addresses + +Each VDC may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. + +!!! tip + NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index d6b26c4eb..f76548695 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -17,6 +17,10 @@ NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. +#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854)) + +A new model representing virtual device contexts (VDCs) has been added. VDCs are logical partitions of resources within a device that can be managed independently. A VDC is created within a device and may have device interfaces assigned to it. An interface can be allocated to any number of VDCs on its device. + ### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347)) NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well. @@ -75,6 +79,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### REST API Changes +* Added the `/api/dcim/virtual-device-contexts/` endpoint * circuits.provider * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields * Added a `description` field @@ -87,6 +92,8 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * dcim.Module * Added a `description` field +* dcim.Interface + * Added the `vdcs` field * dcim.ModuleType * Added a `description` field * Added optional `weight` and `weight_unit` fields diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py index 76627307a..5c95e1177 100644 --- a/netbox/dcim/migrations/0166_virtualdevicecontext.py +++ b/netbox/dcim/migrations/0166_virtualdevicecontext.py @@ -45,10 +45,10 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='virtualdevicecontext', - constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifiers'), + constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifier'), ), migrations.AddConstraint( model_name='virtualdevicecontext', - constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_name'), + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_device_name'), ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3a14af059..afa792ffb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1117,11 +1117,11 @@ class VirtualDeviceContext(PrimaryModel): constraints = ( models.UniqueConstraint( fields=('device', 'identifier',), - name='%(app_label)s_%(class)s_device_identifiers' + name='%(app_label)s_%(class)s_device_identifier' ), models.UniqueConstraint( fields=('device', 'name',), - name='%(app_label)s_%(class)s_name' + name='%(app_label)s_%(class)s_device_name' ), ) From 6eba5d4d96c0b1922f29dacdb72f30a47532e1d4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 3 Nov 2022 11:58:26 -0700 Subject: [PATCH 239/409] 10300 initial translation support use gettext --- netbox/circuits/filtersets.py | 51 +-- netbox/circuits/forms/bulk_edit.py | 12 +- netbox/circuits/forms/bulk_import.py | 13 +- netbox/circuits/forms/model_forms.py | 12 +- netbox/circuits/models/circuits.py | 3 +- netbox/dcim/api/serializers.py | 7 +- netbox/dcim/filtersets.py | 331 +++++++++--------- netbox/dcim/forms/bulk_create.py | 5 +- netbox/dcim/forms/bulk_edit.py | 48 +-- netbox/dcim/forms/bulk_import.py | 177 +++++----- netbox/dcim/forms/common.py | 5 +- netbox/dcim/forms/connections.py | 23 +- netbox/dcim/forms/model_forms.py | 78 ++--- netbox/dcim/forms/object_create.py | 21 +- netbox/dcim/forms/object_import.py | 5 +- .../dcim/models/device_component_templates.py | 13 +- netbox/dcim/models/device_components.py | 35 +- netbox/dcim/models/devices.py | 25 +- netbox/dcim/models/power.py | 3 +- netbox/dcim/models/racks.py | 21 +- netbox/dcim/models/sites.py | 7 +- netbox/extras/filtersets.py | 85 ++--- netbox/extras/forms/bulk_edit.py | 11 +- netbox/extras/forms/bulk_import.py | 21 +- netbox/extras/forms/filtersets.py | 4 +- netbox/extras/forms/mixins.py | 3 +- netbox/extras/forms/model_forms.py | 15 +- netbox/extras/forms/reports.py | 5 +- netbox/extras/forms/scripts.py | 9 +- netbox/extras/models/customfields.py | 45 +-- netbox/extras/models/models.py | 73 ++-- .../extras/tests/dummy_plugin/navigation.py | 3 +- netbox/ipam/filtersets.py | 191 +++++----- netbox/ipam/forms/bulk_create.py | 3 +- netbox/ipam/forms/bulk_edit.py | 29 +- netbox/ipam/forms/bulk_import.py | 85 ++--- netbox/ipam/forms/filtersets.py | 4 +- netbox/ipam/forms/model_forms.py | 105 +++--- netbox/ipam/models/ip.py | 33 +- netbox/ipam/models/vlans.py | 5 +- netbox/ipam/models/vrfs.py | 7 +- netbox/netbox/config/parameters.py | 93 ++--- netbox/netbox/filtersets.py | 3 +- netbox/netbox/forms/__init__.py | 4 +- netbox/netbox/forms/base.py | 3 +- netbox/netbox/navigation/menu.py | 231 ++++++------ netbox/netbox/preferences.py | 13 +- netbox/tenancy/filtersets.py | 37 +- netbox/tenancy/forms/bulk_import.py | 9 +- netbox/users/admin/forms.py | 11 +- netbox/users/filtersets.py | 25 +- netbox/users/forms.py | 9 +- netbox/users/models.py | 11 +- netbox/utilities/forms/fields/csv.py | 7 +- netbox/utilities/forms/fields/expandable.py | 5 +- netbox/utilities/forms/fields/fields.py | 5 +- netbox/utilities/forms/forms.py | 13 +- netbox/virtualization/filtersets.py | 79 ++--- netbox/virtualization/forms/bulk_create.py | 3 +- netbox/virtualization/forms/bulk_edit.py | 21 +- netbox/virtualization/forms/bulk_import.py | 33 +- netbox/virtualization/forms/filtersets.py | 4 +- netbox/virtualization/forms/model_forms.py | 19 +- netbox/wireless/forms/bulk_edit.py | 11 +- netbox/wireless/forms/bulk_import.py | 21 +- netbox/wireless/forms/filtersets.py | 4 +- netbox/wireless/forms/model_forms.py | 21 +- 67 files changed, 1192 insertions(+), 1134 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index cf250584f..8e4c9ab06 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.db.models import Q +from django.utils.translation import gettext as _ from dcim.filtersets import CabledObjectFilterSet from dcim.models import Region, Site, SiteGroup @@ -24,43 +25,43 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=Region.objects.all(), field_name='circuits__terminations__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='circuits__terminations__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='circuits__terminations__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='circuits__terminations__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), - label='Site', + label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), - label='ASN (ID)', + label=_('ASN (ID)'), ) class Meta: @@ -80,13 +81,13 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class ProviderNetworkFilterSet(NetBoxModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), - label='Provider (ID)', + label=_('Provider (ID)'), ) provider = django_filters.ModelMultipleChoiceFilter( field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', - label='Provider (slug)', + label=_('Provider (slug)'), ) class Meta: @@ -114,28 +115,28 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), - label='Provider (ID)', + label=_('Provider (ID)'), ) provider = django_filters.ModelMultipleChoiceFilter( field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', - label='Provider (slug)', + label=_('Provider (slug)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), - label='ProviderNetwork (ID)', + label=_('ProviderNetwork (ID)'), ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), - label='Circuit type (ID)', + label=_('Circuit type (ID)'), ) type = django_filters.ModelMultipleChoiceFilter( field_name='type__slug', queryset=CircuitType.objects.all(), to_field_name='slug', - label='Circuit type (slug)', + label=_('Circuit type (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=CircuitStatusChoices, @@ -145,38 +146,38 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=Region.objects.all(), field_name='terminations__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='terminations__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='terminations__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='terminations__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) class Meta: @@ -199,25 +200,25 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) circuit_id = django_filters.ModelMultipleChoiceFilter( queryset=Circuit.objects.all(), - label='Circuit', + label=_('Circuit'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( queryset=ProviderNetwork.objects.all(), - label='ProviderNetwork (ID)', + label=_('ProviderNetwork (ID)'), ) class Meta: diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 6e9ae516c..e1fe6338d 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -28,7 +28,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): account = forms.CharField( max_length=30, required=False, - label='Account number' + label=_('Account number') ) description = forms.CharField( max_length=200, @@ -36,7 +36,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = Provider @@ -56,7 +56,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): service_id = forms.CharField( max_length=100, required=False, - label='Service ID' + label=_('Service ID') ) description = forms.CharField( max_length=200, @@ -64,7 +64,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = ProviderNetwork @@ -118,7 +118,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) commit_rate = forms.IntegerField( required=False, - label='Commit rate (Kbps)' + label=_('Commit rate (Kbps)') ) description = forms.CharField( max_length=100, @@ -126,7 +126,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = Circuit diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 4976e2d9b..97b6af428 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,5 +1,6 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * +from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField @@ -26,7 +27,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Assigned provider' + help_text=_('Assigned provider') ) class Meta: @@ -43,7 +44,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm): model = CircuitType fields = ('name', 'slug', 'description', 'tags') help_texts = { - 'name': 'Name of circuit type', + 'name': _('Name of circuit type'), } @@ -51,22 +52,22 @@ class CircuitCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Assigned provider' + help_text=_('Assigned provider') ) type = CSVModelChoiceField( queryset=CircuitType.objects.all(), to_field_name='name', - help_text='Type of circuit' + help_text=_('Type of circuit') ) status = CSVChoiceField( choices=CircuitStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index ab1b6bca2..890462aaa 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -39,7 +39,7 @@ class ProviderForm(NetBoxModelForm): 'name', 'slug', 'account', 'asns', 'description', 'comments', 'tags', ] help_texts = { - 'name': "Full name of the provider", + 'name': _("Full name of the provider"), } @@ -98,8 +98,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { - 'cid': "Unique circuit ID", - 'commit_rate': "Committed rate", + 'cid': _("Unique circuit ID"), + 'commit_rate': _("Committed rate"), } widgets = { 'status': StaticSelect(), @@ -157,9 +157,9 @@ class CircuitTerminationForm(NetBoxModelForm): 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] help_texts = { - 'port_speed': "Physical circuit speed", - 'xconnect_id': "ID of the local cross-connect", - 'pp_info': "Patch panel ID and port number(s)" + 'port_speed': _("Physical circuit speed"), + 'xconnect_id': _("ID of the local cross-connect"), + 'pp_info': _("Patch panel ID and port number(s)") } widgets = { 'term_side': StaticSelect(), diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 9d302bb8e..ebba74738 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from circuits.choices import * from dcim.models import CabledObjectModel @@ -168,7 +169,7 @@ class CircuitTermination( blank=True, null=True, verbose_name='Upstream speed (Kbps)', - help_text='Upstream speed, if different from port speed' + help_text=_('Upstream speed, if different from port speed') ) xconnect_id = models.CharField( max_length=50, diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 33d79612a..4b8c95a73 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,6 +1,7 @@ import decimal from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from timezone_field.rest_framework import TimeZoneSerializerField @@ -197,7 +198,7 @@ class RackSerializer(NetBoxModelSerializer): status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) - facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID', + facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), default=None) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) @@ -311,7 +312,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer): u_height = serializers.DecimalField( max_digits=4, decimal_places=1, - label='Position (U)', + label=_('Position (U)'), min_value=0, default=1.0 ) @@ -636,7 +637,7 @@ class DeviceSerializer(NetBoxModelSerializer): max_digits=4, decimal_places=1, allow_null=True, - label='Position (U)', + label=_('Position (U)'), min_value=decimal.Decimal(0.5), default=None ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 88d84a7ab..53576d017 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.contrib.auth.models import User +from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF @@ -72,13 +73,13 @@ __all__ = ( class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), - label='Parent region (ID)', + label=_('Parent region (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=Region.objects.all(), to_field_name='slug', - label='Parent region (slug)', + label=_('Parent region (slug)'), ) class Meta: @@ -89,13 +90,13 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - label='Parent site group (ID)', + label=_('Parent site group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=SiteGroup.objects.all(), to_field_name='slug', - label='Parent site group (slug)', + label=_('Parent site group (slug)'), ) class Meta: @@ -112,36 +113,36 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=Region.objects.all(), field_name='region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='group', lookup_expr='in', - label='Group (ID)', + label=_('Group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), lookup_expr='in', to_field_name='slug', - label='Group (slug)', + label=_('Group (slug)'), ) asn = django_filters.ModelMultipleChoiceFilter( field_name='asns__asn', queryset=ASN.objects.all(), to_field_name='asn', - label='AS (ID)', + label=_('AS (ID)'), ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), - label='AS (ID)', + label=_('AS (ID)'), ) class Meta: @@ -173,50 +174,50 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) parent_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) parent = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=LocationStatusChoices, @@ -248,50 +249,50 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) location = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, @@ -305,13 +306,13 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=RackRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) serial = MultiValueCharFilter( lookup_expr='iexact' @@ -339,67 +340,67 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): rack_id = django_filters.ModelMultipleChoiceFilter( queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='rack__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='rack__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='rack__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='rack__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) location = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', lookup_expr='in', to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) class Meta: @@ -427,57 +428,57 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class DeviceTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) has_front_image = django_filters.BooleanFilter( - label='Has a front image', + label=_('Has a front image'), method='_has_front_image' ) has_rear_image = django_filters.BooleanFilter( - label='Has a rear image', + label=_('Has a rear image'), method='_has_rear_image' ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) module_bays = django_filters.BooleanFilter( method='_module_bays', - label='Has module bays', + label=_('Has module bays'), ) device_bays = django_filters.BooleanFilter( method='_device_bays', - label='Has device bays', + label=_('Has device bays'), ) inventory_items = django_filters.BooleanFilter( method='_inventory_items', - label='Has inventory items', + label=_('Has inventory items'), ) class Meta: @@ -542,37 +543,37 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class ModuleTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) class Meta: @@ -614,12 +615,12 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', - label='Device type (ID)', + label=_('Device type (ID)'), ) def search(self, queryset, name, value): @@ -632,7 +633,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): moduletype_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleType.objects.all(), field_name='module_type_id', - label='Module type (ID)', + label=_('Module type (ID)'), ) @@ -724,27 +725,27 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemTemplate.objects.all(), - label='Parent inventory item (ID)', + label=_('Parent inventory item (ID)'), ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=InventoryItemRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) component_type = ContentTypeFilter() component_id = MultiValueNumberFilter() @@ -775,13 +776,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) class Meta: @@ -793,106 +794,106 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) device_type = django_filters.ModelMultipleChoiceFilter( field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', - label='Device type (slug)', + label=_('Device type (slug)'), ) device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), - label='Device type (ID)', + label=_('Device type (ID)'), ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='device_role_id', queryset=DeviceRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='device_role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) parent_device_id = django_filters.ModelMultipleChoiceFilter( field_name='parent_bay__device', queryset=Device.objects.all(), - label='Parent Device (ID)', + label=_('Parent Device (ID)'), ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), - label='Platform (ID)', + label=_('Platform (ID)'), ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', - label='Platform (slug)', + label=_('Platform (slug)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), - label='VM cluster (ID)', + label=_('VM cluster (ID)'), ) model = django_filters.ModelMultipleChoiceFilter( field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', - label='Device model (slug)', + label=_('Device model (slug)'), ) name = MultiValueCharFilter( lookup_expr='iexact' @@ -903,59 +904,59 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter ) is_full_depth = django_filters.BooleanFilter( field_name='device_type__is_full_depth', - label='Is full depth', + label=_('Is full depth'), ) mac_address = MultiValueMACAddressFilter( field_name='interfaces__mac_address', - label='MAC address', + label=_('MAC address'), ) serial = MultiValueCharFilter( lookup_expr='iexact' ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP'), ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), - label='Virtual chassis (ID)', + label=_('Virtual chassis (ID)'), ) virtual_chassis_member = django_filters.BooleanFilter( method='_virtual_chassis_member', - label='Is a virtual chassis member' + label=_('Is a virtual chassis member') ) console_ports = django_filters.BooleanFilter( method='_console_ports', - label='Has console ports', + label=_('Has console ports'), ) console_server_ports = django_filters.BooleanFilter( method='_console_server_ports', - label='Has console server ports', + label=_('Has console server ports'), ) power_ports = django_filters.BooleanFilter( method='_power_ports', - label='Has power ports', + label=_('Has power ports'), ) power_outlets = django_filters.BooleanFilter( method='_power_outlets', - label='Has power outlets', + label=_('Has power outlets'), ) interfaces = django_filters.BooleanFilter( method='_interfaces', - label='Has interfaces', + label=_('Has interfaces'), ) pass_through_ports = django_filters.BooleanFilter( method='_pass_through_ports', - label='Has pass-through ports', + label=_('Has pass-through ports'), ) module_bays = django_filters.BooleanFilter( method='_module_bays', - label='Has module bays', + label=_('Has module bays'), ) device_bays = django_filters.BooleanFilter( method='_device_bays', - label='Has device bays', + label=_('Has device bays'), ) class Meta: @@ -1052,34 +1053,34 @@ class ModuleFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) module_type_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type', queryset=ModuleType.objects.all(), - label='Module type (ID)', + label=_('Module type (ID)'), ) module_type = django_filters.ModelMultipleChoiceFilter( field_name='module_type__model', queryset=ModuleType.objects.all(), to_field_name='model', - label='Module type (model)', + label=_('Module type (model)'), ) module_bay_id = django_filters.ModelMultipleChoiceFilter( field_name='module_bay', queryset=ModuleBay.objects.all(), to_field_name='id', - label='Module Bay (ID)' + label=_('Module Bay (ID)') ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) serial = MultiValueCharFilter( lookup_expr='iexact' @@ -1102,87 +1103,87 @@ class ModuleFilterSet(NetBoxModelFilterSet): class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='device__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='device__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='device__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='device__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='device__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( field_name='device__location', queryset=Location.objects.all(), - label='Location (ID)', + label=_('Location (ID)'), ) location = django_filters.ModelMultipleChoiceFilter( field_name='device__location__slug', queryset=Location.objects.all(), to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='device__rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) rack = django_filters.ModelMultipleChoiceFilter( field_name='device__rack__name', queryset=Rack.objects.all(), to_field_name='name', - label='Rack (name)', + label=_('Rack (name)'), ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis', queryset=VirtualChassis.objects.all(), - label='Virtual Chassis (ID)' + label=_('Virtual Chassis (ID)') ) virtual_chassis = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis__name', queryset=VirtualChassis.objects.all(), to_field_name='name', - label='Virtual Chassis', + label=_('Virtual Chassis'), ) def search(self, queryset, name, value): @@ -1202,7 +1203,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): """ module_id = django_filters.ModelMultipleChoiceFilter( queryset=Module.objects.all(), - label='Module (ID)', + label=_('Module (ID)'), ) @@ -1314,31 +1315,31 @@ class InterfaceFilterSet( device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device', + label=_('Device'), ) device_id = MultiValueNumberFilter( method='filter_device_id', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) kind = django_filters.CharFilter( method='filter_kind', - label='Kind of interface', + label=_('Kind of interface'), ) parent_id = django_filters.ModelMultipleChoiceFilter( field_name='parent', queryset=Interface.objects.all(), - label='Parent interface (ID)', + label=_('Parent interface (ID)'), ) bridge_id = django_filters.ModelMultipleChoiceFilter( field_name='bridge', queryset=Interface.objects.all(), - label='Bridged interface (ID)', + label=_('Bridged interface (ID)'), ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), - label='LAG interface (ID)', + label=_('LAG interface (ID)'), ) speed = MultiValueNumberFilter() duplex = django_filters.MultipleChoiceFilter( @@ -1354,11 +1355,11 @@ class InterfaceFilterSet( ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', - label='Assigned VLAN' + label=_('Assigned VLAN') ) vlan = django_filters.CharFilter( method='filter_vlan', - label='Assigned VID' + label=_('Assigned VID') ) type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, @@ -1373,13 +1374,13 @@ class InterfaceFilterSet( vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='vrf', queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', @@ -1501,27 +1502,27 @@ class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), - label='Parent inventory item (ID)', + label=_('Parent inventory item (ID)'), ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + label=_('Manufacturer (ID)'), ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Manufacturer (slug)', + label=_('Manufacturer (slug)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=InventoryItemRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) component_type = ContentTypeFilter() component_id = MultiValueNumberFilter() @@ -1556,61 +1557,61 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class VirtualChassisFilterSet(NetBoxModelFilterSet): master_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Master (ID)', + label=_('Master (ID)'), ) master = django_filters.ModelMultipleChoiceFilter( field_name='master__name', queryset=Device.objects.all(), to_field_name='name', - label='Master (name)', + label=_('Master (name)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='master__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='master__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='master__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='master__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='master__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant', queryset=Tenant.objects.all(), - label='Tenant (ID)', + label=_('Tenant (ID)'), ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) class Meta: @@ -1709,43 +1710,43 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='location', lookup_expr='in', - label='Location (ID)', + label=_('Location (ID)'), ) class Meta: @@ -1766,47 +1767,47 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi queryset=Region.objects.all(), field_name='power_panel__site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='power_panel__site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='power_panel__site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site name (slug)', + label=_('Site name (slug)'), ) power_panel_id = django_filters.ModelMultipleChoiceFilter( queryset=PowerPanel.objects.all(), - label='Power panel (ID)', + label=_('Power panel (ID)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', queryset=Rack.objects.all(), - label='Rack (ID)', + label=_('Rack (ID)'), ) status = django_filters.MultipleChoiceFilter( choices=PowerFeedStatusChoices, @@ -1836,7 +1837,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi class ConnectionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) site_id = MultiValueNumberFilter( method='filter_connections', diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index f6bc27079..11fdfa6d2 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -1,6 +1,7 @@ from django import forms from dcim.models import * +from django.utils.translation import gettext as _ from extras.forms import CustomFieldsMixin from extras.models import Tag from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model @@ -105,9 +106,9 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): field_order = ('name', 'label', 'position_pattern', 'description', 'tags') replication_fields = ('name', 'label', 'position') position_pattern = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 7a81ae7fb..c21988b45 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms -from django.utils.translation import gettext as _ from django.contrib.auth.models import User +from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField from dcim.choices import * @@ -126,7 +126,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): ) contact_email = forms.EmailField( required=False, - label='Contact E-mail' + label=_('Contact E-mail') ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), @@ -248,7 +248,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') ) asset_tag = forms.CharField( max_length=50, @@ -266,12 +266,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) u_height = forms.IntegerField( required=False, - label='Height (U)' + label=_('Height (U)') ) desc_units = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Descending units' + label=_('Descending units') ) outer_width = forms.IntegerField( required=False, @@ -380,7 +380,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): is_full_depth = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Is full depth' + label=_('Is full depth') ) airflow = forms.ChoiceField( choices=add_blank_choice(DeviceAirflowChoices), @@ -456,7 +456,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): vm_role = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='VM role' + label=_('VM role') ) description = forms.CharField( max_length=200, @@ -540,7 +540,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') ) description = forms.CharField( max_length=200, @@ -577,7 +577,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): serial = forms.CharField( max_length=50, required=False, - label='Serial Number' + label=_('Serial Number') ) description = forms.CharField( max_length=200, @@ -767,7 +767,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = PowerFeed @@ -838,12 +838,12 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): maximum_draw = forms.IntegerField( min_value=1, required=False, - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = forms.IntegerField( min_value=1, required=False, - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) description = forms.CharField( required=False @@ -916,7 +916,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Management only' + label=_('Management only') ) description = forms.CharField( required=False @@ -926,14 +926,14 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): required=False, initial='', widget=StaticSelect(), - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE type' + label=_('PoE type') ) nullable_fields = ('label', 'description', 'poe_mode', 'poe_type') @@ -1174,31 +1174,31 @@ class InterfaceBulkEditForm( query_params={ 'type': 'lag', }, - label='LAG' + label=_('LAG') ) speed = forms.IntegerField( required=False, widget=SelectSpeedWidget(), - label='Speed' + label=_('Speed') ) mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, - label='Management only' + label=_('Management only') ) poe_mode = forms.ChoiceField( choices=add_blank_choice(InterfacePoEModeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', widget=StaticSelect(), - label='PoE type' + label=_('PoE type') ) mark_connected = forms.NullBooleanField( required=False, @@ -1213,7 +1213,7 @@ class InterfaceBulkEditForm( vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -1221,7 +1221,7 @@ class InterfaceBulkEditForm( query_params={ 'group_id': '$vlan_group', }, - label='Untagged VLAN' + label=_('Untagged VLAN') ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), @@ -1229,12 +1229,12 @@ class InterfaceBulkEditForm( query_params={ 'group_id': '$vlan_group', }, - label='Tagged VLANs' + label=_('Tagged VLANs') ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) model = Interface diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6073ee6fc..c7dbfcb17 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * @@ -52,7 +53,7 @@ class RegionCSVForm(NetBoxModelCSVForm): queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of parent region' + help_text=_('Name of parent region') ) class Meta: @@ -65,7 +66,7 @@ class SiteGroupCSVForm(NetBoxModelCSVForm): queryset=SiteGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent site group' + help_text=_('Name of parent site group') ) class Meta: @@ -76,25 +77,25 @@ class SiteGroupCSVForm(NetBoxModelCSVForm): class SiteCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) region = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Assigned region' + help_text=_('Assigned region') ) group = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -105,7 +106,7 @@ class SiteCSVForm(NetBoxModelCSVForm): ) help_texts = { 'time_zone': mark_safe( - 'Time zone (available options)' + _('Time zone (available options)') ) } @@ -114,26 +115,26 @@ class LocationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) parent = CSVModelChoiceField( queryset=Location.objects.all(), required=False, to_field_name='name', - help_text='Parent location', + help_text=_('Parent location'), error_messages={ - 'invalid_choice': 'Location not found.', + 'invalid_choice': _('Location not found.'), } ) status = CSVChoiceField( choices=LocationStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -148,7 +149,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm): model = RackRole fields = ('name', 'slug', 'color', 'description', 'tags') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } @@ -166,31 +167,31 @@ class RackCSVForm(NetBoxModelCSVForm): queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant' + help_text=_('Name of assigned tenant') ) status = CSVChoiceField( choices=RackStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned role' + help_text=_('Name of assigned role') ) type = CSVChoiceField( choices=RackTypeChoices, required=False, - help_text='Rack type' + help_text=_('Rack type') ) width = forms.ChoiceField( choices=RackWidthChoices, - help_text='Rail-to-rail width (in inches)' + help_text=_('Rail-to-rail width (in inches)') ) outer_unit = CSVChoiceField( choices=RackDimensionUnitChoices, required=False, - help_text='Unit for outer dimensions' + help_text=_('Unit for outer dimensions') ) class Meta: @@ -215,29 +216,29 @@ class RackReservationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Parent site' + help_text=_('Parent site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's location (if any)" + help_text=_("Rack's location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', - help_text='Rack' + help_text=_('Rack') ) units = SimpleArrayField( base_field=forms.IntegerField(), required=True, - help_text='Comma-separated list of individual unit numbers' + help_text=_('Comma-separated list of individual unit numbers') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -275,7 +276,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm): model = DeviceRole fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } @@ -285,7 +286,7 @@ class PlatformCSVForm(NetBoxModelCSVForm): queryset=Manufacturer.objects.all(), required=False, to_field_name='name', - help_text='Limit platform assignments to this manufacturer' + help_text=_('Limit platform assignments to this manufacturer') ) class Meta: @@ -297,45 +298,45 @@ class BaseDeviceCSVForm(NetBoxModelCSVForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', - help_text='Assigned role' + help_text=_('Assigned role') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', - help_text='Device type manufacturer' + help_text=_('Device type manufacturer') ) device_type = CSVModelChoiceField( queryset=DeviceType.objects.all(), to_field_name='model', - help_text='Device type model' + help_text=_('Device type model') ) platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Assigned platform' + help_text=_('Assigned platform') ) status = CSVChoiceField( choices=DeviceStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) virtual_chassis = CSVModelChoiceField( queryset=VirtualChassis.objects.all(), to_field_name='name', required=False, - help_text='Virtual chassis' + help_text=_('Virtual chassis') ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', required=False, - help_text='Virtualization cluster' + help_text=_('Virtualization cluster') ) class Meta: @@ -360,29 +361,29 @@ class DeviceCSVForm(BaseDeviceCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Assigned location (if any)" + help_text=_("Assigned location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text="Assigned rack (if any)" + help_text=_("Assigned rack (if any)") ) face = CSVChoiceField( choices=DeviceFaceChoices, required=False, - help_text='Mounted rack face' + help_text=_('Mounted rack face') ) airflow = CSVChoiceField( choices=DeviceAirflowChoices, required=False, - help_text='Airflow direction' + help_text=_('Airflow direction') ) class Meta(BaseDeviceCSVForm.Meta): @@ -442,12 +443,12 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Parent device' + help_text=_('Parent device') ) device_bay = CSVModelChoiceField( queryset=DeviceBay.objects.all(), to_field_name='name', - help_text='Device bay in which this device is installed' + help_text=_('Device bay in which this device is installed') ) class Meta(BaseDeviceCSVForm.Meta): @@ -492,14 +493,14 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=ConsolePortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) speed = CSVTypedChoiceField( choices=ConsolePortSpeedChoices, coerce=int, empty_value=None, required=False, - help_text='Port speed in bps' + help_text=_('Port speed in bps') ) class Meta: @@ -515,14 +516,14 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=ConsolePortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) speed = CSVTypedChoiceField( choices=ConsolePortSpeedChoices, coerce=int, empty_value=None, required=False, - help_text='Port speed in bps' + help_text=_('Port speed in bps') ) class Meta: @@ -538,7 +539,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=PowerPortTypeChoices, required=False, - help_text='Port type' + help_text=_('Port type') ) class Meta: @@ -556,18 +557,18 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): type = CSVChoiceField( choices=PowerOutletTypeChoices, required=False, - help_text='Outlet type' + help_text=_('Outlet type') ) power_port = CSVModelChoiceField( queryset=PowerPort.objects.all(), required=False, to_field_name='name', - help_text='Local power port which feeds this outlet' + help_text=_('Local power port which feeds this outlet') ) feed_leg = CSVChoiceField( choices=PowerOutletFeedLegChoices, required=False, - help_text='Electrical phase (for three-phase circuits)' + help_text=_('Electrical phase (for three-phase circuits)') ) class Meta: @@ -606,23 +607,23 @@ class InterfaceCSVForm(NetBoxModelCSVForm): queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Parent interface' + help_text=_('Parent interface') ) bridge = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Bridged interface' + help_text=_('Bridged interface') ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Parent LAG interface' + help_text=_('Parent LAG interface') ) type = CSVChoiceField( choices=InterfaceTypeChoices, - help_text='Physical medium' + help_text=_('Physical medium') ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, @@ -631,28 +632,28 @@ class InterfaceCSVForm(NetBoxModelCSVForm): poe_mode = CSVChoiceField( choices=InterfacePoEModeChoices, required=False, - help_text='PoE mode' + help_text=_('PoE mode') ) poe_type = CSVChoiceField( choices=InterfacePoETypeChoices, required=False, - help_text='PoE type' + help_text=_('PoE type') ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, - help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') ) vrf = CSVModelChoiceField( queryset=VRF.objects.all(), required=False, to_field_name='rd', - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) rf_role = CSVChoiceField( choices=WirelessRoleChoices, required=False, - help_text='Wireless role (AP/station)' + help_text=_('Wireless role (AP/station)') ) class Meta: @@ -692,11 +693,11 @@ class FrontPortCSVForm(NetBoxModelCSVForm): rear_port = CSVModelChoiceField( queryset=RearPort.objects.all(), to_field_name='name', - help_text='Corresponding rear port' + help_text=_('Corresponding rear port') ) type = CSVChoiceField( choices=PortTypeChoices, - help_text='Physical medium classification' + help_text=_('Physical medium classification') ) class Meta: @@ -706,7 +707,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): 'description', 'tags' ) help_texts = { - 'rear_port_position': 'Mapped position on corresponding rear port', + 'rear_port_position': _('Mapped position on corresponding rear port'), } def __init__(self, *args, **kwargs): @@ -738,7 +739,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): to_field_name='name' ) type = CSVChoiceField( - help_text='Physical medium classification', + help_text=_('Physical medium classification'), choices=PortTypeChoices, ) @@ -746,7 +747,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): model = RearPort fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags') help_texts = { - 'positions': 'Number of front ports which may be mapped' + 'positions': _('Number of front ports which may be mapped') } @@ -770,9 +771,9 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Child device installed within this bay', + help_text=_('Child device installed within this bay'), error_messages={ - 'invalid_choice': 'Child device not found.', + 'invalid_choice': _('Child device not found.'), } ) @@ -826,7 +827,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), to_field_name='name', required=False, - help_text='Parent inventory item' + help_text=_('Parent inventory item') ) class Meta: @@ -863,7 +864,7 @@ class InventoryItemRoleCSVForm(NetBoxModelCSVForm): model = InventoryItemRole fields = ('name', 'slug', 'color', 'description') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } @@ -876,53 +877,53 @@ class CableCSVForm(NetBoxModelCSVForm): side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side A device' + help_text=_('Side A device') ) side_a_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side A type' + help_text=_('Side A type') ) side_a_name = forms.CharField( - help_text='Side A component name' + help_text=_('Side A component name') ) # Termination B side_b_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side B device' + help_text=_('Side B device') ) side_b_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side B type' + help_text=_('Side B type') ) side_b_name = forms.CharField( - help_text='Side B component name' + help_text=_('Side B component name') ) # Cable attributes status = CSVChoiceField( choices=LinkStatusChoices, required=False, - help_text='Connection status' + help_text=_('Connection status') ) type = CSVChoiceField( choices=CableTypeChoices, required=False, - help_text='Physical medium classification' + help_text=_('Physical medium classification') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) length_unit = CSVChoiceField( choices=CableLengthUnitChoices, required=False, - help_text='Length unit' + help_text=_('Length unit') ) class Meta: @@ -932,7 +933,7 @@ class CableCSVForm(NetBoxModelCSVForm): 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', ] help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } def _clean_side(self, side): @@ -981,7 +982,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), to_field_name='name', required=False, - help_text='Master device' + help_text=_('Master device') ) class Meta: @@ -997,7 +998,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site' + help_text=_('Name of parent site') ) location = CSVModelChoiceField( queryset=Location.objects.all(), @@ -1023,40 +1024,40 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) power_panel = CSVModelChoiceField( queryset=PowerPanel.objects.all(), to_field_name='name', - help_text='Upstream power panel' + help_text=_('Upstream power panel') ) location = CSVModelChoiceField( queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's location (if any)" + help_text=_("Rack's location (if any)") ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text='Rack' + help_text=_('Rack') ) status = CSVChoiceField( choices=PowerFeedStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) type = CSVChoiceField( choices=PowerFeedTypeChoices, - help_text='Primary or redundant' + help_text=_('Primary or redundant') ) supply = CSVChoiceField( choices=PowerFeedSupplyChoices, - help_text='Supply type (AC/DC)' + help_text=_('Supply type (AC/DC)') ) phase = CSVChoiceField( choices=PowerFeedPhaseChoices, - help_text='Single or three-phase' + help_text=_('Single or three-phase') ) class Meta: diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index f484b48e1..9d5232ddf 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * @@ -12,13 +13,13 @@ class InterfaceCommonForm(forms.Form): mac_address = forms.CharField( empty_value=None, required=False, - label='MAC address' + label=_('MAC address') ) mtu = forms.IntegerField( required=False, min_value=INTERFACE_MTU_MIN, max_value=INTERFACE_MTU_MAX, - label='MTU' + label=_('MTU') ) def clean(self): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 537a89bad..ba5e51c41 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * @@ -16,7 +17,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField( queryset=Region.objects.all(), - label='Region', + label=_('Region'), required=False, initial_params={ 'sites': f'$termination_{cable_end}_site' @@ -24,7 +25,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), - label='Site group', + label=_('Site group'), required=False, initial_params={ 'sites': f'$termination_{cable_end}_site' @@ -32,7 +33,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField( queryset=Site.objects.all(), - label='Site', + label=_('Site'), required=False, query_params={ 'region_id': f'$termination_{cable_end}_region', @@ -41,7 +42,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField( queryset=Location.objects.all(), - label='Location', + label=_('Location'), required=False, null_option='None', query_params={ @@ -54,7 +55,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField( queryset=Rack.objects.all(), - label='Rack', + label=_('Rack'), required=False, null_option='None', initial_params={ @@ -67,7 +68,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device', + label=_('Device'), required=False, initial_params={ f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations' @@ -93,7 +94,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - label='Power Panel', + label=_('Power Panel'), required=False, initial_params={ 'powerfeeds__in': f'${cable_end}_terminations' @@ -105,7 +106,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), - label='Power Feed', + label=_('Power Feed'), disabled_indicator='_occupied', query_params={ 'power_panel_id': f'$termination_{cable_end}_powerpanel', @@ -117,7 +118,7 @@ def get_cable_form(a_type, b_type): attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField( queryset=Provider.objects.all(), - label='Provider', + label=_('Provider'), initial_params={ 'circuits': f'$termination_{cable_end}_circuit' }, @@ -125,7 +126,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField( queryset=Circuit.objects.all(), - label='Circuit', + label=_('Circuit'), initial_params={ 'terminations__in': f'${cable_end}_terminations' }, @@ -136,7 +137,7 @@ def get_cable_form(a_type, b_type): ) attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), - label='Side', + label=_('Side'), disabled_indicator='_occupied', query_params={ 'circuit_id': f'$termination_{cable_end}_circuit', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index da0148784..76d2d9204 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,7 +1,7 @@ from django import forms -from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField from dcim.choices import * @@ -163,14 +163,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): 'time_zone': StaticSelect(), } help_texts = { - 'name': "Full name of the site", - 'facility': "Data center provider and facility (e.g. Equinix NY7)", - 'time_zone': "Local time zone", - 'description': "Short description (will appear in sites list)", - 'physical_address': "Physical location of the building (e.g. for GPS)", - 'shipping_address': "If different from the physical address", - 'latitude': "Latitude in decimal format (xx.yyyyyy)", - 'longitude': "Longitude in decimal format (xx.yyyyyy)" + 'name': _("Full name of the site"), + 'facility': _("Data center provider and facility (e.g. Equinix NY7)"), + 'time_zone': _("Local time zone"), + 'description': _("Short description (will appear in sites list)"), + 'physical_address': _("Physical location of the building (e.g. for GPS)"), + 'shipping_address': _("If different from the physical address"), + 'latitude': _("Latitude in decimal format (xx.yyyyyy)"), + 'longitude': _("Longitude in decimal format (xx.yyyyyy)") } @@ -282,10 +282,10 @@ class RackForm(TenancyForm, NetBoxModelForm): 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags', ] help_texts = { - 'site': "The site at which the rack exists", - 'name': "Organizational rack name", - 'facility_id': "The unique rack ID assigned by the facility", - 'u_height': "Height in rack units", + 'site': _("The site at which the rack exists"), + 'name': _("Organizational rack name"), + 'facility_id': _("The unique rack ID assigned by the facility"), + 'u_height': _("Height in rack units"), } widgets = { 'status': StaticSelect(), @@ -335,7 +335,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ) units = NumericArrayField( base_field=forms.IntegerField(), - help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." + help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") ) user = forms.ModelChoiceField( queryset=User.objects.order_by( @@ -519,7 +519,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) position = forms.DecimalField( required=False, - help_text="The lowest-numbered unit occupied by the device", + help_text=_("The lowest-numbered unit occupied by the device"), widget=APISelect( api_url='/api/dcim/racks/{{rack}}/elevation/', attrs={ @@ -577,13 +577,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) vc_position = forms.IntegerField( required=False, - label='Position', - help_text="The position in the virtual chassis this device is identified by" + label=_('Position'), + help_text=_("The position in the virtual chassis this device is identified by") ) vc_priority = forms.IntegerField( required=False, - label='Priority', - help_text="The priority of the device in the virtual chassis" + label=_('Priority'), + help_text=_("The priority of the device in the virtual chassis") ) class Meta: @@ -595,10 +595,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'description', 'comments', 'tags', 'local_context_data' ] help_texts = { - 'device_role': "The function this device serves", - 'serial': "Chassis serial number", - 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " - "config context", + 'device_role': _("The function this device serves"), + 'serial': _("Chassis serial number"), + 'local_context_data': _("Local config context data overwrites all source contexts in the final rendered " + "config context"), } widgets = { 'face': StaticSelect(), @@ -695,13 +695,13 @@ class ModuleForm(NetBoxModelForm): replicate_components = forms.BooleanField( required=False, initial=True, - help_text="Automatically populate components associated with this module type" + help_text=_("Automatically populate components associated with this module type") ) adopt_components = forms.BooleanField( required=False, initial=False, - help_text="Adopt already existing components" + help_text=_("Adopt already existing components") ) fieldsets = ( @@ -1390,7 +1390,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface', + label=_('Parent interface'), query_params={ 'device_id': '$device', } @@ -1398,7 +1398,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): bridge = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Bridged interface', + label=_('Bridged interface'), query_params={ 'device_id': '$device', } @@ -1406,7 +1406,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='LAG interface', + label=_('LAG interface'), query_params={ 'device_id': '$device', 'type': 'lag', @@ -1415,12 +1415,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): wireless_lan_group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, - label='Wireless LAN group' + label=_('Wireless LAN group') ) wireless_lans = DynamicModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), required=False, - label='Wireless LANs', + label=_('Wireless LANs'), query_params={ 'group_id': '$wireless_lan_group', } @@ -1428,12 +1428,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', + label=_('Untagged VLAN'), query_params={ 'group_id': '$vlan_group', 'available_on_device': '$device', @@ -1442,7 +1442,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', + label=_('Tagged VLANs'), query_params={ 'group_id': '$vlan_group', 'available_on_device': '$device', @@ -1451,13 +1451,13 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) wwn = forms.CharField( empty_value=None, required=False, - label='WWN' + label=_('WWN') ) fieldsets = ( @@ -1495,8 +1495,8 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): } help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, - 'rf_channel_frequency': "Populated by selected channel (if set)", - 'rf_channel_width': "Populated by selected channel (if set)", + 'rf_channel_frequency': _("Populated by selected channel (if set)"), + 'rf_channel_width': _("Populated by selected channel (if set)"), } @@ -1570,8 +1570,8 @@ class DeviceBayForm(DeviceComponentForm): class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), - label='Child Device', - help_text="Child devices must first be created and assigned to the site/rack of the parent device.", + label=_('Child Device'), + help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."), widget=StaticSelect(), ) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index afdaa4fcc..6de193043 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.models import * from netbox.forms import NetBoxModelForm @@ -39,7 +40,7 @@ class ComponentCreateForm(forms.Form): name = ExpandableNameField() label = ExpandableNameField( required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by @@ -97,8 +98,8 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): rear_port = forms.MultipleChoiceField( choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', + label=_('Rear ports'), + help_text=_('Select one rear port assignment for each front port being created.'), ) # Override fieldsets from FrontPortTemplateForm to omit rear_port_position @@ -166,9 +167,9 @@ class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemp class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm): position = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) replication_fields = ('name', 'label', 'position') @@ -226,8 +227,8 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): rear_port = forms.MultipleChoiceField( choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', + label=_('Rear ports'), + help_text=_('Select one rear port assignment for each front port being created.'), ) # Override fieldsets from FrontPortForm to omit rear_port_position @@ -290,9 +291,9 @@ class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm): class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm): position = ExpandableNameField( - label='Position', + label=_('Position'), required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') ) replication_fields = ('name', 'label', 'position') @@ -352,7 +353,7 @@ class VirtualChassisCreateForm(NetBoxModelForm): initial_position = forms.IntegerField( initial=1, required=False, - help_text='Position of the first member device. Increases by one for each additional member.' + help_text=_('Position of the first member device. Increases by one for each additional member.') ) class Meta: diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 82ee093dd..920c0081f 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.models import * @@ -115,12 +116,12 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): poe_mode = forms.ChoiceField( choices=InterfacePoEModeChoices, required=False, - label='PoE mode' + label=_('PoE mode') ) poe_type = forms.ChoiceField( choices=InterfacePoETypeChoices, required=False, - label='PoE type' + label=_('PoE type') ) class Meta: diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 15389a2c0..3b136987d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.utils.translation import gettext as _ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * @@ -52,7 +53,7 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): label = models.CharField( max_length=64, blank=True, - help_text="Physical label" + help_text=_("Physical label") ) description = models.CharField( max_length=200, @@ -222,13 +223,13 @@ class PowerPortTemplate(ModularComponentTemplateModel): blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) component_model = PowerPort @@ -283,7 +284,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): max_length=50, choices=PowerOutletFeedLegChoices, blank=True, - help_text="Phase (for three-phase feeds)" + help_text=_("Phase (for three-phase feeds)") ) component_model = PowerOutlet @@ -526,7 +527,7 @@ class ModuleBayTemplate(ComponentTemplateModel): position = models.CharField( max_length=30, blank=True, - help_text='Identifier to reference when renaming installed components' + help_text=_('Identifier to reference when renaming installed components') ) component_model = ModuleBay @@ -621,7 +622,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): max_length=50, verbose_name='Part ID', blank=True, - help_text='Manufacturer-assigned part identifier' + help_text=_('Manufacturer-assigned part identifier') ) objects = TreeManager() diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 23c820571..7b3534a4e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum from django.urls import reverse +from django.utils.translation import gettext as _ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * @@ -60,7 +61,7 @@ class ComponentModel(NetBoxModel): label = models.CharField( max_length=64, blank=True, - help_text="Physical label" + help_text=_("Physical label") ) description = models.CharField( max_length=200, @@ -129,7 +130,7 @@ class CabledObjectModel(models.Model): ) mark_connected = models.BooleanField( default=False, - help_text="Treat as if a cable is connected" + help_text=_("Treat as if a cable is connected") ) cable_terminations = GenericRelation( @@ -261,13 +262,13 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=ConsolePortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) speed = models.PositiveIntegerField( choices=ConsolePortSpeedChoices, blank=True, null=True, - help_text='Port speed in bits per second' + help_text=_('Port speed in bits per second') ) clone_fields = ('device', 'module', 'type', 'speed') @@ -284,13 +285,13 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=ConsolePortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) speed = models.PositiveIntegerField( choices=ConsolePortSpeedChoices, blank=True, null=True, - help_text='Port speed in bits per second' + help_text=_('Port speed in bits per second') ) clone_fields = ('device', 'module', 'type', 'speed') @@ -311,19 +312,19 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerPortTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) maximum_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Maximum power draw (watts)" + help_text=_("Maximum power draw (watts)") ) allocated_draw = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], - help_text="Allocated power draw (watts)" + help_text=_("Allocated power draw (watts)") ) clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') @@ -420,7 +421,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerOutletTypeChoices, blank=True, - help_text='Physical port type' + help_text=_('Physical port type') ) power_port = models.ForeignKey( to='dcim.PowerPort', @@ -433,7 +434,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): max_length=50, choices=PowerOutletFeedLegChoices, blank=True, - help_text="Phase (for three-phase feeds)" + help_text=_("Phase (for three-phase feeds)") ) clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') @@ -550,7 +551,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd mgmt_only = models.BooleanField( default=False, verbose_name='Management only', - help_text='This interface is used only for out-of-band management' + help_text=_('This interface is used only for out-of-band management') ) speed = models.PositiveIntegerField( blank=True, @@ -567,7 +568,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd null=True, blank=True, verbose_name='WWN', - help_text='64-bit World Wide Name' + help_text=_('64-bit World Wide Name') ) rf_role = models.CharField( max_length=30, @@ -970,7 +971,7 @@ class ModuleBay(ComponentModel): position = models.CharField( max_length=30, blank=True, - help_text='Identifier to reference when renaming installed components' + help_text=_('Identifier to reference when renaming installed components') ) clone_fields = ('device',) @@ -1084,7 +1085,7 @@ class InventoryItem(MPTTModel, ComponentModel): max_length=50, verbose_name='Part ID', blank=True, - help_text='Manufacturer-assigned part identifier' + help_text=_('Manufacturer-assigned part identifier') ) serial = models.CharField( max_length=50, @@ -1097,11 +1098,11 @@ class InventoryItem(MPTTModel, ComponentModel): blank=True, null=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this item' + help_text=_('A unique tag used to identify this item') ) discovered = models.BooleanField( default=False, - help_text='This item was automatically discovered' + help_text=_('This item was automatically discovered') ) objects = TreeManager() diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index afa792ffb..b71a185b7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -12,6 +12,7 @@ from django.db.models import F, ProtectedError from django.db.models.functions import Lower from django.urls import reverse from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * @@ -84,7 +85,7 @@ class DeviceType(PrimaryModel, WeightMixin): part_number = models.CharField( max_length=50, blank=True, - help_text='Discrete part number (optional)' + help_text=_('Discrete part number (optional)') ) u_height = models.DecimalField( max_digits=4, @@ -95,15 +96,15 @@ class DeviceType(PrimaryModel, WeightMixin): is_full_depth = models.BooleanField( default=True, verbose_name='Is full depth', - help_text='Device consumes both front and rear rack faces' + help_text=_('Device consumes both front and rear rack faces') ) subdevice_role = models.CharField( max_length=50, choices=SubdeviceRoleChoices, blank=True, verbose_name='Parent/child status', - help_text='Parent devices house child devices in device bays. Leave blank ' - 'if this device type is neither a parent nor a child.' + help_text=_('Parent devices house child devices in device bays. Leave blank ' + 'if this device type is neither a parent nor a child.') ) airflow = models.CharField( max_length=50, @@ -314,7 +315,7 @@ class ModuleType(PrimaryModel, WeightMixin): part_number = models.CharField( max_length=50, blank=True, - help_text='Discrete part number (optional)' + help_text=_('Discrete part number (optional)') ) # Generic relations @@ -400,7 +401,7 @@ class DeviceRole(OrganizationalModel): vm_role = models.BooleanField( default=True, verbose_name='VM Role', - help_text='Virtual machines may be assigned to this role' + help_text=_('Virtual machines may be assigned to this role') ) def get_absolute_url(self): @@ -419,19 +420,19 @@ class Platform(OrganizationalModel): related_name='platforms', blank=True, null=True, - help_text='Optionally limit this platform to devices of a certain manufacturer' + help_text=_('Optionally limit this platform to devices of a certain manufacturer') ) napalm_driver = models.CharField( max_length=50, blank=True, verbose_name='NAPALM driver', - help_text='The name of the NAPALM driver to use when interacting with devices' + help_text=_('The name of the NAPALM driver to use when interacting with devices') ) napalm_args = models.JSONField( blank=True, null=True, verbose_name='NAPALM arguments', - help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' + help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)') ) def get_absolute_url(self): @@ -496,7 +497,7 @@ class Device(PrimaryModel, ConfigContextModel): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this device' + help_text=_('A unique tag used to identify this device') ) site = models.ForeignKey( to='dcim.Site', @@ -524,7 +525,7 @@ class Device(PrimaryModel, ConfigContextModel): null=True, validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', - help_text='The lowest-numbered unit occupied by the device' + help_text=_('The lowest-numbered unit occupied by the device') ) face = models.CharField( max_length=50, @@ -929,7 +930,7 @@ class Module(PrimaryModel, ConfigContextModel): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this device' + help_text=_('A unique tag used to identify this device') ) clone_fields = ('device', 'module_type') diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index e79cf4c44..a910b1437 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.choices import * from netbox.config import ConfigItem @@ -125,7 +126,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'), - help_text="Maximum permissible draw (percentage)" + help_text=_("Maximum permissible draw (percentage)") ) available_power = models.PositiveIntegerField( default=0, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e37fc8dc3..ff37aff5a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,6 +10,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * @@ -64,7 +65,7 @@ class Rack(PrimaryModel, WeightMixin): blank=True, null=True, verbose_name='Facility ID', - help_text='Locally-assigned identifier' + help_text=_('Locally-assigned identifier') ) site = models.ForeignKey( to='dcim.Site', @@ -96,7 +97,7 @@ class Rack(PrimaryModel, WeightMixin): related_name='racks', blank=True, null=True, - help_text='Functional role' + help_text=_('Functional role') ) serial = models.CharField( max_length=50, @@ -109,7 +110,7 @@ class Rack(PrimaryModel, WeightMixin): null=True, unique=True, verbose_name='Asset tag', - help_text='A unique tag used to identify this rack' + help_text=_('A unique tag used to identify this rack') ) type = models.CharField( choices=RackTypeChoices, @@ -121,28 +122,28 @@ class Rack(PrimaryModel, WeightMixin): choices=RackWidthChoices, default=RackWidthChoices.WIDTH_19IN, verbose_name='Width', - help_text='Rail-to-rail width' + help_text=_('Rail-to-rail width') ) u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)], - help_text='Height in rack units' + help_text=_('Height in rack units') ) desc_units = models.BooleanField( default=False, verbose_name='Descending units', - help_text='Units are numbered top-to-bottom' + help_text=_('Units are numbered top-to-bottom') ) outer_width = models.PositiveSmallIntegerField( blank=True, null=True, - help_text='Outer dimension of rack (width)' + help_text=_('Outer dimension of rack (width)') ) outer_depth = models.PositiveSmallIntegerField( blank=True, null=True, - help_text='Outer dimension of rack (depth)' + help_text=_('Outer dimension of rack (depth)') ) outer_unit = models.CharField( max_length=50, @@ -153,8 +154,8 @@ class Rack(PrimaryModel, WeightMixin): blank=True, null=True, help_text=( - 'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the ' - 'distance between the front and rear rails.' + _('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the ' + 'distance between the front and rear rails.') ) ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index c760119fb..33f695e70 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from timezone_field import TimeZoneField from dcim.choices import * @@ -178,7 +179,7 @@ class Site(PrimaryModel): facility = models.CharField( max_length=50, blank=True, - help_text='Local facility ID or description' + help_text=_('Local facility ID or description') ) asns = models.ManyToManyField( to='ipam.ASN', @@ -201,14 +202,14 @@ class Site(PrimaryModel): decimal_places=6, blank=True, null=True, - help_text='GPS coordinate (latitude)' + help_text=_('GPS coordinate (latitude)') ) longitude = models.DecimalField( max_digits=9, decimal_places=6, blank=True, null=True, - help_text='GPS coordinate (longitude)' + help_text=_('GPS coordinate (longitude)') ) # Generic relations diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6010c733a..824ba90cb 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -2,6 +2,7 @@ import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet @@ -32,7 +33,7 @@ __all__ = ( class WebhookFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type_id = MultiValueNumberFilter( field_name='content_types__id' @@ -61,7 +62,7 @@ class WebhookFilterSet(BaseFilterSet): class CustomFieldFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) type = django_filters.MultipleChoiceFilter( choices=CustomFieldTypeChoices @@ -92,7 +93,7 @@ class CustomFieldFilterSet(BaseFilterSet): class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type_id = MultiValueNumberFilter( field_name='content_types__id' @@ -119,7 +120,7 @@ class CustomLinkFilterSet(BaseFilterSet): class ExportTemplateFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type_id = MultiValueNumberFilter( field_name='content_types__id' @@ -142,7 +143,7 @@ class ExportTemplateFilterSet(BaseFilterSet): class SavedFilterFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type_id = MultiValueNumberFilter( field_name='content_types__id' @@ -150,13 +151,13 @@ class SavedFilterFilterSet(BaseFilterSet): content_types = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) usable = django_filters.BooleanFilter( method='_usable' @@ -191,7 +192,7 @@ class SavedFilterFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) created = django_filters.DateTimeFilter() content_type = ContentTypeFilter() @@ -211,13 +212,13 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): assigned_object_type = ContentTypeFilter() created_by_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) kind = django_filters.MultipleChoiceFilter( choices=JournalEntryKindChoices @@ -236,7 +237,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): class TagFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) content_type = MultiValueCharFilter( method='_content_type' @@ -288,138 +289,138 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) region_id = django_filters.ModelMultipleChoiceFilter( field_name='regions', queryset=Region.objects.all(), - label='Region', + label=_('Region'), ) region = django_filters.ModelMultipleChoiceFilter( field_name='regions__slug', queryset=Region.objects.all(), to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group = django_filters.ModelMultipleChoiceFilter( field_name='site_groups__slug', queryset=SiteGroup.objects.all(), to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_group_id = django_filters.ModelMultipleChoiceFilter( field_name='site_groups', queryset=SiteGroup.objects.all(), - label='Site group', + label=_('Site group'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), - label='Site', + label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( field_name='locations', queryset=Location.objects.all(), - label='Location', + label=_('Location'), ) location = django_filters.ModelMultipleChoiceFilter( field_name='locations__slug', queryset=Location.objects.all(), to_field_name='slug', - label='Location (slug)', + label=_('Location (slug)'), ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), - label='Device type', + label=_('Device type'), ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), - label='Role', + label=_('Role'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) platform_id = django_filters.ModelMultipleChoiceFilter( field_name='platforms', queryset=Platform.objects.all(), - label='Platform', + label=_('Platform'), ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platforms__slug', queryset=Platform.objects.all(), to_field_name='slug', - label='Platform (slug)', + label=_('Platform (slug)'), ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_types', queryset=ClusterType.objects.all(), - label='Cluster type', + label=_('Cluster type'), ) cluster_type = django_filters.ModelMultipleChoiceFilter( field_name='cluster_types__slug', queryset=ClusterType.objects.all(), to_field_name='slug', - label='Cluster type (slug)', + label=_('Cluster type (slug)'), ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups', queryset=ClusterGroup.objects.all(), - label='Cluster group', + label=_('Cluster group'), ) cluster_group = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', - label='Cluster group (slug)', + label=_('Cluster group (slug)'), ) cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='clusters', queryset=Cluster.objects.all(), - label='Cluster', + label=_('Cluster'), ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups', queryset=TenantGroup.objects.all(), - label='Tenant group', + label=_('Tenant group'), ) tenant_group = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='tenants', queryset=Tenant.objects.all(), - label='Tenant', + label=_('Tenant'), ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='tenants__slug', queryset=Tenant.objects.all(), to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) tag_id = django_filters.ModelMultipleChoiceFilter( field_name='tags', queryset=Tag.objects.all(), - label='Tag', + label=_('Tag'), ) tag = django_filters.ModelMultipleChoiceFilter( field_name='tags__slug', queryset=Tag.objects.all(), to_field_name='slug', - label='Tag (slug)', + label=_('Tag (slug)'), ) class Meta: @@ -443,7 +444,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): class LocalConfigContextFilterSet(django_filters.FilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', - label='Has local config context data', + label=_('Has local config context data'), ) def _local_context_data(self, queryset, name, value): @@ -453,19 +454,19 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) time = django_filters.DateTimeFromToRangeFilter() changed_object_type = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), - label='User (ID)', + label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User name', + label=_('User name'), ) class Meta: @@ -491,7 +492,7 @@ class ObjectChangeFilterSet(BaseFilterSet): class JobResultFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) created = django_filters.DateTimeFilter() created__before = django_filters.DateTimeFilter( @@ -547,7 +548,7 @@ class JobResultFilterSet(BaseFilterSet): class ContentTypeFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) class Meta: diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index a061d9784..6e245bcaf 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from extras.choices import * from extras.models import * @@ -37,7 +38,7 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False ) ui_visibility = forms.ChoiceField( - label="UI visibility", + label=_("UI visibility"), choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, initial='', @@ -143,23 +144,23 @@ class WebhookBulkEditForm(BulkEditForm): http_method = forms.ChoiceField( choices=add_blank_choice(WebhookHttpMethodChoices), required=False, - label='HTTP method' + label=_('HTTP method') ) payload_url = forms.CharField( required=False, - label='Payload URL' + label=_('Payload URL') ) ssl_verification = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='SSL verification' + label=_('SSL verification') ) secret = forms.CharField( required=False ) ca_file_path = forms.CharField( required=False, - label='CA file path' + label=_('CA file path') ) nullable_fields = ('secret', 'conditions', 'ca_file_path') diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 0f5974698..9def8fda6 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices from extras.models import * @@ -22,26 +23,26 @@ class CustomFieldCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) type = CSVChoiceField( choices=CustomFieldTypeChoices, - help_text='Field data type (e.g. text, integer, etc.)' + help_text=_('Field data type (e.g. text, integer, etc.)') ) object_type = CSVContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False, - help_text="Object type (for object or multi-object fields)" + help_text=_("Object type (for object or multi-object fields)") ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, - help_text='Comma-separated list of field choices' + help_text=_('Comma-separated list of field choices') ) ui_visibility = CSVChoiceField( choices=CustomFieldVisibilityChoices, - help_text='How the custom field is displayed in the user interface' + help_text=_('How the custom field is displayed in the user interface') ) class Meta: @@ -57,7 +58,7 @@ class CustomLinkCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) class Meta: @@ -72,7 +73,7 @@ class ExportTemplateCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) class Meta: @@ -85,7 +86,7 @@ class ExportTemplateCSVForm(CSVModelForm): class SavedFilterCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) class Meta: @@ -99,7 +100,7 @@ class WebhookCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), - help_text="One or more assigned object types" + help_text=_("One or more assigned object types") ) class Meta: @@ -118,5 +119,5 @@ class TagCSVForm(CSVModelForm): model = Tag fields = ('name', 'slug', 'color', 'description') help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 479367ff0..0421cab22 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -41,7 +41,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False, - label='Object type' + label=_('Object type') ) type = MultipleChoiceField( choices=CustomFieldTypeChoices, @@ -209,7 +209,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm): queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), required=False, - label='Object type' + label=_('Object type') ) http_method = MultipleChoiceField( choices=WebhookHttpMethodChoices, diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index 2b64d1a74..0a7dbdbcf 100644 --- a/netbox/extras/forms/mixins.py +++ b/netbox/extras/forms/mixins.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django import forms +from django.utils.translation import gettext as _ from extras.models import * from extras.choices import CustomFieldVisibilityChoices @@ -66,7 +67,7 @@ class SavedFiltersMixin(forms.Form): filter = DynamicModelMultipleChoiceField( queryset=SavedFilter.objects.all(), required=False, - label='Saved Filter', + label=_('Saved Filter'), query_params={ 'usable': True, } diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 97e80100a..192cdeeec 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.http import QueryDict +from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -31,14 +32,14 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - label='Model(s)' + label=_('Model(s)') ) object_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), # TODO: Come up with a canonical way to register suitable models limit_choices_to=FeatureQuery('webhooks'), required=False, - help_text="Type of the related object (for object/multi-object fields only)" + help_text=_("Type of the related object (for object/multi-object fields only)") ) fieldsets = ( @@ -54,8 +55,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' help_texts = { - 'type': "The type of data stored in this field. For object/multi-object fields, select the related object " - "type below." + 'type': _("The type of data stored in this field. For object/multi-object fields, select the related object " + "type below.") } widgets = { 'type': StaticSelect(), @@ -84,9 +85,9 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), } help_texts = { - 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ object }}. ' - 'Links which render as empty text will not be displayed.', - 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ object }}.', + 'link_text': _('Jinja2 template code for the link text. Reference the object as {{ object }}. ' + 'Links which render as empty text will not be displayed.'), + 'link_url': _('Jinja2 template code for the link URL. Reference the object as {{ object }}.'), } diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py index aa4f6223b..863cf29c1 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from utilities.forms import BootstrapMixin, DateTimePicker @@ -11,6 +12,6 @@ class ReportForm(BootstrapMixin, forms.Form): schedule_at = forms.DateTimeField( required=False, widget=DateTimePicker(), - label="Schedule at", - help_text="Schedule execution of report to a set time", + label=_("Schedule at"), + help_text=_("Schedule execution of report to a set time"), ) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index de55a3ee6..74c865c8d 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from utilities.forms import BootstrapMixin, DateTimePicker @@ -11,14 +12,14 @@ class ScriptForm(BootstrapMixin, forms.Form): _commit = forms.BooleanField( required=False, initial=True, - label="Commit changes", - help_text="Commit changes to the database (uncheck for a dry-run)" + label=_("Commit changes"), + help_text=_("Commit changes to the database (uncheck for a dry-run)") ) _schedule_at = forms.DateTimeField( required=False, widget=DateTimePicker(), - label="Schedule at", - help_text="Schedule execution of script to a set time", + label=_("Schedule at"), + help_text=_("Schedule execution of script to a set time"), ) def __init__(self, *args, **kwargs): diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 2de806ca6..b03149698 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -11,6 +11,7 @@ from django.db import models from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from extras.choices import * from extras.utils import FeatureQuery @@ -57,25 +58,25 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge to=ContentType, related_name='custom_fields', limit_choices_to=FeatureQuery('custom_fields'), - help_text='The object(s) to which this field applies.' + help_text=_('The object(s) to which this field applies.') ) type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, default=CustomFieldTypeChoices.TYPE_TEXT, - help_text='The type of data this custom field holds' + help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( to=ContentType, on_delete=models.PROTECT, blank=True, null=True, - help_text='The type of NetBox object this field maps to (for object fields)' + help_text=_('The type of NetBox object this field maps to (for object fields)') ) name = models.CharField( max_length=50, unique=True, - help_text='Internal field name', + help_text=_('Internal field name'), validators=( RegexValidator( regex=r'^[a-z0-9_]+$', @@ -87,13 +88,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge label = models.CharField( max_length=50, blank=True, - help_text='Name of the field as displayed to users (if not provided, ' - 'the field\'s name will be used)' + help_text=_('Name of the field as displayed to users (if not provided, ' + 'the field\'s name will be used)') ) group_name = models.CharField( max_length=50, blank=True, - help_text="Custom fields within the same group will be displayed together" + help_text=_("Custom fields within the same group will be displayed together") ) description = models.CharField( max_length=200, @@ -101,64 +102,64 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge ) required = models.BooleanField( default=False, - help_text='If true, this field is required when creating new objects ' - 'or editing an existing object.' + help_text=_('If true, this field is required when creating new objects ' + 'or editing an existing object.') ) search_weight = models.PositiveSmallIntegerField( default=1000, - help_text='Weighting for search. Lower values are considered more important. ' - 'Fields with a search weight of zero will be ignored.' + help_text=_('Weighting for search. Lower values are considered more important. ' + 'Fields with a search weight of zero will be ignored.') ) filter_logic = models.CharField( max_length=50, choices=CustomFieldFilterLogicChoices, default=CustomFieldFilterLogicChoices.FILTER_LOOSE, - help_text='Loose matches any instance of a given string; exact ' - 'matches the entire field.' + help_text=_('Loose matches any instance of a given string; exact ' + 'matches the entire field.') ) default = models.JSONField( blank=True, null=True, - help_text='Default value for the field (must be a JSON value). Encapsulate ' - 'strings with double quotes (e.g. "Foo").' + help_text=_('Default value for the field (must be a JSON value). Encapsulate ' + 'strings with double quotes (e.g. "Foo").') ) weight = models.PositiveSmallIntegerField( default=100, verbose_name='Display weight', - help_text='Fields with higher weights appear lower in a form.' + help_text=_('Fields with higher weights appear lower in a form.') ) validation_minimum = models.IntegerField( blank=True, null=True, verbose_name='Minimum value', - help_text='Minimum allowed value (for numeric fields)' + help_text=_('Minimum allowed value (for numeric fields)') ) validation_maximum = models.IntegerField( blank=True, null=True, verbose_name='Maximum value', - help_text='Maximum allowed value (for numeric fields)' + help_text=_('Maximum allowed value (for numeric fields)') ) validation_regex = models.CharField( blank=True, validators=[validate_regex], max_length=500, verbose_name='Validation regex', - help_text='Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. ' - 'For example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.' + help_text=_('Regular expression to enforce on text field values. Use ^ and $ to force matching of entire string. ' + 'For example, ^[A-Z]{3}$ will limit values to exactly three uppercase letters.') ) choices = ArrayField( base_field=models.CharField(max_length=100), blank=True, null=True, - help_text='Comma-separated list of available choices (for selection fields)' + help_text=_('Comma-separated list of available choices (for selection fields)') ) ui_visibility = models.CharField( max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, verbose_name='UI visibility', - help_text='Specifies the visibility of custom field in the UI' + help_text=_('Specifies the visibility of custom field in the UI') ) objects = CustomFieldManager() diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4b4e7c0cf..c33245f99 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,6 +12,7 @@ from django.http import HttpResponse, QueryDict from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format +from django.utils.translation import gettext as _ from rest_framework.utils.encoders import JSONEncoder import django_rq @@ -51,7 +52,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): related_name='webhooks', verbose_name='Object types', limit_choices_to=FeatureQuery('webhooks'), - help_text="The object(s) to which this Webhook applies." + help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( max_length=150, @@ -59,21 +60,21 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): ) type_create = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is created." + help_text=_("Call this webhook when a matching object is created.") ) type_update = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is updated." + help_text=_("Call this webhook when a matching object is updated.") ) type_delete = models.BooleanField( default=False, - help_text="Call this webhook when a matching object is deleted." + help_text=_("Call this webhook when a matching object is deleted.") ) payload_url = models.CharField( max_length=500, verbose_name='URL', - help_text='This URL will be called using the HTTP method defined when the webhook is called. ' - 'Jinja2 template processing is supported with the same context as the request body.' + help_text=_('This URL will be called using the HTTP method defined when the webhook is called. ' + 'Jinja2 template processing is supported with the same context as the request body.') ) enabled = models.BooleanField( default=True @@ -88,46 +89,46 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): max_length=100, default=HTTP_CONTENT_TYPE_JSON, verbose_name='HTTP content type', - help_text='The complete list of official content types is available ' - 'here.' + help_text=_('The complete list of official content types is available ' + 'here.') ) additional_headers = models.TextField( blank=True, - help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. " - "Headers should be defined in the format Name: Value. Jinja2 template processing is " - "supported with the same context as the request body (below)." + help_text=_("User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. " + "Headers should be defined in the format Name: Value. Jinja2 template processing is " + "supported with the same context as the request body (below).") ) body_template = models.TextField( blank=True, - help_text='Jinja2 template for a custom request body. If blank, a JSON object representing the change will be ' - 'included. Available context data includes: event, model, ' - 'timestamp, username, request_id, and data.' + help_text=_('Jinja2 template for a custom request body. If blank, a JSON object representing the change will be ' + 'included. Available context data includes: event, model, ' + 'timestamp, username, request_id, and data.') ) secret = models.CharField( max_length=255, blank=True, - help_text="When provided, the request will include a 'X-Hook-Signature' " - "header containing a HMAC hex digest of the payload body using " - "the secret as the key. The secret is not transmitted in " - "the request." + help_text=_("When provided, the request will include a 'X-Hook-Signature' " + "header containing a HMAC hex digest of the payload body using " + "the secret as the key. The secret is not transmitted in " + "the request.") ) conditions = models.JSONField( blank=True, null=True, - help_text="A set of conditions which determine whether the webhook will be generated." + help_text=_("A set of conditions which determine whether the webhook will be generated.") ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', - help_text="Enable SSL certificate verification. Disable with caution!" + help_text=_("Enable SSL certificate verification. Disable with caution!") ) ca_file_path = models.CharField( max_length=4096, null=True, blank=True, verbose_name='CA File Path', - help_text='The specific CA certificate file to use for SSL verification. ' - 'Leave blank to use the system defaults.' + help_text=_('The specific CA certificate file to use for SSL verification. ' + 'Leave blank to use the system defaults.') ) class Meta: @@ -201,7 +202,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged content_types = models.ManyToManyField( to=ContentType, related_name='custom_links', - help_text='The object type(s) to which this link applies.' + help_text=_('The object type(s) to which this link applies.') ) name = models.CharField( max_length=100, @@ -211,11 +212,11 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged default=True ) link_text = models.TextField( - help_text="Jinja2 template code for link text" + help_text=_("Jinja2 template code for link text") ) link_url = models.TextField( verbose_name='Link URL', - help_text="Jinja2 template code for link URL" + help_text=_("Jinja2 template code for link URL") ) weight = models.PositiveSmallIntegerField( default=100 @@ -223,17 +224,17 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged group_name = models.CharField( max_length=50, blank=True, - help_text="Links with the same group will appear as a dropdown menu" + help_text=_("Links with the same group will appear as a dropdown menu") ) button_class = models.CharField( max_length=30, choices=CustomLinkButtonClassChoices, default=CustomLinkButtonClassChoices.DEFAULT, - help_text="The class of the first link in a group will be used for the dropdown button" + help_text=_("The class of the first link in a group will be used for the dropdown button") ) new_window = models.BooleanField( default=False, - help_text="Force link to open in a new window" + help_text=_("Force link to open in a new window") ) clone_fields = ( @@ -272,7 +273,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='export_templates', - help_text='The object type(s) to which this template applies.' + help_text=_('The object type(s) to which this template applies.') ) name = models.CharField( max_length=100 @@ -282,23 +283,23 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): blank=True ) template_code = models.TextField( - help_text='Jinja2 template code. The list of objects being exported is passed as a context variable named ' - 'queryset.' + help_text=_('Jinja2 template code. The list of objects being exported is passed as a context variable named ' + 'queryset.') ) mime_type = models.CharField( max_length=50, blank=True, verbose_name='MIME type', - help_text='Defaults to text/plain' + help_text=_('Defaults to text/plain') ) file_extension = models.CharField( max_length=15, blank=True, - help_text='Extension to append to the rendered filename' + help_text=_('Extension to append to the rendered filename') ) as_attachment = models.BooleanField( default=True, - help_text="Download file as attachment" + help_text=_("Download file as attachment") ) class Meta: @@ -358,7 +359,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge content_types = models.ManyToManyField( to=ContentType, related_name='saved_filters', - help_text='The object type(s) to which this filter applies.' + help_text=_('The object type(s) to which this filter applies.') ) name = models.CharField( max_length=100, @@ -553,7 +554,7 @@ class JobResult(models.Model): related_name='job_results', verbose_name='Object types', limit_choices_to=FeatureQuery('job_results'), - help_text="The object type to which this job result applies", + help_text=_("The object type to which this job result applies"), on_delete=models.CASCADE, ) created = models.DateTimeField( diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py index a475b1cde..8aa161520 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem @@ -25,7 +26,7 @@ items = ( ) menu = PluginMenu( - label='Dummy', + label=_('Dummy'), groups=(('Group 1', items),), ) menu_items = items diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 360cf2a56..b5478b7a0 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -3,6 +3,7 @@ import netaddr from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q +from django.utils.translation import gettext as _ from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -41,24 +42,24 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), - label='Import target', + label=_('Import target'), ) import_target = django_filters.ModelMultipleChoiceFilter( field_name='import_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Import target (name)', + label=_('Import target (name)'), ) export_target_id = django_filters.ModelMultipleChoiceFilter( field_name='export_targets', queryset=RouteTarget.objects.all(), - label='Export target', + label=_('Export target'), ) export_target = django_filters.ModelMultipleChoiceFilter( field_name='export_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Export target (name)', + label=_('Export target (name)'), ) def search(self, queryset, name, value): @@ -79,24 +80,24 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): importing_vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='importing_vrfs', queryset=VRF.objects.all(), - label='Importing VRF', + label=_('Importing VRF'), ) importing_vrf = django_filters.ModelMultipleChoiceFilter( field_name='importing_vrfs__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='Import VRF (RD)', + label=_('Import VRF (RD)'), ) exporting_vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='exporting_vrfs', queryset=VRF.objects.all(), - label='Exporting VRF', + label=_('Exporting VRF'), ) exporting_vrf = django_filters.ModelMultipleChoiceFilter( field_name='exporting_vrfs__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='Export VRF (RD)', + label=_('Export VRF (RD)'), ) def search(self, queryset, name, value): @@ -126,17 +127,17 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) prefix = django_filters.CharFilter( method='filter_prefix', - label='Prefix', + label=_('Prefix'), ) rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), - label='RIR (ID)', + label=_('RIR (ID)'), ) rir = django_filters.ModelMultipleChoiceFilter( field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', - label='RIR (slug)', + label=_('RIR (slug)'), ) class Meta: @@ -169,24 +170,24 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), - label='RIR (ID)', + label=_('RIR (ID)'), ) rir = django_filters.ModelMultipleChoiceFilter( field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', - label='RIR (slug)', + label=_('RIR (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) class Meta: @@ -218,19 +219,19 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) prefix = MultiValueCharFilter( method='filter_prefix', - label='Prefix', + label=_('Prefix'), ) within = django_filters.CharFilter( method='search_within', - label='Within prefix', + label=_('Within prefix'), ) within_include = django_filters.CharFilter( method='search_within_include', - label='Within and including prefix', + label=_('Within and including prefix'), ) contains = django_filters.CharFilter( method='search_contains', - label='Prefixes which contain this prefix or IP', + label=_('Prefixes which contain this prefix or IP'), ) depth = MultiValueNumberFilter( field_name='_depth' @@ -252,78 +253,78 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) present_in_vrf_id = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', - label='VRF' + label=_('VRF') ) present_in_vrf = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), - label='VLAN (ID)', + label=_('VLAN (ID)'), ) vlan_vid = django_filters.NumberFilter( field_name='vlan__vid', - label='VLAN number (1-4094)', + label=_('VLAN number (1-4094)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=PrefixStatusChoices, @@ -406,27 +407,27 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): ) contains = django_filters.CharFilter( method='search_contains', - label='Ranges which contain this prefix or IP', + label=_('Ranges which contain this prefix or IP'), ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=IPRangeStatusChoices, @@ -468,87 +469,87 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) parent = MultiValueCharFilter( method='search_by_parent', - label='Parent prefix', + label=_('Parent prefix'), ) address = MultiValueCharFilter( method='filter_address', - label='Address', + label=_('Address'), ) mask_length = django_filters.NumberFilter( method='filter_mask_length', - label='Mask length', + label=_('Mask length'), ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) present_in_vrf_id = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', - label='VRF' + label=_('VRF') ) present_in_vrf = django_filters.ModelChoiceFilter( queryset=VRF.objects.all(), method='filter_present_in_vrf', to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = MultiValueCharFilter( method='filter_virtual_machine', field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = MultiValueNumberFilter( method='filter_virtual_machine', field_name='pk', - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.all(), to_field_name='name', - label='Interface (name)', + label=_('Interface (name)'), ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), - label='Interface (ID)', + label=_('Interface (ID)'), ) vminterface = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__name', queryset=VMInterface.objects.all(), to_field_name='name', - label='VM interface (name)', + label=_('VM interface (name)'), ) vminterface_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface', queryset=VMInterface.objects.all(), - label='VM interface (ID)', + label=_('VM interface (ID)'), ) fhrpgroup_id = django_filters.ModelMultipleChoiceFilter( field_name='fhrpgroup', queryset=FHRPGroup.objects.all(), - label='FHRP group (ID)', + label=_('FHRP group (ID)'), ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', - label='Is assigned to an interface', + label=_('Is assigned to an interface'), ) status = django_filters.MultipleChoiceFilter( choices=IPAddressStatusChoices, @@ -688,27 +689,27 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): interface_type = ContentTypeFilter() group_id = django_filters.ModelMultipleChoiceFilter( queryset=FHRPGroup.objects.all(), - label='Group (ID)', + label=_('Group (ID)'), ) device = MultiValueCharFilter( method='filter_device', field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = MultiValueNumberFilter( method='filter_device', field_name='pk', - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = MultiValueCharFilter( method='filter_virtual_machine', field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = MultiValueNumberFilter( method='filter_virtual_machine', field_name='pk', - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) class Meta: @@ -787,57 +788,57 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) group_id = django_filters.ModelMultipleChoiceFilter( queryset=VLANGroup.objects.all(), - label='Group (ID)', + label=_('Group (ID)'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='group__slug', queryset=VLANGroup.objects.all(), to_field_name='slug', - label='Group', + label=_('Group'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=VLANStatusChoices, @@ -893,23 +894,23 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet): class ServiceFilterSet(NetBoxModelFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( queryset=VirtualMachine.objects.all(), - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) port = NumericArrayFilter( field_name='ports', @@ -939,24 +940,24 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), - label='Import target', + label=_('Import target'), ) import_target = django_filters.ModelMultipleChoiceFilter( field_name='import_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Import target (name)', + label=_('Import target (name)'), ) export_target_id = django_filters.ModelMultipleChoiceFilter( field_name='export_targets', queryset=RouteTarget.objects.all(), - label='Export target', + label=_('Export target'), ) export_target = django_filters.ModelMultipleChoiceFilter( field_name='export_targets__name', queryset=RouteTarget.objects.all(), to_field_name='name', - label='Export target (name)', + label=_('Export target (name)'), ) class Meta: @@ -977,92 +978,92 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class L2VPNTerminationFilterSet(NetBoxModelFilterSet): l2vpn_id = django_filters.ModelMultipleChoiceFilter( queryset=L2VPN.objects.all(), - label='L2VPN (ID)', + label=_('L2VPN (ID)'), ) l2vpn = django_filters.ModelMultipleChoiceFilter( field_name='l2vpn__slug', queryset=L2VPN.objects.all(), to_field_name='slug', - label='L2VPN (slug)', + label=_('L2VPN (slug)'), ) region = MultiValueCharFilter( method='filter_region', field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) region_id = MultiValueNumberFilter( method='filter_region', field_name='pk', - label='Region (ID)', + label=_('Region (ID)'), ) site = MultiValueCharFilter( method='filter_site', field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) site_id = MultiValueNumberFilter( method='filter_site', field_name='pk', - label='Site (ID)', + label=_('Site (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='interface__device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device (name)', + label=_('Device (name)'), ) device_id = django_filters.ModelMultipleChoiceFilter( field_name='interface__device', queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', - label='Virtual machine (name)', + label=_('Virtual machine (name)'), ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__virtual_machine', queryset=VirtualMachine.objects.all(), - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.all(), to_field_name='name', - label='Interface (name)', + label=_('Interface (name)'), ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), - label='Interface (ID)', + label=_('Interface (ID)'), ) vminterface = django_filters.ModelMultipleChoiceFilter( field_name='vminterface__name', queryset=VMInterface.objects.all(), to_field_name='name', - label='VM interface (name)', + label=_('VM interface (name)'), ) vminterface_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface', queryset=VMInterface.objects.all(), - label='VM Interface (ID)', + label=_('VM Interface (ID)'), ) vlan = django_filters.ModelMultipleChoiceFilter( field_name='vlan__name', queryset=VLAN.objects.all(), to_field_name='name', - label='VLAN (name)', + label=_('VLAN (name)'), ) vlan_vid = django_filters.NumberFilter( field_name='vlan__vid', - label='VLAN number (1-4094)', + label=_('VLAN number (1-4094)'), ) vlan_id = django_filters.ModelMultipleChoiceFilter( field_name='vlan', queryset=VLAN.objects.all(), - label='VLAN (ID)', + label=_('VLAN (ID)'), ) assigned_object_type = ContentTypeFilter() diff --git a/netbox/ipam/forms/bulk_create.py b/netbox/ipam/forms/bulk_create.py index 790474c6e..6d07951a3 100644 --- a/netbox/ipam/forms/bulk_create.py +++ b/netbox/ipam/forms/bulk_create.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from utilities.forms import BootstrapMixin, ExpandableIPAddressField @@ -9,5 +10,5 @@ __all__ = ( class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): pattern = ExpandableIPAddressField( - label='Address pattern' + label=_('Address pattern') ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index ed1d1d9e9..d0af43975 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.models import Region, Site, SiteGroup from ipam.choices import * @@ -40,7 +41,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): enforce_unique = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Enforce unique space' + label=_('Enforce unique space') ) description = forms.CharField( max_length=200, @@ -104,7 +105,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, - label='RIR' + label=_('RIR') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -130,7 +131,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, - label='RIR' + label=_('RIR') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -191,7 +192,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) prefix_length = forms.IntegerField( min_value=PREFIX_LENGTH_MIN, @@ -214,12 +215,12 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): is_pool = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Is a pool' + label=_('Is a pool') ) mark_utilized = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect(), - label='Treat as 100% utilized' + label=_('Treat as 100% utilized') ) description = forms.CharField( max_length=200, @@ -245,7 +246,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -282,7 +283,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) mask_length = forms.IntegerField( min_value=IPADDRESS_MASK_LENGTH_MIN, @@ -306,7 +307,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): dns_name = forms.CharField( max_length=255, required=False, - label='DNS name' + label=_('DNS name') ) description = forms.CharField( max_length=200, @@ -336,18 +337,18 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): group_id = forms.IntegerField( min_value=0, required=False, - label='Group ID' + label=_('Group ID') ) auth_type = forms.ChoiceField( choices=add_blank_choice(FHRPGroupAuthTypeChoices), required=False, widget=StaticSelect(), - label='Authentication type' + label=_('Authentication type') ) auth_key = forms.CharField( max_length=255, required=False, - label='Authentication key' + label=_('Authentication key') ) name = forms.CharField( max_length=100, @@ -379,13 +380,13 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, required=False, - label='Minimum child VLAN VID' + label=_('Minimum child VLAN VID') ) max_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, required=False, - label='Maximum child VLAN VID' + label=_('Maximum child VLAN VID') ) description = forms.CharField( max_length=200, diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 4cd0bb69f..177233717 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Site from ipam.choices import * @@ -36,7 +37,7 @@ class VRFCSVForm(NetBoxModelCSVForm): queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -49,7 +50,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -64,7 +65,7 @@ class RIRCSVForm(NetBoxModelCSVForm): model = RIR fields = ('name', 'slug', 'is_private', 'description', 'tags') help_texts = { - 'name': 'RIR name', + 'name': _('RIR name'), } @@ -72,13 +73,13 @@ class AggregateCSVForm(NetBoxModelCSVForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', - help_text='Assigned RIR' + help_text=_('Assigned RIR') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -90,13 +91,13 @@ class ASNCSVForm(NetBoxModelCSVForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', - help_text='Assigned RIR' + help_text=_('Assigned RIR') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -117,41 +118,41 @@ class PrefixCSVForm(NetBoxModelCSVForm): queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) vlan_group = CSVModelChoiceField( queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - help_text="VLAN's group (if any)" + help_text=_("VLAN's group (if any)") ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, to_field_name='vid', - help_text="Assigned VLAN" + help_text=_("Assigned VLAN") ) status = CSVChoiceField( choices=PrefixStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: @@ -181,23 +182,23 @@ class IPRangeCSVForm(NetBoxModelCSVForm): queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=IPRangeStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: @@ -212,43 +213,43 @@ class IPAddressCSVForm(NetBoxModelCSVForm): queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=IPAddressStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVChoiceField( choices=IPAddressRoleChoices, required=False, - help_text='Functional role' + help_text=_('Functional role') ) device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Parent device of assigned interface (if any)' + help_text=_('Parent device of assigned interface (if any)') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Parent VM of assigned interface (if any)' + help_text=_('Parent VM of assigned interface (if any)') ) interface = CSVModelChoiceField( queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', - help_text='Assigned interface' + help_text=_('Assigned interface') ) is_primary = forms.BooleanField( - help_text='Make this the primary IP for the assigned device', + help_text=_('Make this the primary IP for the assigned device'), required=False ) @@ -333,7 +334,7 @@ class VLANGroupCSVForm(NetBoxModelCSVForm): scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - label='Scope type (app & model)' + label=_('Scope type (app & model)') ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, @@ -361,29 +362,29 @@ class VLANCSVForm(NetBoxModelCSVForm): queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Assigned site' + help_text=_('Assigned site') ) group = CSVModelChoiceField( queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned VLAN group' + help_text=_('Assigned VLAN group') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( choices=VLANStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) class Meta: @@ -398,7 +399,7 @@ class VLANCSVForm(NetBoxModelCSVForm): class ServiceTemplateCSVForm(NetBoxModelCSVForm): protocol = CSVChoiceField( choices=ServiceProtocolChoices, - help_text='IP protocol' + help_text=_('IP protocol') ) class Meta: @@ -411,17 +412,17 @@ class ServiceCSVForm(NetBoxModelCSVForm): queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Required if not assigned to a VM' + help_text=_('Required if not assigned to a VM') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Required if not assigned to a device' + help_text=_('Required if not assigned to a device') ) protocol = CSVChoiceField( choices=ServiceProtocolChoices, - help_text='IP protocol' + help_text=_('IP protocol') ) class Meta: @@ -437,7 +438,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): ) type = CSVChoiceField( choices=L2VPNTypeChoices, - help_text='L2VPN type' + help_text=_('L2VPN type') ) class Meta: @@ -450,31 +451,31 @@ class L2VPNTerminationCSVForm(NetBoxModelCSVForm): queryset=L2VPN.objects.all(), required=True, to_field_name='name', - label='L2VPN', + label=_('L2VPN'), ) device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Parent device (for interface)' + help_text=_('Parent device (for interface)') ) virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Parent virtual machine (for interface)' + help_text=_('Parent virtual machine (for interface)') ) interface = CSVModelChoiceField( queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', - help_text='Assigned interface (device or VM)' + help_text=_('Assigned interface (device or VM)') ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), required=False, to_field_name='name', - help_text='Assigned VLAN' + help_text=_('Assigned VLAN') ) class Meta: diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 7d277b33b..1a1496d7b 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -397,13 +397,13 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, - label='Minimum VID' + label=_('Minimum VID') ) max_vid = forms.IntegerField( required=False, min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, - label='Maximum VID' + label=_('Maximum VID') ) tag = TagFilterField(model) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 9a5abc082..56c0631f4 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from ipam.choices import * @@ -67,7 +68,7 @@ class VRFForm(TenancyForm, NetBoxModelForm): 'rd': "RD", } help_texts = { - 'rd': "Route distinguisher in any format", + 'rd': _("Route distinguisher in any format"), } @@ -104,7 +105,7 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label='RIR' + label=_('RIR') ) comments = CommentField() @@ -119,8 +120,8 @@ class AggregateForm(TenancyForm, NetBoxModelForm): 'prefix', 'rir', 'date_added', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] help_texts = { - 'prefix': "IPv4 or IPv6 network", - 'rir': "Regional Internet Registry responsible for this prefix", + 'prefix': _("IPv4 or IPv6 network"), + 'rir': _("Regional Internet Registry responsible for this prefix"), } widgets = { 'date_added': DatePicker(), @@ -130,11 +131,11 @@ class AggregateForm(TenancyForm, NetBoxModelForm): class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label='RIR', + label=_('RIR'), ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - label='Sites', + label=_('Sites'), required=False ) comments = CommentField() @@ -150,8 +151,8 @@ class ASNForm(TenancyForm, NetBoxModelForm): 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'comments', 'tags' ] help_texts = { - 'asn': "AS number", - 'rir': "Regional Internet Registry responsible for this prefix", + 'asn': _("AS number"), + 'rir': _("Regional Internet Registry responsible for this prefix"), } widgets = { 'date_added': DatePicker(), @@ -189,7 +190,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -217,7 +218,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group', + label=_('VLAN group'), null_option='None', query_params={ 'site': '$site' @@ -229,7 +230,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN', + label=_('VLAN'), query_params={ 'site_id': '$site', 'group_id': '$vlan_group', @@ -262,7 +263,7 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -311,7 +312,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Interface', + label=_('Interface'), query_params={ 'virtual_machine_id': '$virtual_machine' } @@ -319,17 +320,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): fhrpgroup = DynamicModelChoiceField( queryset=FHRPGroup.objects.all(), required=False, - label='FHRP Group' + label=_('FHRP Group') ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) nat_region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, - label='Region', + label=_('Region'), initial_params={ 'sites': '$nat_site' } @@ -337,7 +338,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_site_group = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False, - label='Site group', + label=_('Site group'), initial_params={ 'sites': '$nat_site' } @@ -345,7 +346,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', + label=_('Site'), query_params={ 'region_id': '$nat_region', 'group_id': '$nat_site_group', @@ -354,7 +355,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', + label=_('Rack'), null_option='None', query_params={ 'site_id': '$site' @@ -363,7 +364,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - label='Device', + label=_('Device'), query_params={ 'site_id': '$site', 'rack_id': '$nat_rack', @@ -372,12 +373,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster' + label=_('Cluster') ) nat_virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, - label='Virtual Machine', + label=_('Virtual Machine'), query_params={ 'cluster_id': '$nat_cluster', } @@ -385,12 +386,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): nat_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), required=False, - label='IP Address', + label=_('IP Address'), query_params={ 'device_id': '$nat_device', 'virtual_machine_id': '$nat_virtual_machine', @@ -399,7 +400,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ) primary_for_parent = forms.BooleanField( required=False, - label='Make this the primary IP for the device/VM' + label=_('Make this the primary IP for the device/VM') ) comments = CommentField() @@ -500,7 +501,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) class Meta: @@ -518,11 +519,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) q = forms.CharField( required=False, - label='Search', + label=_('Search'), ) @@ -532,16 +533,16 @@ class FHRPGroupForm(NetBoxModelForm): ip_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) ip_address = IPNetworkFormField( required=False, - label='Address' + label=_('Address') ) ip_status = forms.ChoiceField( choices=add_blank_choice(IPAddressStatusChoices), required=False, - label='Status' + label=_('Status') ) comments = CommentField() @@ -633,7 +634,7 @@ class VLANGroupForm(NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Site group' + label=_('Site group') ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -670,7 +671,7 @@ class VLANGroupForm(NetBoxModelForm): initial_params={ 'clusters': '$cluster' }, - label='Cluster group' + label=_('Cluster group') ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -734,7 +735,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): ), required=False, widget=StaticSelect, - label='Group scope' + label=_('Group scope') ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -742,7 +743,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): query_params={ 'scope_type': '$scope_type', }, - label='VLAN Group' + label=_('VLAN Group') ) # Site assignment fields @@ -752,7 +753,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Region' + label=_('Region') ) sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), @@ -760,7 +761,7 @@ class VLANForm(TenancyForm, NetBoxModelForm): initial_params={ 'sites': '$site' }, - label='Site group' + label=_('Site group') ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -786,12 +787,12 @@ class VLANForm(TenancyForm, NetBoxModelForm): 'tags', ] help_texts = { - 'site': "Leave blank if this VLAN spans multiple sites", - 'group': "VLAN group (optional)", - 'vid': "Configured VLAN ID", - 'name': "Configured VLAN name", - 'status': "Operational status of this VLAN", - 'role': "The primary function of this VLAN", + 'site': _("Leave blank if this VLAN spans multiple sites"), + 'group': _("VLAN group (optional)"), + 'vid': _("Configured VLAN ID"), + 'name': _("Configured VLAN name"), + 'status': _("Operational status of this VLAN"), + 'role': _("The primary function of this VLAN"), } widgets = { 'status': StaticSelect(), @@ -804,7 +805,7 @@ class ServiceTemplateForm(NetBoxModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ), - help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") ) comments = CommentField() @@ -836,12 +837,12 @@ class ServiceForm(NetBoxModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ), - help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") ) ipaddresses = DynamicModelMultipleChoiceField( queryset=IPAddress.objects.all(), required=False, - label='IP Addresses', + label=_('IP Addresses'), query_params={ 'device_id': '$device', 'virtual_machine_id': '$virtual_machine', @@ -855,8 +856,8 @@ class ServiceForm(NetBoxModelForm): 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] help_texts = { - 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " - "reachable via all IPs assigned to the device.", + 'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be " + "reachable via all IPs assigned to the device."), } widgets = { 'protocol': StaticSelect(), @@ -937,12 +938,12 @@ class L2VPNTerminationForm(NetBoxModelForm): queryset=L2VPN.objects.all(), required=True, query_params={}, - label='L2VPN', + label=_('L2VPN'), fetch_trigger='open' ) device_vlan = DynamicModelChoiceField( queryset=Device.objects.all(), - label="Available on Device", + label=_("Available on Device"), required=False, query_params={} ) @@ -952,7 +953,7 @@ class L2VPNTerminationForm(NetBoxModelForm): query_params={ 'available_on_device': '$device_vlan' }, - label='VLAN' + label=_('VLAN') ) device = DynamicModelChoiceField( queryset=Device.objects.all(), @@ -977,7 +978,7 @@ class L2VPNTerminationForm(NetBoxModelForm): query_params={ 'virtual_machine_id': '$virtual_machine' }, - label='Interface' + label=_('Interface') ) class Meta: diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index bf9bd6d7f..dd92f97cc 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -6,6 +6,7 @@ from django.db import models from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property +from django.utils.translation import gettext as _ from dcim.fields import ASNField from dcim.models import Device @@ -64,7 +65,7 @@ class RIR(OrganizationalModel): is_private = models.BooleanField( default=False, verbose_name='Private', - help_text='IP space managed by this RIR is considered private' + help_text=_('IP space managed by this RIR is considered private') ) class Meta: @@ -84,7 +85,7 @@ class ASN(PrimaryModel): asn = ASNField( unique=True, verbose_name='ASN', - help_text='32-bit autonomous system number' + help_text=_('32-bit autonomous system number') ) rir = models.ForeignKey( to='ipam.RIR', @@ -263,7 +264,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): assigned to a VLAN where appropriate. """ prefix = IPNetworkField( - help_text='IPv4 or IPv6 network with mask' + help_text=_('IPv4 or IPv6 network with mask') ) site = models.ForeignKey( to='dcim.Site', @@ -300,7 +301,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): choices=PrefixStatusChoices, default=PrefixStatusChoices.STATUS_ACTIVE, verbose_name='Status', - help_text='Operational status of this prefix' + help_text=_('Operational status of this prefix') ) role = models.ForeignKey( to='ipam.Role', @@ -308,16 +309,16 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): related_name='prefixes', blank=True, null=True, - help_text='The primary function of this prefix' + help_text=_('The primary function of this prefix') ) is_pool = models.BooleanField( verbose_name='Is a pool', default=False, - help_text='All IP addresses within this prefix are considered usable' + help_text=_('All IP addresses within this prefix are considered usable') ) mark_utilized = models.BooleanField( default=False, - help_text="Treat as 100% utilized" + help_text=_("Treat as 100% utilized") ) # Cached depth & child counts @@ -538,10 +539,10 @@ class IPRange(PrimaryModel): A range of IP addresses, defined by start and end addresses. """ start_address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) end_address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) size = models.PositiveIntegerField( editable=False @@ -565,7 +566,7 @@ class IPRange(PrimaryModel): max_length=50, choices=IPRangeStatusChoices, default=IPRangeStatusChoices.STATUS_ACTIVE, - help_text='Operational status of this range' + help_text=_('Operational status of this range') ) role = models.ForeignKey( to='ipam.Role', @@ -573,7 +574,7 @@ class IPRange(PrimaryModel): related_name='ip_ranges', blank=True, null=True, - help_text='The primary function of this range' + help_text=_('The primary function of this range') ) clone_fields = ( @@ -736,7 +737,7 @@ class IPAddress(PrimaryModel): which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. """ address = IPAddressField( - help_text='IPv4 or IPv6 address (with mask)' + help_text=_('IPv4 or IPv6 address (with mask)') ) vrf = models.ForeignKey( to='ipam.VRF', @@ -757,13 +758,13 @@ class IPAddress(PrimaryModel): max_length=50, choices=IPAddressStatusChoices, default=IPAddressStatusChoices.STATUS_ACTIVE, - help_text='The operational status of this IP' + help_text=_('The operational status of this IP') ) role = models.CharField( max_length=50, choices=IPAddressRoleChoices, blank=True, - help_text='The functional role of this IP' + help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( to=ContentType, @@ -788,14 +789,14 @@ class IPAddress(PrimaryModel): blank=True, null=True, verbose_name='NAT (Inside)', - help_text='The IP for which this address is the "outside" IP' + help_text=_('The IP for which this address is the "outside" IP') ) dns_name = models.CharField( max_length=255, blank=True, validators=[DNSValidator], verbose_name='DNS Name', - help_text='Hostname or FQDN (not case-sensitive)' + help_text=_('Hostname or FQDN (not case-sensitive)') ) objects = IPAddressManager() diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4f5d513cf..bf6c6a52e 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.models import Interface from ipam.choices import * @@ -50,7 +51,7 @@ class VLANGroup(OrganizationalModel): MinValueValidator(VLAN_VID_MIN), MaxValueValidator(VLAN_VID_MAX) ), - help_text='Lowest permissible ID of a child VLAN' + help_text=_('Lowest permissible ID of a child VLAN') ) max_vid = models.PositiveSmallIntegerField( verbose_name='Maximum VLAN ID', @@ -59,7 +60,7 @@ class VLANGroup(OrganizationalModel): MinValueValidator(VLAN_VID_MIN), MaxValueValidator(VLAN_VID_MAX) ), - help_text='Highest permissible ID of a child VLAN' + help_text=_('Highest permissible ID of a child VLAN') ) class Meta: diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 0f3c9793c..a1a53b3a7 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,5 +1,6 @@ from django.db import models from django.urls import reverse +from django.utils.translation import gettext as _ from ipam.constants import * from netbox.models import PrimaryModel @@ -26,7 +27,7 @@ class VRF(PrimaryModel): blank=True, null=True, verbose_name='Route distinguisher', - help_text='Unique route distinguisher (as defined in RFC 4364)' + help_text=_('Unique route distinguisher (as defined in RFC 4364)') ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -38,7 +39,7 @@ class VRF(PrimaryModel): enforce_unique = models.BooleanField( default=True, verbose_name='Enforce unique space', - help_text='Prevent duplicate prefixes/IP addresses within this VRF' + help_text=_('Prevent duplicate prefixes/IP addresses within this VRF') ) import_targets = models.ManyToManyField( to='ipam.RouteTarget', @@ -76,7 +77,7 @@ class RouteTarget(PrimaryModel): name = models.CharField( max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) unique=True, - help_text='Route target value (formatted in accordance with RFC 4360)' + help_text=_('Route target value (formatted in accordance with RFC 4360)') ) tenant = models.ForeignKey( to='tenancy.Tenant', diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index e2295888f..8efb0a033 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ class ConfigParam: @@ -18,9 +19,9 @@ PARAMS = ( # Banners ConfigParam( name='BANNER_LOGIN', - label='Login banner', + label=_('Login banner'), default='', - description="Additional content to display on the login page", + description=_("Additional content to display on the login page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -29,9 +30,9 @@ PARAMS = ( ), ConfigParam( name='BANNER_TOP', - label='Top banner', + label=_('Top banner'), default='', - description="Additional content to display at the top of every page", + description=_("Additional content to display at the top of every page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -40,9 +41,9 @@ PARAMS = ( ), ConfigParam( name='BANNER_BOTTOM', - label='Bottom banner', + label=_('Bottom banner'), default='', - description="Additional content to display at the bottom of every page", + description=_("Additional content to display at the bottom of every page"), field_kwargs={ 'widget': forms.Textarea( attrs={'class': 'vLargeTextField'} @@ -53,69 +54,69 @@ PARAMS = ( # IPAM ConfigParam( name='ENFORCE_GLOBAL_UNIQUE', - label='Globally unique IP space', + label=_('Globally unique IP space'), default=False, - description="Enforce unique IP addressing within the global table", + description=_("Enforce unique IP addressing within the global table"), field=forms.BooleanField ), ConfigParam( name='PREFER_IPV4', - label='Prefer IPv4', + label=_('Prefer IPv4'), default=False, - description="Prefer IPv4 addresses over IPv6", + description=_("Prefer IPv4 addresses over IPv6"), field=forms.BooleanField ), # Racks ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', - label='Rack unit height', + label=_('Rack unit height'), default=22, - description="Default unit height for rendered rack elevations", + description=_("Default unit height for rendered rack elevations"), field=forms.IntegerField ), ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', - label='Rack unit width', + label=_('Rack unit width'), default=220, - description="Default unit width for rendered rack elevations", + description=_("Default unit width for rendered rack elevations"), field=forms.IntegerField ), # Power ConfigParam( name='POWERFEED_DEFAULT_VOLTAGE', - label='Powerfeed voltage', + label=_('Powerfeed voltage'), default=120, - description="Default voltage for powerfeeds", + description=_("Default voltage for powerfeeds"), field=forms.IntegerField ), ConfigParam( name='POWERFEED_DEFAULT_AMPERAGE', - label='Powerfeed amperage', + label=_('Powerfeed amperage'), default=15, - description="Default amperage for powerfeeds", + description=_("Default amperage for powerfeeds"), field=forms.IntegerField ), ConfigParam( name='POWERFEED_DEFAULT_MAX_UTILIZATION', - label='Powerfeed max utilization', + label=_('Powerfeed max utilization'), default=80, - description="Default max utilization for powerfeeds", + description=_("Default max utilization for powerfeeds"), field=forms.IntegerField ), # Security ConfigParam( name='ALLOWED_URL_SCHEMES', - label='Allowed URL schemes', + label=_('Allowed URL schemes'), default=( 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', ), - description="Permitted schemes for URLs in user-provided content", + description=_("Permitted schemes for URLs in user-provided content"), field=SimpleArrayField, field_kwargs={'base_field': forms.CharField()} ), @@ -123,13 +124,13 @@ PARAMS = ( # Pagination ConfigParam( name='PAGINATE_COUNT', - label='Default page size', + label=_('Default page size'), default=50, field=forms.IntegerField ), ConfigParam( name='MAX_PAGE_SIZE', - label='Maximum page size', + label=_('Maximum page size'), default=1000, field=forms.IntegerField ), @@ -137,9 +138,9 @@ PARAMS = ( # Validation ConfigParam( name='CUSTOM_VALIDATORS', - label='Custom validators', + label=_('Custom validators'), default={}, - description="Custom validation rules (JSON)", + description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ 'widget': forms.Textarea( @@ -151,28 +152,28 @@ PARAMS = ( # NAPALM ConfigParam( name='NAPALM_USERNAME', - label='NAPALM username', + label=_('NAPALM username'), default='', - description="Username to use when connecting to devices via NAPALM" + description=_("Username to use when connecting to devices via NAPALM") ), ConfigParam( name='NAPALM_PASSWORD', - label='NAPALM password', + label=_('NAPALM password'), default='', - description="Password to use when connecting to devices via NAPALM" + description=_("Password to use when connecting to devices via NAPALM") ), ConfigParam( name='NAPALM_TIMEOUT', - label='NAPALM timeout', + label=_('NAPALM timeout'), default=30, - description="NAPALM connection timeout (in seconds)", + description=_("NAPALM connection timeout (in seconds)"), field=forms.IntegerField ), ConfigParam( name='NAPALM_ARGS', - label='NAPALM arguments', + label=_('NAPALM arguments'), default={}, - description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", + description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"), field=forms.JSONField, field_kwargs={ 'widget': forms.Textarea( @@ -184,46 +185,46 @@ PARAMS = ( # User preferences ConfigParam( name='DEFAULT_USER_PREFERENCES', - label='Default preferences', + label=_('Default preferences'), default={}, - description="Default preferences for new users", + description=_("Default preferences for new users"), field=forms.JSONField ), # Miscellaneous ConfigParam( name='MAINTENANCE_MODE', - label='Maintenance mode', + label=_('Maintenance mode'), default=False, - description="Enable maintenance mode", + description=_("Enable maintenance mode"), field=forms.BooleanField ), ConfigParam( name='GRAPHQL_ENABLED', - label='GraphQL enabled', + label=_('GraphQL enabled'), default=True, - description="Enable the GraphQL API", + description=_("Enable the GraphQL API"), field=forms.BooleanField ), ConfigParam( name='CHANGELOG_RETENTION', - label='Changelog retention', + label=_('Changelog retention'), default=90, - description="Days to retain changelog history (set to zero for unlimited)", + description=_("Days to retain changelog history (set to zero for unlimited)"), field=forms.IntegerField ), ConfigParam( name='JOBRESULT_RETENTION', - label='Job result retention', + label=_('Job result retention'), default=90, - description="Days to retain job result history (set to zero for unlimited)", + description=_("Days to retain job result history (set to zero for unlimited)"), field=forms.IntegerField ), ConfigParam( name='MAPS_URL', - label='Maps URL', + label=_('Maps URL'), default='https://maps.google.com/?q=', - description="Base URL for mapping geographic locations" + description=_("Base URL for mapping geographic locations") ), ) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 02ccdca50..8c39db287 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -5,6 +5,7 @@ from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext as _ from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter @@ -235,7 +236,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): """ q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) tag = TagFilter() diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index dd1fb7726..fd4998274 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -17,7 +17,7 @@ LOOKUP_CHOICES = ( class SearchForm(BootstrapMixin, forms.Form): q = forms.CharField( - label='Search', + label=_('Search'), widget=forms.TextInput( attrs={ 'hx-get': '', @@ -29,7 +29,7 @@ class SearchForm(BootstrapMixin, forms.Form): obj_types = forms.MultipleChoiceField( choices=[], required=False, - label='Object type(s)', + label=_('Object type(s)'), widget=StaticSelectMultiple() ) lookup = forms.ChoiceField( diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 4a4368a65..2d785400c 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q +from django.utils.translation import gettext as _ from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin @@ -132,7 +133,7 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi """ q = forms.CharField( required=False, - label='Search' + label=_('Search') ) def __init__(self, *args, **kwargs): diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index dcec76d91..a912c84d5 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,119 +1,120 @@ +from django.utils.translation import gettext as _ + from netbox.registry import registry from . import * - # # Nav menus # ORGANIZATION_MENU = Menu( - label='Organization', + label=_('Organization'), icon_class='mdi mdi-domain', groups=( MenuGroup( - label='Sites', + label=_('Sites'), items=( - get_model_item('dcim', 'site', 'Sites'), - get_model_item('dcim', 'region', 'Regions'), - get_model_item('dcim', 'sitegroup', 'Site Groups'), - get_model_item('dcim', 'location', 'Locations'), + get_model_item('dcim', 'site', _('Sites')), + get_model_item('dcim', 'region', _('Regions')), + get_model_item('dcim', 'sitegroup', _('Site Groups')), + get_model_item('dcim', 'location', _('Locations')), ), ), MenuGroup( - label='Racks', + label=_('Racks'), items=( - get_model_item('dcim', 'rack', 'Racks'), - get_model_item('dcim', 'rackrole', 'Rack Roles'), - get_model_item('dcim', 'rackreservation', 'Reservations'), + get_model_item('dcim', 'rack', _('Racks')), + get_model_item('dcim', 'rackrole', _('Rack Roles')), + get_model_item('dcim', 'rackreservation', _('Reservations')), MenuItem( link='dcim:rack_elevation_list', - link_text='Elevations', + link_text=_('Elevations'), permissions=['dcim.view_rack'] ), ), ), MenuGroup( - label='Tenancy', + label=_('Tenancy'), items=( - get_model_item('tenancy', 'tenant', 'Tenants'), - get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'), + get_model_item('tenancy', 'tenant', _('Tenants')), + get_model_item('tenancy', 'tenantgroup', _('Tenant Groups')), ), ), MenuGroup( - label='Contacts', + label=_('Contacts'), items=( - get_model_item('tenancy', 'contact', 'Contacts'), - get_model_item('tenancy', 'contactgroup', 'Contact Groups'), - get_model_item('tenancy', 'contactrole', 'Contact Roles'), + get_model_item('tenancy', 'contact', _('Contacts')), + get_model_item('tenancy', 'contactgroup', _('Contact Groups')), + get_model_item('tenancy', 'contactrole', _('Contact Roles')), ), ), ), ) DEVICES_MENU = Menu( - label='Devices', + label=_('Devices'), icon_class='mdi mdi-server', groups=( MenuGroup( - label='Devices', + label=_('Devices'), items=( - get_model_item('dcim', 'device', 'Devices'), - get_model_item('dcim', 'module', 'Modules'), - get_model_item('dcim', 'devicerole', 'Device Roles'), - get_model_item('dcim', 'platform', 'Platforms'), - get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), - get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'), + get_model_item('dcim', 'device', _('Devices')), + get_model_item('dcim', 'module', _('Modules')), + get_model_item('dcim', 'devicerole', _('Device Roles')), + get_model_item('dcim', 'platform', _('Platforms')), + get_model_item('dcim', 'virtualchassis', _('Virtual Chassis')), + get_model_item('dcim', 'virtualdevicecontext', _('Virtual Device Contexts')), ), ), MenuGroup( - label='Device Types', + label=_('Device Types'), items=( - get_model_item('dcim', 'devicetype', 'Device Types'), - get_model_item('dcim', 'moduletype', 'Module Types'), - get_model_item('dcim', 'manufacturer', 'Manufacturers'), + get_model_item('dcim', 'devicetype', _('Device Types')), + get_model_item('dcim', 'moduletype', _('Module Types')), + get_model_item('dcim', 'manufacturer', _('Manufacturers')), ), ), MenuGroup( - label='Device Components', + label=_('Device Components'), items=( - get_model_item('dcim', 'interface', 'Interfaces', actions=['import']), - get_model_item('dcim', 'frontport', 'Front Ports', actions=['import']), - get_model_item('dcim', 'rearport', 'Rear Ports', actions=['import']), - get_model_item('dcim', 'consoleport', 'Console Ports', actions=['import']), - get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']), - get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']), - get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']), - get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']), - get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']), - get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']), - get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'), + get_model_item('dcim', 'interface', _('Interfaces'), actions=['import']), + get_model_item('dcim', 'frontport', _('Front Ports'), actions=['import']), + get_model_item('dcim', 'rearport', _('Rear Ports'), actions=['import']), + get_model_item('dcim', 'consoleport', _('Console Ports'), actions=['import']), + get_model_item('dcim', 'consoleserverport', _('Console Server Ports'), actions=['import']), + get_model_item('dcim', 'powerport', _('Power Ports'), actions=['import']), + get_model_item('dcim', 'poweroutlet', _('Power Outlets'), actions=['import']), + get_model_item('dcim', 'modulebay', _('Module Bays'), actions=['import']), + get_model_item('dcim', 'devicebay', _('Device Bays'), actions=['import']), + get_model_item('dcim', 'inventoryitem', _('Inventory Items'), actions=['import']), + get_model_item('dcim', 'inventoryitemrole', _('Inventory Item Roles')), ), ), ), ) CONNECTIONS_MENU = Menu( - label='Connections', + label=_('Connections'), icon_class='mdi mdi-connection', groups=( MenuGroup( - label='Connections', + label=_('Connections'), items=( - get_model_item('dcim', 'cable', 'Cables', actions=['import']), - get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']), + get_model_item('dcim', 'cable', _('Cables'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), MenuItem( link='dcim:interface_connections_list', - link_text='Interface Connections', + link_text=_('Interface Connections'), permissions=['dcim.view_interface'] ), MenuItem( link='dcim:console_connections_list', - link_text='Console Connections', + link_text=_('Console Connections'), permissions=['dcim.view_consoleport'] ), MenuItem( link='dcim:power_connections_list', - link_text='Power Connections', + link_text=_('Power Connections'), permissions=['dcim.view_powerport'] ), ), @@ -122,192 +123,192 @@ CONNECTIONS_MENU = Menu( ) WIRELESS_MENU = Menu( - label='Wireless', + label=_('Wireless'), icon_class='mdi mdi-wifi', groups=( MenuGroup( - label='Wireless', + label=_('Wireless'), items=( - get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), - get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'), + get_model_item('wireless', 'wirelesslan', _('Wireless LANs')), + get_model_item('wireless', 'wirelesslangroup', _('Wireless LAN Groups')), ), ), ), ) IPAM_MENU = Menu( - label='IPAM', + label=_('IPAM'), icon_class='mdi mdi-counter', groups=( MenuGroup( - label='IP Addresses', + label=_('IP Addresses'), items=( - get_model_item('ipam', 'ipaddress', 'IP Addresses'), - get_model_item('ipam', 'iprange', 'IP Ranges'), + get_model_item('ipam', 'ipaddress', _('IP Addresses')), + get_model_item('ipam', 'iprange', _('IP Ranges')), ), ), MenuGroup( - label='Prefixes', + label=_('Prefixes'), items=( - get_model_item('ipam', 'prefix', 'Prefixes'), - get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), + get_model_item('ipam', 'prefix', _('Prefixes')), + get_model_item('ipam', 'role', _('Prefix & VLAN Roles')), ), ), MenuGroup( - label='ASNs', + label=_('ASNs'), items=( - get_model_item('ipam', 'asn', 'ASNs'), + get_model_item('ipam', 'asn', _('ASNs')), ), ), MenuGroup( - label='Aggregates', + label=_('Aggregates'), items=( - get_model_item('ipam', 'aggregate', 'Aggregates'), - get_model_item('ipam', 'rir', 'RIRs'), + get_model_item('ipam', 'aggregate', _('Aggregates')), + get_model_item('ipam', 'rir', _('RIRs')), ), ), MenuGroup( - label='VRFs', + label=_('VRFs'), items=( - get_model_item('ipam', 'vrf', 'VRFs'), - get_model_item('ipam', 'routetarget', 'Route Targets'), + get_model_item('ipam', 'vrf', _('VRFs')), + get_model_item('ipam', 'routetarget', _('Route Targets')), ), ), MenuGroup( - label='VLANs', + label=_('VLANs'), items=( - get_model_item('ipam', 'vlan', 'VLANs'), - get_model_item('ipam', 'vlangroup', 'VLAN Groups'), + get_model_item('ipam', 'vlan', _('VLANs')), + get_model_item('ipam', 'vlangroup', _('VLAN Groups')), ), ), MenuGroup( - label='Other', + label=_('Other'), items=( - get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), - get_model_item('ipam', 'servicetemplate', 'Service Templates'), - get_model_item('ipam', 'service', 'Services'), + get_model_item('ipam', 'fhrpgroup', _('FHRP Groups')), + get_model_item('ipam', 'servicetemplate', _('Service Templates')), + get_model_item('ipam', 'service', _('Services')), ), ), ), ) OVERLAY_MENU = Menu( - label='Overlay', + label=_('Overlay'), icon_class='mdi mdi-graph-outline', groups=( MenuGroup( label='L2VPNs', items=( - get_model_item('ipam', 'l2vpn', 'L2VPNs'), - get_model_item('ipam', 'l2vpntermination', 'Terminations'), + get_model_item('ipam', 'l2vpn', _('L2VPNs')), + get_model_item('ipam', 'l2vpntermination', _('Terminations')), ), ), ), ) VIRTUALIZATION_MENU = Menu( - label='Virtualization', + label=_('Virtualization'), icon_class='mdi mdi-monitor', groups=( MenuGroup( - label='Virtual Machines', + label=_('Virtual Machines'), items=( - get_model_item('virtualization', 'virtualmachine', 'Virtual Machines'), - get_model_item('virtualization', 'vminterface', 'Interfaces', actions=['import']), + get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), + get_model_item('virtualization', 'vminterface', _('Interfaces'), actions=['import']), ), ), MenuGroup( - label='Clusters', + label=_('Clusters'), items=( - get_model_item('virtualization', 'cluster', 'Clusters'), - get_model_item('virtualization', 'clustertype', 'Cluster Types'), - get_model_item('virtualization', 'clustergroup', 'Cluster Groups'), + get_model_item('virtualization', 'cluster', _('Clusters')), + get_model_item('virtualization', 'clustertype', _('Cluster Types')), + get_model_item('virtualization', 'clustergroup', _('Cluster Groups')), ), ), ), ) CIRCUITS_MENU = Menu( - label='Circuits', + label=_('Circuits'), icon_class='mdi mdi-transit-connection-variant', groups=( MenuGroup( - label='Circuits', + label=_('Circuits'), items=( - get_model_item('circuits', 'circuit', 'Circuits'), - get_model_item('circuits', 'circuittype', 'Circuit Types'), + get_model_item('circuits', 'circuit', _('Circuits')), + get_model_item('circuits', 'circuittype', _('Circuit Types')), ), ), MenuGroup( - label='Providers', + label=_('Providers'), items=( - get_model_item('circuits', 'provider', 'Providers'), - get_model_item('circuits', 'providernetwork', 'Provider Networks'), + get_model_item('circuits', 'provider', _('Providers')), + get_model_item('circuits', 'providernetwork', _('Provider Networks')), ), ), ), ) POWER_MENU = Menu( - label='Power', + label=_('Power'), icon_class='mdi mdi-flash', groups=( MenuGroup( - label='Power', + label=_('Power'), items=( - get_model_item('dcim', 'powerfeed', 'Power Feeds'), - get_model_item('dcim', 'powerpanel', 'Power Panels'), + get_model_item('dcim', 'powerfeed', _('Power Feeds')), + get_model_item('dcim', 'powerpanel', _('Power Panels')), ), ), ), ) OTHER_MENU = Menu( - label='Other', + label=_('Other'), icon_class='mdi mdi-notification-clear-all', groups=( MenuGroup( - label='Logging', + label=_('Logging'), items=( - get_model_item('extras', 'journalentry', 'Journal Entries', actions=[]), - get_model_item('extras', 'objectchange', 'Change Log', actions=[]), + get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]), + get_model_item('extras', 'objectchange', _('Change Log'), actions=[]), ), ), MenuGroup( - label='Customization', + label=_('Customization'), items=( - get_model_item('extras', 'customfield', 'Custom Fields'), - get_model_item('extras', 'customlink', 'Custom Links'), - get_model_item('extras', 'exporttemplate', 'Export Templates'), - get_model_item('extras', 'savedfilter', 'Saved Filters'), + get_model_item('extras', 'customfield', _('Custom Fields')), + get_model_item('extras', 'customlink', _('Custom Links')), + get_model_item('extras', 'exporttemplate', _('Export Templates')), + get_model_item('extras', 'savedfilter', _('Saved Filters')), ), ), MenuGroup( - label='Integrations', + label=_('Integrations'), items=( - get_model_item('extras', 'webhook', 'Webhooks'), + get_model_item('extras', 'webhook', _('Webhooks')), MenuItem( link='extras:report_list', - link_text='Reports', + link_text=_('Reports'), permissions=['extras.view_report'] ), MenuItem( link='extras:script_list', - link_text='Scripts', + link_text=_('Scripts'), permissions=['extras.view_script'] ), MenuItem( link='extras:jobresult_list', - link_text='Job Results', + link_text=_('Job Results'), permissions=['extras.view_jobresult'], ), ), ), MenuGroup( - label='Other', + label=_('Other'), items=( get_model_item('extras', 'tag', 'Tags'), - get_model_item('extras', 'configcontext', 'Config Contexts', actions=['add']), + get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), ), ), ), @@ -342,7 +343,7 @@ if registry['plugins']['menu_items']: for label, items in registry['plugins']['menu_items'].items() ] plugins_menu = Menu( - label="Plugins", + label=_("Plugins"), icon_class="mdi mdi-puzzle", groups=groups ) diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 95fd101c3..c88b56072 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from netbox.registry import registry from users.preferences import UserPreference from utilities.paginator import EnhancedPaginator @@ -13,7 +14,7 @@ PREFERENCES = { # User interface 'ui.colormode': UserPreference( - label='Color mode', + label=_('Color mode'), choices=( ('light', 'Light'), ('dark', 'Dark'), @@ -21,25 +22,25 @@ PREFERENCES = { default='light', ), 'pagination.per_page': UserPreference( - label='Page length', + label=_('Page length'), choices=get_page_lengths(), - description='The number of objects to display per page', + description=_('The number of objects to display per page'), coerce=lambda x: int(x) ), 'pagination.placement': UserPreference( - label='Paginator placement', + label=_('Paginator placement'), choices=( ('bottom', 'Bottom'), ('top', 'Top'), ('both', 'Both'), ), - description='Where the paginator controls will be displayed relative to a table', + description=_('Where the paginator controls will be displayed relative to a table'), default='bottom' ), # Miscellaneous 'data_format': UserPreference( - label='Data format', + label=_('Data format'), choices=( ('json', 'JSON'), ('yaml', 'YAML'), diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index dd14a412b..ab74949ff 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.db.models import Q +from django.utils.translation import gettext as _ from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter @@ -25,13 +26,13 @@ __all__ = ( class ContactGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - label='Contact group (ID)', + label=_('Contact group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=ContactGroup.objects.all(), to_field_name='slug', - label='Contact group (slug)', + label=_('Contact group (slug)'), ) class Meta: @@ -51,14 +52,14 @@ class ContactFilterSet(NetBoxModelFilterSet): queryset=ContactGroup.objects.all(), field_name='group', lookup_expr='in', - label='Contact group (ID)', + label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='group', lookup_expr='in', to_field_name='slug', - label='Contact group (slug)', + label=_('Contact group (slug)'), ) class Meta: @@ -83,17 +84,17 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): content_type = ContentTypeFilter() contact_id = django_filters.ModelMultipleChoiceFilter( queryset=Contact.objects.all(), - label='Contact (ID)', + label=_('Contact (ID)'), ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactRole.objects.all(), - label='Contact role (ID)', + label=_('Contact role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=ContactRole.objects.all(), to_field_name='slug', - label='Contact role (slug)', + label=_('Contact role (slug)'), ) class Meta: @@ -105,18 +106,18 @@ class ContactModelFilterSet(django_filters.FilterSet): contact = django_filters.ModelMultipleChoiceFilter( field_name='contacts__contact', queryset=Contact.objects.all(), - label='Contact', + label=_('Contact'), ) contact_role = django_filters.ModelMultipleChoiceFilter( field_name='contacts__role', queryset=ContactRole.objects.all(), - label='Contact Role' + label=_('Contact Role') ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='contacts__contact__group', lookup_expr='in', - label='Contact group', + label=_('Contact group'), ) @@ -127,13 +128,13 @@ class ContactModelFilterSet(django_filters.FilterSet): class TenantGroupFilterSet(OrganizationalModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), - label='Tenant group (ID)', + label=_('Tenant group (ID)'), ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) class Meta: @@ -146,14 +147,14 @@ class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=TenantGroup.objects.all(), field_name='group', lookup_expr='in', - label='Tenant group (ID)', + label=_('Tenant group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='group', lookup_expr='in', to_field_name='slug', - label='Tenant group (slug)', + label=_('Tenant group (slug)'), ) class Meta: @@ -179,22 +180,22 @@ class TenancyFilterSet(django_filters.FilterSet): queryset=TenantGroup.objects.all(), field_name='tenant__group', lookup_expr='in', - label='Tenant Group (ID)', + label=_('Tenant Group (ID)'), ) tenant_group = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='tenant__group', to_field_name='slug', lookup_expr='in', - label='Tenant Group (slug)', + label=_('Tenant Group (slug)'), ) tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), - label='Tenant (ID)', + label=_('Tenant (ID)'), ) tenant = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), field_name='tenant__slug', to_field_name='slug', - label='Tenant (slug)', + label=_('Tenant (slug)'), ) diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 137f79d42..27092f9e8 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelCSVForm from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField @@ -20,7 +21,7 @@ class TenantGroupCSVForm(NetBoxModelCSVForm): queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Parent group' + help_text=_('Parent group') ) slug = SlugField() @@ -35,7 +36,7 @@ class TenantCSVForm(NetBoxModelCSVForm): queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) class Meta: @@ -52,7 +53,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm): queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text='Parent group' + help_text=_('Parent group') ) slug = SlugField() @@ -74,7 +75,7 @@ class ContactCSVForm(NetBoxModelCSVForm): queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) class Meta: diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 540735ecc..986ddd0aa 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import Group, User from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError +from django.utils.translation import gettext as _ from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import ObjectPermission, Token @@ -46,7 +47,7 @@ class GroupAdminForm(forms.ModelForm): class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, - help_text="If no key is provided, one will be generated automatically." + help_text=_("If no key is provided, one will be generated automatically.") ) class Meta: @@ -70,10 +71,10 @@ class ObjectPermissionForm(forms.ModelForm): model = ObjectPermission exclude = [] help_texts = { - 'actions': 'Actions granted in addition to those listed above', - 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.' + 'actions': _('Actions granted in addition to those listed above'), + 'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.') } labels = { 'actions': 'Additional actions' diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 71246956b..4ae9df89a 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,6 +1,7 @@ import django_filters from django.contrib.auth.models import Group, User from django.db.models import Q +from django.utils.translation import gettext as _ from netbox.filtersets import BaseFilterSet from users.models import ObjectPermission, Token @@ -15,7 +16,7 @@ __all__ = ( class GroupFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) class Meta: @@ -31,18 +32,18 @@ class GroupFilterSet(BaseFilterSet): class UserFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) group_id = django_filters.ModelMultipleChoiceFilter( field_name='groups', queryset=Group.objects.all(), - label='Group', + label=_('Group'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='groups__name', queryset=Group.objects.all(), to_field_name='name', - label='Group (name)', + label=_('Group (name)'), ) class Meta: @@ -63,18 +64,18 @@ class UserFilterSet(BaseFilterSet): class TokenFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='user', queryset=User.objects.all(), - label='User', + label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) created = django_filters.DateTimeFilter() created__gte = django_filters.DateTimeFilter( @@ -111,29 +112,29 @@ class TokenFilterSet(BaseFilterSet): class ObjectPermissionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', - label='Search', + label=_('Search'), ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=User.objects.all(), - label='User', + label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='users__username', queryset=User.objects.all(), to_field_name='username', - label='User (name)', + label=_('User (name)'), ) group_id = django_filters.ModelMultipleChoiceFilter( field_name='groups', queryset=Group.objects.all(), - label='Group', + label=_('Group'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='groups__name', queryset=Group.objects.all(), to_field_name='name', - label='Group (name)', + label=_('Group (name)'), ) class Meta: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 048005f13..e8647aa5f 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from django.utils.translation import gettext as _ from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES @@ -100,14 +101,14 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( required=False, - help_text="If no key is provided, one will be generated automatically." + help_text=_("If no key is provided, one will be generated automatically.") ) allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(), required=False, - label='Allowed IPs', - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64', + label=_('Allowed IPs'), + help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64'), ) class Meta: diff --git a/netbox/users/models.py b/netbox/users/models.py index 441ed2eee..07e903569 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -10,6 +10,7 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.utils.translation import gettext as _ from netaddr import IPNetwork from ipam.fields import IPNetworkField @@ -216,7 +217,7 @@ class Token(models.Model): ) write_enabled = models.BooleanField( default=True, - help_text='Permit create/update/delete operations using this key' + help_text=_('Permit create/update/delete operations using this key') ) description = models.CharField( max_length=200, @@ -227,8 +228,8 @@ class Token(models.Model): blank=True, null=True, verbose_name='Allowed IPs', - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'), ) def __str__(self): @@ -304,12 +305,12 @@ class ObjectPermission(models.Model): ) actions = ArrayField( base_field=models.CharField(max_length=30), - help_text="The list of actions granted by this permission" + help_text=_("The list of actions granted by this permission") ) constraints = models.JSONField( blank=True, null=True, - help_text="Queryset filter matching the applicable objects of the selected type(s)" + help_text=_("Queryset filter matching the applicable objects of the selected type(s)") ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py index 59765cae8..f964d0db0 100644 --- a/netbox/utilities/forms/fields/csv.py +++ b/netbox/utilities/forms/fields/csv.py @@ -5,6 +5,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import Q +from django.utils.translation import gettext as _ from utilities.choices import unpack_grouped_choices from utilities.forms.utils import parse_csv, validate_csv @@ -50,9 +51,9 @@ class CSVDataField(forms.CharField): if not self.initial: self.initial = ','.join(self.required_fields) + '\n' if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' + self.help_text = _('Enter the list of column headers followed by one line per record to be imported, using ' + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' + 'in double quotes.') def to_python(self, value): reader = csv.reader(StringIO(value.strip())) diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py index fca370c26..781de9f76 100644 --- a/netbox/utilities/forms/fields/expandable.py +++ b/netbox/utilities/forms/fields/expandable.py @@ -1,6 +1,7 @@ import re from django import forms +from django.utils.translation import gettext as _ from utilities.forms.constants import * from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern @@ -42,8 +43,8 @@ class ExpandableIPAddressField(forms.CharField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
    '\ - 'Example: 192.0.2.[1,5,100-254]/24' + self.help_text = _('Specify a numeric range to create multiple IPs.
    ' + 'Example: 192.0.2.[1,5,100-254]/24') def to_python(self, value): # Hackish address family detection but it's all we have to work with diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index df69339e5..bb6c3f73b 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -4,6 +4,7 @@ from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.templatetags.static import static +from django.utils.translation import gettext as _ from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -45,7 +46,7 @@ class SlugField(forms.SlugField): slug_source: Name of the form field from which the slug value will be derived """ widget = widgets.SlugWidget - help_text = "URL-friendly unique shorthand" + help_text = _("URL-friendly unique shorthand") def __init__(self, *, slug_source='name', help_text=help_text, **kwargs): super().__init__(help_text=help_text, **kwargs) @@ -97,7 +98,7 @@ class JSONField(_JSONField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Enter context data in JSON format.' + self.help_text = _('Enter context data in JSON format.') self.widget.attrs['placeholder'] = '' self.widget.attrs['class'] = 'font-monospace' diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 0569853b8..5756cf0e3 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -5,8 +5,9 @@ from io import StringIO import yaml from django import forms -from utilities.forms.utils import parse_csv +from django.utils.translation import gettext as _ +from utilities.forms.utils import parse_csv from .choices import ImportFormatChoices from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect @@ -103,7 +104,7 @@ class BulkRenameForm(BootstrapMixin, forms.Form): use_regex = forms.BooleanField( required=False, initial=True, - label='Use regular expressions' + label=_('Use regular expressions') ) def clean(self): @@ -145,7 +146,7 @@ class ImportForm(BootstrapMixin, forms.Form): data = forms.CharField( required=False, widget=forms.Textarea(attrs={'class': 'font-monospace'}), - help_text="Enter object data in CSV, JSON or YAML format." + help_text=_("Enter object data in CSV, JSON or YAML format.") ) data_file = forms.FileField( label="Data file", @@ -219,7 +220,7 @@ class FilterForm(BootstrapMixin, forms.Form): """ q = forms.CharField( required=False, - label='Search' + label=_('Search') ) @@ -233,7 +234,7 @@ class TableConfigForm(BootstrapMixin, forms.Form): widget=forms.SelectMultiple( attrs={'size': 10, 'class': 'form-select'} ), - label='Available Columns' + label=_('Available Columns') ) columns = forms.MultipleChoiceField( choices=[], @@ -241,7 +242,7 @@ class TableConfigForm(BootstrapMixin, forms.Form): widget=forms.SelectMultiple( attrs={'size': 10, 'class': 'form-select'} ), - label='Selected Columns' + label=_('Selected Columns') ) def __init__(self, table, *args, **kwargs): diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 1b9c5bc78..ef33ebddc 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.db.models import Q +from django.utils.translation import gettext as _ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet @@ -38,57 +39,57 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), - label='Parent group (ID)', + label=_('Parent group (ID)'), ) group = django_filters.ModelMultipleChoiceFilter( field_name='group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', - label='Parent group (slug)', + label=_('Parent group (slug)'), ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterType.objects.all(), - label='Cluster type (ID)', + label=_('Cluster type (ID)'), ) type = django_filters.ModelMultipleChoiceFilter( field_name='type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', - label='Cluster type (slug)', + label=_('Cluster type (slug)'), ) status = django_filters.MultipleChoiceFilter( choices=ClusterStatusChoices, @@ -121,111 +122,111 @@ class VirtualMachineFilterSet( cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__group', queryset=ClusterGroup.objects.all(), - label='Cluster group (ID)', + label=_('Cluster group (ID)'), ) cluster_group = django_filters.ModelMultipleChoiceFilter( field_name='cluster__group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', - label='Cluster group (slug)', + label=_('Cluster group (slug)'), ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__type', queryset=ClusterType.objects.all(), - label='Cluster type (ID)', + label=_('Cluster type (ID)'), ) cluster_type = django_filters.ModelMultipleChoiceFilter( field_name='cluster__type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', - label='Cluster type (slug)', + label=_('Cluster type (slug)'), ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), - label='Cluster (ID)', + label=_('Cluster (ID)'), ) cluster = django_filters.ModelMultipleChoiceFilter( field_name='cluster__name', queryset=Cluster.objects.all(), to_field_name='name', - label='Cluster', + label=_('Cluster'), ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label=_('Device (ID)'), ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', queryset=Device.objects.all(), to_field_name='name', - label='Device', + label=_('Device'), ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', - label='Region (ID)', + label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', lookup_expr='in', to_field_name='slug', - label='Region (slug)', + label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', - label='Site group (ID)', + label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='site__group', lookup_expr='in', to_field_name='slug', - label='Site group (slug)', + label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), - label='Site (ID)', + label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', - label='Site (slug)', + label=_('Site (slug)'), ) name = MultiValueCharFilter( lookup_expr='iexact' ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceRole.objects.all(), - label='Role (ID)', + label=_('Role (ID)'), ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', - label='Role (slug)', + label=_('Role (slug)'), ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), - label='Platform (ID)', + label=_('Platform (ID)'), ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', - label='Platform (slug)', + label=_('Platform (slug)'), ) mac_address = MultiValueMACAddressFilter( field_name='interfaces__mac_address', - label='MAC address', + label=_('MAC address'), ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP'), ) class Meta: @@ -251,48 +252,48 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', queryset=Cluster.objects.all(), - label='Cluster (ID)', + label=_('Cluster (ID)'), ) cluster = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster__name', queryset=Cluster.objects.all(), to_field_name='name', - label='Cluster', + label=_('Cluster'), ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine', queryset=VirtualMachine.objects.all(), - label='Virtual machine (ID)', + label=_('Virtual machine (ID)'), ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', - label='Virtual machine', + label=_('Virtual machine'), ) parent_id = django_filters.ModelMultipleChoiceFilter( field_name='parent', queryset=VMInterface.objects.all(), - label='Parent interface (ID)', + label=_('Parent interface (ID)'), ) bridge_id = django_filters.ModelMultipleChoiceFilter( field_name='bridge', queryset=VMInterface.objects.all(), - label='Bridged interface (ID)', + label=_('Bridged interface (ID)'), ) mac_address = MultiValueMACAddressFilter( - label='MAC address', + label=_('MAC address'), ) vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='vrf', queryset=VRF.objects.all(), - label='VRF', + label=_('VRF'), ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', - label='VRF (RD)', + label=_('VRF (RD)'), ) class Meta: diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py index 03997f88d..54722c7b1 100644 --- a/netbox/virtualization/forms/bulk_create.py +++ b/netbox/virtualization/forms/bulk_create.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from utilities.forms import BootstrapMixin, ExpandableNameField, form_from_model from virtualization.models import VMInterface, VirtualMachine @@ -14,7 +15,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): widget=forms.MultipleHiddenInput() ) name = ExpandableNameField( - label='Name' + label=_('Name') ) def clean_tags(self): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index a94b2da1c..14ae89c37 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN @@ -90,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = Cluster @@ -147,15 +148,15 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): ) vcpus = forms.IntegerField( required=False, - label='vCPUs' + label=_('vCPUs') ) memory = forms.IntegerField( required=False, - label='Memory (MB)' + label=_('Memory (MB)') ) disk = forms.IntegerField( required=False, - label='Disk (GB)' + label=_('Disk (GB)') ) description = forms.CharField( max_length=200, @@ -163,7 +164,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField( widget=SmallTextarea, - label='Comments' + label=_('Comments') ) model = VirtualMachine @@ -199,7 +200,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=INTERFACE_MTU_MIN, max_value=INTERFACE_MTU_MAX, - label='MTU' + label=_('MTU') ) description = forms.CharField( max_length=100, @@ -213,7 +214,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -221,7 +222,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): query_params={ 'group_id': '$vlan_group', }, - label='Untagged VLAN' + label=_('Untagged VLAN') ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), @@ -229,12 +230,12 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): query_params={ 'group_id': '$vlan_group', }, - label='Tagged VLANs' + label=_('Tagged VLANs') ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) model = VMInterface diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 6fc704ae4..154c21328 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,6 @@ from dcim.choices import InterfaceModeChoices from dcim.models import Device, DeviceRole, Platform, Site +from django.utils.translation import gettext as _ from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -36,29 +37,29 @@ class ClusterCSVForm(NetBoxModelCSVForm): type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', - help_text='Type of cluster' + help_text=_('Type of cluster') ) group = CSVModelChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='name', required=False, - help_text='Assigned cluster group' + help_text=_('Assigned cluster group') ) status = CSVChoiceField( choices=ClusterStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', required=False, - help_text='Assigned site' + help_text=_('Assigned site') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) class Meta: @@ -69,25 +70,25 @@ class ClusterCSVForm(NetBoxModelCSVForm): class VirtualMachineCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, - help_text='Operational status' + help_text=_('Operational status') ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', required=False, - help_text='Assigned site' + help_text=_('Assigned site') ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', required=False, - help_text='Assigned cluster' + help_text=_('Assigned cluster') ) device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', required=False, - help_text='Assigned device within cluster' + help_text=_('Assigned device within cluster') ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( @@ -95,19 +96,19 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): ), required=False, to_field_name='name', - help_text='Functional role' + help_text=_('Functional role') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Assigned platform' + help_text=_('Assigned platform') ) class Meta: @@ -127,24 +128,24 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): queryset=VMInterface.objects.all(), required=False, to_field_name='name', - help_text='Parent interface' + help_text=_('Parent interface') ) bridge = CSVModelChoiceField( queryset=VMInterface.objects.all(), required=False, to_field_name='name', - help_text='Bridged interface' + help_text=_('Bridged interface') ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, - help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') ) vrf = CSVModelChoiceField( queryset=VRF.objects.all(), required=False, to_field_name='rd', - help_text='Assigned VRF' + help_text=_('Assigned VRF') ) class Meta: diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 62fa4002e..c4fdf033a 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -160,11 +160,11 @@ class VirtualMachineFilterForm( ) mac_address = forms.CharField( required=False, - label='MAC address' + label=_('MAC address') ) has_primary_ip = forms.NullBooleanField( required=False, - label='Has a primary IP', + label=_('Has a primary IP'), widget=StaticSelect( choices=BOOLEAN_WITH_BLANK_CHOICES ) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 3f598d061..037af0b5c 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ from dcim.forms.common import InterfaceCommonForm from dcim.forms.model_forms import INTERFACE_MODE_HELP_TEXT @@ -204,7 +205,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): 'cluster_id': '$cluster', 'site_id': '$site', }, - help_text="Optionally pin this VM to a specific host device within the cluster" + help_text=_("Optionally pin this VM to a specific host device within the cluster") ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), @@ -240,8 +241,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): 'local_context_data', ] help_texts = { - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " - "config context", + 'local_context_data': _("Local config context data overwrites all sources contexts in the final rendered " + "config context"), } widgets = { "status": StaticSelect(), @@ -297,7 +298,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Parent interface', + label=_('Parent interface'), query_params={ 'virtual_machine_id': '$virtual_machine', } @@ -305,7 +306,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): bridge = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Bridged interface', + label=_('Bridged interface'), query_params={ 'virtual_machine_id': '$virtual_machine', } @@ -313,12 +314,12 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group' + label=_('VLAN group') ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='Untagged VLAN', + label=_('Untagged VLAN'), query_params={ 'group_id': '$vlan_group', 'available_on_virtualmachine': '$virtual_machine', @@ -327,7 +328,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - label='Tagged VLANs', + label=_('Tagged VLANs'), query_params={ 'group_id': '$vlan_group', 'available_on_virtualmachine': '$virtual_machine', @@ -336,7 +337,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF' + label=_('VRF') ) fieldsets = ( diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 7544327a5..be54faf9e 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from ipam.models import VLAN @@ -45,12 +46,12 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN' + label=_('VLAN') ) ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False, - label='SSID' + label=_('SSID') ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -66,7 +67,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): ) auth_psk = forms.CharField( required=False, - label='Pre-shared key' + label=_('Pre-shared key') ) description = forms.CharField( max_length=200, @@ -91,7 +92,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False, - label='SSID' + label=_('SSID') ) status = forms.ChoiceField( choices=add_blank_choice(LinkStatusChoices), @@ -111,7 +112,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ) auth_psk = forms.CharField( required=False, - label='Pre-shared key' + label=_('Pre-shared key') ) description = forms.CharField( max_length=200, diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 4d96f60ad..adf2a2b6c 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN @@ -19,7 +20,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm): queryset=WirelessLANGroup.objects.all(), required=False, to_field_name='name', - help_text='Parent group' + help_text=_('Parent group') ) slug = SlugField() @@ -33,7 +34,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): queryset=WirelessLANGroup.objects.all(), required=False, to_field_name='name', - help_text='Assigned group' + help_text=_('Assigned group') ) status = CSVChoiceField( choices=WirelessLANStatusChoices, @@ -43,23 +44,23 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): queryset=VLAN.objects.all(), required=False, to_field_name='name', - help_text='Bridged VLAN' + help_text=_('Bridged VLAN') ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, - help_text='Authentication type' + help_text=_('Authentication type') ) auth_cipher = CSVChoiceField( choices=WirelessAuthCipherChoices, required=False, - help_text='Authentication cipher' + help_text=_('Authentication cipher') ) class Meta: @@ -73,7 +74,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class WirelessLinkCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=LinkStatusChoices, - help_text='Connection status' + help_text=_('Connection status') ) interface_a = CSVModelChoiceField( queryset=Interface.objects.all() @@ -85,17 +86,17 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, - help_text='Authentication type' + help_text=_('Authentication type') ) auth_cipher = CSVChoiceField( choices=WirelessAuthCipherChoices, required=False, - help_text='Authentication cipher' + help_text=_('Authentication cipher') ) class Meta: diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index c3e63687d..287ef779c 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -35,7 +35,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ) ssid = forms.CharField( required=False, - label='SSID' + label=_('SSID') ) group_id = DynamicModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), @@ -74,7 +74,7 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ) ssid = forms.CharField( required=False, - label='SSID' + label=_('SSID') ) status = forms.ChoiceField( required=False, diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index e59c36696..8b45b0116 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm @@ -63,7 +64,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group', + label=_('VLAN group'), null_option='None', query_params={ 'site': '$site' @@ -75,7 +76,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN', + label=_('VLAN'), query_params={ 'site_id': '$site', 'group_id': '$vlan_group', @@ -107,7 +108,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', + label=_('Site'), initial_params={ 'devices': '$device_a', } @@ -118,7 +119,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'site_id': '$site_a', }, required=False, - label='Location', + label=_('Location'), initial_params={ 'devices': '$device_a', } @@ -130,7 +131,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'location_id': '$location_a', }, required=False, - label='Device', + label=_('Device'), initial_params={ 'interfaces': '$interface_a' } @@ -142,12 +143,12 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'device_id': '$device_a', }, disabled_indicator='_occupied', - label='Interface' + label=_('Interface') ) site_b = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', + label=_('Site'), initial_params={ 'devices': '$device_b', } @@ -158,7 +159,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'site_id': '$site_b', }, required=False, - label='Location', + label=_('Location'), initial_params={ 'devices': '$device_b', } @@ -170,7 +171,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'location_id': '$location_b', }, required=False, - label='Device', + label=_('Device'), initial_params={ 'interfaces': '$interface_b' } @@ -182,7 +183,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm): 'device_id': '$device_b', }, disabled_indicator='_occupied', - label='Interface' + label=_('Interface') ) comments = CommentField() From 5729a06348afb5640146f2e7a7c1452ca3f1fede Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 14 Nov 2022 09:20:02 -0500 Subject: [PATCH 240/409] Fixes #10910: Fix cable creation links on power port view --- docs/release-notes/version-3.3.md | 1 + netbox/templates/dcim/powerport.html | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f23b03a25..89e6909ff 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ * [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set * [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count * [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page +* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view --- diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 18814a428..476ee44d3 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -77,10 +77,10 @@ From d97113119812962ea89192d9acd33b0eb7c4b988 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 14 Nov 2022 09:24:12 -0500 Subject: [PATCH 241/409] Fixes #10897: Fix form widget styling on FHRP group form --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/forms/models.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 89e6909ff..aa396f475 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ * [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set * [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count * [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page +* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form * [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view --- diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 1986b1590..95723f80d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -549,6 +549,11 @@ class FHRPGroupForm(NetBoxModelForm): fields = ( 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', ) + widgets = { + 'protocol': StaticSelect(), + 'auth_type': StaticSelect(), + 'ip_status': StaticSelect(), + } def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) From 84005093581f51da59344e86b79c0b72d3b437af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 14 Nov 2022 09:28:03 -0500 Subject: [PATCH 242/409] Fixes #10891: Populate tag selection list for service filter form --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/forms/filtersets.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index aa396f475..df84c8de4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ * [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set * [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count * [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page +* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form * [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form * [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index ecf63b49f..9566e69a9 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + tag = TagFilterField(model) class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): From cd8943144bec52ff608ddad3db5d0155832a4a23 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 2 Nov 2022 17:40:43 -0400 Subject: [PATCH 243/409] Use context vars instead of thread-local storage for change logging --- netbox/extras/context_managers.py | 15 +++++++-------- netbox/extras/signals.py | 29 ++++++++++++++--------------- netbox/netbox/__init__.py | 3 --- netbox/netbox/context.py | 10 ++++++++++ netbox/netbox/request_context.py | 9 --------- 5 files changed, 31 insertions(+), 35 deletions(-) create mode 100644 netbox/netbox/context.py delete mode 100644 netbox/netbox/request_context.py diff --git a/netbox/extras/context_managers.py b/netbox/extras/context_managers.py index 9f73fe9c3..d4aeb8364 100644 --- a/netbox/extras/context_managers.py +++ b/netbox/extras/context_managers.py @@ -3,8 +3,7 @@ from contextlib import contextmanager from django.db.models.signals import m2m_changed, pre_delete, post_save from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object -from netbox import thread_locals -from netbox.request_context import set_request +from netbox.context import current_request, webhooks_queue from .webhooks import flush_webhooks @@ -16,8 +15,8 @@ def change_logging(request): :param request: WSGIRequest object with a unique `id` set """ - set_request(request) - thread_locals.webhook_queue = [] + current_request.set(request) + webhooks_queue.set([]) # Connect our receivers to the post_save and post_delete signals. post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object') @@ -35,8 +34,8 @@ def change_logging(request): clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue') # Flush queued webhooks to RQ - flush_webhooks(thread_locals.webhook_queue) - del thread_locals.webhook_queue + flush_webhooks(webhooks_queue.get()) - # Clear the request from thread-local storage - set_request(None) + # Clear context vars + current_request.set(None) + webhooks_queue.set([]) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index aff350cc4..8854d6314 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -7,9 +7,8 @@ from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.validators import CustomValidator -from netbox import thread_locals from netbox.config import get_config -from netbox.request_context import get_request +from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange @@ -30,7 +29,7 @@ def handle_changed_object(sender, instance, **kwargs): if not hasattr(instance, 'to_objectchange'): return - request = get_request() + request = current_request.get() m2m_changed = False def is_same_object(instance, webhook_data): @@ -69,13 +68,14 @@ def handle_changed_object(sender, instance, **kwargs): objectchange.save() # If this is an M2M change, update the previously queued webhook (from post_save) - webhook_queue = thread_locals.webhook_queue - if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]): + queue = webhooks_queue.get() + if m2m_changed and queue and is_same_object(instance, queue[-1]): instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments - webhook_queue[-1]['data'] = serialize_for_webhook(instance) - webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] + queue[-1]['data'] = serialize_for_webhook(instance) + queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] else: - enqueue_object(webhook_queue, instance, request.user, request.id, action) + enqueue_object(queue, instance, request.user, request.id, action) + webhooks_queue.set(queue) # Increment metric counters if action == ObjectChangeActionChoices.ACTION_CREATE: @@ -91,7 +91,7 @@ def handle_deleted_object(sender, instance, **kwargs): if not hasattr(instance, 'to_objectchange'): return - request = get_request() + request = current_request.get() # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): @@ -101,8 +101,9 @@ def handle_deleted_object(sender, instance, **kwargs): objectchange.save() # Enqueue webhooks - webhook_queue = thread_locals.webhook_queue - enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) + queue = webhooks_queue.get() + enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) + webhooks_queue.set(queue) # Increment metric counters model_deletes.labels(instance._meta.model_name).inc() @@ -113,10 +114,8 @@ def clear_webhook_queue(sender, **kwargs): Delete any queued webhooks (e.g. because of an aborted bulk transaction) """ logger = logging.getLogger('webhooks') - webhook_queue = thread_locals.webhook_queue - - logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})") - webhook_queue.clear() + logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})") + webhooks_queue.set([]) # diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index 5cf431025..e69de29bb 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -1,3 +0,0 @@ -import threading - -thread_locals = threading.local() diff --git a/netbox/netbox/context.py b/netbox/netbox/context.py new file mode 100644 index 000000000..02c6fccae --- /dev/null +++ b/netbox/netbox/context.py @@ -0,0 +1,10 @@ +from contextvars import ContextVar + +__all__ = ( + 'current_request', + 'webhooks_queue', +) + + +current_request = ContextVar('current_request') +webhooks_queue = ContextVar('webhooks_queue') diff --git a/netbox/netbox/request_context.py b/netbox/netbox/request_context.py deleted file mode 100644 index 41e8283e8..000000000 --- a/netbox/netbox/request_context.py +++ /dev/null @@ -1,9 +0,0 @@ -from netbox import thread_locals - - -def set_request(request): - thread_locals.request = request - - -def get_request(): - return getattr(thread_locals, 'request', None) From 4a95cfd1c4435e6eda01745fe06d902c25d2493e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Nov 2022 08:48:13 -0400 Subject: [PATCH 244/409] Permanently connect change logging & webhook receivers --- netbox/extras/context_managers.py | 16 --------------- netbox/extras/signals.py | 34 +++++++++++++++++++++++-------- netbox/netbox/context.py | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/netbox/extras/context_managers.py b/netbox/extras/context_managers.py index d4aeb8364..32323999e 100644 --- a/netbox/extras/context_managers.py +++ b/netbox/extras/context_managers.py @@ -1,8 +1,5 @@ from contextlib import contextmanager -from django.db.models.signals import m2m_changed, pre_delete, post_save - -from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object from netbox.context import current_request, webhooks_queue from .webhooks import flush_webhooks @@ -18,21 +15,8 @@ def change_logging(request): current_request.set(request) webhooks_queue.set([]) - # Connect our receivers to the post_save and post_delete signals. - post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object') - m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object') - pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object') - clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue') - yield - # Disconnect change logging signals. This is necessary to avoid recording any errant - # changes during test cleanup. - post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object') - m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object') - pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object') - clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue') - # Flush queued webhooks to RQ flush_webhooks(webhooks_queue.get()) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 8854d6314..31e0c126c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -14,6 +14,7 @@ from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook + # # Change logging/webhooks # @@ -22,22 +23,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook clear_webhooks = Signal() +def is_same_object(instance, webhook_data, request_id): + """ + Compare the given instance to the most recent queued webhook object, returning True + if they match. This check is used to avoid creating duplicate webhook entries. + """ + return ( + ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and + instance.pk == webhook_data['object_id'] and + request_id == webhook_data['request_id'] + ) + + +@receiver((post_save, m2m_changed)) def handle_changed_object(sender, instance, **kwargs): """ Fires when an object is created or updated. """ + m2m_changed = False + if not hasattr(instance, 'to_objectchange'): return + # Get the current request, or bail if not set request = current_request.get() - m2m_changed = False - - def is_same_object(instance, webhook_data): - return ( - ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and - instance.pk == webhook_data['object_id'] and - request.id == webhook_data['request_id'] - ) + if request is None: + return # Determine the type of change being made if kwargs.get('created'): @@ -69,7 +80,7 @@ def handle_changed_object(sender, instance, **kwargs): # If this is an M2M change, update the previously queued webhook (from post_save) queue = webhooks_queue.get() - if m2m_changed and queue and is_same_object(instance, queue[-1]): + if m2m_changed and queue and is_same_object(instance, queue[-1], request.id): instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments queue[-1]['data'] = serialize_for_webhook(instance) queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] @@ -84,6 +95,7 @@ def handle_changed_object(sender, instance, **kwargs): model_updates.labels(instance._meta.model_name).inc() +@receiver(pre_delete) def handle_deleted_object(sender, instance, **kwargs): """ Fires when an object is deleted. @@ -91,7 +103,10 @@ def handle_deleted_object(sender, instance, **kwargs): if not hasattr(instance, 'to_objectchange'): return + # Get the current request, or bail if not set request = current_request.get() + if request is None: + return # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): @@ -109,6 +124,7 @@ def handle_deleted_object(sender, instance, **kwargs): model_deletes.labels(instance._meta.model_name).inc() +@receiver(clear_webhooks) def clear_webhook_queue(sender, **kwargs): """ Delete any queued webhooks (e.g. because of an aborted bulk transaction) diff --git a/netbox/netbox/context.py b/netbox/netbox/context.py index 02c6fccae..b5e4dc28e 100644 --- a/netbox/netbox/context.py +++ b/netbox/netbox/context.py @@ -6,5 +6,5 @@ __all__ = ( ) -current_request = ContextVar('current_request') +current_request = ContextVar('current_request', default=None) webhooks_queue = ContextVar('webhooks_queue') From a57c937aaa565222c21ae8629103070bd5f43c45 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 14 Nov 2022 13:46:32 -0500 Subject: [PATCH 245/409] #10694: Emit post_save signal when creating/updating device components in bulk (#10900) * Emit post_save signal when creating/updating device components in bulk * Fix post_save for bulk_update() --- netbox/dcim/models/devices.py | 97 +++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index b71a185b7..c89dd67ef 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -10,6 +10,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError from django.db.models.functions import Lower +from django.db.models.signals import post_save from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ @@ -769,6 +770,32 @@ class Device(PrimaryModel, ConfigContextModel): 'vc_position': "A device assigned to a virtual chassis must have its position defined." }) + def _instantiate_components(self, queryset, bulk_create=True): + """ + Instantiate components for the device from the specified component templates. + + Args: + bulk_create: If True, bulk_create() will be called to create all components in a single query + (default). Otherwise, save() will be called on each instance individually. + """ + components = [obj.instantiate(device=self) for obj in queryset] + if components and bulk_create: + model = components[0]._meta.model + model.objects.bulk_create(components) + # Manually send the post_save signal for each of the newly created components + for component in components: + post_save.send( + sender=model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + elif components: + for component in components: + component.save() + def save(self, *args, **kwargs): is_new = not bool(self.pk) @@ -778,38 +805,19 @@ class Device(PrimaryModel, ConfigContextModel): super().save(*args, **kwargs) - # If this is a new Device, instantiate all of the related components per the DeviceType definition + # If this is a new Device, instantiate all the related components per the DeviceType definition if is_new: - ConsolePort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] - ) - ConsoleServerPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] - ) - PowerPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] - ) - PowerOutlet.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] - ) - Interface.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] - ) - RearPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] - ) - FrontPort.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] - ) - ModuleBay.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] - ) - DeviceBay.objects.bulk_create( - [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] - ) - # Avoid bulk_create to handle MPTT - for x in self.device_type.inventoryitemtemplates.all(): - x.instantiate(device=self).save() + self._instantiate_components(self.device_type.consoleporttemplates.all()) + self._instantiate_components(self.device_type.consoleserverporttemplates.all()) + self._instantiate_components(self.device_type.powerporttemplates.all()) + self._instantiate_components(self.device_type.poweroutlettemplates.all()) + self._instantiate_components(self.device_type.interfacetemplates.all()) + self._instantiate_components(self.device_type.rearporttemplates.all()) + self._instantiate_components(self.device_type.frontporttemplates.all()) + self._instantiate_components(self.device_type.modulebaytemplates.all()) + self._instantiate_components(self.device_type.devicebaytemplates.all()) + # Disable bulk_create to accommodate MPTT + self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) @@ -980,7 +988,8 @@ class Module(PrimaryModel, ConfigContextModel): # Prefetch installed components installed_components = { - component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True) + component.name: component + for component in getattr(self.device, component_attribute).filter(module__isnull=True) } # Get the template for the module type. @@ -1002,7 +1011,29 @@ class Module(PrimaryModel, ConfigContextModel): create_instances.append(template_instance) component_model.objects.bulk_create(create_instances) - component_model.objects.bulk_update(update_instances, ['module']) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + + update_fields = ['module'] + component_model.objects.bulk_update(update_instances, update_fields) + # Emit the post_save signal for each updated object + for component in update_instances: + post_save.send( + sender=component_model, + instance=component, + created=False, + raw=False, + using='default', + update_fields=update_fields + ) # From 27bf7b4a9add27b4f3f8b0f4fd5dfc4cfe74a65b Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 14 Nov 2022 10:51:58 -0800 Subject: [PATCH 246/409] 4751 Enable plugins to inject content within object list views (#10901) * 4751 add plugin buttons to list templates * 4751 add plugin buttons to list templates * 4751 add documentation * 4751 fix object reference * 4751 update docs --- docs/plugins/development/views.md | 7 ++++++- netbox/extras/plugins/templates.py | 8 ++++++++ netbox/extras/templatetags/plugins.py | 8 ++++++++ netbox/extras/tests/dummy_plugin/template_content.py | 3 +++ netbox/templates/generic/object_list.html | 3 +++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 45d7064cf..6d1329a4a 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -187,11 +187,16 @@ Plugins can inject custom content into certain areas of the detail views of appl * `full_width_page()` - Inject content across the entire bottom of the page * `buttons()` - Add buttons to the top of the page +Plugins can also inject custom content into certain areas of the list views of applicable models using the same subclass of `PluginTemplateExtension`. One method is available: + +* `list_buttons()` - Add buttons to the top of the list view page + Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: -* `object` - The object being viewed +* `object` - The object being viewed (for detail views only) +* `model` - The model of the list view (for list views only) * `request` - The current request * `settings` - Global NetBox settings * `config` - Plugin-specific configuration parameters diff --git a/netbox/extras/plugins/templates.py b/netbox/extras/plugins/templates.py index 5f3d038c6..e9b9a9dca 100644 --- a/netbox/extras/plugins/templates.py +++ b/netbox/extras/plugins/templates.py @@ -63,3 +63,11 @@ class PluginTemplateExtension: automatically handled. """ raise NotImplementedError + + def list_buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the list view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py index b2f4ec0a7..560d15e01 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/extras/templatetags/plugins.py @@ -73,3 +73,11 @@ def plugin_full_width_page(context, obj): Render all full width page content registered by plugins """ return _get_registered_content(obj, 'full_width_page', context) + + +@register.simple_tag(takes_context=True) +def plugin_list_buttons(context, model): + """ + Render all list buttons registered by plugins + """ + return _get_registered_content(model, 'list_buttons', context) diff --git a/netbox/extras/tests/dummy_plugin/template_content.py b/netbox/extras/tests/dummy_plugin/template_content.py index 6151454ea..364768a22 100644 --- a/netbox/extras/tests/dummy_plugin/template_content.py +++ b/netbox/extras/tests/dummy_plugin/template_content.py @@ -16,5 +16,8 @@ class SiteContent(PluginTemplateExtension): def buttons(self): return "SITE CONTENT - BUTTONS" + def list_buttons(self): + return "SITE CONTENT - LIST BUTTONS" + template_extensions = [SiteContent] diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index c58565c31..98a09b0b0 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -1,6 +1,7 @@ {% extends 'base/layout.html' %} {% load buttons %} {% load helpers %} +{% load plugins %} {% load render_table from django_tables2 %} {% load static %} @@ -24,6 +25,8 @@ Context: {% block controls %}
    + {% plugin_list_buttons model %} + {% block extra_controls %}{% endblock %} {% if 'add' in actions %} {% add_button model %} From a5308ea28e851a4ddb65a4e7ca2297b641e5891f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 14 Nov 2022 13:55:03 -0500 Subject: [PATCH 247/409] Closes #10851: New staging mechanism (#10890) * WIP * Convert checkout() context manager to a class * Misc cleanup * Drop unique constraint from Change model * Extend staging tests * Misc cleanup * Incorporate M2M changes * Don't cancel wipe out creation records when an object is deleted * Rename Change to StagedChange * Add documentation for change staging --- docs/models/extras/branch.md | 13 ++ docs/models/extras/stagedchange.md | 26 +++ docs/plugins/development/staged-changes.md | 42 +++++ mkdocs.yml | 3 + netbox/extras/choices.py | 17 ++ netbox/extras/migrations/0084_staging.py | 45 +++++ netbox/extras/models/__init__.py | 3 + netbox/extras/models/staging.py | 114 +++++++++++ netbox/netbox/staging.py | 148 +++++++++++++++ netbox/netbox/tests/test_staging.py | 210 +++++++++++++++++++++ netbox/utilities/utils.py | 30 ++- 11 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 docs/models/extras/branch.md create mode 100644 docs/models/extras/stagedchange.md create mode 100644 docs/plugins/development/staged-changes.md create mode 100644 netbox/extras/migrations/0084_staging.py create mode 100644 netbox/extras/models/staging.py create mode 100644 netbox/netbox/staging.py create mode 100644 netbox/netbox/tests/test_staging.py diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md new file mode 100644 index 000000000..be124ebde --- /dev/null +++ b/docs/models/extras/branch.md @@ -0,0 +1,13 @@ +# Branches + +A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes. + +## Fields + +### Name + +The branch's name. + +### User + +The user to which the branch belongs (optional). diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md new file mode 100644 index 000000000..feda2fee6 --- /dev/null +++ b/docs/models/extras/stagedchange.md @@ -0,0 +1,26 @@ +# Staged Changes + +A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md). + +Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method. + +## Fields + +!!! warning + Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager. + +### Branch + +The [branch](./branch.md) to which this change belongs. + +### Action + +The type of action this change represents: `create`, `update`, or `delete`. + +### Object + +A generic foreign key referencing the existing object to which this change applies. + +### Data + +JSON representation of the changes being made to the object (not applicable for deletions). diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md new file mode 100644 index 000000000..7a4446eea --- /dev/null +++ b/docs/plugins/development/staged-changes.md @@ -0,0 +1,42 @@ +# Staged Changes + +!!! danger "Experimental Feature" + This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time. + +!!! note + This feature was introduced in NetBox v3.4. + +NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example. + +To begin staging changes, first create a [branch](../../models/extras/branch.md): + +```python +from extras.models import Branch + +branch1 = Branch.objects.create(name='branch1') +``` + +Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction. + +```python +from extras.models import Branch +from netbox.staging import checkout + +branch1 = Branch.objects.get(name='branch1') +with checkout(branch1): + Site.objects.create(name='New Site', slug='new-site') + # ... +``` + +Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch. + +To apply the changes within a branch, call the branch's `commit()` method: + +```python +from extras.models import Branch + +branch1 = Branch.objects.get(name='branch1') +branch1.commit() +``` + +Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused). diff --git a/mkdocs.yml b/mkdocs.yml index 011d4414f..a147785d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -131,6 +131,7 @@ nav: - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' - Search: 'plugins/development/search.md' - Administration: @@ -191,12 +192,14 @@ nav: - SiteGroup: 'models/dcim/sitegroup.md' - VirtualChassis: 'models/dcim/virtualchassis.md' - Extras: + - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - CustomField: 'models/extras/customfield.md' - CustomLink: 'models/extras/customlink.md' - ExportTemplate: 'models/extras/exporttemplate.md' - ImageAttachment: 'models/extras/imageattachment.md' - JournalEntry: 'models/extras/journalentry.md' + - StagedChange: 'models/extras/stagedchange.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' - IPAM: diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index ee806f094..ef74ed67b 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -182,3 +182,20 @@ class WebhookHttpMethodChoices(ChoiceSet): (METHOD_PATCH, 'PATCH'), (METHOD_DELETE, 'DELETE'), ) + + +# +# Staging +# + +class ChangeActionChoices(ChoiceSet): + + ACTION_CREATE = 'create' + ACTION_UPDATE = 'update' + ACTION_DELETE = 'delete' + + CHOICES = ( + (ACTION_CREATE, 'Create'), + (ACTION_UPDATE, 'Update'), + (ACTION_DELETE, 'Delete'), + ) diff --git a/netbox/extras/migrations/0084_staging.py b/netbox/extras/migrations/0084_staging.py new file mode 100644 index 000000000..25c3f164f --- /dev/null +++ b/netbox/extras/migrations/0084_staging.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0083_savedfilter'), + ] + + operations = [ + migrations.CreateModel( + name='Branch', + 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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='StagedChange', + 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)), + ('action', models.CharField(max_length=20)), + ('object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('data', models.JSONField(blank=True, null=True)), + ('branch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staged_changes', to='extras.branch')), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ('pk',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 6d2bf288c..9b5c660c4 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -3,9 +3,11 @@ from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField from .models import * from .search import * +from .staging import * from .tags import Tag, TaggedItem __all__ = ( + 'Branch', 'CachedValue', 'ConfigContext', 'ConfigContextModel', @@ -20,6 +22,7 @@ __all__ = ( 'Report', 'SavedFilter', 'Script', + 'StagedChange', 'Tag', 'TaggedItem', 'Webhook', diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py new file mode 100644 index 000000000..b46d6a7bc --- /dev/null +++ b/netbox/extras/models/staging.py @@ -0,0 +1,114 @@ +import logging + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction + +from extras.choices import ChangeActionChoices +from netbox.models import ChangeLoggedModel +from utilities.utils import deserialize_object + +__all__ = ( + 'Branch', + 'StagedChange', +) + +logger = logging.getLogger('netbox.staging') + + +class Branch(ChangeLoggedModel): + """ + A collection of related StagedChanges. + """ + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=get_user_model(), + on_delete=models.SET_NULL, + blank=True, + null=True + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return f'{self.name} ({self.pk})' + + def merge(self): + logger.info(f'Merging changes in branch {self}') + with transaction.atomic(): + for change in self.staged_changes.all(): + change.apply() + self.staged_changes.all().delete() + + +class StagedChange(ChangeLoggedModel): + """ + The prepared creation, modification, or deletion of an object to be applied to the active database at a + future point. + """ + branch = models.ForeignKey( + to=Branch, + on_delete=models.CASCADE, + related_name='staged_changes' + ) + action = models.CharField( + max_length=20, + choices=ChangeActionChoices + ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + object_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + object = GenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + data = models.JSONField( + blank=True, + null=True + ) + + class Meta: + ordering = ('pk',) + + def __str__(self): + action = self.get_action_display() + app_label, model_name = self.object_type.natural_key() + return f"{action} {app_label}.{model_name} ({self.object_id})" + + @property + def model(self): + return self.object_type.model_class() + + def apply(self): + """ + Apply the staged create/update/delete action to the database. + """ + if self.action == ChangeActionChoices.ACTION_CREATE: + instance = deserialize_object(self.model, self.data, pk=self.object_id) + logger.info(f'Creating {self.model._meta.verbose_name} {instance}') + instance.save() + + if self.action == ChangeActionChoices.ACTION_UPDATE: + instance = deserialize_object(self.model, self.data, pk=self.object_id) + logger.info(f'Updating {self.model._meta.verbose_name} {instance}') + instance.save() + + if self.action == ChangeActionChoices.ACTION_DELETE: + instance = self.model.objects.get(pk=self.object_id) + logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') + instance.delete() diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py new file mode 100644 index 000000000..ec38dcadc --- /dev/null +++ b/netbox/netbox/staging.py @@ -0,0 +1,148 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models.signals import m2m_changed, pre_delete, post_save + +from extras.choices import ChangeActionChoices +from extras.models import StagedChange +from utilities.utils import serialize_object + +logger = logging.getLogger('netbox.staging') + + +class checkout: + """ + Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band + (as Change instances) for application at a later time, without modifying the production + database. + + branch = Branch.objects.create(name='my-branch') + with checkout(branch): + # All changes made herein will be rolled back and stored for later + + Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks, + and restores its original value upon exit. + """ + def __init__(self, branch): + self.branch = branch + self.queue = {} + + def __enter__(self): + + # Disable autocommit to effect a new transaction + logger.debug(f"Entering transaction for {self.branch}") + self._autocommit = transaction.get_autocommit() + transaction.set_autocommit(False) + + # Apply any existing Changes assigned to this Branch + staged_changes = self.branch.staged_changes.all() + if change_count := staged_changes.count(): + logger.debug(f"Applying {change_count} pre-staged changes...") + for change in staged_changes: + change.apply() + else: + logger.debug("No pre-staged changes found") + + # Connect signal handlers + logger.debug("Connecting signal handlers") + post_save.connect(self.post_save_handler) + m2m_changed.connect(self.post_save_handler) + pre_delete.connect(self.pre_delete_handler) + + def __exit__(self, exc_type, exc_val, exc_tb): + + # Disconnect signal handlers + logger.debug("Disconnecting signal handlers") + post_save.disconnect(self.post_save_handler) + m2m_changed.disconnect(self.post_save_handler) + pre_delete.disconnect(self.pre_delete_handler) + + # Roll back the transaction to return the database to its original state + logger.debug("Rolling back database transaction") + transaction.rollback() + logger.debug(f"Restoring autocommit state ({self._autocommit})") + transaction.set_autocommit(self._autocommit) + + # Process queued changes + self.process_queue() + + # + # Queuing + # + + @staticmethod + def get_key_for_instance(instance): + return ContentType.objects.get_for_model(instance), instance.pk + + def process_queue(self): + """ + Create Change instances for all actions stored in the queue. + """ + if not self.queue: + logger.debug(f"No queued changes; aborting") + return + logger.debug(f"Processing {len(self.queue)} queued changes") + + # Iterate through the in-memory queue, creating Change instances + changes = [] + for key, change in self.queue.items(): + logger.debug(f' {key}: {change}') + object_type, pk = key + action, data = change + + changes.append(StagedChange( + branch=self.branch, + action=action, + object_type=object_type, + object_id=pk, + data=data + )) + + # Save all Change instances to the database + StagedChange.objects.bulk_create(changes) + + # + # Signal handlers + # + + def post_save_handler(self, sender, instance, **kwargs): + """ + Hooks to the post_save signal when a branch is active to queue create and update actions. + """ + key = self.get_key_for_instance(instance) + object_type = instance._meta.verbose_name + + # Creating a new object + if kwargs.get('created'): + logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data) + return + + # Ignore pre_* many-to-many actions + if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'): + return + + # Object has already been created/updated in the queue; update its queued representation + if key in self.queue: + logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (self.queue[key][0], data) + return + + # Modifying an existing object for the first time + logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})") + data = serialize_object(instance, resolve_tags=False) + self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data) + + def pre_delete_handler(self, sender, instance, **kwargs): + """ + Hooks to the pre_delete signal when a branch is active to queue delete actions. + """ + key = self.get_key_for_instance(instance) + object_type = instance._meta.verbose_name + + # Delete an existing object + logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})") + self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None) diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py new file mode 100644 index 000000000..ed3a69f10 --- /dev/null +++ b/netbox/netbox/tests/test_staging.py @@ -0,0 +1,210 @@ +from django.test import TransactionTestCase + +from circuits.models import Provider, Circuit, CircuitType +from extras.choices import ChangeActionChoices +from extras.models import Branch, StagedChange, Tag +from ipam.models import ASN, RIR +from netbox.staging import checkout +from utilities.testing import create_tags + + +class StagingTestCase(TransactionTestCase): + + def setUp(self): + create_tags('Alpha', 'Bravo', 'Charlie') + + rir = RIR.objects.create(name='RIR 1', slug='rir-1') + asns = ( + ASN(asn=65001, rir=rir), + ASN(asn=65002, rir=rir), + ASN(asn=65003, rir=rir), + ) + ASN.objects.bulk_create(asns) + + providers = ( + Provider(name='Provider A', slug='provider-a'), + Provider(name='Provider B', slug='provider-b'), + Provider(name='Provider C', slug='provider-c'), + ) + Provider.objects.bulk_create(providers) + + circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + + Circuit.objects.bulk_create(( + Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type), + Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type), + Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type), + Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type), + Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type), + )) + + def test_object_creation(self): + branch = Branch.objects.create(name='Branch 1') + tags = Tag.objects.all() + asns = ASN.objects.all() + + with checkout(branch): + provider = Provider.objects.create(name='Provider D', slug='provider-d') + provider.asns.set(asns) + circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first()) + circuit.tags.set(tags) + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 4) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 10) + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(StagedChange.objects.count(), 5) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 4) + self.assertEqual(Circuit.objects.count(), 10) + provider = Provider.objects.get(name='Provider D') + self.assertListEqual(list(provider.asns.all()), list(asns)) + circuit = Circuit.objects.get(cid='Circuit D1') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 4) + self.assertEqual(Circuit.objects.count(), 10) + provider = Provider.objects.get(name='Provider D') + self.assertListEqual(list(provider.asns.all()), list(asns)) + circuit = Circuit.objects.get(cid='Circuit D1') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_object_modification(self): + branch = Branch.objects.create(name='Branch 1') + tags = Tag.objects.all() + asns = ASN.objects.all() + + with checkout(branch): + provider = Provider.objects.get(name='Provider A') + provider.name = 'Provider X' + provider.save() + provider.asns.set(asns) + circuit = Circuit.objects.get(cid='Circuit A1') + circuit.cid = 'Circuit X' + circuit.save() + circuit.tags.set(tags) + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), []) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit A1') + self.assertListEqual(list(circuit.tags.all()), []) + self.assertEqual(StagedChange.objects.count(), 5) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') + provider = Provider.objects.get(pk=provider.pk) + self.assertListEqual(list(provider.asns.all()), list(asns)) + self.assertEqual(Circuit.objects.count(), 9) + circuit = Circuit.objects.get(pk=circuit.pk) + self.assertEqual(circuit.cid, 'Circuit X') + self.assertListEqual(list(circuit.tags.all()), list(tags)) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_object_deletion(self): + branch = Branch.objects.create(name='Branch 1') + + with checkout(branch): + provider = Provider.objects.get(name='Provider A') + provider.circuits.all().delete() + provider.delete() + + # Sanity-checking + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + + # Verify that changes have been rolled back after exiting the context + self.assertEqual(Provider.objects.count(), 3) + self.assertEqual(Circuit.objects.count(), 9) + self.assertEqual(StagedChange.objects.count(), 4) + + # Verify that changes are replayed upon entering the context + with checkout(branch): + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + + # Verify that changes are applied and deleted upon branch merge + branch.merge() + self.assertEqual(Provider.objects.count(), 2) + self.assertEqual(Circuit.objects.count(), 6) + self.assertEqual(StagedChange.objects.count(), 0) + + def test_exit_enter_context(self): + branch = Branch.objects.create(name='Branch 1') + + with checkout(branch): + + # Create a new object + provider = Provider.objects.create(name='Provider D', slug='provider-d') + provider.save() + + # Check that a create Change was recorded + self.assertEqual(StagedChange.objects.count(), 1) + change = StagedChange.objects.first() + self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE) + self.assertEqual(change.data['name'], provider.name) + + with checkout(branch): + + # Update the staged object + provider = Provider.objects.get(name='Provider D') + provider.comments = 'New comments' + provider.save() + + # Check that a second Change object has been created for the object + self.assertEqual(StagedChange.objects.count(), 2) + change = StagedChange.objects.last() + self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE) + self.assertEqual(change.data['name'], provider.name) + self.assertEqual(change.data['comments'], provider.comments) + + with checkout(branch): + + # Delete the staged object + provider = Provider.objects.get(name='Provider D') + provider.delete() + + # Check that a third Change has recorded the object's deletion + self.assertEqual(StagedChange.objects.count(), 3) + change = StagedChange.objects.last() + self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE) + self.assertIsNone(change.data) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index a5bccfbea..a26940ac1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -6,7 +6,8 @@ from decimal import Decimal from itertools import count, groupby import bleach -from django.core.serializers import serialize +from django.contrib.contenttypes.models import ContentType +from django.core import serializers from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict @@ -135,14 +136,14 @@ def count_related(model, field): return Coalesce(subquery, 0) -def serialize_object(obj, extra=None): +def serialize_object(obj, resolve_tags=True, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are implicitly excluded. """ - json_str = serialize('json', [obj]) + json_str = serializers.serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] # Exclude any MPTTModel fields @@ -154,8 +155,9 @@ def serialize_object(obj, extra=None): if hasattr(obj, 'custom_field_data'): data['custom_fields'] = data.pop('custom_field_data') - # Include any tags. Check for tags cached on the instance; fall back to using the manager. - if is_taggable(obj): + # Resolve any assigned tags to their names. Check for tags cached on the instance; + # fall back to using the manager. + if resolve_tags and is_taggable(obj): tags = getattr(obj, '_tags', None) or obj.tags.all() data['tags'] = sorted([tag.name for tag in tags]) @@ -172,6 +174,24 @@ def serialize_object(obj, extra=None): return data +def deserialize_object(model, fields, pk=None): + """ + Instantiate an object from the given model and field data. Functions as + the complement to serialize_object(). + """ + content_type = ContentType.objects.get_for_model(model) + if 'custom_fields' in fields: + fields['custom_field_data'] = fields.pop('custom_fields') + data = { + 'model': '.'.join(content_type.natural_key()), + 'pk': pk, + 'fields': fields, + } + instance = list(serializers.deserialize('python', [data]))[0] + + return instance + + def dict_to_filter_params(d, prefix=''): """ Translate a dictionary of attributes to a nested set of parameters suitable for QuerySet filtering. For example: From 42e9dc0da7d6e118be8f478cbfe3169132fe4989 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 14 Nov 2022 14:06:30 -0500 Subject: [PATCH 248/409] Changelog for #4751, #10694, #10851 --- docs/release-notes/version-3.4.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index f76548695..315d69287 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -33,6 +33,13 @@ NetBox's CSV-based bulk import functionality has been extended to support also m A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. +#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851)) + +This release introduces a new programmatic API that enables plugins and custom scripts to prepare changes in NetBox without actually committing them to the active database. To stage changes, create and activate a branch using the `checkout()` context manager. Any changes made within this context will be captured, recorded, and rolled back for future use. Once ready, a branch can be applied to the active database by calling `merge()`. + +!!! danger "Experimental Feature" + This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time. + ### Enhancements * [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import @@ -57,6 +64,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Plugins API +* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists * [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex` * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models @@ -71,6 +79,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 +* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk * [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app * [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function * [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views From 7401fd70501d9431f7f8624455dde59db8f5802e Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Nov 2022 10:40:48 -0800 Subject: [PATCH 249/409] 10909 add l2vpn to tenant stats --- netbox/templates/tenancy/tenant.html | 6 ++++++ netbox/tenancy/views.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e8dc4b23a..7d2ff0149 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -93,6 +93,12 @@

    {{ stats.vlan_count }}

    VLANs

    + + +

    {{ stats.circuit_count }}

    Circuits

    diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index d8b810ad9..03b454321 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -111,6 +111,7 @@ class TenantView(generic.ObjectView): 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), From 564884a7749a94f79b9fa954b5aa65da27845368 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 14 Nov 2022 10:20:38 -0800 Subject: [PATCH 250/409] 10903 add module type on manufacturer page --- netbox/templates/dcim/manufacturer.html | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 43d16afcb..ad975f2ea 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -4,10 +4,24 @@ {% load render_table from django_tables2 %} {% block extra_controls %} - {% if perms.dcim.add_devicetype %} - - Add Device Type - + {% if perms.dcim.add_devicetype or perms.dcim.add_moduletype %} + {% endif %} {% endblock extra_controls %} From 1b707e07f20d24dd3b2efaffb43803f8be9259e9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 14 Nov 2022 17:07:39 -0500 Subject: [PATCH 251/409] Add missing milestones to release notes --- docs/release-notes/version-3.4.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 315d69287..d8df552fb 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -15,12 +15,16 @@ #### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560)) -NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. +NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much speedier, more intelligent search capability. Matches are returned in order of precedence regardless of object type, and matched field values are highlighted in the results. Additionally, custom field values are now included in global search results (when enabled). Plugins can also register their own models with the new global search engine. #### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854)) A new model representing virtual device contexts (VDCs) has been added. VDCs are logical partitions of resources within a device that can be managed independently. A VDC is created within a device and may have device interfaces assigned to it. An interface can be allocated to any number of VDCs on its device. +#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623)) + +Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse as a convenience. Saved filters can be kept private, or shared among NetBox users. + ### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347)) NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well. @@ -29,9 +33,9 @@ NetBox's bulk import feature, which was previously limited to CSV-formatted data NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects. -#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071)) +#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366)) -A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. +Reports and custom scripts can now be scheduled for execution at a desired time. #### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851)) @@ -42,6 +46,7 @@ This release introduces a new programmatic API that enables plugins and custom s ### Enhancements +* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search * [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types @@ -55,6 +60,7 @@ This release introduces a new programmatic API that enables plugins and custom s * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type +* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types * [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields * [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns @@ -66,9 +72,9 @@ This release introduces a new programmatic API that enables plugins and custom s * [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists * [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex` -* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus +* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models -* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter +* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps * [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin * [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function @@ -117,6 +123,8 @@ This release introduces a new programmatic API that enables plugins and custom s * Added a `comments` field * dcim.VirtualChassis * Added `description` and `comments` fields +* extras.CustomField + * Added the `search_weight` field * extras.CustomLink * Renamed `content_type` field to `content_types` * extras.ExportTemplate From 94dd07e1e64d3f19eb15eaf30575b82bfe2cc62a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 08:34:14 -0500 Subject: [PATCH 252/409] #10560: Build search index as part of migration --- .../migrations/0166_virtualdevicecontext.py | 2 +- ...es.py => 0080_customlink_content_types.py} | 2 +- ...y => 0081_exporttemplate_content_types.py} | 2 +- ...083_savedfilter.py => 0082_savedfilter.py} | 2 +- .../{0080_search.py => 0083_search.py} | 25 ++++++++++++++++--- netbox/extras/migrations/0084_staging.py | 2 +- 6 files changed, 27 insertions(+), 8 deletions(-) rename netbox/extras/migrations/{0081_customlink_content_types.py => 0080_customlink_content_types.py} (94%) rename netbox/extras/migrations/{0082_exporttemplate_content_types.py => 0081_exporttemplate_content_types.py} (95%) rename netbox/extras/migrations/{0083_savedfilter.py => 0082_savedfilter.py} (96%) rename netbox/extras/migrations/{0080_search.py => 0083_search.py} (64%) diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py index 5c95e1177..05becbdc6 100644 --- a/netbox/dcim/migrations/0166_virtualdevicecontext.py +++ b/netbox/dcim/migrations/0166_virtualdevicecontext.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0063_standardize_description_comments'), - ('extras', '0083_savedfilter'), + ('extras', '0082_savedfilter'), ('tenancy', '0009_standardize_description_comments'), ('dcim', '0165_standardize_description_comments'), ] diff --git a/netbox/extras/migrations/0081_customlink_content_types.py b/netbox/extras/migrations/0080_customlink_content_types.py similarity index 94% rename from netbox/extras/migrations/0081_customlink_content_types.py rename to netbox/extras/migrations/0080_customlink_content_types.py index 2f0f23509..91fe453c3 100644 --- a/netbox/extras/migrations/0081_customlink_content_types.py +++ b/netbox/extras/migrations/0080_customlink_content_types.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0080_search'), + ('extras', '0079_jobresult_scheduled_time'), ] operations = [ diff --git a/netbox/extras/migrations/0082_exporttemplate_content_types.py b/netbox/extras/migrations/0081_exporttemplate_content_types.py similarity index 95% rename from netbox/extras/migrations/0082_exporttemplate_content_types.py rename to netbox/extras/migrations/0081_exporttemplate_content_types.py index 34a9c77e6..afa21c5b8 100644 --- a/netbox/extras/migrations/0082_exporttemplate_content_types.py +++ b/netbox/extras/migrations/0081_exporttemplate_content_types.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0081_customlink_content_types'), + ('extras', '0080_customlink_content_types'), ] operations = [ diff --git a/netbox/extras/migrations/0083_savedfilter.py b/netbox/extras/migrations/0082_savedfilter.py similarity index 96% rename from netbox/extras/migrations/0083_savedfilter.py rename to netbox/extras/migrations/0082_savedfilter.py index 6bae7ccde..67ccc325f 100644 --- a/netbox/extras/migrations/0083_savedfilter.py +++ b/netbox/extras/migrations/0082_savedfilter.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0082_exporttemplate_content_types'), + ('extras', '0081_exporttemplate_content_types'), ] operations = [ diff --git a/netbox/extras/migrations/0080_search.py b/netbox/extras/migrations/0083_search.py similarity index 64% rename from netbox/extras/migrations/0080_search.py rename to netbox/extras/migrations/0083_search.py index 7a133e84b..64118a05e 100644 --- a/netbox/extras/migrations/0080_search.py +++ b/netbox/extras/migrations/0083_search.py @@ -1,13 +1,28 @@ -from django.db import migrations, models -import django.db.models.deletion +import sys import uuid +import django.db.models.deletion +from django.core import management +from django.db import migrations, models + + +def reindex(apps, schema_editor): + # Build the search index (except during tests) + if 'test' not in sys.argv: + management.call_command('reindex') + class Migration(migrations.Migration): dependencies = [ + ('circuits', '0041_standardize_description_comments'), ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0079_jobresult_scheduled_time'), + ('dcim', '0166_virtualdevicecontext'), + ('extras', '0082_savedfilter'), + ('ipam', '0063_standardize_description_comments'), + ('tenancy', '0009_standardize_description_comments'), + ('virtualization', '0034_standardize_description_comments'), + ('wireless', '0008_wirelesslan_status'), ] operations = [ @@ -32,4 +47,8 @@ class Migration(migrations.Migration): 'ordering': ('weight', 'object_type', 'object_id'), }, ), + migrations.RunPython( + code=reindex, + reverse_code=migrations.RunPython.noop + ), ] diff --git a/netbox/extras/migrations/0084_staging.py b/netbox/extras/migrations/0084_staging.py index 25c3f164f..3129d7f5b 100644 --- a/netbox/extras/migrations/0084_staging.py +++ b/netbox/extras/migrations/0084_staging.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('extras', '0083_savedfilter'), + ('extras', '0083_search'), ] operations = [ From ecf5304a148a2301e38d41fb396a12c4c1224542 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 10:37:35 -0500 Subject: [PATCH 253/409] #10560: Add new fields to indexers --- netbox/circuits/search.py | 1 + netbox/dcim/search.py | 30 +++++++++++++++++++++++++++--- netbox/ipam/search.py | 20 ++++++++++++++++++++ netbox/tenancy/search.py | 1 + netbox/virtualization/search.py | 2 ++ netbox/wireless/search.py | 2 ++ 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index 673f6308f..2859295d5 100644 --- a/netbox/circuits/search.py +++ b/netbox/circuits/search.py @@ -40,6 +40,7 @@ class ProviderIndex(SearchIndex): fields = ( ('name', 100), ('account', 200), + ('description', 500), ('comments', 5000), ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index d34a78888..bae4f030f 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -7,6 +7,8 @@ class CableIndex(SearchIndex): model = models.Cable fields = ( ('label', 100), + ('description', 500), + ('comments', 5000), ) @@ -39,6 +41,7 @@ class DeviceIndex(SearchIndex): ('asset_tag', 50), ('serial', 60), ('name', 100), + ('description', 500), ('comments', 5000), ) @@ -69,6 +72,7 @@ class DeviceTypeIndex(SearchIndex): fields = ( ('model', 100), ('part_number', 200), + ('description', 500), ('comments', 5000), ) @@ -136,6 +140,7 @@ class ModuleIndex(SearchIndex): fields = ( ('asset_tag', 50), ('serial', 60), + ('description', 500), ('comments', 5000), ) @@ -156,6 +161,7 @@ class ModuleTypeIndex(SearchIndex): fields = ( ('model', 100), ('part_number', 200), + ('description', 500), ('comments', 5000), ) @@ -176,6 +182,7 @@ class PowerFeedIndex(SearchIndex): model = models.PowerFeed fields = ( ('name', 100), + ('description', 500), ('comments', 5000), ) @@ -195,6 +202,8 @@ class PowerPanelIndex(SearchIndex): model = models.PowerPanel fields = ( ('name', 100), + ('description', 500), + ('comments', 5000), ) @@ -218,6 +227,7 @@ class RackIndex(SearchIndex): ('serial', 60), ('name', 100), ('facility_id', 200), + ('description', 500), ('comments', 5000), ) @@ -227,6 +237,7 @@ class RackReservationIndex(SearchIndex): model = models.RackReservation fields = ( ('description', 500), + ('comments', 5000), ) @@ -256,7 +267,7 @@ class RegionIndex(SearchIndex): fields = ( ('name', 100), ('slug', 110), - ('description', 500) + ('description', 500), ) @@ -280,7 +291,7 @@ class SiteGroupIndex(SearchIndex): fields = ( ('name', 100), ('slug', 110), - ('description', 500) + ('description', 500), ) @@ -289,5 +300,18 @@ class VirtualChassisIndex(SearchIndex): model = models.VirtualChassis fields = ( ('name', 100), - ('domain', 300) + ('domain', 300), + ('description', 500), + ('comments', 5000), + ) + + +@register_search +class VirtualDeviceContextIndex(SearchIndex): + model = models.VirtualDeviceContext + fields = ( + ('name', 100), + ('identifier', 300), + ('description', 500), + ('comments', 5000), ) diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index d1d25da76..fd6db6a63 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -9,6 +9,7 @@ class AggregateIndex(SearchIndex): ('prefix', 100), ('description', 500), ('date_added', 2000), + ('comments', 5000), ) @@ -28,6 +29,7 @@ class FHRPGroupIndex(SearchIndex): ('name', 100), ('group_id', 2000), ('description', 500), + ('comments', 5000), ) @@ -38,6 +40,7 @@ class IPAddressIndex(SearchIndex): ('address', 100), ('dns_name', 300), ('description', 500), + ('comments', 5000), ) @@ -48,6 +51,7 @@ class IPRangeIndex(SearchIndex): ('start_address', 100), ('end_address', 300), ('description', 500), + ('comments', 5000), ) @@ -58,6 +62,7 @@ class L2VPNIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) @@ -67,6 +72,7 @@ class PrefixIndex(SearchIndex): fields = ( ('prefix', 100), ('description', 500), + ('comments', 5000), ) @@ -96,6 +102,7 @@ class RouteTargetIndex(SearchIndex): fields = ( ('name', 100), ('description', 500), + ('comments', 5000), ) @@ -105,6 +112,17 @@ class ServiceIndex(SearchIndex): fields = ( ('name', 100), ('description', 500), + ('comments', 5000), + ) + + +@register_search +class ServiceTemplateIndex(SearchIndex): + model = models.ServiceTemplate + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), ) @@ -115,6 +133,7 @@ class VLANIndex(SearchIndex): ('name', 100), ('vid', 100), ('description', 500), + ('comments', 5000), ) @@ -136,4 +155,5 @@ class VRFIndex(SearchIndex): ('name', 100), ('rd', 200), ('description', 500), + ('comments', 5000), ) diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 8cb3c4ccb..bee497608 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -12,6 +12,7 @@ class ContactIndex(SearchIndex): ('email', 300), ('address', 300), ('link', 300), + ('description', 500), ('comments', 5000), ) diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py index 184bf7049..aef3425f0 100644 --- a/netbox/virtualization/search.py +++ b/netbox/virtualization/search.py @@ -7,6 +7,7 @@ class ClusterIndex(SearchIndex): model = models.Cluster fields = ( ('name', 100), + ('description', 500), ('comments', 5000), ) @@ -36,6 +37,7 @@ class VirtualMachineIndex(SearchIndex): model = models.VirtualMachine fields = ( ('name', 100), + ('description', 500), ('comments', 5000), ) diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index 55ca2977c..1f8097cd7 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -9,6 +9,7 @@ class WirelessLANIndex(SearchIndex): ('ssid', 100), ('description', 500), ('auth_psk', 2000), + ('comments', 5000), ) @@ -29,4 +30,5 @@ class WirelessLinkIndex(SearchIndex): ('ssid', 100), ('description', 500), ('auth_psk', 2000), + ('comments', 5000), ) From 355678274d5528ddd08ad64fbb16e14d26f960bb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 10:44:12 -0500 Subject: [PATCH 254/409] #9623: Add slug field to SavedFilter --- netbox/circuits/forms/filtersets.py | 6 +-- netbox/dcim/forms/filtersets.py | 52 ++++++++++---------- netbox/extras/api/nested_serializers.py | 2 +- netbox/extras/api/serializers.py | 4 +- netbox/extras/filtersets.py | 2 +- netbox/extras/forms/bulk_import.py | 2 +- netbox/extras/forms/filtersets.py | 18 +++---- netbox/extras/forms/mixins.py | 2 +- netbox/extras/forms/model_forms.py | 3 +- netbox/extras/migrations/0082_savedfilter.py | 5 +- netbox/extras/models/models.py | 4 ++ netbox/extras/tables/tables.py | 2 +- netbox/extras/tests/test_api.py | 8 ++- netbox/extras/tests/test_filtersets.py | 7 +++ netbox/extras/tests/test_views.py | 33 ++++++++++--- netbox/ipam/forms/filtersets.py | 26 +++++----- netbox/netbox/filtersets.py | 9 ++-- netbox/netbox/forms/base.py | 2 +- netbox/tenancy/forms/filtersets.py | 2 +- netbox/utilities/templatetags/helpers.py | 2 +- netbox/virtualization/forms/filtersets.py | 8 +-- netbox/wireless/forms/filtersets.py | 4 +- 22 files changed, 121 insertions(+), 82 deletions(-) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 9ad825299..d7cfc494d 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -20,7 +20,7 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( @@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index cc4dd635c..1b850b403 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -117,7 +117,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'filter', 'tag', 'parent_id')), + (None, ('q', 'filter_id', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -131,7 +131,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'filter', 'tag', 'parent_id')), + (None, ('q', 'filter_id', 'tag', 'parent_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( @@ -145,7 +145,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -175,7 +175,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -223,7 +223,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), @@ -307,7 +307,7 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('User', ('user_id',)), ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -363,7 +363,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -372,7 +372,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( @@ -487,7 +487,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('manufacturer_id', 'part_number')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -579,7 +579,7 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), @@ -735,7 +735,7 @@ class VirtualDeviceContextFilterForm( ): model = VirtualDeviceContext fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('device', 'status', )), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Miscellaneous', ('has_primary_ip',)) @@ -763,7 +763,7 @@ class VirtualDeviceContextFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -793,7 +793,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -822,7 +822,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -894,7 +894,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -932,7 +932,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ) @@ -1034,7 +1034,7 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1053,7 +1053,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1072,7 +1072,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1087,7 +1087,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Connection', ('cabled', 'connected', 'occupied')), @@ -1102,7 +1102,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), @@ -1200,7 +1200,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1219,7 +1219,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Cable', ('cabled', 'occupied')), @@ -1237,7 +1237,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'position')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1250,7 +1250,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) @@ -1260,7 +1260,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index dce062b84..5644b0b4e 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -64,7 +64,7 @@ class NestedSavedFilterSerializer(WritableNestedSerializer): class Meta: model = models.SavedFilter - fields = ['id', 'url', 'display', 'name'] + fields = ['id', 'url', 'display', 'name', 'slug'] class NestedImageAttachmentSerializer(WritableNestedSerializer): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1afb8fa8f..921720dea 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -164,8 +164,8 @@ class SavedFilterSerializer(ValidatedModelSerializer): class Meta: model = SavedFilter fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight', - 'enabled', 'shared', 'parameters', 'created', 'last_updated', + 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', + 'shared', 'parameters', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 824ba90cb..8ed68e850 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -165,7 +165,7 @@ class SavedFilterFilterSet(BaseFilterSet): class Meta: model = SavedFilter - fields = ['id', 'content_types', 'name', 'description', 'enabled', 'shared', 'weight'] + fields = ['id', 'content_types', 'name', 'slug', 'description', 'enabled', 'shared', 'weight'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 9def8fda6..7ef4739ad 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -92,7 +92,7 @@ class SavedFilterCSVForm(CSVModelForm): class Meta: model = SavedFilter fields = ( - 'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + 'name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 0421cab22..d63378f62 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -34,7 +34,7 @@ __all__ = ( class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( @@ -70,7 +70,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('obj_type', 'status')), ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', 'scheduled_time__before', 'scheduled_time__after', 'user')), @@ -122,7 +122,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), ) content_types = ContentTypeMultipleChoiceField( @@ -149,7 +149,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')), ) content_types = ContentTypeMultipleChoiceField( @@ -174,7 +174,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('content_types', 'enabled', 'shared', 'weight')), ) content_types = ContentTypeMultipleChoiceField( @@ -201,7 +201,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): class WebhookFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) @@ -253,7 +253,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter', 'tag_id')), + (None, ('q', 'filter_id', 'tag_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), @@ -340,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(NetBoxModelFilterSetForm): model = JournalEntry fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Creation', ('created_before', 'created_after', 'created_by_id')), ('Attributes', ('assigned_object_type_id', 'kind')) ) @@ -381,7 +381,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): model = ObjectChange fieldsets = ( - (None, ('q', 'filter')), + (None, ('q', 'filter_id')), ('Time', ('time_before', 'time_after')), ('Attributes', ('action', 'user_id', 'changed_object_type_id')), ) diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index 0a7dbdbcf..640bcc3dc 100644 --- a/netbox/extras/forms/mixins.py +++ b/netbox/extras/forms/mixins.py @@ -64,7 +64,7 @@ class CustomFieldsMixin: class SavedFiltersMixin(forms.Form): - filter = DynamicModelMultipleChoiceField( + filter_id = DynamicModelMultipleChoiceField( queryset=SavedFilter.objects.all(), required=False, label=_('Saved Filter'), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 192cdeeec..048d64d54 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -112,12 +112,13 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): class SavedFilterForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all() ) fieldsets = ( - ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')), ('Parameters', ('parameters',)), ) diff --git a/netbox/extras/migrations/0082_savedfilter.py b/netbox/extras/migrations/0082_savedfilter.py index 67ccc325f..e2626ec6a 100644 --- a/netbox/extras/migrations/0082_savedfilter.py +++ b/netbox/extras/migrations/0082_savedfilter.py @@ -1,8 +1,6 @@ -# Generated by Django 4.1.1 on 2022-10-27 18:18 - +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -21,6 +19,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ('weight', models.PositiveSmallIntegerField(default=100)), ('enabled', models.BooleanField(default=True)), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index c33245f99..b41e89d6c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -365,6 +365,10 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge max_length=100, unique=True ) + slug = models.SlugField( + max_length=100, + unique=True + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index da4241e69..172fbfbf9 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -100,7 +100,7 @@ class SavedFilterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = SavedFilter fields = ( - 'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 045391ea8..2b4a4aa5f 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -192,11 +192,12 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): class SavedFilterTest(APIViewTestCases.APIViewTestCase): model = SavedFilter - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'slug', 'url'] create_data = [ { 'content_types': ['dcim.site'], 'name': 'Saved Filter 4', + 'slug': 'saved-filter-4', 'weight': 100, 'enabled': True, 'shared': True, @@ -205,6 +206,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): { 'content_types': ['dcim.site'], 'name': 'Saved Filter 5', + 'slug': 'saved-filter-5', 'weight': 200, 'enabled': True, 'shared': True, @@ -213,6 +215,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): { 'content_types': ['dcim.site'], 'name': 'Saved Filter 6', + 'slug': 'saved-filter-6', 'weight': 300, 'enabled': True, 'shared': True, @@ -232,6 +235,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): saved_filters = ( SavedFilter( name='Saved Filter 1', + slug='saved-filter-1', weight=100, enabled=True, shared=True, @@ -239,6 +243,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): ), SavedFilter( name='Saved Filter 2', + slug='saved-filter-2', weight=200, enabled=True, shared=True, @@ -246,6 +251,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase): ), SavedFilter( name='Saved Filter 3', + slug='saved-filter-3', weight=300, enabled=True, shared=True, diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 140f05906..3c8899b5e 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -240,6 +240,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): saved_filters = ( SavedFilter( name='Saved Filter 1', + slug='saved-filter-1', user=users[0], weight=100, enabled=True, @@ -248,6 +249,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): ), SavedFilter( name='Saved Filter 2', + slug='saved-filter-2', user=users[1], weight=200, enabled=True, @@ -256,6 +258,7 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): ), SavedFilter( name='Saved Filter 3', + slug='saved-filter-3', user=users[2], weight=300, enabled=False, @@ -271,6 +274,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests): params = {'name': ['Saved Filter 1', 'Saved Filter 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_slug(self): + params = {'slug': ['saved-filter-1', 'saved-filter-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_content_types(self): params = {'content_types': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 175ffb9ca..98de95e8f 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -122,9 +122,27 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): User.objects.bulk_create(users) saved_filters = ( - SavedFilter(name='Saved Filter 1', user=users[0], weight=100, parameters={'status': ['active']}), - SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}), - SavedFilter(name='Saved Filter 3', user=users[2], weight=300, parameters={'status': ['retired']}), + SavedFilter( + name='Saved Filter 1', + slug='saved-filter-1', + user=users[0], + weight=100, + parameters={'status': ['active']} + ), + SavedFilter( + name='Saved Filter 2', + slug='saved-filter-2', + user=users[1], + weight=200, + parameters={'status': ['planned']} + ), + SavedFilter( + name='Saved Filter 3', + slug='saved-filter-3', + user=users[2], + weight=300, + parameters={'status': ['retired']} + ), ) SavedFilter.objects.bulk_create(saved_filters) for i, savedfilter in enumerate(saved_filters): @@ -132,6 +150,7 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'Saved Filter X', + 'slug': 'saved-filter-x', 'content_types': [site_ct.pk], 'description': 'Foo', 'weight': 1000, @@ -141,10 +160,10 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,content_types,weight,enabled,shared,parameters', - 'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}', - 'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}', - 'Saved Filter 6,dcim.device,600,True,True,{"foo": "c"}', + 'name,slug,content_types,weight,enabled,shared,parameters', + 'Saved Filter 4,saved-filter-4,dcim.device,400,True,True,{"foo": "a"}', + 'Saved Filter 5,saved-filter-5,dcim.device,500,True,True,{"foo": "b"}', + 'Saved Filter 6,saved-filter-6,dcim.device,600,True,True,{"foo": "c"}', ) cls.csv_update_data = ( diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 1a1496d7b..6f273b158 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -45,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Route Targets', ('import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -65,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('VRF', ('importing_vrf_id', 'exporting_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -97,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('family', 'rir_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -118,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Assignment', ('rir_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -143,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), @@ -232,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -264,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), ('VRF', ('vrf_id', 'present_in_vrf_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -333,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) @@ -363,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), ('VLAN ID', ('min_vid', 'max_vid')), ) @@ -411,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -464,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( @@ -485,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -510,7 +510,7 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('filter', 'l2vpn_id',)), + (None, ('filter_id', 'l2vpn_id',)), ('Assigned Object', ( 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', )), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 8c39db287..ee0ab330c 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -2,9 +2,9 @@ import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field -from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from extras.choices import CustomFieldFilterLogicChoices @@ -89,9 +89,12 @@ class BaseFilterSet(django_filters.FilterSet): self.base_filters = self.__class__.get_filters() # Apply any referenced SavedFilters - if data and 'filter' in data: + if data and ('filter' in data or 'filter_id' in data): data = data.copy() # Get a mutable copy - saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter')) + saved_filters = SavedFilter.objects.filter( + Q(slug__in=data.pop('filter', [])) | + Q(pk__in=data.pop('filter_id', [])) + ) for sf in saved_filters: for key, value in sf.parameters.items(): # QueryDicts are... fun diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2d785400c..b64871290 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -141,7 +141,7 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi # Limit saved filters to those applicable to the form's model content_type = ContentType.objects.get_for_model(self.model) - self.fields['filter'].widget.add_query_params({ + self.fields['filter_id'].widget.add_query_params({ 'content_type_id': content_type.pk, }) diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index f840a2177..c5d7fca0c 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( - (None, ('q', 'filter', 'tag', 'group_id')), + (None, ('q', 'filter_id', 'tag', 'group_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index ed2e39041..3b21a2c30 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -309,7 +309,7 @@ def applied_filters(context, model, form, query_params): }) save_link = None - if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET: + if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET: content_type = ContentType.objects.get_for_model(model).pk parameters = context['request'].GET.urlencode() url = reverse('extras:savedfilter_add') diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index c4fdf033a..2ead56595 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -90,7 +90,7 @@ class VirtualMachineFilterForm( ): model = VirtualMachine fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), @@ -175,7 +175,7 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id')), ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 287ef779c..70aef9610 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -28,7 +28,7 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('ssid', 'group_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), @@ -67,7 +67,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink fieldsets = ( - (None, ('q', 'filter', 'tag')), + (None, ('q', 'filter_id', 'tag')), ('Attributes', ('ssid', 'status',)), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), From 42301622944aa91ac0368eeaf3a07944f73de8cc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 10:50:26 -0500 Subject: [PATCH 255/409] Add missing model documentation --- docs/development/models.md | 1 + docs/models/extras/savedfilter.md | 47 +++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 3 files changed, 50 insertions(+) create mode 100644 docs/models/extras/savedfilter.md diff --git a/docs/development/models.md b/docs/development/models.md index 01070fa3d..af11617c8 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -40,6 +40,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [dcim.RackReservation](../models/dcim/rackreservation.md) * [dcim.Site](../models/dcim/site.md) * [dcim.VirtualChassis](../models/dcim/virtualchassis.md) +* [dcim.VirtualDeviceContext](../models/dcim/virtualdevicecontext.md) * [ipam.Aggregate](../models/ipam/aggregate.md) * [ipam.ASN](../models/ipam/asn.md) * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) diff --git a/docs/models/extras/savedfilter.md b/docs/models/extras/savedfilter.md new file mode 100644 index 000000000..68278403e --- /dev/null +++ b/docs/models/extras/savedfilter.md @@ -0,0 +1,47 @@ +# Saved Filters + +When filtering lists of objects in NetBox, users can save applied filters for future use. This is handy for complex filter strategies involving multiple discrete filters. For example, you might want to find all planned devices within a region that have a specific platform. Once you've applied the desired filters to the object list, simply create a saved filter with name and optional description. This filter can then be applied directly for future queries via both the UI and REST API. + +## Fields + +### Name + +The filter's human-friendly name. + +### Slug + +The unique identifier by which this filter will be referenced during application (e.g. `?filter=my-slug`). + +### User + +The user to which this filter belongs. The current user will be assigned automatically when creating saved filters via the UI, and cannot be changed. + +### Weight + +A numeric weight used to override alphabetic ordering of filters by name. Saved filters with a lower weight will be listed before those with a higher weight. + +### Enabled + +Determines whether this filter can be used. Disabled filters will not appear as options in the UI, however they will be included in API results. + +### Shared + +Determines whether this filter is intended for use by all users or only its owner. Note that disabling this field does **not** hide the filter from other users; it is merely excluded from the list of available filters in UI object list views. + +### Parameters + +The query parameters to apply when the filter is active. These must be specified as JSON data. For example, the URL query string + +``` +?status=active®ion_id=51&tag=alpha&tag=bravo +``` + +is represented in JSON as + +```json +{ + 'tag': ['alpha', 'bravo'], + 'status': 'active', + 'region_id': 51 +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index a147785d9..e5258dda9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -191,6 +191,7 @@ nav: - Site: 'models/dcim/site.md' - SiteGroup: 'models/dcim/sitegroup.md' - VirtualChassis: 'models/dcim/virtualchassis.md' + - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' @@ -199,6 +200,7 @@ nav: - ExportTemplate: 'models/extras/exporttemplate.md' - ImageAttachment: 'models/extras/imageattachment.md' - JournalEntry: 'models/extras/journalentry.md' + - SavedFilter: 'models/extras/savedfilter.md' - StagedChange: 'models/extras/stagedchange.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' From 640fd8045df3477d4d3410bf70980c5c8fbc4105 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 11:00:13 -0500 Subject: [PATCH 256/409] #9887: Add missing model documentation links --- netbox/extras/models/configcontexts.py | 5 +++++ netbox/extras/models/customfields.py | 5 +++++ netbox/extras/models/models.py | 17 +++++++++++++++++ netbox/extras/models/tags.py | 5 +++++ 4 files changed, 32 insertions(+) diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 30fb07069..d8d3510d7 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -116,6 +117,10 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:configcontext', kwargs={'pk': self.pk}) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/configcontext/' + def clean(self): super().clean() diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index b03149698..2890e6784 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -4,6 +4,7 @@ import decimal import django_filters from django import forms +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError @@ -179,6 +180,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge def get_absolute_url(self): return reverse('extras:customfield', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/customfield/' + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index b41e89d6c..fa6bb3ab9 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,6 +1,7 @@ import json import uuid +from django.conf import settings from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey @@ -146,6 +147,10 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:webhook', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/webhook/' + def clean(self): super().clean() @@ -250,6 +255,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged def get_absolute_url(self): return reverse('extras:customlink', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/customlink/' + def render(self, context): """ Render the CustomLink given the provided context, and return the text, link, and link_target. @@ -311,6 +320,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:exporttemplate', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/exporttemplate/' + def clean(self): super().clean() @@ -403,6 +416,10 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge def get_absolute_url(self): return reverse('extras:savedfilter', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/savedfilter/' + def clean(self): super().clean() diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index a4e4049d7..827d969e3 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models from django.urls import reverse from django.utils.text import slugify @@ -31,6 +32,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): def get_absolute_url(self): return reverse('extras:tag', args=[self.pk]) + @property + def docs_url(self): + return f'{settings.STATIC_URL}docs/models/extras/tag/' + def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) From 6f8a7fdbe3106d11079b1fa858b5fce6d531a54f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 11:24:36 -0500 Subject: [PATCH 257/409] Cleanup for #7854 --- netbox/dcim/api/urls.py | 2 +- netbox/dcim/choices.py | 4 +- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/filtersets.py | 3 +- netbox/dcim/forms/model_forms.py | 14 ++-- netbox/dcim/models/devices.py | 5 +- netbox/dcim/urls.py | 12 ++-- netbox/dcim/views.py | 5 +- netbox/templates/dcim/device.html | 68 +++++++++++++------ .../templates/dcim/virtualdevicecontext.html | 10 ++- 10 files changed, 82 insertions(+), 43 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 2e16e2786..36a0c99a5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,7 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) -router.register('vdcs', views.VirtualDeviceContextViewSet) +router.register('virtual-device-contexts', views.VirtualDeviceContextViewSet) router.register('modules', views.ModuleViewSet) # Device components diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index ce637fb3d..9c4ab569b 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1407,12 +1407,12 @@ class PowerFeedPhaseChoices(ChoiceSet): class VirtualDeviceContextStatusChoices(ChoiceSet): key = 'VirtualDeviceContext.status' - STATUS_PLANNED = 'planned' STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' STATUS_OFFLINE = 'offline' CHOICES = [ - (STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_OFFLINE, 'Offline', 'red'), ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 53576d017..b190ae65a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1032,7 +1032,7 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = VirtualDeviceContext - fields = ['id', 'device', 'name', ] + fields = ['id', 'device', 'name'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1b850b403..53adb4d56 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -736,9 +736,8 @@ class VirtualDeviceContextFilterForm( model = VirtualDeviceContext fieldsets = ( (None, ('q', 'filter_id', 'tag')), - ('Hardware', ('device', 'status', )), + ('Attributes', ('device', 'status', 'has_primary_ip')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Miscellaneous', ('has_primary_ip',)) ) device = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 76d2d9204..23498af06 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1698,21 +1698,19 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): ) fieldsets = ( - ('Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), - ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', - 'tenant')), - (None, ('tags', )) + ('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), + ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')) ) class Meta: model = VirtualDeviceContext fields = [ - 'region', 'site_group', 'site', 'location', 'rack', - 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', - 'comments', 'tags' + 'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier', + 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags' ] - help_texts = {} widgets = { + 'status': StaticSelect(), 'primary_ip4': StaticSelect(), 'primary_ip6': StaticSelect(), } diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index c89dd67ef..3a76c826f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1113,7 +1113,7 @@ class VirtualDeviceContext(PrimaryModel): choices=VirtualDeviceContextStatusChoices, ) identifier = models.PositiveSmallIntegerField( - help_text='Unique identifier provided by the platform being virtualized (Example: Nexus VDC Identifier)', + help_text='Numeric identifier unique to the parent device', blank=True, null=True, ) @@ -1163,6 +1163,9 @@ class VirtualDeviceContext(PrimaryModel): def get_absolute_url(self): return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk}) + def get_status_color(self): + return VirtualDeviceContextStatusChoices.colors.get(self.status) + @property def primary_ip(self): if ConfigItem('PREFER_IPV4')() and self.primary_ip4: diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 33b61309e..6772f96ad 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -184,12 +184,12 @@ urlpatterns = [ path('devices//', include(get_model_urls('dcim', 'device'))), # Virtual Device Context - path('vdcs/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'), - path('vdcs/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'), - path('vdcs/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'), - path('vdcs/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'), - path('vdcs/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'), - path('vdcs//', include(get_model_urls('dcim', 'virtualdevicecontext'))), + path('virtual-device-contexts/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'), + path('virtual-device-contexts/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'), + path('virtual-device-contexts/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'), + path('virtual-device-contexts/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'), + path('virtual-device-contexts/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'), + path('virtual-device-contexts//', include(get_model_urls('dcim', 'virtualdevicecontext'))), # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ae621008a..b04222600 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1837,11 +1837,14 @@ class DeviceView(generic.ObjectView): else: vc_members = [] - # Services services = Service.objects.restrict(request.user, 'view').filter(device=instance) + vdcs = VirtualDeviceContext.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( + 'tenant' + ) return { 'services': services, + 'vdcs': vdcs, 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}' } diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 046600d08..f0e7bb33a 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -155,6 +155,38 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} +
    +
    Virtual Device Contexts
    +
    + {% if vdcs %} + + + + + + + + {% for vdc in vdcs %} + + + + + + + {% endfor %} +
    NameStatusIdentifierTenant
    {{ vdc|linkify }}{% badge vdc.get_status_display bg_color=vdc.get_status_color %}{{ vdc.identifier|placeholder }}{{ vdc.tenant|linkify|placeholder }}
    + {% else %} +
    None
    + {% endif %} +
    + {% if perms.dcim.add_virtualdevicecontext %} + + {% endif %} +
    {% plugin_left_page object %}
    @@ -264,34 +296,30 @@
    {% endif %}
    -
    - Services -
    -
    +
    Services
    +
    {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
    + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    {% else %} -
    - None -
    +
    None
    {% endif %} -
    - {% if perms.ipam.add_service %} +
    + {% if perms.ipam.add_service %} - {% endif %} + {% endif %}
    {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% if object.rack and object.position %} -
    +

    Front

    @@ -304,7 +332,7 @@ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
    -
    +
    {% endif %} {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html index f5eb820f0..c76fed803 100644 --- a/netbox/templates/dcim/virtualdevicecontext.html +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -28,7 +28,6 @@ Identifier {{ object.identifier|placeholder }} - Primary IPv4 @@ -41,6 +40,15 @@ {{ object.primary_ip6|placeholder }} + + Tenant + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + +
    From d9d25ff4e70c65d7b2c276076a8ce46d6859d38a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 12:09:03 -0500 Subject: [PATCH 258/409] Cleanup for #4347 --- netbox/dcim/forms/bulk_import.py | 27 +++++++++++++++++++++ netbox/dcim/forms/object_import.py | 27 --------------------- netbox/netbox/forms/base.py | 4 +++ netbox/templates/generic/object_import.html | 23 ------------------ 4 files changed, 31 insertions(+), 50 deletions(-) delete mode 100644 netbox/templates/generic/object_import.html diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index c7dbfcb17..3b4990c33 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -23,6 +23,7 @@ __all__ = ( 'DeviceBayCSVForm', 'DeviceCSVForm', 'DeviceRoleCSVForm', + 'DeviceTypeImportForm', 'FrontPortCSVForm', 'InterfaceCSVForm', 'InventoryItemCSVForm', @@ -31,6 +32,7 @@ __all__ = ( 'ManufacturerCSVForm', 'ModuleCSVForm', 'ModuleBayCSVForm', + 'ModuleTypeImportForm', 'PlatformCSVForm', 'PowerFeedCSVForm', 'PowerOutletCSVForm', @@ -269,6 +271,31 @@ class ManufacturerCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'description', 'tags') +class DeviceTypeImportForm(NetBoxModelCSVForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = DeviceType + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'description', 'comments', + ] + + +class ModuleTypeImportForm(NetBoxModelCSVForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleType + fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] + + class DeviceRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 920c0081f..dfa1a4c6a 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -9,43 +9,16 @@ __all__ = ( 'ConsolePortTemplateImportForm', 'ConsoleServerPortTemplateImportForm', 'DeviceBayTemplateImportForm', - 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', - 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', ) -class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name' - ) - - class Meta: - model = DeviceType - fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'description', 'comments', - ] - - -class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name' - ) - - class Meta: - model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] - - # # Component template import forms # diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index b64871290..6bed8f44f 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -63,6 +63,10 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ + id = forms.IntegerField( + required=False, + help_text='Numeric ID of an existing object to update (if not creating a new object)' + ) tags = CSVModelMultipleChoiceField( queryset=Tag.objects.all(), required=False, diff --git a/netbox/templates/generic/object_import.html b/netbox/templates/generic/object_import.html deleted file mode 100644 index 4d54fde61..000000000 --- a/netbox/templates/generic/object_import.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'base/layout.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block title %}{{ obj_type|bettertitle }} Import{% endblock %} - -{% block content %} -
    -
    -
    - {% csrf_token %} - {% render_form form %} -
    - - - {% if return_url %} - Cancel - {% endif %} -
    -
    -
    -
    -{% endblock content %} From 23c0ca456f2753dc35aa2717f69cdc62b405a233 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 12:24:57 -0500 Subject: [PATCH 259/409] #4347: Rename NetBoxModelCSVForm to NetBoxModelImportForm --- docs/plugins/development/forms.md | 27 +++-- docs/release-notes/version-3.4.md | 1 + netbox/circuits/forms/bulk_import.py | 18 +-- netbox/circuits/views.py | 8 +- netbox/dcim/forms/bulk_import.py | 128 ++++++++++----------- netbox/dcim/views.py | 58 +++++----- netbox/extras/forms/bulk_import.py | 24 ++-- netbox/extras/tests/test_customfields.py | 6 +- netbox/extras/views.py | 12 +- netbox/ipam/forms/bulk_import.py | 66 +++++------ netbox/ipam/views.py | 32 +++--- netbox/netbox/forms/base.py | 13 ++- netbox/tenancy/forms/bulk_import.py | 22 ++-- netbox/tenancy/views.py | 10 +- netbox/utilities/tests/test_forms.py | 4 +- netbox/virtualization/forms/bulk_import.py | 22 ++-- netbox/virtualization/views.py | 10 +- netbox/wireless/forms/bulk_import.py | 14 +-- netbox/wireless/views.py | 6 +- 19 files changed, 248 insertions(+), 233 deletions(-) diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index dee0d3796..d819b76cb 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -4,11 +4,11 @@ NetBox provides several base form classes for use by plugins. -| Form Class | Purpose | -|---------------------------|--------------------------------------| -| `NetBoxModelForm` | Create/edit individual objects | -| `NetBoxModelCSVForm` | Bulk import objects from CSV data | -| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | +| Form Class | Purpose | +|----------------------------|--------------------------------------| +| `NetBoxModelForm` | Create/edit individual objects | +| `NetBoxModelImportForm` | Bulk import objects from CSV data | +| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | | `NetBoxModelFilterSetForm` | Filter objects within a list view | ### `NetBoxModelForm` @@ -45,19 +45,20 @@ class MyModelForm(NetBoxModelForm): !!! tip "Comment fields" If your form has a `comments` field, there's no need to list it; this will always appear last on the page. -### `NetBoxModelCSVForm` +### `NetBoxModelImportForm` -This form facilitates the bulk import of new objects from CSV data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. +This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. **Example** ```python from dcim.models import Site -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelChoiceField from .models import MyModel -class MyModelCSVForm(NetBoxModelCSVForm): + +class MyModelImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -69,6 +70,9 @@ class MyModelCSVForm(NetBoxModelCSVForm): fields = ('name', 'status', 'site', 'comments') ``` +!!! note "Previously NetBoxModelCSVForm" + This form class was previously named `NetBoxModelCSVForm`. It was renamed in NetBox v3.4 to convey support for JSON and YAML formats in addition to CSV. The `NetBoxModelCSVForm` class has been retained for backward compatibility and functions exactly the same as `NetBoxModelImportForm`. However, plugin authors should be aware that this backward compatability will be removed in NetBox v3.5. + ### `NetBoxModelBulkEditForm` This form facilitates editing multiple objects in bulk. Unlike a model form, this form does not have a child `Meta` class, and must explicitly define each field. All fields in a bulk edit form are generally declared with `required=False`. @@ -84,11 +88,12 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi ```python from django import forms from dcim.models import Site -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from utilities.forms import CommentField, DynamicModelChoiceField from .models import MyModel, MyModelStatusChoices -class MyModelEditForm(NetBoxModelCSVForm): + +class MyModelEditForm(NetBoxModelImportForm): name = forms.CharField( required=False ) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index d8df552fb..19860872a 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -10,6 +10,7 @@ * The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. * The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types. * The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead. +* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5. ### New Features diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 97b6af428..9566c2e12 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,19 +1,19 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * from django.utils.translation import gettext as _ -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( - 'CircuitCSVForm', - 'CircuitTypeCSVForm', - 'ProviderCSVForm', - 'ProviderNetworkCSVForm', + 'CircuitImportForm', + 'CircuitTypeImportForm', + 'ProviderImportForm', + 'ProviderNetworkImportForm', ) -class ProviderCSVForm(NetBoxModelCSVForm): +class ProviderImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -23,7 +23,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): ) -class ProviderNetworkCSVForm(NetBoxModelCSVForm): +class ProviderNetworkImportForm(NetBoxModelImportForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -37,7 +37,7 @@ class ProviderNetworkCSVForm(NetBoxModelCSVForm): ] -class CircuitTypeCSVForm(NetBoxModelCSVForm): +class CircuitTypeImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -48,7 +48,7 @@ class CircuitTypeCSVForm(NetBoxModelCSVForm): } -class CircuitCSVForm(NetBoxModelCSVForm): +class CircuitImportForm(NetBoxModelImportForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index dc809666b..5fe8eb7b7 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -57,7 +57,7 @@ class ProviderDeleteView(generic.ObjectDeleteView): class ProviderBulkImportView(generic.BulkImportView): queryset = Provider.objects.all() - model_form = forms.ProviderCSVForm + model_form = forms.ProviderImportForm table = tables.ProviderTable @@ -122,7 +122,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView): class ProviderNetworkBulkImportView(generic.BulkImportView): queryset = ProviderNetwork.objects.all() - model_form = forms.ProviderNetworkCSVForm + model_form = forms.ProviderNetworkImportForm table = tables.ProviderNetworkTable @@ -179,7 +179,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView): class CircuitTypeBulkImportView(generic.BulkImportView): queryset = CircuitType.objects.all() - model_form = forms.CircuitTypeCSVForm + model_form = forms.CircuitTypeImportForm table = tables.CircuitTypeTable @@ -231,7 +231,7 @@ class CircuitDeleteView(generic.ObjectDeleteView): class CircuitBulkImportView(generic.BulkImportView): queryset = Circuit.objects.all() - model_form = forms.CircuitCSVForm + model_form = forms.CircuitImportForm table = tables.CircuitTable diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 3b4990c33..6861395d1 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -9,48 +9,48 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VRF -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster from wireless.choices import WirelessRoleChoices __all__ = ( - 'CableCSVForm', - 'ChildDeviceCSVForm', - 'ConsolePortCSVForm', - 'ConsoleServerPortCSVForm', - 'DeviceBayCSVForm', - 'DeviceCSVForm', - 'DeviceRoleCSVForm', + 'CableImportForm', + 'ChildDeviceImportForm', + 'ConsolePortImportForm', + 'ConsoleServerPortImportForm', + 'DeviceBayImportForm', + 'DeviceImportForm', + 'DeviceRoleImportForm', 'DeviceTypeImportForm', - 'FrontPortCSVForm', - 'InterfaceCSVForm', - 'InventoryItemCSVForm', - 'InventoryItemRoleCSVForm', - 'LocationCSVForm', - 'ManufacturerCSVForm', - 'ModuleCSVForm', - 'ModuleBayCSVForm', + 'FrontPortImportForm', + 'InterfaceImportForm', + 'InventoryItemImportForm', + 'InventoryItemRoleImportForm', + 'LocationImportForm', + 'ManufacturerImportForm', + 'ModuleImportForm', + 'ModuleBayImportForm', 'ModuleTypeImportForm', - 'PlatformCSVForm', - 'PowerFeedCSVForm', - 'PowerOutletCSVForm', - 'PowerPanelCSVForm', - 'PowerPortCSVForm', - 'RackCSVForm', - 'RackReservationCSVForm', - 'RackRoleCSVForm', - 'RearPortCSVForm', - 'RegionCSVForm', - 'SiteCSVForm', - 'SiteGroupCSVForm', - 'VirtualChassisCSVForm', - 'VirtualDeviceContextCSVForm' + 'PlatformImportForm', + 'PowerFeedImportForm', + 'PowerOutletImportForm', + 'PowerPanelImportForm', + 'PowerPortImportForm', + 'RackImportForm', + 'RackReservationImportForm', + 'RackRoleImportForm', + 'RearPortImportForm', + 'RegionImportForm', + 'SiteImportForm', + 'SiteGroupImportForm', + 'VirtualChassisImportForm', + 'VirtualDeviceContextImportForm' ) -class RegionCSVForm(NetBoxModelCSVForm): +class RegionImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -63,7 +63,7 @@ class RegionCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class SiteGroupCSVForm(NetBoxModelCSVForm): +class SiteGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -76,7 +76,7 @@ class SiteGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteCSVForm(NetBoxModelCSVForm): +class SiteImportForm(NetBoxModelImportForm): status = CSVChoiceField( choices=SiteStatusChoices, help_text=_('Operational status') @@ -113,7 +113,7 @@ class SiteCSVForm(NetBoxModelCSVForm): } -class LocationCSVForm(NetBoxModelCSVForm): +class LocationImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -144,7 +144,7 @@ class LocationCSVForm(NetBoxModelCSVForm): fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') -class RackRoleCSVForm(NetBoxModelCSVForm): +class RackRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -155,7 +155,7 @@ class RackRoleCSVForm(NetBoxModelCSVForm): } -class RackCSVForm(NetBoxModelCSVForm): +class RackImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' @@ -214,7 +214,7 @@ class RackCSVForm(NetBoxModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class RackReservationCSVForm(NetBoxModelCSVForm): +class RackReservationImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -264,14 +264,14 @@ class RackReservationCSVForm(NetBoxModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ManufacturerCSVForm(NetBoxModelCSVForm): +class ManufacturerImportForm(NetBoxModelImportForm): class Meta: model = Manufacturer fields = ('name', 'slug', 'description', 'tags') -class DeviceTypeImportForm(NetBoxModelCSVForm): +class DeviceTypeImportForm(NetBoxModelImportForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name' @@ -285,7 +285,7 @@ class DeviceTypeImportForm(NetBoxModelCSVForm): ] -class ModuleTypeImportForm(NetBoxModelCSVForm): +class ModuleTypeImportForm(NetBoxModelImportForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name' @@ -296,7 +296,7 @@ class ModuleTypeImportForm(NetBoxModelCSVForm): fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] -class DeviceRoleCSVForm(NetBoxModelCSVForm): +class DeviceRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -307,7 +307,7 @@ class DeviceRoleCSVForm(NetBoxModelCSVForm): } -class PlatformCSVForm(NetBoxModelCSVForm): +class PlatformImportForm(NetBoxModelImportForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -321,7 +321,7 @@ class PlatformCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') -class BaseDeviceCSVForm(NetBoxModelCSVForm): +class BaseDeviceImportForm(NetBoxModelImportForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -384,7 +384,7 @@ class BaseDeviceCSVForm(NetBoxModelCSVForm): self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params) -class DeviceCSVForm(BaseDeviceCSVForm): +class DeviceImportForm(BaseDeviceImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -413,7 +413,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): help_text=_('Airflow direction') ) - class Meta(BaseDeviceCSVForm.Meta): + class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', @@ -437,7 +437,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ModuleCSVForm(NetBoxModelCSVForm): +class ModuleImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -466,7 +466,7 @@ class ModuleCSVForm(NetBoxModelCSVForm): self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) -class ChildDeviceCSVForm(BaseDeviceCSVForm): +class ChildDeviceImportForm(BaseDeviceImportForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -478,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): help_text=_('Device bay in which this device is installed') ) - class Meta(BaseDeviceCSVForm.Meta): + class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags' @@ -512,7 +512,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): # Device components # -class ConsolePortCSVForm(NetBoxModelCSVForm): +class ConsolePortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -535,7 +535,7 @@ class ConsolePortCSVForm(NetBoxModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') -class ConsoleServerPortCSVForm(NetBoxModelCSVForm): +class ConsoleServerPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -558,7 +558,7 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags') -class PowerPortCSVForm(NetBoxModelCSVForm): +class PowerPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -576,7 +576,7 @@ class PowerPortCSVForm(NetBoxModelCSVForm): ) -class PowerOutletCSVForm(NetBoxModelCSVForm): +class PowerOutletImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -625,7 +625,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class InterfaceCSVForm(NetBoxModelCSVForm): +class InterfaceImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -712,7 +712,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm): return self.cleaned_data['enabled'] -class FrontPortCSVForm(NetBoxModelCSVForm): +class FrontPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -760,7 +760,7 @@ class FrontPortCSVForm(NetBoxModelCSVForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class RearPortCSVForm(NetBoxModelCSVForm): +class RearPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -778,7 +778,7 @@ class RearPortCSVForm(NetBoxModelCSVForm): } -class ModuleBayCSVForm(NetBoxModelCSVForm): +class ModuleBayImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -789,7 +789,7 @@ class ModuleBayCSVForm(NetBoxModelCSVForm): fields = ('device', 'name', 'label', 'position', 'description', 'tags') -class DeviceBayCSVForm(NetBoxModelCSVForm): +class DeviceBayImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -835,7 +835,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm): self.fields['installed_device'].queryset = Interface.objects.none() -class InventoryItemCSVForm(NetBoxModelCSVForm): +class InventoryItemImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -884,7 +884,7 @@ class InventoryItemCSVForm(NetBoxModelCSVForm): # Device component roles # -class InventoryItemRoleCSVForm(NetBoxModelCSVForm): +class InventoryItemRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -899,7 +899,7 @@ class InventoryItemRoleCSVForm(NetBoxModelCSVForm): # Cables # -class CableCSVForm(NetBoxModelCSVForm): +class CableImportForm(NetBoxModelImportForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -1004,7 +1004,7 @@ class CableCSVForm(NetBoxModelCSVForm): # Virtual chassis # -class VirtualChassisCSVForm(NetBoxModelCSVForm): +class VirtualChassisImportForm(NetBoxModelImportForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -1021,7 +1021,7 @@ class VirtualChassisCSVForm(NetBoxModelCSVForm): # Power # -class PowerPanelCSVForm(NetBoxModelCSVForm): +class PowerPanelImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -1047,7 +1047,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerFeedCSVForm(NetBoxModelCSVForm): +class PowerFeedImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -1115,7 +1115,7 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class VirtualDeviceContextCSVForm(NetBoxModelCSVForm): +class VirtualDeviceContextImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b04222600..12f9ff2fc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -248,7 +248,7 @@ class RegionDeleteView(generic.ObjectDeleteView): class RegionBulkImportView(generic.BulkImportView): queryset = Region.objects.all() - model_form = forms.RegionCSVForm + model_form = forms.RegionImportForm table = tables.RegionTable @@ -336,7 +336,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView): class SiteGroupBulkImportView(generic.BulkImportView): queryset = SiteGroup.objects.all() - model_form = forms.SiteGroupCSVForm + model_form = forms.SiteGroupImportForm table = tables.SiteGroupTable @@ -442,7 +442,7 @@ class SiteDeleteView(generic.ObjectDeleteView): class SiteBulkImportView(generic.BulkImportView): queryset = Site.objects.all() - model_form = forms.SiteCSVForm + model_form = forms.SiteImportForm table = tables.SiteTable @@ -535,7 +535,7 @@ class LocationDeleteView(generic.ObjectDeleteView): class LocationBulkImportView(generic.BulkImportView): queryset = Location.objects.all() - model_form = forms.LocationCSVForm + model_form = forms.LocationImportForm table = tables.LocationTable @@ -609,7 +609,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView): class RackRoleBulkImportView(generic.BulkImportView): queryset = RackRole.objects.all() - model_form = forms.RackRoleCSVForm + model_form = forms.RackRoleImportForm table = tables.RackRoleTable @@ -751,7 +751,7 @@ class RackDeleteView(generic.ObjectDeleteView): class RackBulkImportView(generic.BulkImportView): queryset = Rack.objects.all() - model_form = forms.RackCSVForm + model_form = forms.RackImportForm table = tables.RackTable @@ -804,7 +804,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView): class RackReservationImportView(generic.BulkImportView): queryset = RackReservation.objects.all() - model_form = forms.RackReservationCSVForm + model_form = forms.RackReservationImportForm table = tables.RackReservationTable def save_object(self, obj_form, request): @@ -886,7 +886,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView): class ManufacturerBulkImportView(generic.BulkImportView): queryset = Manufacturer.objects.all() - model_form = forms.ManufacturerCSVForm + model_form = forms.ManufacturerImportForm table = tables.ManufacturerTable @@ -1730,7 +1730,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView): class DeviceRoleBulkImportView(generic.BulkImportView): queryset = DeviceRole.objects.all() - model_form = forms.DeviceRoleCSVForm + model_form = forms.DeviceRoleImportForm table = tables.DeviceRoleTable @@ -1796,7 +1796,7 @@ class PlatformDeleteView(generic.ObjectDeleteView): class PlatformBulkImportView(generic.BulkImportView): queryset = Platform.objects.all() - model_form = forms.PlatformCSVForm + model_form = forms.PlatformImportForm table = tables.PlatformTable @@ -2020,14 +2020,14 @@ class DeviceConfigContextView(ObjectConfigContextView): class DeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() - model_form = forms.DeviceCSVForm + model_form = forms.DeviceImportForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' class ChildDeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() - model_form = forms.ChildDeviceCSVForm + model_form = forms.ChildDeviceImportForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' @@ -2151,7 +2151,7 @@ class ModuleDeleteView(generic.ObjectDeleteView): class ModuleBulkImportView(generic.BulkImportView): queryset = Module.objects.all() - model_form = forms.ModuleCSVForm + model_form = forms.ModuleImportForm table = tables.ModuleTable @@ -2204,7 +2204,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView): class ConsolePortBulkImportView(generic.BulkImportView): queryset = ConsolePort.objects.all() - model_form = forms.ConsolePortCSVForm + model_form = forms.ConsolePortImportForm table = tables.ConsolePortTable @@ -2269,7 +2269,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView): class ConsoleServerPortBulkImportView(generic.BulkImportView): queryset = ConsoleServerPort.objects.all() - model_form = forms.ConsoleServerPortCSVForm + model_form = forms.ConsoleServerPortImportForm table = tables.ConsoleServerPortTable @@ -2334,7 +2334,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView): class PowerPortBulkImportView(generic.BulkImportView): queryset = PowerPort.objects.all() - model_form = forms.PowerPortCSVForm + model_form = forms.PowerPortImportForm table = tables.PowerPortTable @@ -2399,7 +2399,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView): class PowerOutletBulkImportView(generic.BulkImportView): queryset = PowerOutlet.objects.all() - model_form = forms.PowerOutletCSVForm + model_form = forms.PowerOutletImportForm table = tables.PowerOutletTable @@ -2517,7 +2517,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView): class InterfaceBulkImportView(generic.BulkImportView): queryset = Interface.objects.all() - model_form = forms.InterfaceCSVForm + model_form = forms.InterfaceImportForm table = tables.InterfaceTable @@ -2582,7 +2582,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView): class FrontPortBulkImportView(generic.BulkImportView): queryset = FrontPort.objects.all() - model_form = forms.FrontPortCSVForm + model_form = forms.FrontPortImportForm table = tables.FrontPortTable @@ -2647,7 +2647,7 @@ class RearPortDeleteView(generic.ObjectDeleteView): class RearPortBulkImportView(generic.BulkImportView): queryset = RearPort.objects.all() - model_form = forms.RearPortCSVForm + model_form = forms.RearPortImportForm table = tables.RearPortTable @@ -2712,7 +2712,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView): class ModuleBayBulkImportView(generic.BulkImportView): queryset = ModuleBay.objects.all() - model_form = forms.ModuleBayCSVForm + model_form = forms.ModuleBayImportForm table = tables.ModuleBayTable @@ -2838,7 +2838,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView): class DeviceBayBulkImportView(generic.BulkImportView): queryset = DeviceBay.objects.all() - model_form = forms.DeviceBayCSVForm + model_form = forms.DeviceBayImportForm table = tables.DeviceBayTable @@ -2906,7 +2906,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView): class InventoryItemBulkImportView(generic.BulkImportView): queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemCSVForm + model_form = forms.InventoryItemImportForm table = tables.InventoryItemTable @@ -2963,7 +2963,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView): class InventoryItemRoleBulkImportView(generic.BulkImportView): queryset = InventoryItemRole.objects.all() - model_form = forms.InventoryItemRoleCSVForm + model_form = forms.InventoryItemRoleImportForm table = tables.InventoryItemRoleTable @@ -3158,7 +3158,7 @@ class CableDeleteView(generic.ObjectDeleteView): class CableBulkImportView(generic.BulkImportView): queryset = Cable.objects.all() - model_form = forms.CableCSVForm + model_form = forms.CableImportForm table = tables.CableTable @@ -3441,7 +3441,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL class VirtualChassisBulkImportView(generic.BulkImportView): queryset = VirtualChassis.objects.all() - model_form = forms.VirtualChassisCSVForm + model_form = forms.VirtualChassisImportForm table = tables.VirtualChassisTable @@ -3503,7 +3503,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView): class PowerPanelBulkImportView(generic.BulkImportView): queryset = PowerPanel.objects.all() - model_form = forms.PowerPanelCSVForm + model_form = forms.PowerPanelImportForm table = tables.PowerPanelTable @@ -3551,7 +3551,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView): class PowerFeedBulkImportView(generic.BulkImportView): queryset = PowerFeed.objects.all() - model_form = forms.PowerFeedCSVForm + model_form = forms.PowerFeedImportForm table = tables.PowerFeedTable @@ -3611,7 +3611,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView): class VirtualDeviceContextBulkImportView(generic.BulkImportView): queryset = VirtualDeviceContext.objects.all() - model_form = forms.VirtualDeviceContextCSVForm + model_form = forms.VirtualDeviceContextImportForm table = tables.VirtualDeviceContextTable diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 7ef4739ad..cf723c4f7 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -10,16 +10,16 @@ from extras.utils import FeatureQuery from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField __all__ = ( - 'CustomFieldCSVForm', - 'CustomLinkCSVForm', - 'ExportTemplateCSVForm', - 'SavedFilterCSVForm', - 'TagCSVForm', - 'WebhookCSVForm', + 'CustomFieldImportForm', + 'CustomLinkImportForm', + 'ExportTemplateImportForm', + 'SavedFilterImportForm', + 'TagImportForm', + 'WebhookImportForm', ) -class CustomFieldCSVForm(CSVModelForm): +class CustomFieldImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -54,7 +54,7 @@ class CustomFieldCSVForm(CSVModelForm): ) -class CustomLinkCSVForm(CSVModelForm): +class CustomLinkImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_links'), @@ -69,7 +69,7 @@ class CustomLinkCSVForm(CSVModelForm): ) -class ExportTemplateCSVForm(CSVModelForm): +class ExportTemplateImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('export_templates'), @@ -83,7 +83,7 @@ class ExportTemplateCSVForm(CSVModelForm): ) -class SavedFilterCSVForm(CSVModelForm): +class SavedFilterImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), help_text=_("One or more assigned object types") @@ -96,7 +96,7 @@ class SavedFilterCSVForm(CSVModelForm): ) -class WebhookCSVForm(CSVModelForm): +class WebhookImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), @@ -112,7 +112,7 @@ class WebhookCSVForm(CSVModelForm): ) -class TagCSVForm(CSVModelForm): +class TagImportForm(CSVModelForm): slug = SlugField() class Meta: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 2f3c7932a..9c1c7c2db 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -6,7 +6,7 @@ from django.urls import reverse from rest_framework import status from dcim.filtersets import SiteFilterSet -from dcim.forms import SiteCSVForm +from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField @@ -983,7 +983,7 @@ class CustomFieldImportTest(TestCase): 'slug': 'site-1', } - form = SiteCSVForm(data=form_data) + form = SiteImportForm(data=form_data) self.assertFalse(form.is_valid()) self.assertIn('cf_text', form.errors) @@ -997,7 +997,7 @@ class CustomFieldImportTest(TestCase): 'cf_select': 'Choice X' } - form = SiteCSVForm(data=form_data) + form = SiteImportForm(data=form_data) self.assertFalse(form.is_valid()) self.assertIn('cf_select', form.errors) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4a1350bde..283d37465 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -49,7 +49,7 @@ class CustomFieldDeleteView(generic.ObjectDeleteView): class CustomFieldBulkImportView(generic.BulkImportView): queryset = CustomField.objects.all() - model_form = forms.CustomFieldCSVForm + model_form = forms.CustomFieldImportForm table = tables.CustomFieldTable @@ -95,7 +95,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView): class CustomLinkBulkImportView(generic.BulkImportView): queryset = CustomLink.objects.all() - model_form = forms.CustomLinkCSVForm + model_form = forms.CustomLinkImportForm table = tables.CustomLinkTable @@ -141,7 +141,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView): class ExportTemplateBulkImportView(generic.BulkImportView): queryset = ExportTemplate.objects.all() - model_form = forms.ExportTemplateCSVForm + model_form = forms.ExportTemplateImportForm table = tables.ExportTemplateTable @@ -209,7 +209,7 @@ class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView): class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView): queryset = SavedFilter.objects.all() - model_form = forms.SavedFilterCSVForm + model_form = forms.SavedFilterImportForm table = tables.SavedFilterTable @@ -255,7 +255,7 @@ class WebhookDeleteView(generic.ObjectDeleteView): class WebhookBulkImportView(generic.BulkImportView): queryset = Webhook.objects.all() - model_form = forms.WebhookCSVForm + model_form = forms.WebhookImportForm table = tables.WebhookTable @@ -324,7 +324,7 @@ class TagDeleteView(generic.ObjectDeleteView): class TagBulkImportView(generic.BulkImportView): queryset = Tag.objects.all() - model_form = forms.TagCSVForm + model_form = forms.TagImportForm table = tables.TagTable diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 177233717..972b98db2 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -7,32 +7,32 @@ from dcim.models import Device, Interface, Site from ipam.choices import * from ipam.constants import * from ipam.models import * -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface __all__ = ( - 'AggregateCSVForm', - 'ASNCSVForm', - 'FHRPGroupCSVForm', - 'IPAddressCSVForm', - 'IPRangeCSVForm', - 'L2VPNCSVForm', - 'L2VPNTerminationCSVForm', - 'PrefixCSVForm', - 'RIRCSVForm', - 'RoleCSVForm', - 'RouteTargetCSVForm', - 'ServiceCSVForm', - 'ServiceTemplateCSVForm', - 'VLANCSVForm', - 'VLANGroupCSVForm', - 'VRFCSVForm', + 'AggregateImportForm', + 'ASNImportForm', + 'FHRPGroupImportForm', + 'IPAddressImportForm', + 'IPRangeImportForm', + 'L2VPNImportForm', + 'L2VPNTerminationImportForm', + 'PrefixImportForm', + 'RIRImportForm', + 'RoleImportForm', + 'RouteTargetImportForm', + 'ServiceImportForm', + 'ServiceTemplateImportForm', + 'VLANImportForm', + 'VLANGroupImportForm', + 'VRFImportForm', ) -class VRFCSVForm(NetBoxModelCSVForm): +class VRFImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -45,7 +45,7 @@ class VRFCSVForm(NetBoxModelCSVForm): fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') -class RouteTargetCSVForm(NetBoxModelCSVForm): +class RouteTargetImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -58,7 +58,7 @@ class RouteTargetCSVForm(NetBoxModelCSVForm): fields = ('name', 'tenant', 'description', 'comments', 'tags') -class RIRCSVForm(NetBoxModelCSVForm): +class RIRImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -69,7 +69,7 @@ class RIRCSVForm(NetBoxModelCSVForm): } -class AggregateCSVForm(NetBoxModelCSVForm): +class AggregateImportForm(NetBoxModelImportForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -87,7 +87,7 @@ class AggregateCSVForm(NetBoxModelCSVForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') -class ASNCSVForm(NetBoxModelCSVForm): +class ASNImportForm(NetBoxModelImportForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -105,7 +105,7 @@ class ASNCSVForm(NetBoxModelCSVForm): fields = ('asn', 'rir', 'tenant', 'description', 'comments', 'tags') -class RoleCSVForm(NetBoxModelCSVForm): +class RoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -113,7 +113,7 @@ class RoleCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'weight', 'description', 'tags') -class PrefixCSVForm(NetBoxModelCSVForm): +class PrefixImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -177,7 +177,7 @@ class PrefixCSVForm(NetBoxModelCSVForm): self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) -class IPRangeCSVForm(NetBoxModelCSVForm): +class IPRangeImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -208,7 +208,7 @@ class IPRangeCSVForm(NetBoxModelCSVForm): ) -class IPAddressCSVForm(NetBoxModelCSVForm): +class IPAddressImportForm(NetBoxModelImportForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -315,7 +315,7 @@ class IPAddressCSVForm(NetBoxModelCSVForm): return ipaddress -class FHRPGroupCSVForm(NetBoxModelCSVForm): +class FHRPGroupImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( choices=FHRPGroupProtocolChoices ) @@ -329,7 +329,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'comments', 'tags') -class VLANGroupCSVForm(NetBoxModelCSVForm): +class VLANGroupImportForm(NetBoxModelImportForm): slug = SlugField() scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), @@ -357,7 +357,7 @@ class VLANGroupCSVForm(NetBoxModelCSVForm): } -class VLANCSVForm(NetBoxModelCSVForm): +class VLANImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, @@ -396,7 +396,7 @@ class VLANCSVForm(NetBoxModelCSVForm): } -class ServiceTemplateCSVForm(NetBoxModelCSVForm): +class ServiceTemplateImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( choices=ServiceProtocolChoices, help_text=_('IP protocol') @@ -407,7 +407,7 @@ class ServiceTemplateCSVForm(NetBoxModelCSVForm): fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags') -class ServiceCSVForm(NetBoxModelCSVForm): +class ServiceImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, @@ -430,7 +430,7 @@ class ServiceCSVForm(NetBoxModelCSVForm): fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') -class L2VPNCSVForm(NetBoxModelCSVForm): +class L2VPNImportForm(NetBoxModelImportForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -446,7 +446,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags') -class L2VPNTerminationCSVForm(NetBoxModelCSVForm): +class L2VPNTerminationImportForm(NetBoxModelImportForm): l2vpn = CSVModelChoiceField( queryset=L2VPN.objects.all(), required=True, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index df307c99f..1d4504d92 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -72,7 +72,7 @@ class VRFDeleteView(generic.ObjectDeleteView): class VRFBulkImportView(generic.BulkImportView): queryset = VRF.objects.all() - model_form = forms.VRFCSVForm + model_form = forms.VRFImportForm table = tables.VRFTable @@ -133,7 +133,7 @@ class RouteTargetDeleteView(generic.ObjectDeleteView): class RouteTargetBulkImportView(generic.BulkImportView): queryset = RouteTarget.objects.all() - model_form = forms.RouteTargetCSVForm + model_form = forms.RouteTargetImportForm table = tables.RouteTargetTable @@ -192,7 +192,7 @@ class RIRDeleteView(generic.ObjectDeleteView): class RIRBulkImportView(generic.BulkImportView): queryset = RIR.objects.all() - model_form = forms.RIRCSVForm + model_form = forms.RIRImportForm table = tables.RIRTable @@ -265,7 +265,7 @@ class ASNDeleteView(generic.ObjectDeleteView): class ASNBulkImportView(generic.BulkImportView): queryset = ASN.objects.all() - model_form = forms.ASNCSVForm + model_form = forms.ASNImportForm table = tables.ASNTable @@ -351,7 +351,7 @@ class AggregateDeleteView(generic.ObjectDeleteView): class AggregateBulkImportView(generic.BulkImportView): queryset = Aggregate.objects.all() - model_form = forms.AggregateCSVForm + model_form = forms.AggregateImportForm table = tables.AggregateTable @@ -417,7 +417,7 @@ class RoleDeleteView(generic.ObjectDeleteView): class RoleBulkImportView(generic.BulkImportView): queryset = Role.objects.all() - model_form = forms.RoleCSVForm + model_form = forms.RoleImportForm table = tables.RoleTable @@ -592,7 +592,7 @@ class PrefixDeleteView(generic.ObjectDeleteView): class PrefixBulkImportView(generic.BulkImportView): queryset = Prefix.objects.all() - model_form = forms.PrefixCSVForm + model_form = forms.PrefixImportForm table = tables.PrefixTable @@ -655,7 +655,7 @@ class IPRangeDeleteView(generic.ObjectDeleteView): class IPRangeBulkImportView(generic.BulkImportView): queryset = IPRange.objects.all() - model_form = forms.IPRangeCSVForm + model_form = forms.IPRangeImportForm table = tables.IPRangeTable @@ -836,7 +836,7 @@ class IPAddressBulkCreateView(generic.BulkCreateView): class IPAddressBulkImportView(generic.BulkImportView): queryset = IPAddress.objects.all() - model_form = forms.IPAddressCSVForm + model_form = forms.IPAddressImportForm table = tables.IPAddressTable @@ -910,7 +910,7 @@ class VLANGroupDeleteView(generic.ObjectDeleteView): class VLANGroupBulkImportView(generic.BulkImportView): queryset = VLANGroup.objects.all() - model_form = forms.VLANGroupCSVForm + model_form = forms.VLANGroupImportForm table = tables.VLANGroupTable @@ -999,7 +999,7 @@ class FHRPGroupDeleteView(generic.ObjectDeleteView): class FHRPGroupBulkImportView(generic.BulkImportView): queryset = FHRPGroup.objects.all() - model_form = forms.FHRPGroupCSVForm + model_form = forms.FHRPGroupImportForm table = tables.FHRPGroupTable @@ -1113,7 +1113,7 @@ class VLANDeleteView(generic.ObjectDeleteView): class VLANBulkImportView(generic.BulkImportView): queryset = VLAN.objects.all() - model_form = forms.VLANCSVForm + model_form = forms.VLANImportForm table = tables.VLANTable @@ -1159,7 +1159,7 @@ class ServiceTemplateDeleteView(generic.ObjectDeleteView): class ServiceTemplateBulkImportView(generic.BulkImportView): queryset = ServiceTemplate.objects.all() - model_form = forms.ServiceTemplateCSVForm + model_form = forms.ServiceTemplateImportForm table = tables.ServiceTemplateTable @@ -1212,7 +1212,7 @@ class ServiceDeleteView(generic.ObjectDeleteView): class ServiceBulkImportView(generic.BulkImportView): queryset = Service.objects.all() - model_form = forms.ServiceCSVForm + model_form = forms.ServiceImportForm table = tables.ServiceTable @@ -1276,7 +1276,7 @@ class L2VPNDeleteView(generic.ObjectDeleteView): class L2VPNBulkImportView(generic.BulkImportView): queryset = L2VPN.objects.all() - model_form = forms.L2VPNCSVForm + model_form = forms.L2VPNImportForm table = tables.L2VPNTable @@ -1323,7 +1323,7 @@ class L2VPNTerminationDeleteView(generic.ObjectDeleteView): class L2VPNTerminationBulkImportView(generic.BulkImportView): queryset = L2VPNTermination.objects.all() - model_form = forms.L2VPNTerminationCSVForm + model_form = forms.L2VPNTerminationImportForm table = tables.L2VPNTerminationTable diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 6bed8f44f..20a3793da 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -11,8 +11,9 @@ from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField __all__ = ( - 'NetBoxModelForm', 'NetBoxModelCSVForm', + 'NetBoxModelForm', + 'NetBoxModelImportForm', 'NetBoxModelBulkEditForm', 'NetBoxModelFilterSetForm', ) @@ -59,7 +60,7 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): return super().clean() -class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): +class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ @@ -83,6 +84,14 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) +class NetBoxModelCSVForm(NetBoxModelImportForm): + """ + Maintains backward compatibility for NetBoxModelImportForm for plugins. + """ + # TODO: Remove in NetBox v3.5 + pass + + class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 27092f9e8..8a251a316 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,14 +1,14 @@ from django.utils.translation import gettext as _ -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField __all__ = ( - 'ContactCSVForm', - 'ContactGroupCSVForm', - 'ContactRoleCSVForm', - 'TenantCSVForm', - 'TenantGroupCSVForm', + 'ContactImportForm', + 'ContactGroupImportForm', + 'ContactRoleImportForm', + 'TenantImportForm', + 'TenantGroupImportForm', ) @@ -16,7 +16,7 @@ __all__ = ( # Tenants # -class TenantGroupCSVForm(NetBoxModelCSVForm): +class TenantGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -30,7 +30,7 @@ class TenantGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class TenantCSVForm(NetBoxModelCSVForm): +class TenantImportForm(NetBoxModelImportForm): slug = SlugField() group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), @@ -48,7 +48,7 @@ class TenantCSVForm(NetBoxModelCSVForm): # Contacts # -class ContactGroupCSVForm(NetBoxModelCSVForm): +class ContactGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -62,7 +62,7 @@ class ContactGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class ContactRoleCSVForm(NetBoxModelCSVForm): +class ContactRoleImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -70,7 +70,7 @@ class ContactRoleCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'description') -class ContactCSVForm(NetBoxModelCSVForm): +class ContactImportForm(NetBoxModelImportForm): group = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 228e8cd3f..2761bb2f8 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -59,7 +59,7 @@ class TenantGroupDeleteView(generic.ObjectDeleteView): class TenantGroupBulkImportView(generic.BulkImportView): queryset = TenantGroup.objects.all() - model_form = forms.TenantGroupCSVForm + model_form = forms.TenantGroupImportForm table = tables.TenantGroupTable @@ -143,7 +143,7 @@ class TenantDeleteView(generic.ObjectDeleteView): class TenantBulkImportView(generic.BulkImportView): queryset = Tenant.objects.all() - model_form = forms.TenantCSVForm + model_form = forms.TenantImportForm table = tables.TenantTable @@ -221,7 +221,7 @@ class ContactGroupDeleteView(generic.ObjectDeleteView): class ContactGroupBulkImportView(generic.BulkImportView): queryset = ContactGroup.objects.all() - model_form = forms.ContactGroupCSVForm + model_form = forms.ContactGroupImportForm table = tables.ContactGroupTable @@ -291,7 +291,7 @@ class ContactRoleDeleteView(generic.ObjectDeleteView): class ContactRoleBulkImportView(generic.BulkImportView): queryset = ContactRole.objects.all() - model_form = forms.ContactRoleCSVForm + model_form = forms.ContactRoleImportForm table = tables.ContactRoleTable @@ -351,7 +351,7 @@ class ContactDeleteView(generic.ObjectDeleteView): class ContactBulkImportView(generic.BulkImportView): queryset = Contact.objects.all() - model_form = forms.ContactCSVForm + model_form = forms.ContactImportForm table = tables.ContactTable diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index dcd15872b..abacc6ff5 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,7 +1,7 @@ from django import forms from django.test import TestCase -from ipam.forms import IPAddressCSVForm +from ipam.forms import IPAddressImportForm from utilities.forms.fields import CSVDataField from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern @@ -288,7 +288,7 @@ class ExpandAlphanumeric(TestCase): class CSVDataFieldTest(TestCase): def setUp(self): - self.field = CSVDataField(from_form=IPAddressCSVForm) + self.field = CSVDataField(from_form=IPAddressImportForm) def test_clean(self): input = """ diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 154c21328..8ed83b46c 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -2,22 +2,22 @@ from dcim.choices import InterfaceModeChoices from dcim.models import Device, DeviceRole, Platform, Site from django.utils.translation import gettext as _ from ipam.models import VRF -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from virtualization.choices import * from virtualization.models import * __all__ = ( - 'ClusterCSVForm', - 'ClusterGroupCSVForm', - 'ClusterTypeCSVForm', - 'VirtualMachineCSVForm', - 'VMInterfaceCSVForm', + 'ClusterImportForm', + 'ClusterGroupImportForm', + 'ClusterTypeImportForm', + 'VirtualMachineImportForm', + 'VMInterfaceImportForm', ) -class ClusterTypeCSVForm(NetBoxModelCSVForm): +class ClusterTypeImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -25,7 +25,7 @@ class ClusterTypeCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'description', 'tags') -class ClusterGroupCSVForm(NetBoxModelCSVForm): +class ClusterGroupImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: @@ -33,7 +33,7 @@ class ClusterGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'description', 'tags') -class ClusterCSVForm(NetBoxModelCSVForm): +class ClusterImportForm(NetBoxModelImportForm): type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -67,7 +67,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') -class VirtualMachineCSVForm(NetBoxModelCSVForm): +class VirtualMachineImportForm(NetBoxModelImportForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, help_text=_('Operational status') @@ -119,7 +119,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): ) -class VMInterfaceCSVForm(NetBoxModelCSVForm): +class VMInterfaceImportForm(NetBoxModelImportForm): virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), to_field_name='name' diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index bda2415a5..c9d450567 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -63,7 +63,7 @@ class ClusterTypeDeleteView(generic.ObjectDeleteView): class ClusterTypeBulkImportView(generic.BulkImportView): queryset = ClusterType.objects.all() - model_form = forms.ClusterTypeCSVForm + model_form = forms.ClusterTypeImportForm table = tables.ClusterTypeTable @@ -130,7 +130,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ) - model_form = forms.ClusterGroupCSVForm + model_form = forms.ClusterGroupImportForm table = tables.ClusterGroupTable @@ -217,7 +217,7 @@ class ClusterDeleteView(generic.ObjectDeleteView): class ClusterBulkImportView(generic.BulkImportView): queryset = Cluster.objects.all() - model_form = forms.ClusterCSVForm + model_form = forms.ClusterImportForm table = tables.ClusterTable @@ -403,7 +403,7 @@ class VirtualMachineDeleteView(generic.ObjectDeleteView): class VirtualMachineBulkImportView(generic.BulkImportView): queryset = VirtualMachine.objects.all() - model_form = forms.VirtualMachineCSVForm + model_form = forms.VirtualMachineImportForm table = tables.VirtualMachineTable @@ -491,7 +491,7 @@ class VMInterfaceDeleteView(generic.ObjectDeleteView): class VMInterfaceBulkImportView(generic.BulkImportView): queryset = VMInterface.objects.all() - model_form = forms.VMInterfaceCSVForm + model_form = forms.VMInterfaceImportForm table = tables.VMInterfaceTable diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index adf2a2b6c..236ad9c1d 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -2,20 +2,20 @@ from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN -from netbox.forms import NetBoxModelCSVForm +from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * __all__ = ( - 'WirelessLANCSVForm', - 'WirelessLANGroupCSVForm', - 'WirelessLinkCSVForm', + 'WirelessLANImportForm', + 'WirelessLANGroupImportForm', + 'WirelessLinkImportForm', ) -class WirelessLANGroupCSVForm(NetBoxModelCSVForm): +class WirelessLANGroupImportForm(NetBoxModelImportForm): parent = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -29,7 +29,7 @@ class WirelessLANGroupCSVForm(NetBoxModelCSVForm): fields = ('name', 'slug', 'parent', 'description', 'tags') -class WirelessLANCSVForm(NetBoxModelCSVForm): +class WirelessLANImportForm(NetBoxModelImportForm): group = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -71,7 +71,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): ) -class WirelessLinkCSVForm(NetBoxModelCSVForm): +class WirelessLinkImportForm(NetBoxModelImportForm): status = CSVChoiceField( choices=LinkStatusChoices, help_text=_('Connection status') diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index e4e7617ed..0e164cb1e 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -52,7 +52,7 @@ class WirelessLANGroupDeleteView(generic.ObjectDeleteView): class WirelessLANGroupBulkImportView(generic.BulkImportView): queryset = WirelessLANGroup.objects.all() - model_form = forms.WirelessLANGroupCSVForm + model_form = forms.WirelessLANGroupImportForm table = tables.WirelessLANGroupTable @@ -123,7 +123,7 @@ class WirelessLANDeleteView(generic.ObjectDeleteView): class WirelessLANBulkImportView(generic.BulkImportView): queryset = WirelessLAN.objects.all() - model_form = forms.WirelessLANCSVForm + model_form = forms.WirelessLANImportForm table = tables.WirelessLANTable @@ -169,7 +169,7 @@ class WirelessLinkDeleteView(generic.ObjectDeleteView): class WirelessLinkBulkImportView(generic.BulkImportView): queryset = WirelessLink.objects.all() - model_form = forms.WirelessLinkCSVForm + model_form = forms.WirelessLinkImportForm table = tables.WirelessLinkTable From 87727c71f75ee887a834e0524528db614917f63f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 13:37:13 -0500 Subject: [PATCH 260/409] #8366: name scheduled_time to scheduled for consistency with other fields --- docs/release-notes/version-3.4.md | 2 ++ netbox/extras/api/serializers.py | 3 ++- netbox/extras/filtersets.py | 12 ++++++------ netbox/extras/forms/filtersets.py | 10 ++++++---- ...scheduled_time.py => 0079_jobresult_scheduled.py} | 2 +- .../migrations/0080_customlink_content_types.py | 2 +- netbox/extras/models/models.py | 6 +++--- netbox/extras/tables/tables.py | 4 ++-- netbox/extras/views.py | 1 - netbox/templates/extras/htmx/report_result.html | 4 ++-- netbox/templates/extras/htmx/script_result.html | 4 ++-- 11 files changed, 27 insertions(+), 23 deletions(-) rename netbox/extras/migrations/{0079_jobresult_scheduled_time.py => 0079_jobresult_scheduled.py} (92%) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 19860872a..36ed48a6a 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -130,6 +130,8 @@ This release introduces a new programmatic API that enables plugins and custom s * Renamed `content_type` field to `content_types` * extras.ExportTemplate * Renamed `content_type` field to `content_types` +* extras.JobResult + * Added the `scheduled` field * ipam.Aggregate * Added a `comments` field * ipam.ASN diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 921720dea..dfca997d8 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -385,7 +385,8 @@ class JobResultSerializer(BaseModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + 'id', 'url', 'display', 'status', 'created', 'scheduled', 'completed', 'name', 'obj_type', 'user', 'data', + 'job_id', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 8ed68e850..9609f45db 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -512,13 +512,13 @@ class JobResultFilterSet(BaseFilterSet): field_name='completed', lookup_expr='gte' ) - scheduled_time = django_filters.DateTimeFilter() - scheduled_time__before = django_filters.DateTimeFilter( - field_name='scheduled_time', + scheduled = django_filters.DateTimeFilter() + scheduled__before = django_filters.DateTimeFilter( + field_name='scheduled', lookup_expr='lte' ) - scheduled_time__after = django_filters.DateTimeFilter( - field_name='scheduled_time', + scheduled__after = django_filters.DateTimeFilter( + field_name='scheduled', lookup_expr='gte' ) status = django_filters.MultipleChoiceFilter( @@ -529,7 +529,7 @@ class JobResultFilterSet(BaseFilterSet): class Meta: model = JobResult fields = [ - 'id', 'created', 'completed', 'scheduled_time', 'status', 'user', 'obj_type', 'name' + 'id', 'status', 'created', 'scheduled', 'completed', 'user', 'obj_type', 'name' ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index d63378f62..4464c227f 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -72,8 +72,10 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), ('Attributes', ('obj_type', 'status')), - ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after', - 'scheduled_time__before', 'scheduled_time__after', 'user')), + ('Creation', ( + 'created__before', 'created__after', 'completed__before', 'completed__after', 'scheduled__before', + 'scheduled__after', 'user', + )), ) obj_type = ContentTypeChoiceField( @@ -102,11 +104,11 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): required=False, widget=DateTimePicker() ) - scheduled_time__after = forms.DateTimeField( + scheduled__after = forms.DateTimeField( required=False, widget=DateTimePicker() ) - scheduled_time__before = forms.DateTimeField( + scheduled__before = forms.DateTimeField( required=False, widget=DateTimePicker() ) diff --git a/netbox/extras/migrations/0079_jobresult_scheduled_time.py b/netbox/extras/migrations/0079_jobresult_scheduled.py similarity index 92% rename from netbox/extras/migrations/0079_jobresult_scheduled_time.py rename to netbox/extras/migrations/0079_jobresult_scheduled.py index c9646f13c..b970042c2 100644 --- a/netbox/extras/migrations/0079_jobresult_scheduled_time.py +++ b/netbox/extras/migrations/0079_jobresult_scheduled.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='jobresult', - name='scheduled_time', + name='scheduled', field=models.DateTimeField(blank=True, null=True), ), migrations.AlterModelOptions( diff --git a/netbox/extras/migrations/0080_customlink_content_types.py b/netbox/extras/migrations/0080_customlink_content_types.py index 91fe453c3..539db3f0f 100644 --- a/netbox/extras/migrations/0080_customlink_content_types.py +++ b/netbox/extras/migrations/0080_customlink_content_types.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0079_jobresult_scheduled_time'), + ('extras', '0079_jobresult_scheduled'), ] operations = [ diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index fa6bb3ab9..9f99d37ff 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -581,11 +581,11 @@ class JobResult(models.Model): created = models.DateTimeField( auto_now_add=True ) - completed = models.DateTimeField( + scheduled = models.DateTimeField( null=True, blank=True ) - scheduled_time = models.DateTimeField( + completed = models.DateTimeField( null=True, blank=True ) @@ -672,7 +672,7 @@ class JobResult(models.Model): if schedule_at: job_result.status = JobResultStatusChoices.STATUS_SCHEDULED - job_result.scheduled_time = schedule_at + job_result.scheduled = schedule_at job_result.save() queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 172fbfbf9..d918bdf5a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -49,9 +49,9 @@ class JobResultTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = JobResult fields = ( - 'pk', 'id', 'name', 'obj_type', 'job_id', 'created', 'completed', 'scheduled_time', 'user', 'status', + 'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'completed', 'user', 'job_id', ) - default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',) + default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'completed', 'user',) class CustomLinkTable(NetBoxTable): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 283d37465..fec82a13b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -673,7 +673,6 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if report is None: raise Http404 - schedule_at = None form = ReportForm(request.POST) if form.is_valid(): diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index a51b2663d..cfa8b2523 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -2,8 +2,8 @@

    Initiated: {{ result.created|annotated_date }} - {% if result.scheduled_time %} - Scheduled for: {{ result.scheduled_time|annotated_date }} + {% if result.scheduled %} + Scheduled for: {{ result.scheduled|annotated_date }} {% endif %} {% if result.completed %} Duration: {{ result.duration }} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index d2af99c9b..ca658fa2e 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,8 +3,8 @@

    Initiated: {{ result.created|annotated_date }} - {% if result.scheduled_time %} - Scheduled for: {{ result.scheduled_time|annotated_date }} + {% if result.scheduled %} + Scheduled for: {{ result.scheduled|annotated_date }} {% endif %} {% if result.completed %} Duration: {{ result.duration }} From 0bcc59a1e99065c1c0a143983fed4d0828d744f4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 14:38:58 -0500 Subject: [PATCH 261/409] #8366: Add started field to JobResult --- docs/release-notes/version-3.4.md | 2 +- netbox/extras/api/serializers.py | 4 +-- netbox/extras/filtersets.py | 31 ++++++++++++------- netbox/extras/forms/filtersets.py | 29 ++++++++++------- ...lt_scheduled.py => 0079_scheduled_jobs.py} | 5 +++ .../0080_customlink_content_types.py | 2 +- netbox/extras/models/models.py | 15 ++++++++- netbox/extras/reports.py | 1 + netbox/extras/scripts.py | 8 ++--- netbox/extras/tables/tables.py | 6 ++-- netbox/extras/views.py | 4 +-- .../templates/extras/htmx/report_result.html | 9 ++++-- .../templates/extras/htmx/script_result.html | 9 ++++-- netbox/templates/extras/report_result.html | 2 +- netbox/templates/extras/script_result.html | 2 +- 15 files changed, 84 insertions(+), 45 deletions(-) rename netbox/extras/migrations/{0079_jobresult_scheduled.py => 0079_scheduled_jobs.py} (74%) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 36ed48a6a..caa39fc2a 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -131,7 +131,7 @@ This release introduces a new programmatic API that enables plugins and custom s * extras.ExportTemplate * Renamed `content_type` field to `content_types` * extras.JobResult - * Added the `scheduled` field + * Added `scheduled` and `started` datetime fields * ipam.Aggregate * Added a `comments` field * ipam.ASN diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index dfca997d8..7dbecc5af 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'display', 'status', 'created', 'scheduled', 'completed', 'name', 'obj_type', 'user', 'data', - 'job_id', + 'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type', + 'user', 'data', 'job_id', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 9609f45db..0dbbaa314 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -503,15 +503,6 @@ class JobResultFilterSet(BaseFilterSet): field_name='created', lookup_expr='gte' ) - completed = django_filters.DateTimeFilter() - completed__before = django_filters.DateTimeFilter( - field_name='completed', - lookup_expr='lte' - ) - completed__after = django_filters.DateTimeFilter( - field_name='completed', - lookup_expr='gte' - ) scheduled = django_filters.DateTimeFilter() scheduled__before = django_filters.DateTimeFilter( field_name='scheduled', @@ -521,6 +512,24 @@ class JobResultFilterSet(BaseFilterSet): field_name='scheduled', lookup_expr='gte' ) + started = django_filters.DateTimeFilter() + started__before = django_filters.DateTimeFilter( + field_name='started', + lookup_expr='lte' + ) + started__after = django_filters.DateTimeFilter( + field_name='started', + lookup_expr='gte' + ) + completed = django_filters.DateTimeFilter() + completed__before = django_filters.DateTimeFilter( + field_name='completed', + lookup_expr='lte' + ) + completed__after = django_filters.DateTimeFilter( + field_name='completed', + lookup_expr='gte' + ) status = django_filters.MultipleChoiceFilter( choices=JobResultStatusChoices, null_value=None @@ -528,9 +537,7 @@ class JobResultFilterSet(BaseFilterSet): class Meta: model = JobResult - fields = [ - 'id', 'status', 'created', 'scheduled', 'completed', 'user', 'obj_type', 'name' - ] + fields = ('id', 'status', 'user', 'obj_type', 'name') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 4464c227f..e6a9089bc 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -73,11 +73,10 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): (None, ('q', 'filter_id')), ('Attributes', ('obj_type', 'status')), ('Creation', ( - 'created__before', 'created__after', 'completed__before', 'completed__after', 'scheduled__before', - 'scheduled__after', 'user', + 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', + 'started__after', 'completed__before', 'completed__after', 'user', )), ) - obj_type = ContentTypeChoiceField( label=_('Object Type'), queryset=ContentType.objects.all(), @@ -96,14 +95,6 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): required=False, widget=DateTimePicker() ) - completed__after = forms.DateTimeField( - required=False, - widget=DateTimePicker() - ) - completed__before = forms.DateTimeField( - required=False, - widget=DateTimePicker() - ) scheduled__after = forms.DateTimeField( required=False, widget=DateTimePicker() @@ -112,6 +103,22 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm): required=False, widget=DateTimePicker() ) + started__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + started__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + completed__after = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) + completed__before = forms.DateTimeField( + required=False, + widget=DateTimePicker() + ) user = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, diff --git a/netbox/extras/migrations/0079_jobresult_scheduled.py b/netbox/extras/migrations/0079_scheduled_jobs.py similarity index 74% rename from netbox/extras/migrations/0079_jobresult_scheduled.py rename to netbox/extras/migrations/0079_scheduled_jobs.py index b970042c2..807e980a4 100644 --- a/netbox/extras/migrations/0079_jobresult_scheduled.py +++ b/netbox/extras/migrations/0079_scheduled_jobs.py @@ -13,6 +13,11 @@ class Migration(migrations.Migration): name='scheduled', field=models.DateTimeField(blank=True, null=True), ), + migrations.AddField( + model_name='jobresult', + name='started', + field=models.DateTimeField(blank=True, null=True), + ), migrations.AlterModelOptions( name='jobresult', options={'ordering': ['-created']}, diff --git a/netbox/extras/migrations/0080_customlink_content_types.py b/netbox/extras/migrations/0080_customlink_content_types.py index 539db3f0f..7f8456c67 100644 --- a/netbox/extras/migrations/0080_customlink_content_types.py +++ b/netbox/extras/migrations/0080_customlink_content_types.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0079_jobresult_scheduled'), + ('extras', '0079_scheduled_jobs'), ] operations = [ diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 9f99d37ff..95a414225 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -585,6 +585,10 @@ class JobResult(models.Model): null=True, blank=True ) + started = models.DateTimeField( + null=True, + blank=True + ) completed = models.DateTimeField( null=True, blank=True @@ -639,9 +643,18 @@ class JobResult(models.Model): return f"{int(minutes)} minutes, {seconds:.2f} seconds" + def start(self): + """ + Record the job's start time and update its status to "running." + """ + if self.started is None: + self.started = timezone.now() + self.status = JobResultStatusChoices.STATUS_RUNNING + JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status) + def set_status(self, status): """ - Helper method to change the status of the job result. If the target status is terminal, the completion + Helper method to change the status of the job result. If the target status is terminal, the completion time is also set. """ self.status = status diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 525608c86..647f17149 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -83,6 +83,7 @@ def run_report(job_result, *args, **kwargs): report = get_report(module_name, report_name) try: + job_result.start() report.run(job_result) except Exception as e: job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 23a778789..a4bcd0748 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -433,16 +433,14 @@ def is_variable(obj): def run_script(data, request, commit=True, *args, **kwargs): """ A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It - exists outside of the Script class to ensure it cannot be overridden by a script author. + exists outside the Script class to ensure it cannot be overridden by a script author. """ job_result = kwargs.pop('job_result') + job_result.start() + module, script_name = job_result.name.split('.', 1) - script = get_script(module, script_name)() - job_result.status = JobResultStatusChoices.STATUS_RUNNING - job_result.save() - logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}") logger.info(f"Running script (commit={commit})") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d918bdf5a..7e5357dcd 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -49,9 +49,11 @@ class JobResultTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = JobResult fields = ( - 'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'completed', 'user', 'job_id', + 'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', 'job_id', + ) + default_columns = ( + 'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', ) - default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'completed', 'user',) class CustomLinkTable(NetBoxTable): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index fec82a13b..9ce643ca5 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -726,7 +726,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): 'report': report, 'result': result, }) - if result.completed: + if result.completed or not result.started: response.status_code = 286 return response @@ -860,7 +860,7 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View) 'script': script, 'result': result, }) - if result.completed: + if result.completed or not result.started: response.status_code = 286 return response diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index cfa8b2523..acc0fe9ab 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -1,9 +1,12 @@ {% load helpers %}

    - Initiated: {{ result.created|annotated_date }} - {% if result.scheduled %} + {% if result.started %} + Started: {{ result.started|annotated_date }} + {% elif result.scheduled %} Scheduled for: {{ result.scheduled|annotated_date }} + {% else %} + Created: {{ result.created|annotated_date }} {% endif %} {% if result.completed %} Duration: {{ result.duration }} @@ -71,6 +74,6 @@

    -{% else %} +{% elif result.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index ca658fa2e..457548d28 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -2,9 +2,12 @@ {% load log_levels %}

    - Initiated: {{ result.created|annotated_date }} - {% if result.scheduled %} + {% if result.started %} + Started: {{ result.started|annotated_date }} + {% elif result.scheduled %} Scheduled for: {{ result.scheduled|annotated_date }} + {% else %} + Created: {{ result.created|annotated_date }} {% endif %} {% if result.completed %} Duration: {{ result.duration }} @@ -48,6 +51,6 @@ {% else %}

    None

    {% endif %} -{% else %} +{% elif result.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index 0c61c63f9..ffa52f9b7 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -4,7 +4,7 @@ {% block content-wrapper %}
    -
    +
    {% include 'extras/htmx/report_result.html' %}
    diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 2fc01e9fa..bff3fc61e 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -47,7 +47,7 @@
    -
    +
    {% include 'extras/htmx/script_result.html' %}
    From 87fd09ca8b5a0d3ec692e241351e1bbc4ac298a7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 15:30:39 -0500 Subject: [PATCH 262/409] Cleanup for #9654 --- netbox/dcim/forms/model_forms.py | 7 ++----- netbox/templates/dcim/rack.html | 5 ++++- netbox/templates/dcim/rack_edit.html | 19 +++++++++++-------- netbox/utilities/templatetags/helpers.py | 8 ++++++++ 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 23498af06..7bfde23eb 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -384,13 +384,10 @@ class DeviceTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'description', 'tags', - )), + ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')), ('Chassis', ( - 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', )), - ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), ) diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 185634e8a..3c31dc49d 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -171,7 +171,10 @@ Total Weight - {{ object.total_weight|floatformat }} Kilograms + + {{ object.total_weight|floatformat }} Kilograms + ({{ object.total_weight|kg_to_pounds|floatformat }} Pounds) +
    diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index d214bbee8..1d8dee3ab 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -56,17 +56,20 @@
    Unit
    +
    + +
    + {{ form.weight }} +
    Weight
    +
    +
    + {{ form.weight_unit }} +
    Unit
    +
    +
    {% render_field form.mounting_depth %} {% render_field form.desc_units %}
    -
    -
    -
    Weight
    -
    - {% render_field form.weight %} - {% render_field form.weight_unit %} -
    - {% if form.custom_fields %}
    diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 3b21a2c30..7c5b193fd 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -171,6 +171,14 @@ def meters_to_feet(n): return float(n) * 3.28084 +@register.filter() +def kg_to_pounds(n): + """ + Convert a weight from kilograms to pounds. + """ + return float(n) * 2.204623 + + @register.filter("startswith") def startswith(text: str, starts: str) -> bool: """ From 23077821f6411a62994f5f34048b1882ce58f3d6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 15:44:36 -0500 Subject: [PATCH 263/409] #10052: Serialize date fields --- netbox/extras/models/customfields.py | 7 ++++ netbox/extras/tests/test_customfields.py | 51 +++++++++++++----------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 2890e6784..14b033bcd 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -302,6 +302,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ if value is None: return value + if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date: + return value.isoformat() if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: @@ -314,6 +316,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ if value is None: return value + if self.type == CustomFieldTypeChoices.TYPE_DATE: + try: + return date.fromisoformat(value) + except ValueError: + return value if self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() return model.objects.filter(pk=value).first() diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 9c1c7c2db..8fa5f1e77 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -523,7 +523,7 @@ class CustomFieldAPITest(APITestCase): Validate that custom fields are present and correctly set for an object with values defined. """ site2 = Site.objects.get(name='Site 2') - site2_cfvs = site2.custom_field_data + site2_cfvs = site2.cf url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') @@ -539,10 +539,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) - self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], - site2_cfvs['multiobject_field'] + [obj.pk for obj in site2_cfvs['multiobject_field']] ) def test_create_single_object_with_defaults(self): @@ -569,7 +569,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) - self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) @@ -631,7 +631,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['integer_field'], data_cf['integer_field']) self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) - self.assertEqual(response_cf['date_field'], data_cf['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['select_field'], data_cf['select_field']) @@ -695,7 +695,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) - self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) @@ -772,7 +772,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field']) self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) - self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) + self.assertEqual(response_cf['date_field'].isoformat(), custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) @@ -804,7 +804,7 @@ class CustomFieldAPITest(APITestCase): modified. """ site2 = Site.objects.get(name='Site 2') - original_cfvs = {**site2.custom_field_data} + original_cfvs = {**site2.cf} data = { 'custom_fields': { 'text_field': 'ABCD', @@ -829,26 +829,29 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) - self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field']) - self.assertEqual( + self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk) + self.assertListEqual( [obj['id'] for obj in response_cf['multiobject_field']], - original_cfvs['multiobject_field'] + [obj.pk for obj in original_cfvs['multiobject_field']] ) # Validate database data - site2.refresh_from_db() - self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field']) - self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field']) - self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field']) - self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field']) - self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) - self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) + site2 = Site.objects.get(pk=site2.pk) + self.assertEqual(site2.cf['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.cf['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.cf['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field']) + self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.cf['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.cf['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.cf['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.cf['select_field'], original_cfvs['select_field']) + self.assertEqual(site2.cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(site2.cf['object_field'], original_cfvs['object_field']) + self.assertListEqual( + list(site2.cf['multiobject_field']), + list(original_cfvs['multiobject_field']) + ) def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') From 0c0c8485975d301357413000136a972bb528134a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 15 Nov 2022 16:55:18 -0500 Subject: [PATCH 264/409] Clean up plugins documentation --- docs/plugins/development/navigation.md | 2 +- docs/plugins/development/search.md | 3 +++ docs/plugins/development/views.md | 26 ++++++++++++++------------ mkdocs.yml | 2 +- netbox/netbox/search/__init__.py | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index a52a9803a..63402c747 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -16,7 +16,7 @@ menu = PluginMenu( ('Foo', (item1, item2, item3)), ('Bar', (item4, item5)), ), - icon='mdi mdi-router' + icon_class='mdi mdi-router' ) ``` diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md index e3b861f00..b6f24f58d 100644 --- a/docs/plugins/development/search.md +++ b/docs/plugins/development/search.md @@ -1,5 +1,8 @@ # Search +!!! note + This feature was introduced in NetBox v3.4. + Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below). ```python diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 6d1329a4a..9be1a9b6e 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -156,6 +156,9 @@ These views are provided to enable or enhance certain NetBox model features, suc ### Additional Tabs +!!! note + This feature was introduced in NetBox v3.4. + Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`: ```python @@ -164,7 +167,7 @@ from myplugin.models import Stuff from netbox.views import generic from utilities.views import ViewTab, register_model_view -@register_model_view(Site, 'mview', path='some-other-stuff') +@register_model_view(Site, name='myview', path='some-other-stuff') class MyView(generic.ObjectView): ... tab = ViewTab( @@ -180,23 +183,22 @@ class MyView(generic.ObjectView): ### Extra Template Content -Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: +Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired method(s) to render custom content. Five methods are available: -* `left_page()` - Inject content on the left side of the page -* `right_page()` - Inject content on the right side of the page -* `full_width_page()` - Inject content across the entire bottom of the page -* `buttons()` - Add buttons to the top of the page - -Plugins can also inject custom content into certain areas of the list views of applicable models using the same subclass of `PluginTemplateExtension`. One method is available: - -* `list_buttons()` - Add buttons to the top of the list view page +| Method | View | Description | +|---------------------|-------------|-----------------------------------------------------| +| `left_page()` | Object view | Inject content on the left side of the page | +| `right_page()` | Object view | Inject content on the right side of the page | +| `full_width_page()` | Object view | Inject content across the entire bottom of the page | +| `buttons()` | Object view | Add buttons to the top of the page | +| `list_buttons()` | List view | Add buttons to the top of the page | Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: -* `object` - The object being viewed (for detail views only) -* `model` - The model of the list view (for list views only) +* `object` - The object being viewed (object views only) +* `model` - The model of the list view (list views only) * `request` - The current request * `settings` - Global NetBox settings * `config` - Plugin-specific configuration parameters diff --git a/mkdocs.yml b/mkdocs.yml index e5258dda9..fdf802468 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,12 +128,12 @@ nav: - Tables: 'plugins/development/tables.md' - Forms: 'plugins/development/forms.md' - Filters & Filter Sets: 'plugins/development/filtersets.md' + - Search: 'plugins/development/search.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' - - Search: 'plugins/development/search.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 82fff68c6..1e856885a 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -24,7 +24,7 @@ class SearchIndex: """ Base class for building search indexes. - Attrs: + Attributes: model: The model class for which this index is used. category: The label of the group under which this indexer is categorized (for form field display). If none, the name of the model's app will be used. From 96818cacf0922cfd85ca3939b9e933e160e0afc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 08:11:42 -0500 Subject: [PATCH 265/409] #10608: Use register_model_view() for token views --- netbox/users/urls.py | 9 ++++++--- netbox/users/views.py | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 62b17a663..ed1c21c02 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -1,16 +1,19 @@ -from django.urls import path +from django.urls import include, path +from utilities.urls import get_model_urls from . import views app_name = 'users' urlpatterns = [ + # User path('profile/', views.ProfileView.as_view(), name='profile'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), + + # API tokens path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), - path('api-tokens//edit/', views.TokenEditView.as_view(), name='token_edit'), - path('api-tokens//delete/', views.TokenDeleteView.as_view(), name='token_delete'), + path('api-tokens//', include(get_model_urls('users', 'token'))), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index fe1181fc1..1ceeb8211 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -20,6 +20,7 @@ from extras.tables import ObjectChangeTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from utilities.forms import ConfirmationForm +from utilities.views import register_model_view from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token, UserConfig from .tables import TokenTable @@ -246,6 +247,7 @@ class TokenListView(LoginRequiredMixin, View): }) +@register_model_view(Token, 'edit') class TokenEditView(LoginRequiredMixin, View): def get(self, request, pk=None): @@ -300,6 +302,7 @@ class TokenEditView(LoginRequiredMixin, View): }) +@register_model_view(Token, 'delete') class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): From de1b346da0a590366a1f9b40b89b3b9b01cafa46 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 08:43:22 -0500 Subject: [PATCH 266/409] Split core views into separate modules --- netbox/netbox/middleware.py | 4 +- netbox/netbox/urls.py | 5 +- netbox/netbox/views/__init__.py | 229 +------------------------------- netbox/netbox/views/errors.py | 56 ++++++++ netbox/netbox/views/misc.py | 183 +++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 232 deletions(-) create mode 100644 netbox/netbox/views/errors.py create mode 100644 netbox/netbox/views/misc.py diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 5c4b2813d..edf88a234 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -11,7 +11,7 @@ from django.http import Http404, HttpResponseRedirect from extras.context_managers import change_logging from netbox.config import clear_config -from netbox.views import server_error +from netbox.views import handler_500 from utilities.api import is_api_request, rest_api_server_error @@ -201,4 +201,4 @@ class ExceptionHandlingMiddleware: # Return a custom error message, or fall back to Django's default 500 error handling if custom_template: - return server_error(request, template_name=custom_template) + return handler_500(request, template_name=custom_template) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e8ee4b7b6..84e899ed2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -14,7 +14,6 @@ from netbox.views import HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site - openapi_info = openapi.Info( title="NetBox API", default_version='v3', @@ -100,5 +99,5 @@ urlpatterns = [ path('{}'.format(settings.BASE_PATH), include(_patterns)) ] -handler404 = 'netbox.views.handler_404' -handler500 = 'netbox.views.server_error' +handler404 = 'netbox.views.errors.handler_404' +handler500 = 'netbox.views.errors.handler_500' diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index bfad4af99..d1f1f3d1a 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -1,227 +1,2 @@ -import platform -import sys -from collections import namedtuple - -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.cache import cache -from django.http import HttpResponseServerError -from django.shortcuts import redirect, render -from django.template import loader -from django.template.exceptions import TemplateDoesNotExist -from django.utils.translation import gettext as _ -from django.views.decorators.csrf import requires_csrf_token -from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found -from django.views.generic import View -from django_tables2 import RequestConfig -from packaging import version -from sentry_sdk import capture_message - -from circuits.models import Circuit, Provider -from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, -) -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF -from netbox.forms import SearchForm -from netbox.search import LookupTypes -from netbox.search.backends import search_backend -from netbox.tables import SearchTable -from tenancy.models import Contact, Tenant -from utilities.htmx import is_htmx -from utilities.paginator import EnhancedPaginator, get_paginate_count -from virtualization.models import Cluster, VirtualMachine -from wireless.models import WirelessLAN, WirelessLink - -Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) - - -class HomeView(View): - template_name = 'home.html' - - def get(self, request): - if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - return redirect('login') - - console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__is_complete=True - ).count - - def get_count_queryset(model): - return model.objects.restrict(request.user, 'view').count - - def build_stats(): - org = ( - Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), - Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), - Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), - ) - dcim = ( - Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), - Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), - Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), - ) - ipam = ( - Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), - Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), - Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), - Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), - Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), - Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), - ) - circuits = ( - Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), - Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) - ) - virtualization = ( - Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', - get_count_queryset(Cluster)), - Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', - get_count_queryset(VirtualMachine)), - ) - connections = ( - Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), - Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), - Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), - Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), - ) - power = ( - Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), - Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), - ) - wireless = ( - Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', - get_count_queryset(WirelessLAN)), - Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', - get_count_queryset(WirelessLink)), - ) - stats = ( - (_('Organization'), org, 'domain'), - (_('IPAM'), ipam, 'counter'), - (_('Virtualization'), virtualization, 'monitor'), - (_('Inventory'), dcim, 'server'), - (_('Circuits'), circuits, 'transit-connection-variant'), - (_('Connections'), connections, 'cable-data'), - (_('Power'), power, 'flash'), - (_('Wireless'), wireless, 'wifi'), - ) - - return stats - - # Compile changelog table - changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( - 'user', 'changed_object_type' - )[:10] - changelog_table = ObjectChangeTable(changelog, user=request.user) - - # Check whether a new release is available. (Only for staff/superusers.) - new_release = None - if request.user.is_staff or request.user.is_superuser: - latest_release = cache.get('latest_release') - if latest_release: - release_version, release_url = latest_release - if release_version > version.parse(settings.VERSION): - new_release = { - 'version': str(release_version), - 'url': release_url, - } - - return render(request, self.template_name, { - 'search_form': SearchForm(), - 'stats': build_stats(), - 'changelog_table': changelog_table, - 'new_release': new_release, - }) - - -class SearchView(View): - - def get(self, request): - results = [] - highlight = None - - # Initialize search form - form = SearchForm(request.GET) if 'q' in request.GET else SearchForm() - - if form.is_valid(): - - # Restrict results by object type - object_types = [] - for obj_type in form.cleaned_data['obj_types']: - app_label, model_name = obj_type.split('.') - object_types.append(ContentType.objects.get_by_natural_key(app_label, model_name)) - - lookup = form.cleaned_data['lookup'] or LookupTypes.PARTIAL - results = search_backend.search( - form.cleaned_data['q'], - user=request.user, - object_types=object_types, - lookup=lookup - ) - - if form.cleaned_data['lookup'] != LookupTypes.EXACT: - highlight = form.cleaned_data['q'] - - table = SearchTable(results, highlight=highlight) - - # Paginate the table results - RequestConfig(request, { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - }).configure(table) - - # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): - return render(request, 'htmx/table.html', { - 'table': table, - }) - - return render(request, 'search.html', { - 'form': form, - 'table': table, - }) - - -class StaticMediaFailureView(View): - """ - Display a user-friendly error message with troubleshooting tips when a static media file fails to load. - """ - def get(self, request): - return render(request, 'media_failure.html', { - 'filename': request.GET.get('filename') - }) - - -def handler_404(request, exception): - """ - Wrap Django's default 404 handler to enable Sentry reporting. - """ - capture_message("Page not found", level="error") - - return page_not_found(request, exception) - - -@requires_csrf_token -def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): - """ - Custom 500 handler to provide additional context when rendering 500.html. - """ - try: - template = loader.get_template(template_name) - except TemplateDoesNotExist: - return HttpResponseServerError('

    Server Error (500)

    ', content_type='text/html') - type_, error, traceback = sys.exc_info() - - return HttpResponseServerError(template.render({ - 'error': error, - 'exception': str(type_), - 'netbox_version': settings.VERSION, - 'python_version': platform.python_version(), - })) +from .misc import * +from .errors import * diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py new file mode 100644 index 000000000..c74c67cef --- /dev/null +++ b/netbox/netbox/views/errors.py @@ -0,0 +1,56 @@ +import platform +import sys + +from django.conf import settings +from django.http import HttpResponseServerError +from django.shortcuts import render +from django.template import loader +from django.template.exceptions import TemplateDoesNotExist +from django.views.decorators.csrf import requires_csrf_token +from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found +from django.views.generic import View +from sentry_sdk import capture_message + +__all__ = ( + 'handler_404', + 'handler_500', + 'StaticMediaFailureView', +) + + +class StaticMediaFailureView(View): + """ + Display a user-friendly error message with troubleshooting tips when a static media file fails to load. + """ + def get(self, request): + return render(request, 'media_failure.html', { + 'filename': request.GET.get('filename') + }) + + +def handler_404(request, exception): + """ + Wrap Django's default 404 handler to enable Sentry reporting. + """ + capture_message("Page not found", level="error") + + return page_not_found(request, exception) + + +@requires_csrf_token +def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): + """ + Custom 500 handler to provide additional context when rendering 500.html. + """ + try: + template = loader.get_template(template_name) + except TemplateDoesNotExist: + return HttpResponseServerError('

    Server Error (500)

    ', content_type='text/html') + type_, error, traceback = sys.exc_info() + + return HttpResponseServerError(template.render({ + 'error': error, + 'exception': str(type_), + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), + })) diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py new file mode 100644 index 000000000..adf539bbe --- /dev/null +++ b/netbox/netbox/views/misc.py @@ -0,0 +1,183 @@ +from collections import namedtuple + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.shortcuts import redirect, render +from django.utils.translation import gettext as _ +from django.views.generic import View +from django_tables2 import RequestConfig +from packaging import version + +from circuits.models import Circuit, Provider +from dcim.models import ( + Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, +) +from extras.models import ObjectChange +from extras.tables import ObjectChangeTable +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF +from netbox.forms import SearchForm +from netbox.search import LookupTypes +from netbox.search.backends import search_backend +from netbox.tables import SearchTable +from tenancy.models import Contact, Tenant +from utilities.htmx import is_htmx +from utilities.paginator import EnhancedPaginator, get_paginate_count +from virtualization.models import Cluster, VirtualMachine +from wireless.models import WirelessLAN, WirelessLink + +__all__ = ( + 'HomeView', + 'SearchView', +) + +Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) + + +class HomeView(View): + template_name = 'home.html' + + def get(self, request): + if settings.LOGIN_REQUIRED and not request.user.is_authenticated: + return redirect('login') + + console_connections = ConsolePort.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + power_connections = PowerPort.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + interface_connections = Interface.objects.restrict(request.user, 'view')\ + .prefetch_related('_path').filter(_path__is_complete=True).count + + def get_count_queryset(model): + return model.objects.restrict(request.user, 'view').count + + def build_stats(): + org = ( + Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), + Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), + Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), + ) + dcim = ( + Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), + Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), + Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), + ) + ipam = ( + Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), + Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), + Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), + Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), + Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), + Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), + ) + circuits = ( + Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), + Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) + ) + virtualization = ( + Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', + get_count_queryset(Cluster)), + Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', + get_count_queryset(VirtualMachine)), + ) + connections = ( + Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), + Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), + Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), + Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), + ) + power = ( + Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), + Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), + ) + wireless = ( + Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', + get_count_queryset(WirelessLAN)), + Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', + get_count_queryset(WirelessLink)), + ) + stats = ( + (_('Organization'), org, 'domain'), + (_('IPAM'), ipam, 'counter'), + (_('Virtualization'), virtualization, 'monitor'), + (_('Inventory'), dcim, 'server'), + (_('Circuits'), circuits, 'transit-connection-variant'), + (_('Connections'), connections, 'cable-data'), + (_('Power'), power, 'flash'), + (_('Wireless'), wireless, 'wifi'), + ) + + return stats + + # Compile changelog table + changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( + 'user', 'changed_object_type' + )[:10] + changelog_table = ObjectChangeTable(changelog, user=request.user) + + # Check whether a new release is available. (Only for staff/superusers.) + new_release = None + if request.user.is_staff or request.user.is_superuser: + latest_release = cache.get('latest_release') + if latest_release: + release_version, release_url = latest_release + if release_version > version.parse(settings.VERSION): + new_release = { + 'version': str(release_version), + 'url': release_url, + } + + return render(request, self.template_name, { + 'search_form': SearchForm(), + 'stats': build_stats(), + 'changelog_table': changelog_table, + 'new_release': new_release, + }) + + +class SearchView(View): + + def get(self, request): + results = [] + highlight = None + + # Initialize search form + form = SearchForm(request.GET) if 'q' in request.GET else SearchForm() + + if form.is_valid(): + + # Restrict results by object type + object_types = [] + for obj_type in form.cleaned_data['obj_types']: + app_label, model_name = obj_type.split('.') + object_types.append(ContentType.objects.get_by_natural_key(app_label, model_name)) + + lookup = form.cleaned_data['lookup'] or LookupTypes.PARTIAL + results = search_backend.search( + form.cleaned_data['q'], + user=request.user, + object_types=object_types, + lookup=lookup + ) + + if form.cleaned_data['lookup'] != LookupTypes.EXACT: + highlight = form.cleaned_data['q'] + + table = SearchTable(results, highlight=highlight) + + # Paginate the table results + RequestConfig(request, { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + }).configure(table) + + # If this is an HTMX request, return only the rendered table HTML + if is_htmx(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + return render(request, 'search.html', { + 'form': form, + 'table': table, + }) From 80f5c96af3e78232ffe2bcce7c27995612964596 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 08:47:45 -0500 Subject: [PATCH 267/409] Document save_object() on BulkImportView --- docs/plugins/development/views.md | 3 ++- netbox/dcim/views.py | 8 ++++---- netbox/netbox/views/generic/bulk_views.py | 8 ++++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 9be1a9b6e..7f8a64744 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -127,7 +127,8 @@ Below are the class definitions for NetBox's multi-object views. These views han ::: netbox.views.generic.BulkImportView options: - members: false + members: + - save_object ::: netbox.views.generic.BulkEditView options: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 12f9ff2fc..efedc58ab 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -807,11 +807,11 @@ class RackReservationImportView(generic.BulkImportView): model_form = forms.RackReservationImportForm table = tables.RackReservationTable - def save_object(self, obj_form, request): + def save_object(self, object_form, request): """ Assign the currently authenticated user to the RackReservation. """ - instance = obj_form.save(commit=False) + instance = object_form.save(commit=False) instance.user = request.user instance.save() @@ -2031,8 +2031,8 @@ class ChildDeviceBulkImportView(generic.BulkImportView): table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' - def save_object(self, obj_form, request): - obj = obj_form.save() + def save_object(self, object_form, request): + obj = object_form.save() # Save the reverse relation to the parent device bay device_bay = obj.parent_bay diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 86ffbf224..444004623 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -345,11 +345,15 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return obj - def save_object(self, obj_form, request): + def save_object(self, object_form, request): """ Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). + + Args: + object_form: The model form instance + request: The current request """ - return obj_form.save() + return object_form.save() def create_and_update_objects(self, form, request): saved_objects = [] From db77e9428fe8ea11032a2f9f2a5be83c100f2d82 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 09:11:03 -0500 Subject: [PATCH 268/409] Update features docs for v3.4 --- docs/features/customization.md | 4 ++-- docs/features/devices-cabling.md | 4 ++++ docs/features/search.md | 27 +++++++++++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 docs/features/search.md diff --git a/docs/features/customization.md b/docs/features/customization.md index 813914ae2..abce4bcba 100644 --- a/docs/features/customization.md +++ b/docs/features/customization.md @@ -71,13 +71,13 @@ To learn more about this feature, check out the [export template documentation]( NetBox administrators can install custom Python scripts, known as _reports_, which run within NetBox and can be executed and analyzed within the NetBox UI. Reports are a great way to evaluate NetBox objects against a set of arbitrary rules. For example, you could write a report to check that every router has a loopback interface with an IP address assigned, or that every site has a minimum set of VLANs defined. -When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). +When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). They can be run immediately or scheduled to run at a future time. To learn more about this feature, check out the [documentation for reports](../customization/reports.md). ## Custom Scripts -Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. +Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. As with reports, they can be run via the UI, REST API, or CLI, and be scheduled to execute at a future time. The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model. diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md index bec3e56de..03f386977 100644 --- a/docs/features/devices-cabling.md +++ b/docs/features/devices-cabling.md @@ -65,6 +65,10 @@ Each device can have an operational status, functional role, and software platfo Sometimes it is necessary to model a set of physical devices as sharing a single management plane. Perhaps the most common example of such a scenario is stackable switches. These can be modeled as virtual chassis in NetBox, with one device acting as the chassis master and the rest as members. All components of member devices will appear on the master. +### Virtual Device Contexts + +A virtual device context (VDC) is a logical partition within a device. Each VDC operates autonomously but shares a common pool of resources. Each interface can be assigned to one or more VDCs on its device. + ## Module Types & Modules Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays. diff --git a/docs/features/search.md b/docs/features/search.md new file mode 100644 index 000000000..07394af97 --- /dev/null +++ b/docs/features/search.md @@ -0,0 +1,27 @@ +# Search + +## Global Search + +NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy. + +When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance. + +Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models. + +## Saved Filters + +Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use. + +For example, suppose you often need to locate all planned devices of a certain type within a region. The applicable filters can be applied and then saved as custom named filter for reuse, such that + +``` +?status=planned&device_type_id=78®ion_id=12 +``` + +becomes + +``` +?filter=my-custom-filter +``` + +These saved filters can be used both within the UI and for API queries. diff --git a/mkdocs.yml b/mkdocs.yml index fdf802468..2317dad6d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - Virtualization: 'features/virtualization.md' - Tenancy: 'features/tenancy.md' - Contacts: 'features/contacts.md' + - Search: 'features/search.md' - Context Data: 'features/context-data.md' - Change Logging: 'features/change-logging.md' - Journaling: 'features/journaling.md' From c6930e3ea85155086eef7e045bd0fa8c87632984 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Nov 2022 08:10:13 -0800 Subject: [PATCH 269/409] 10919 add location to cable termination panels --- netbox/templates/dcim/inc/cable_termination.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index ced9bda50..0ee4c1ccf 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -7,6 +7,10 @@ Site {{ terminations.0.device.site|linkify }} + + Location + {{ terminations.0.device.location|linkify|placeholder }} + Rack {{ terminations.0.device.rack|linkify|placeholder }} From 928d880f0e830aa317ece2670ab16f2c77046e06 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 16 Nov 2022 06:27:49 -0800 Subject: [PATCH 270/409] 10902 add location to power feed form (#10913) * 10902 add location to power feed form * Bind location field to selected site Co-authored-by: jeremystretch --- netbox/dcim/forms/models.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8f1626361..da50b8f2a 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -877,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm): 'site_id': '$site' } ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' + } + ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, query_params={ + 'location_id': '$location', 'site_id': '$site' } ) @@ -888,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm): fieldsets = ( ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) class Meta: model = PowerFeed fields = [ - 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', + 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] widgets = { From 316c3808f7fcfca282600dbd85a5f63f9a28c92e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 09:43:11 -0500 Subject: [PATCH 271/409] Changelog for #9439, #10902, #10914, #10915, #10919 --- docs/release-notes/version-3.3.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index df84c8de4..c7a2db6d7 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,8 +2,16 @@ ## v3.3.8 (FUTURE) +### Enhancements + +* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form +* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view +* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view +* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view + ### Bug Fixes +* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions * [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend * [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists * [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set From a2007a4728faafeee5c296afabc116707db42344 Mon Sep 17 00:00:00 2001 From: Derick Vigne Date: Wed, 16 Nov 2022 09:57:49 -0500 Subject: [PATCH 272/409] Closes #10904: Added Colors to SVG for Front and Rear Ports (#10905) * Added Colors to SVG for Front and Reaer Ports Fix for feature request 10904 thanks to @TheZackCodec * Simplify termination color resolution Co-authored-by: jeremystretch --- netbox/dcim/svg/cables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9a847acc9..33adef798 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -166,7 +166,7 @@ class CableTraceSVG: """ if hasattr(instance, 'parent_object'): # Termination - return 'f0f0f0' + return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0' if hasattr(instance, 'device_role'): # Device return instance.device_role.color From 3a62fd49e6f0b7d6ef5080321a54df9479e4de76 Mon Sep 17 00:00:00 2001 From: Patrick Hurrelmann Date: Wed, 16 Nov 2022 16:26:46 +0100 Subject: [PATCH 273/409] Fixes: #10356 backplane connections (#10554) * Fixes: #10356 Add interface type and cable for backplane connections * Allow Backplone for front and readports , too. * Correct tyo in port definition * pep8 fix (blank lines) * Remove port type and changed name/description of backplane cable * Omit backplane cable type Co-authored-by: Patrick Hurrelmann Co-authored-by: jeremystretch --- netbox/dcim/choices.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 7d35a40f9..14ddc9930 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_OSFP = '400gbase-x-osfp' + # Ethernet Backplane + TYPE_1GE_KX = '1000base-kx' + TYPE_10GE_KR = '10gbase-kr' + TYPE_10GE_KX4 = '10gbase-kx4' + TYPE_25GE_KR = '25gbase-kr' + TYPE_40GE_KR4 = '40gbase-kr4' + TYPE_50GE_KR = '50gbase-kr' + TYPE_100GE_KP4 = '100gbase-kp4' + TYPE_100GE_KR2 = '100gbase-kr2' + TYPE_100GE_KR4 = '100gbase-kr4' + # Wireless TYPE_80211A = 'ieee802.11a' TYPE_80211G = 'ieee802.11g' @@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_400GE_OSFP, 'OSFP (400GE)'), ) ), + ( + 'Ethernet (backplane)', + ( + (TYPE_1GE_KX, '1000BASE-KX (1GE)'), + (TYPE_10GE_KR, '10GBASE-KR (10GE)'), + (TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'), + (TYPE_25GE_KR, '25GBASE-KR (25GE)'), + (TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'), + (TYPE_50GE_KR, '50GBASE-KR (50GE)'), + (TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'), + (TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'), + (TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'), + ) + ), ( 'Wireless', ( From 44c248e6c280fa2421f8b17adc1424131398b144 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 10:36:30 -0500 Subject: [PATCH 274/409] Closes #10934: Update release package URL --- docs/installation/3-netbox.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index f42e28deb..72c054e5b 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root. ```no-highlight -sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz +sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz sudo tar -xzf vX.Y.Z.tar.gz -C /opt sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox ``` From 540bba4544d9f31c126571cc1a45a6783b3b6a89 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 10:37:06 -0500 Subject: [PATCH 275/409] Closes #10920: Include request cookies when queuing a custom script --- netbox/utilities/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 69ab615fc..ba3388c75 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -410,6 +410,7 @@ def copy_safe_request(request): } return NetBoxFakeRequest({ 'META': meta, + 'COOKIES': request.COOKIES, 'POST': request.POST, 'GET': request.GET, 'FILES': request.FILES, From ceec1055e0ea5d09d2606c9742f8ac240aa49d33 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 10:40:18 -0500 Subject: [PATCH 276/409] Changelog for #10356, #10904, #10920 --- docs/release-notes/version-3.3.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c7a2db6d7..3872b4c34 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,10 +4,13 @@ ### Enhancements +* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types * [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form +* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG * [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view * [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view * [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view +* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script ### Bug Fixes From 62a80c46a87cb924d49931fc5dc1628bd7f1089a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 10:45:29 -0500 Subject: [PATCH 277/409] Release v3.3.8 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 4de82d4e3..3beec4cf7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.7 + placeholder: v3.3.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 5f0a17aa7..6688de9fe 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.7 + placeholder: v3.3.8 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3872b4c34..4894690de 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.8 (FUTURE) +## v3.3.8 (2022-11-16) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4ff440c46..46663f08c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.8-dev' +VERSION = '3.3.8' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 73abfa259..8e89b47c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 django-rich==1.4.0 -django-rq==2.5.1 +django-rq==2.6.0 django-tables2==2.4.1 django-taggit==3.0.0 django-timezone-field==5.0 @@ -19,13 +19,13 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==8.5.7 +mkdocs-material==8.5.10 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.3.0 psycopg2-binary==2.9.5 PyYAML==6.0 -sentry-sdk==1.10.1 +sentry-sdk==1.11.0 social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3 From e40e2550a618fbf2bf7d7227bc38889d08c45046 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 11:34:45 -0500 Subject: [PATCH 278/409] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4894690de..a46424df0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.9 (FUTURE) + +--- + ## v3.3.8 (2022-11-16) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 46663f08c..ff0551fff 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.8' +VERSION = '3.3.9-dev' # Hostname HOSTNAME = platform.node() From cb2b2569341f7ef332a45d17978beb70216229a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 16:38:29 -0500 Subject: [PATCH 279/409] Fix typo --- docs/models/extras/branch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md index be124ebde..fd7922b7e 100644 --- a/docs/models/extras/branch.md +++ b/docs/models/extras/branch.md @@ -1,6 +1,6 @@ # Branches -A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes. +A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes. ## Fields From 216d8d24b8128cf2fd2fcfd5572ef28a17467f93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 16:40:01 -0500 Subject: [PATCH 280/409] Add note to update model's SearchIndex --- docs/development/extending-models.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index ad8fe5024..b7fd5e1e5 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model. If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default. -## 8. Update the UI templates +## 8. Update the SearchIndex + +Where applicable, add the new field to the model's SearchIndex for inclusion in global search. + +## 9. Update the UI templates Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. -## 9. Create/extend test cases +## 10. Create/extend test cases Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including: @@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality. -## 10. Update the model's documentation +## 11. Update the model's documentation Each model has a dedicated page in the documentation, at `models//.md`. Update this file to include any relevant information about the new field. From f411c4f4399ee196b33dc453c442c520d7adae9e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 16:52:35 -0500 Subject: [PATCH 281/409] Introduce panel template for services --- netbox/templates/dcim/device.html | 22 +------- netbox/templates/inc/panels/services.html | 50 +++++++++++++++++++ netbox/templates/ipam/inc/service.html | 28 ----------- netbox/templates/ipam/ipaddress.html | 19 +------ .../virtualization/virtualmachine.html | 24 +-------- 5 files changed, 53 insertions(+), 90 deletions(-) create mode 100644 netbox/templates/inc/panels/services.html delete mode 100644 netbox/templates/ipam/inc/service.html diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index f0e7bb33a..cb7a7133d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -295,27 +295,7 @@
    {% endif %} -
    -
    Services
    -
    - {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
    - {% else %} -
    None
    - {% endif %} -
    - {% if perms.ipam.add_service %} - - {% endif %} -
    + {% include 'inc/panels/services.html' %} {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% if object.rack and object.position %} diff --git a/netbox/templates/inc/panels/services.html b/netbox/templates/inc/panels/services.html new file mode 100644 index 000000000..b7109f497 --- /dev/null +++ b/netbox/templates/inc/panels/services.html @@ -0,0 +1,50 @@ +
    +
    Services
    +
    + {% if services %} + + {% for service in services %} + + + + + + + + + {% endfor %} +
    {{ service|linkify:"name" }}{{ service.get_protocol_display }}{{ service.port_list }} + {% for ip in service.ipaddresses.all %} + {{ ip.address.ip }}
    + {% empty %} + All IPs + {% endfor %} +
    {{ service.description }} + + + + {% if perms.ipam.change_service %} + + + + {% endif %} + {% if perms.ipam.delete_service %} + + + + {% endif %} +
    + {% else %} +
    None
    + {% endif %} +
    + {% if perms.ipam.add_service %} + {% with object|meta:"model_name" as object_type %} + + {% endwith %} + {% endif %} +
    diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html deleted file mode 100644 index 0d6faf1dc..000000000 --- a/netbox/templates/ipam/inc/service.html +++ /dev/null @@ -1,28 +0,0 @@ - - {{ service|linkify:"name" }} - {{ service.get_protocol_display }} - {{ service.port_list }} - - {% for ip in service.ipaddresses.all %} - {{ ip.address.ip }}
    - {% empty %} - All IPs - {% endfor %} - - {{ service.description }} - - - - - {% if perms.ipam.change_service %} - - - - {% endif %} - {% if perms.ipam.delete_service %} - - - - {% endif %} - - diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 4a110c2e6..131087253 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -138,24 +138,7 @@
    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
    -
    -
    - Services -
    -
    - {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
    - {% else %} -
    - None -
    - {% endif %} -
    -
    + {% include 'inc/panels/services.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 9d95b02ea..9b5708486 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -144,29 +144,7 @@ -
    -
    - Services -
    -
    - {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
    - {% else %} - None - {% endif %} -
    - {% if perms.ipam.add_service %} - - {% endif %} -
    + {% include 'inc/panels/services.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} From ebf555e1fb1267348ca620c15ce456767d91042a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 16 Nov 2022 17:22:09 -0500 Subject: [PATCH 282/409] Use strings to specify prerequisite models --- netbox/circuits/models/circuits.py | 8 +++---- netbox/dcim/models/devices.py | 23 +++++++++---------- netbox/dcim/models/power.py | 16 ++++++------- netbox/dcim/models/racks.py | 16 ++++++------- netbox/dcim/models/sites.py | 7 +++--- netbox/ipam/models/ip.py | 18 +++++++-------- netbox/ipam/models/l2vpn.py | 8 +++---- netbox/netbox/models/__init__.py | 10 +------- netbox/netbox/views/generic/utils.py | 21 +++++++++-------- netbox/virtualization/models/clusters.py | 7 +++--- .../virtualization/models/virtualmachines.py | 8 +++---- netbox/wireless/models.py | 5 ---- 12 files changed, 61 insertions(+), 86 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index ebba74738..8ef5761fd 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -104,6 +104,10 @@ class Circuit(PrimaryModel): clone_fields = ( 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', ) + prerequisite_models = ( + 'circuits.CircuitType', + 'circuits.Provider', + ) class Meta: ordering = ['provider', 'cid'] @@ -117,10 +121,6 @@ class Circuit(PrimaryModel): def __str__(self): return self.cid - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('circuits.Provider'), CircuitType] - def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3a76c826f..8b03015be 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -124,6 +124,9 @@ class DeviceType(PrimaryModel, WeightMixin): clone_fields = ( 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) + prerequisite_models = ( + 'dcim.Manufacturer', + ) class Meta: ordering = ['manufacturer', 'model'] @@ -151,10 +154,6 @@ class DeviceType(PrimaryModel, WeightMixin): self._original_front_image = self.front_image self._original_rear_image = self.rear_image - @classmethod - def get_prerequisite_models(cls): - return [Manufacturer, ] - def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) @@ -325,6 +324,9 @@ class ModuleType(PrimaryModel, WeightMixin): ) clone_fields = ('manufacturer', 'weight', 'weight_unit',) + prerequisite_models = ( + 'dcim.Manufacturer', + ) class Meta: ordering = ('manufacturer', 'model') @@ -338,10 +340,6 @@ class ModuleType(PrimaryModel, WeightMixin): def __str__(self): return self.model - @classmethod - def get_prerequisite_models(cls): - return [Manufacturer, ] - def get_absolute_url(self): return reverse('dcim:moduletype', args=[self.pk]) @@ -599,6 +597,11 @@ class Device(PrimaryModel, ConfigContextModel): 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow', 'cluster', 'virtual_chassis', ) + prerequisite_models = ( + 'dcim.Site', + 'dcim.DeviceRole', + 'dcim.DeviceType', + ) class Meta: ordering = ('_name', 'pk') # Name may be null @@ -638,10 +641,6 @@ class Device(PrimaryModel, ConfigContextModel): return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ] - def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index a910b1437..3377a9edb 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -48,6 +47,10 @@ class PowerPanel(PrimaryModel): to='extras.ImageAttachment' ) + prerequisite_models = ( + 'dcim.Site', + ) + class Meta: ordering = ['site', 'name'] constraints = ( @@ -60,10 +63,6 @@ class PowerPanel(PrimaryModel): def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), ] - def get_absolute_url(self): return reverse('dcim:powerpanel', args=[self.pk]) @@ -137,6 +136,9 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', ) + prerequisite_models = ( + 'dcim.PowerPanel', + ) class Meta: ordering = ['power_panel', 'name'] @@ -150,10 +152,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [PowerPanel, ] - def get_absolute_url(self): return reverse('dcim:powerfeed', args=[self.pk]) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index ff37aff5a..8cdc9c691 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,7 +1,6 @@ import decimal from functools import cached_property -from django.apps import apps from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField @@ -177,6 +176,9 @@ class Rack(PrimaryModel, WeightMixin): 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', ) + prerequisite_models = ( + 'dcim.Site', + ) class Meta: ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique @@ -197,10 +199,6 @@ class Rack(PrimaryModel, WeightMixin): return f'{self.name} ({self.facility_id})' return self.name - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), ] - def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) @@ -488,16 +486,16 @@ class RackReservation(PrimaryModel): max_length=200 ) + prerequisite_models = ( + 'dcim.Rack', + ) + class Meta: ordering = ['created', 'pk'] def __str__(self): return "Reservation for rack {}".format(self.rack) - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Site'), Rack, ] - def get_absolute_url(self): return reverse('dcim:rackreservation', args=[self.pk]) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 33f695e70..c035fc1db 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -286,6 +286,9 @@ class Location(NestedGroupModel): ) clone_fields = ('site', 'parent', 'status', 'tenant', 'description') + prerequisite_models = ( + 'dcim.Site', + ) class Meta: ordering = ['site', 'name'] @@ -312,10 +315,6 @@ class Location(NestedGroupModel): ), ) - @classmethod - def get_prerequisite_models(cls): - return [Site, ] - def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index dd92f97cc..20530d66c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -10,7 +10,6 @@ from django.utils.translation import gettext as _ from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -18,9 +17,9 @@ from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator from netbox.config import get_config +from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VirtualMachine - __all__ = ( 'Aggregate', 'ASN', @@ -101,6 +100,10 @@ class ASN(PrimaryModel): null=True ) + prerequisite_models = ( + 'ipam.RIR', + ) + class Meta: ordering = ['asn'] verbose_name = 'ASN' @@ -109,10 +112,6 @@ class ASN(PrimaryModel): def __str__(self): return f'AS{self.asn_with_asdot}' - @classmethod - def get_prerequisite_models(cls): - return [RIR, ] - def get_absolute_url(self): return reverse('ipam:asn', args=[self.pk]) @@ -163,6 +162,9 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): clone_fields = ( 'rir', 'tenant', 'date_added', 'description', ) + prerequisite_models = ( + 'ipam.RIR', + ) class Meta: ordering = ('prefix', 'pk') # prefix may be non-unique @@ -170,10 +172,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): def __str__(self): return str(self.prefix) - @classmethod - def get_prerequisite_models(cls): - return [RIR, ] - def get_absolute_url(self): return reverse('ipam:aggregate', args=[self.pk]) diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index f3f7a1d55..c858d1a0c 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -95,6 +94,9 @@ class L2VPNTermination(NetBoxModel): ) clone_fields = ('l2vpn',) + prerequisite_models = ( + 'ipam.L2VPN', + ) class Meta: ordering = ('l2vpn',) @@ -111,10 +113,6 @@ class L2VPNTermination(NetBoxModel): return f'{self.assigned_object} <> {self.l2vpn}' return super().__str__() - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('ipam.L2VPN'), ] - def get_absolute_url(self): return reverse('ipam:l2vpntermination', args=[self.pk]) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 661470ee0..d3f3e78bc 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -3,9 +3,9 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from netbox.models.features import * from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet -from netbox.models.features import * __all__ = ( 'ChangeLoggedModel', @@ -33,14 +33,6 @@ class NetBoxFeatureSet( def docs_url(self): return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' - @classmethod - def get_prerequisite_models(cls): - """ - Return a list of model types that are required to create this model or empty list if none. This is used for - showing prerequisite warnings in the UI on the list and detail views. - """ - return [] - # # Base model classes diff --git a/netbox/netbox/views/generic/utils.py b/netbox/netbox/views/generic/utils.py index 61c6dc242..61d73e811 100644 --- a/netbox/netbox/views/generic/utils.py +++ b/netbox/netbox/views/generic/utils.py @@ -1,12 +1,13 @@ +from django.apps import apps + + def get_prerequisite_model(queryset): - model = queryset.model - + """ + Return any prerequisite model that must be created prior to creating + an instance of the current model. + """ if not queryset.exists(): - if hasattr(model, 'get_prerequisite_models'): - prerequisites = model.get_prerequisite_models() - if prerequisites: - for prereq in prerequisites: - if not prereq.objects.exists(): - return prereq - - return None + for prereq in getattr(queryset.model, 'prerequisite_models', []): + model = apps.get_model(prereq) + if not model.objects.exists(): + return model diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index e7c1294c2..517b92ef2 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -94,6 +94,9 @@ class Cluster(PrimaryModel): clone_fields = ( 'type', 'group', 'status', 'tenant', 'site', ) + prerequisite_models = ( + 'virtualization.ClusterType', + ) class Meta: ordering = ['name'] @@ -111,10 +114,6 @@ class Cluster(PrimaryModel): def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [ClusterType, ] - def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index d64289eb2..cc39044f9 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -15,7 +15,6 @@ from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from virtualization.choices import * -from .clusters import Cluster __all__ = ( 'VirtualMachine', @@ -131,6 +130,9 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): clone_fields = ( 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ) + prerequisite_models = ( + 'virtualization.Cluster', + ) class Meta: ordering = ('_name', 'pk') # Name may be non-unique @@ -150,10 +152,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): def __str__(self): return self.name - @classmethod - def get_prerequisite_models(cls): - return [Cluster, ] - def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 5858e641c..261e5c67d 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -193,10 +192,6 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): def __str__(self): return f'#{self.pk}' - @classmethod - def get_prerequisite_models(cls): - return [apps.get_model('dcim.Interface'), ] - def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) From 5202d0add9f71e2a7be110ddaf9205d07352767a Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 17 Nov 2022 11:34:12 +0100 Subject: [PATCH 283/409] Linkify primary IP for VDC --- netbox/templates/dcim/virtualdevicecontext.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html index c76fed803..c7b2b9659 100644 --- a/netbox/templates/dcim/virtualdevicecontext.html +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -31,13 +31,13 @@ Primary IPv4 - {{ object.primary_ip4|placeholder }} + {{ object.primary_ip4|linkify|placeholder }} Primary IPv6 - {{ object.primary_ip6|placeholder }} + {{ object.primary_ip6|linkify|placeholder }} From 977b79ecee4d1d8054c0fd9528c563376fe3bcd9 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 17 Nov 2022 11:14:40 +0100 Subject: [PATCH 284/409] Check that device has a platform set before rendering napalm tab --- netbox/dcim/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index efedc58ab..a1c11bb3d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2072,6 +2072,7 @@ class NAPALMViewTab(ViewTab): if not ( instance.status == 'active' and instance.primary_ip and + instance.platform and instance.platform.napalm_driver ): return None From d4a231585ac9a25d9739552d8c9e433dbf9398af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 10:24:03 -0500 Subject: [PATCH 285/409] Clean up tests --- netbox/dcim/tests/test_api.py | 30 +-- netbox/dcim/tests/test_models.py | 204 ++++++++++++--------- netbox/dcim/tests/test_natural_ordering.py | 29 +-- netbox/extras/tests/test_api.py | 43 +++-- netbox/extras/tests/test_customfields.py | 3 +- netbox/extras/tests/test_models.py | 172 ++++++++++------- netbox/extras/tests/test_webhooks.py | 1 + netbox/ipam/tests/test_ordering.py | 146 +++++++-------- netbox/users/tests/test_models.py | 13 +- 9 files changed, 353 insertions(+), 288 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bd3cb3f01..672551f42 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1954,37 +1954,37 @@ class CableTest(APIViewTestCases.APIViewTestCase): class ConnectedDeviceTest(APITestCase): - def setUp(self): - - super().setUp() - + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000') - self.device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + devices = ( + Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site), + Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site), ) - self.device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site + Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='eth0'), + Interface(device=devices[1], name='eth0'), + Interface(device=devices[0], name='eth1'), # Not connected ) - self.interface1 = Interface.objects.create(device=self.device1, name='eth0') - self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected + Interface.objects.bulk_create(interfaces) - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) + cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]]) cable.save() @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_get_connected_device(self): url = reverse('dcim-api:connected-device-list') - url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}' + url_params = f'?peer_device=TestDevice1&peer_interface=eth0' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['name'], self.device2.name) + self.assertEqual(response.data['name'], 'TestDevice2') - url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}' + url_params = f'?peer_device=TestDevice1&peer_interface=eth1' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index a64c6d56b..e9a577648 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -73,7 +73,8 @@ class LocationTestCase(TestCase): class RackTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): sites = ( Site(name='Site 1', slug='site-1'), @@ -240,30 +241,31 @@ class RackTestCase(TestCase): class DeviceTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): - self.site = Site.objects.create(name='Test Site 1', slug='test-site-1') + site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - self.device_type = DeviceType.objects.create( + device_type = DeviceType.objects.create( manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) - self.device_role = DeviceRole.objects.create( + device_role = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) # Create DeviceType components ConsolePortTemplate( - device_type=self.device_type, + device_type=device_type, name='Console Port 1' ).save() ConsoleServerPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Console Server Port 1' ).save() ppt = PowerPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Power Port 1', maximum_draw=1000, allocated_draw=500 @@ -271,21 +273,21 @@ class DeviceTestCase(TestCase): ppt.save() PowerOutletTemplate( - device_type=self.device_type, + device_type=device_type, name='Power Outlet 1', power_port=ppt, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A ).save() InterfaceTemplate( - device_type=self.device_type, + device_type=device_type, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True ).save() rpt = RearPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=8 @@ -293,7 +295,7 @@ class DeviceTestCase(TestCase): rpt.save() FrontPortTemplate( - device_type=self.device_type, + device_type=device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rpt, @@ -301,12 +303,12 @@ class DeviceTestCase(TestCase): ).save() ModuleBayTemplate( - device_type=self.device_type, + device_type=device_type, name='Module Bay 1' ).save() DeviceBayTemplate( - device_type=self.device_type, + device_type=device_type, name='Device Bay 1' ).save() @@ -315,9 +317,9 @@ class DeviceTestCase(TestCase): Ensure that all Device components are copied automatically from the DeviceType. """ d = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name='Test Device 1' ) d.save() @@ -381,9 +383,9 @@ class DeviceTestCase(TestCase): def test_multiple_unnamed_devices(self): device1 = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name=None ) device1.save() @@ -402,9 +404,9 @@ class DeviceTestCase(TestCase): def test_device_name_case_sensitivity(self): device1 = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name='device 1' ) device1.save() @@ -423,9 +425,9 @@ class DeviceTestCase(TestCase): def test_device_duplicate_names(self): device1 = Device( - site=self.site, - device_type=self.device_type, - device_role=self.device_role, + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), name='Test Device 1' ) device1.save() @@ -459,7 +461,8 @@ class DeviceTestCase(TestCase): class CableTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -469,72 +472,76 @@ class CableTestCase(TestCase): devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.device1 = Device.objects.create( + device1 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site ) - self.device2 = Device.objects.create( + device2 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site ) - self.interface1 = Interface.objects.create(device=self.device1, name='eth0') - self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - self.interface3 = Interface.objects.create(device=self.device2, name='eth1') - self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) - self.cable.save() + interface1 = Interface.objects.create(device=device1, name='eth0') + interface2 = Interface.objects.create(device=device2, name='eth0') + interface3 = Interface.objects.create(device=device2, name='eth1') + Cable(a_terminations=[interface1], b_terminations=[interface2]).save() - self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') - self.patch_pannel = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site + power_port1 = PowerPort.objects.create(device=device2, name='psu1') + patch_pannel = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site ) - self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c') - self.front_port1 = FrontPort.objects.create( - device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1 + rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c') + front_port1 = FrontPort.objects.create( + device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1 ) - self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2) - self.front_port2 = FrontPort.objects.create( - device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1 + rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2) + front_port2 = FrontPort.objects.create( + device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1 ) - self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3) - self.front_port3 = FrontPort.objects.create( - device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1 + rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3) + front_port3 = FrontPort.objects.create( + device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1 ) - self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3) - self.front_port4 = FrontPort.objects.create( - device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 + rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3) + front_port4 = FrontPort.objects.create( + device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1 ) - self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') - provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider) - self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') - self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A') + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) + circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') + circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') + circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ When a new Cable is created, it must be cached on either termination point. """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.assertEqual(self.interface1.cable, self.cable) - self.assertEqual(self.interface2.cable, self.cable) - self.assertEqual(self.interface1.cable_end, 'A') - self.assertEqual(self.interface2.cable_end, 'B') - self.assertEqual(self.interface1.link_peers, [self.interface2]) - self.assertEqual(self.interface2.link_peers, [self.interface1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + cable = Cable.objects.first() + self.assertEqual(interface1.cable, cable) + self.assertEqual(interface2.cable, cable) + self.assertEqual(interface1.cable_end, 'A') + self.assertEqual(interface2.cable_end, 'B') + self.assertEqual(interface1.link_peers, [interface2]) + self.assertEqual(interface2.link_peers, [interface1]) def test_cable_deletion(self): """ When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method should still return the PK of the string even after being nullified. """ - self.cable.delete() - self.assertIsNone(self.cable.pk) - self.assertNotEqual(str(self.cable), '#None') - interface1 = Interface.objects.get(pk=self.interface1.pk) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + cable = Cable.objects.first() + + cable.delete() + self.assertIsNone(cable.pk) + self.assertNotEqual(str(cable), '#None') + interface1 = Interface.objects.get(pk=interface1.pk) self.assertIsNone(interface1.cable) self.assertListEqual(interface1.link_peers, []) - interface2 = Interface.objects.get(pk=self.interface2.pk) + interface2 = Interface.objects.get(pk=interface2.pk) self.assertIsNone(interface2.cable) self.assertListEqual(interface2.link_peers, []) @@ -542,7 +549,10 @@ class CableTestCase(TestCase): """ The clean method should ensure that all terminations at either end of a Cable belong to the same parent object. """ - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1') + + cable = Cable(a_terminations=[interface1], b_terminations=[powerport1]) with self.assertRaises(ValidationError): cable.clean() @@ -550,7 +560,11 @@ class CableTestCase(TestCase): """ The clean method should ensure that all terminations at either end of a Cable are of the same type. """ - cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1]) + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1') + rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1') + + cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1]) with self.assertRaises(ValidationError): cable.clean() @@ -558,8 +572,11 @@ class CableTestCase(TestCase): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ + interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0') + powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1') + # An interface cannot be connected to a power port, for example - cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + cable = Cable(a_terminations=[interface1], b_terminations=[powerport1]) with self.assertRaises(ValidationError): cable.clean() @@ -567,7 +584,10 @@ class CableTestCase(TestCase): """ Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork """ - cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3]) + interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1') + circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A') + + cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3]) with self.assertRaises(ValidationError): cable.clean() @@ -575,8 +595,11 @@ class CableTestCase(TestCase): """ A cable cannot terminate to a virtual interface """ - virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) - cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface]) + device1 = Device.objects.get(name='TestDevice1') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + + virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) + cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface]) with self.assertRaises(ValidationError): cable.clean() @@ -584,15 +607,19 @@ class CableTestCase(TestCase): """ A cable cannot terminate to a wireless interface """ - wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) - cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) + device1 = Device.objects.get(name='TestDevice1') + interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0') + + wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) + cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() class VirtualDeviceContextTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -602,36 +629,41 @@ class VirtualDeviceContextTestCase(TestCase): devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.device = Device.objects.create( + Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site ) def test_vdc_and_interface_creation(self): + device = Device.objects.first() - vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') vdc.full_clean() vdc.save() - interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t') + interface = Interface(device=device, name='Eth1/1', type='10gbase-t') interface.full_clean() interface.save() interface.vdcs.set([vdc]) def test_vdc_duplicate_name(self): - vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + device = Device.objects.first() + + vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') vdc1.full_clean() vdc1.save() - vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active') + vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active') with self.assertRaises(ValidationError): vdc2.full_clean() def test_vdc_duplicate_identifier(self): - vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + device = Device.objects.first() + + vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active') vdc1.full_clean() vdc1.save() - vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active') + vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active') with self.assertRaises(ValidationError): vdc2.full_clean() diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py index 5c42b3ab4..8edaf0a90 100644 --- a/netbox/dcim/tests/test_natural_ordering.py +++ b/netbox/dcim/tests/test_natural_ordering.py @@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, class NaturalOrderingTestCase(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase): devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.device = Device.objects.create( + Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) def test_interface_ordering_numeric(self): - + device = Device.objects.first() INTERFACES = [ '0', '0.0', @@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_linux(self): - + device = Device.objects.first() INTERFACES = [ 'eth0', 'eth0.1', @@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_junos(self): - + device = Device.objects.first() INTERFACES = [ 'xe-0/0/0', 'xe-0/0/1', @@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) def test_interface_ordering_ios(self): - + device = Device.objects.first() INTERFACES = [ 'GigabitEthernet0/1', 'GigabitEthernet0/2', @@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase): ] for name in INTERFACES: - iface = Interface(device=self.device, name=name) + iface = Interface(device=device, name=name) iface.save() self.assertListEqual( - list(Interface.objects.filter(device=self.device).values_list('name', flat=True)), + list(Interface.objects.filter(device=device).values_list('name', flat=True)), INTERFACES ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 2b4a4aa5f..b959587ce 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -611,73 +611,76 @@ class ScriptTest(APITestCase): class CreatedUpdatedFilterTest(APITestCase): - def setUp(self): - - super().setUp() - - self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') - self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1') - self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') - self.rack1 = Rack.objects.create( - site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42, - ) - self.rack2 = Rack.objects.create( - site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42, + @classmethod + def setUpTestData(cls): + site1 = Site.objects.create(name='Site 1', slug='site-1') + location1 = Location.objects.create(site=site1, name='Location 1', slug='location-1') + rackrole1 = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000') + racks = ( + Rack(site=site1, location=location1, role=rackrole1, name='Rack 1', u_height=42), + Rack(site=site1, location=location1, role=rackrole1, name='Rack 2', u_height=42) ) + Rack.objects.bulk_create(racks) - # change the created and last_updated of one - Rack.objects.filter(pk=self.rack2.pk).update( + # Change the created and last_updated of the second rack + Rack.objects.filter(pk=racks[1].pk).update( last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)), created=make_aware(datetime.datetime(2001, 2, 3)) ) def test_get_rack_created(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created=2001-02-03'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_created_gte(self): + rack1 = Rack.objects.get(name='Rack 1') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) + self.assertEqual(response.data['results'][0]['id'], rack1.pk) def test_get_rack_created_lte(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_last_updated(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) def test_get_rack_last_updated_gte(self): + rack1 = Rack.objects.get(name='Rack 1') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) + self.assertEqual(response.data['results'][0]['id'], rack1.pk) def test_get_rack_last_updated_lte(self): + rack2 = Rack.objects.get(name='Rack 2') self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header) self.assertEqual(response.data['count'], 1) - self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + self.assertEqual(response.data['results'][0]['id'], rack2.pk) class ContentTypeTest(APITestCase): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 8fa5f1e77..6ca354438 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -373,7 +373,8 @@ class CustomFieldTest(TestCase): class CustomFieldManagerTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') custom_field.save() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 4929690e7..0ac63c086 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -21,32 +21,32 @@ class ConfigContextTest(TestCase): It also ensures the various config context querysets are consistent. """ - - def setUp(self): + @classmethod + def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') - self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - self.region = Region.objects.create(name="Region") - self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) - self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) - self.platform = Platform.objects.create(name="Platform") - self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") - self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) - self.tag = Tag.objects.create(name="Tag", slug="tag") - self.tag2 = Tag.objects.create(name="Tag2", slug="tag2") + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + region = Region.objects.create(name='Region') + sitegroup = SiteGroup.objects.create(name='Site Group') + site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) + location = Location.objects.create(name='Location 1', slug='location-1', site=site) + platform = Platform.objects.create(name='Platform') + tenantgroup = TenantGroup.objects.create(name='Tenant Group') + tenant = Tenant.objects.create(name='Tenant', group=tenantgroup) + tag1 = Tag.objects.create(name='Tag', slug='tag') + tag2 = Tag.objects.create(name='Tag2', slug='tag2') - self.device = Device.objects.create( + Device.objects.create( name='Device 1', - device_type=self.devicetype, - device_role=self.devicerole, - site=self.site, - location=self.location + device_type=devicetype, + device_role=devicerole, + site=site, + location=location ) def test_higher_weight_wins(self): - + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=101, @@ -72,10 +72,10 @@ class ConfigContextTest(TestCase): "b": 456, "c": 777 } - self.assertEqual(self.device.get_config_context(), expected_data) + self.assertEqual(device.get_config_context(), expected_data) def test_name_ordering_after_weight(self): - + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=100, @@ -101,13 +101,14 @@ class ConfigContextTest(TestCase): "b": 456, "c": 789 } - self.assertEqual(self.device.get_config_context(), expected_data) + self.assertEqual(device.get_config_context(), expected_data) def test_annotation_same_as_get_for_object(self): """ - This test incorperates features from all of the above tests cases to ensure + This test incorporates features from all of the above tests cases to ensure the annotate_config_context_data() and get_for_object() queryset methods are the same. """ + device = Device.objects.first() context1 = ConfigContext( name="context 1", weight=101, @@ -142,10 +143,19 @@ class ConfigContextTest(TestCase): ) ConfigContext.objects.bulk_create([context1, context2, context3, context4]) - annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data() - self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) + annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() + self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): + region = Region.objects.first() + sitegroup = SiteGroup.objects.first() + site = Site.objects.first() + location = Location.objects.first() + platform = Platform.objects.first() + tenantgroup = TenantGroup.objects.first() + tenant = Tenant.objects.first() + tag = Tag.objects.first() + region_context = ConfigContext.objects.create( name="region", weight=100, @@ -153,7 +163,8 @@ class ConfigContextTest(TestCase): "region": 1 } ) - region_context.regions.add(self.region) + region_context.regions.add(region) + sitegroup_context = ConfigContext.objects.create( name="sitegroup", weight=100, @@ -161,7 +172,8 @@ class ConfigContextTest(TestCase): "sitegroup": 1 } ) - sitegroup_context.site_groups.add(self.sitegroup) + sitegroup_context.site_groups.add(sitegroup) + site_context = ConfigContext.objects.create( name="site", weight=100, @@ -169,7 +181,8 @@ class ConfigContextTest(TestCase): "site": 1 } ) - site_context.sites.add(self.site) + site_context.sites.add(site) + location_context = ConfigContext.objects.create( name="location", weight=100, @@ -177,7 +190,8 @@ class ConfigContextTest(TestCase): "location": 1 } ) - location_context.locations.add(self.location) + location_context.locations.add(location) + platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -185,7 +199,8 @@ class ConfigContextTest(TestCase): "platform": 1 } ) - platform_context.platforms.add(self.platform) + platform_context.platforms.add(platform) + tenant_group_context = ConfigContext.objects.create( name="tenant group", weight=100, @@ -193,7 +208,8 @@ class ConfigContextTest(TestCase): "tenant_group": 1 } ) - tenant_group_context.tenant_groups.add(self.tenantgroup) + tenant_group_context.tenant_groups.add(tenantgroup) + tenant_context = ConfigContext.objects.create( name="tenant", weight=100, @@ -201,7 +217,8 @@ class ConfigContextTest(TestCase): "tenant": 1 } ) - tenant_context.tenants.add(self.tenant) + tenant_context.tenants.add(tenant) + tag_context = ConfigContext.objects.create( name="tag", weight=100, @@ -209,23 +226,30 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context.tags.add(self.tag) + tag_context.tags.add(tag) device = Device.objects.create( name="Device 2", - site=self.site, - location=self.location, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + location=location, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) + device.tags.add(tag) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_virtualmachine_relations(self): + region = Region.objects.first() + sitegroup = SiteGroup.objects.first() + site = Site.objects.first() + platform = Platform.objects.first() + tenantgroup = TenantGroup.objects.first() + tenant = Tenant.objects.first() + tag = Tag.objects.first() cluster_type = ClusterType.objects.create(name="Cluster Type") cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) @@ -235,49 +259,49 @@ class ConfigContextTest(TestCase): weight=100, data={"region": 1} ) - region_context.regions.add(self.region) + region_context.regions.add(region) sitegroup_context = ConfigContext.objects.create( name="sitegroup", weight=100, data={"sitegroup": 1} ) - sitegroup_context.site_groups.add(self.sitegroup) + sitegroup_context.site_groups.add(sitegroup) site_context = ConfigContext.objects.create( name="site", weight=100, data={"site": 1} ) - site_context.sites.add(self.site) + site_context.sites.add(site) platform_context = ConfigContext.objects.create( name="platform", weight=100, data={"platform": 1} ) - platform_context.platforms.add(self.platform) + platform_context.platforms.add(platform) tenant_group_context = ConfigContext.objects.create( name="tenant group", weight=100, data={"tenant_group": 1} ) - tenant_group_context.tenant_groups.add(self.tenantgroup) + tenant_group_context.tenant_groups.add(tenantgroup) tenant_context = ConfigContext.objects.create( name="tenant", weight=100, data={"tenant": 1} ) - tenant_context.tenants.add(self.tenant) + tenant_context.tenants.add(tenant) tag_context = ConfigContext.objects.create( name="tag", weight=100, data={"tag": 1} ) - tag_context.tags.add(self.tag) + tag_context.tags.add(tag) cluster_type_context = ConfigContext.objects.create( name="cluster type", @@ -303,11 +327,11 @@ class ConfigContextTest(TestCase): virtual_machine = VirtualMachine.objects.create( name="VM 1", cluster=cluster, - tenant=self.tenant, - platform=self.platform, - role=self.devicerole + tenant=tenant, + platform=platform, + role=DeviceRole.objects.first() ) - virtual_machine.tags.add(self.tag) + virtual_machine.tags.add(tag) annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data() self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context()) @@ -315,12 +339,17 @@ class ConfigContextTest(TestCase): def test_multiple_tags_return_distinct_objects(self): """ Tagged items use a generic relationship, which results in duplicate rows being returned when queried. - This is combatted by by appending distinct() to the config context querysets. This test creates a config + This is combated by appending distinct() to the config context querysets. This test creates a config context assigned to two tags and ensures objects related by those same two tags result in only a single config context record being returned. See https://github.com/netbox-community/netbox/issues/5314 """ + site = Site.objects.first() + platform = Platform.objects.first() + tenant = Tenant.objects.first() + tags = Tag.objects.all() + tag_context = ConfigContext.objects.create( name="tag", weight=100, @@ -328,19 +357,17 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context.tags.add(self.tag) - tag_context.tags.add(self.tag2) + tag_context.tags.set(tags) device = Device.objects.create( name="Device 3", - site=self.site, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) - device.tags.add(self.tag2) + device.tags.set(tags) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1) @@ -357,6 +384,11 @@ class ConfigContextTest(TestCase): See https://github.com/netbox-community/netbox/issues/5387 """ + site = Site.objects.first() + platform = Platform.objects.first() + tenant = Tenant.objects.first() + tag1, tag2 = list(Tag.objects.all()) + tag_context_1 = ConfigContext.objects.create( name="tag-1", weight=100, @@ -364,7 +396,8 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context_1.tags.add(self.tag) + tag_context_1.tags.add(tag1) + tag_context_2 = ConfigContext.objects.create( name="tag-2", weight=100, @@ -372,18 +405,17 @@ class ConfigContextTest(TestCase): "tag": 1 } ) - tag_context_2.tags.add(self.tag2) + tag_context_2.tags.add(tag2) device = Device.objects.create( name="Device 3", - site=self.site, - tenant=self.tenant, - platform=self.platform, - device_role=self.devicerole, - device_type=self.devicetype + site=site, + tenant=tenant, + platform=platform, + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first() ) - device.tags.add(self.tag) - device.tags.add(self.tag2) + device.tags.set([tag1, tag2]) annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 111ec6353..19264dabb 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -23,6 +23,7 @@ class WebhookTest(APITestCase): def setUp(self): super().setUp() + # Ensure the queue has been cleared for each test self.queue = django_rq.get_queue('default') self.queue.empty() diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 690501e53..8d69af847 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -9,12 +9,17 @@ import netaddr class OrderingTestBase(TestCase): vrfs = None - def setUp(self): + @classmethod + def setUpTestData(cls): """ Setup the VRFs for the class as a whole """ - self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) - VRF.objects.bulk_create(self.vrfs) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) def _compare(self, queryset, objectset): """ @@ -37,10 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase): """ This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Prefixes + vrf1, vrf2, vrf3 = list(VRF.objects.all()) prefixes = ( Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')), @@ -50,37 +52,37 @@ class PrefixOrderingTestCase(OrderingTestBase): Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.4.0/24')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.5.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/8')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/12')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.4.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.2.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.3.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/12')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.4.0/24')), ) Prefix.objects.bulk_create(prefixes) @@ -104,20 +106,17 @@ class PrefixOrderingTestCase(OrderingTestBase): VRF A:10.1.1.0/24 None: 192.168.0.0/16 """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Prefixes + vrf1, vrf2, vrf3 = list(VRF.objects.all()) prefixes = [ Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')), Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('10.1.0.0/16')), Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/25')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/24')), - Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/25')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')), ] Prefix.objects.bulk_create(prefixes) @@ -131,37 +130,34 @@ class IPAddressOrderingTestCase(OrderingTestBase): """ This function tests ordering with the inclusion of vrfs """ - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup Addresses + vrf1, vrf2, vrf3 = list(VRF.objects.all()) addresses = ( - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.4.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.0.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.1.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.2.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.3.1/24')), - IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.4.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.0.1/24')), IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.1.1/24')), diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 48d440278..7a2337f33 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -4,7 +4,8 @@ from django.test import TestCase class UserConfigTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): user = User.objects.create_user(username='testuser') user.config.data = { @@ -27,10 +28,8 @@ class UserConfigTest(TestCase): } user.config.save() - self.userconfig = user.config - def test_get(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Retrieve root and nested values self.assertEqual(userconfig.get('a'), True) @@ -50,7 +49,7 @@ class UserConfigTest(TestCase): self.assertEqual(userconfig.get('b.foo.x.invalid', 'DEFAULT'), 'DEFAULT') def test_all(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config flattened_data = { 'a': True, 'b.foo': 101, @@ -64,7 +63,7 @@ class UserConfigTest(TestCase): self.assertEqual(userconfig.all(), flattened_data) def test_set(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Overwrite existing values userconfig.set('a', 'abc') @@ -93,7 +92,7 @@ class UserConfigTest(TestCase): userconfig.set('a.x', 1) def test_clear(self): - userconfig = self.userconfig + userconfig = User.objects.get(username='testuser').config # Clear existing values userconfig.clear('a') From 43bbd42d3c7261deb6da1f598fc2802cbaeeadc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 12:29:30 -0500 Subject: [PATCH 286/409] Fixes #10957: Add missing VDCs column to interface tables --- docs/release-notes/version-3.4.md | 8 ++++++++ netbox/dcim/tables/devices.py | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index caa39fc2a..679d8a22a 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,13 @@ # NetBox v3.4 +## v3.4.0 (FUTURE) + +### Bug Fixes + +* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables + +## v3.4-beta1 (2022-11-16) + !!! warning "PostgreSQL 11 Required" NetBox v3.4 requires PostgreSQL 11 or later. diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3a089ae93..1b2ccf563 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -521,6 +521,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi orderable=False, verbose_name='Wireless LANs' ) + vdcs = columns.ManyToManyColumn( + linkify_item=True, + verbose_name='VDCs' + ) vrf = tables.Column( linkify=True ) @@ -534,7 +538,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', - 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -568,7 +572,7 @@ class DeviceInterfaceTable(InterfaceTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) From ae114190454133f3178827dfee2466ff27eb45e0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 12:41:24 -0500 Subject: [PATCH 287/409] Changelog for #10946, #10948 --- docs/release-notes/version-3.4.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 679d8a22a..ee07fa83c 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -4,6 +4,8 @@ ### Bug Fixes +* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned +* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs * [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables ## v3.4-beta1 (2022-11-16) From eb591731ef3459f7a194fea6a69d8559452c3d49 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 13:06:51 -0500 Subject: [PATCH 288/409] #10712: Remove pin for swagger-spec-validator (fixed in v3.0.3) --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8e89b47c5..4504925f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,6 +34,3 @@ tzdata==2022.6 # Workaround for #7401 jsonschema==3.2.0 - -# Temporary fix for #10712 -swagger-spec-validator==2.7.6 From d3911e2a4cedc2d4182b13a74caa1d155f102f28 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 15:13:37 -0500 Subject: [PATCH 289/409] Fixes #9878: Fix spurious error message when rendering REST API docs --- docs/release-notes/version-3.3.md | 4 ++++ netbox/utilities/custom_inspectors.py | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a46424df0..8c0634e96 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,10 @@ ## v3.3.9 (FUTURE) +### Bug Fixes + +* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs + --- ## v3.3.8 (2022-11-16) diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 258399e86..d87613b20 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -28,13 +28,12 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): serializer = super().get_request_serializer() if serializer is not None and self.method in self.implicit_body_methods: - writable_class = self.get_writable_class(serializer) - if writable_class is not None: + if writable_class := self.get_writable_class(serializer): if hasattr(serializer, 'child'): child_serializer = self.get_writable_class(serializer.child) - serializer = writable_class(child=child_serializer) + serializer = writable_class(context=serializer.context, child=child_serializer) else: - serializer = writable_class() + serializer = writable_class(context=serializer.context) return serializer def get_writable_class(self, serializer): From bd29d1581461f1b97cf0bcdaa10752d89e3ac0ae Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 16:08:29 -0500 Subject: [PATCH 290/409] Fixes #10579: Mark cable traces terminating to a provider network as complete --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/models/cables.py | 1 + netbox/dcim/tests/test_cablepaths.py | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 8c0634e96..46d4da7f2 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs +* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete --- diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e05eb6d51..4dd8d98a1 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -570,6 +570,7 @@ class CablePath(models.Model): [object_to_path_node(circuit_termination)], [object_to_path_node(circuit_termination.provider_network)], ]) + is_complete = True break elif circuit_termination.site and not circuit_termination.cable: # Circuit terminates to a Site diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cfbbbc63b..50a707bc6 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase): is_active=True ) self.assertEqual(CablePath.objects.count(), 1) + self.assertTrue(CablePath.objects.first().is_complete) # Delete cable 1 cable1.delete() From cf55e9624154ee84c87968bf0798484e002d43a6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 16:30:54 -0500 Subject: [PATCH 291/409] Fixes #10721: Disable ordering by custom object field columns --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/tables/columns.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 46d4da7f2..ac047ee62 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs * [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete +* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns --- diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c7545192a..81fdaa20f 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -425,6 +425,12 @@ class CustomFieldColumn(tables.Column): kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') if 'verbose_name' not in kwargs: kwargs['verbose_name'] = customfield.label or customfield.name + # We can't logically sort on FK values + if customfield.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT + ): + kwargs['orderable'] = False super().__init__(*args, **kwargs) From 3a5914827b59620441bd5dbb51839ca860c0fecb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 17 Nov 2022 21:04:55 -0500 Subject: [PATCH 292/409] Fixes #6389: Call snapshot() on object when processing deletions --- docs/release-notes/version-3.3.md | 1 + netbox/extras/signals.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ac047ee62..7a7f0fdf9 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,6 +4,7 @@ ### Bug Fixes +* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions * [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs * [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete * [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 31e0c126c..4972d9e85 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook - # # Change logging/webhooks # @@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs): """ Fires when an object is deleted. """ - if not hasattr(instance, 'to_objectchange'): - return - # Get the current request, or bail if not set request = current_request.get() if request is None: @@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): + if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): + instance.snapshot() objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) objectchange.user = request.user objectchange.request_id = request.id From dd2520d675c1fae1085383bccca0f5d824bd93d3 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 18 Nov 2022 05:55:28 -0800 Subject: [PATCH 293/409] 10236 fix device detail for power-feed (#10961) * 10236 fix device detail for power-feed * 10236 optimize with statement --- netbox/templates/dcim/device.html | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0cd76de4..19e7e6e3c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -247,10 +247,15 @@ Leg {{ leg.name }} {{ leg.outlet_count }} {{ leg.allocated }} - {{ powerfeed.available_power|divide:3 }}VA - {% with phase_available=powerfeed.available_power|divide:3 %} - {% utilization_graph leg.allocated|percentage:phase_available %} - {% endwith %} + {% if powerfeed.available_power %} + {% with phase_available=powerfeed.available_power|divide:3 %} + {{ phase_available }}VA + {% utilization_graph leg.allocated|percentage:phase_available %} + {% endwith %} + {% else %} + — + — + {% endif %} {% endfor %} {% endwith %} From de9646d0969e22a4de41615a21e0a2554521199e Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 18 Nov 2022 05:57:57 -0800 Subject: [PATCH 294/409] 10653 log failed login attempts on INFO (#10843) * 10653 log failed login attempts on INFO * 10653 use signal to log failed login attempts * 10653 use signal to log failed login attempts * Update netbox/users/signals.py Co-authored-by: Jeremy Stretch * Update netbox/users/apps.py Co-authored-by: Jeremy Stretch Co-authored-by: Jeremy Stretch --- netbox/users/apps.py | 8 ++++++++ netbox/users/signals.py | 10 ++++++++++ netbox/users/views.py | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 netbox/users/apps.py create mode 100644 netbox/users/signals.py diff --git a/netbox/users/apps.py b/netbox/users/apps.py new file mode 100644 index 000000000..b8d67f1c3 --- /dev/null +++ b/netbox/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' + + def ready(self): + import users.signals diff --git a/netbox/users/signals.py b/netbox/users/signals.py new file mode 100644 index 000000000..8915af1dc --- /dev/null +++ b/netbox/users/signals.py @@ -0,0 +1,10 @@ +import logging +from django.dispatch import receiver +from django.contrib.auth.signals import user_login_failed + + +@receiver(user_login_failed) +def log_user_login_failed(sender, credentials, request, **kwargs): + logger = logging.getLogger('netbox.auth.login') + username = credentials.get("username") + logger.info(f"Failed login attempt for username: {username}") diff --git a/netbox/users/views.py b/netbox/users/views.py index 33ef3fadd..c688d6b4f 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -106,7 +106,7 @@ class LoginView(View): return self.redirect_to_next(request, logger) else: - logger.debug("Login form validation failed") + logger.debug(f"Login form validation failed for username: {form['username'].value()}") return render(request, self.template_name, { 'form': form, From c287641363593ed9ddfe01d8b108a2d17e55fdb6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 18 Nov 2022 11:23:30 -0500 Subject: [PATCH 295/409] Changelog for #10236, #10653 --- docs/release-notes/version-3.3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7a7f0fdf9..0b84bf8da 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,10 +2,15 @@ ## v3.3.9 (FUTURE) +### Enhancements + +* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts + ### Bug Fixes * [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions * [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs +* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power * [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete * [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns From 0885333b116640f6164f084a22b6c562d445881d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 18 Nov 2022 11:24:14 -0500 Subject: [PATCH 296/409] Fixes #9223: Fix serialization of array field values in change log --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/settings.py | 4 ++++ netbox/utilities/serializers/json.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 netbox/utilities/serializers/json.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 0b84bf8da..d99dc7099 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions +* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log * [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs * [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power * [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ff0551fff..fa5480e19 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -445,6 +445,10 @@ EXEMPT_PATHS = ( f'/{BASE_PATH}metrics', ) +SERIALIZATION_MODULES = { + 'json': 'utilities.serializers.json', +} + # # Sentry diff --git a/netbox/utilities/serializers/json.py b/netbox/utilities/serializers/json.py new file mode 100644 index 000000000..d2e682678 --- /dev/null +++ b/netbox/utilities/serializers/json.py @@ -0,0 +1,19 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import Serializer as Serializer_ +from django.utils.encoding import is_protected_type + + +class Serializer(Serializer_): + """ + Custom extension of Django's JSON serializer to support ArrayFields (see + https://code.djangoproject.com/ticket/33974). + """ + def _value_from_field(self, obj, field): + value = field.value_from_object(obj) + + # Handle ArrayFields of protected types + if type(field) is ArrayField: + if not value or is_protected_type(value[0]): + return value + + return value if is_protected_type(value) else field.value_to_string(obj) From 3a89a676cdc8d27c0cf367546165107dda4714ae Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 17 Nov 2022 13:40:27 -0800 Subject: [PATCH 297/409] 10869 convert docstring to comment --- netbox/netbox/api/viewsets/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c50ad9ca6..d6504282e 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali ) def list(self, request, *args, **kwargs): - """ - Overrides ListModelMixin to allow processing ExportTemplates. - """ + # Overrides ListModelMixin to allow processing ExportTemplates. if 'export' in request.GET: content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) From 46e3883f19a8cb25230910e5cca6f6f9e6e6d704 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 18 Nov 2022 15:22:24 -0500 Subject: [PATCH 298/409] Closes #815: Enable specifying terminations when bulk importing circuits --- docs/release-notes/version-3.4.md | 4 ++++ netbox/circuits/forms/bulk_import.py | 26 ++++++++++++++++++++++- netbox/circuits/tests/test_views.py | 7 ++++++ netbox/circuits/views.py | 10 +++++++++ netbox/netbox/views/generic/bulk_views.py | 2 +- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index ee07fa83c..651dc4f82 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -2,6 +2,10 @@ ## v3.4.0 (FUTURE) +### Enhancements + +* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits + ### Bug Fixes * [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 9566c2e12..b61fb1bc7 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,12 +1,16 @@ +from django import forms + from circuits.choices import CircuitStatusChoices from circuits.models import * +from dcim.models import Site from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( 'CircuitImportForm', + 'CircuitTerminationImportForm', 'CircuitTypeImportForm', 'ProviderImportForm', 'ProviderNetworkImportForm', @@ -76,3 +80,23 @@ class CircuitImportForm(NetBoxModelImportForm): 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'tags' ] + + +class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False + ) + provider_network = CSVModelChoiceField( + queryset=ProviderNetwork.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = CircuitTermination + fields = [ + 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'pp_info', 'description', + ] diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 54d001c8d..231d6a43c 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -108,6 +108,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit + def setUp(self): + super().setUp() + + self.add_permissions( + 'circuits.add_circuittermination', + ) + @classmethod def setUpTestData(cls): diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 5fe8eb7b7..3168509ba 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -233,6 +233,16 @@ class CircuitBulkImportView(generic.BulkImportView): queryset = Circuit.objects.all() model_form = forms.CircuitImportForm table = tables.CircuitTable + additional_permissions = [ + 'circuits.add_circuittermination', + ] + related_object_forms = { + 'terminations': forms.CircuitTerminationImportForm, + } + + def prep_related_object_data(self, parent, data): + data.update({'circuit': parent}) + return data class CircuitBulkEditView(generic.BulkEditView): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 444004623..445bfa261 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -458,7 +458,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): 'return_url': self.get_return_url(request), }) - except ValidationError: + except (AbortTransaction, ValidationError): clear_webhooks.send(sender=self) except (AbortRequest, PermissionsViolation) as e: From 84c0c45da9342a88a56b84c4c677716f2f210670 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 18 Nov 2022 16:26:08 -0500 Subject: [PATCH 299/409] Fixes #10980: Fix view tabs for plugin objects --- docs/release-notes/version-3.4.md | 1 + netbox/utilities/templatetags/tabs.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 651dc4f82..15cfdefbd 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -11,6 +11,7 @@ * [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned * [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs * [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables +* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects ## v3.4-beta1 (2022-11-16) diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py index 70f40d742..65f52167d 100644 --- a/netbox/utilities/templatetags/tabs.py +++ b/netbox/utilities/templatetags/tabs.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.utils.module_loading import import_string from netbox.registry import registry +from utilities.utils import get_viewname register = template.Library() @@ -33,7 +34,7 @@ def model_view_tabs(context, instance): continue if attrs := tab.render(instance): - viewname = f"{app_label}:{model_name}_{config['name']}" + viewname = get_viewname(instance, action=config['name']) active_tab = context.get('tab') tabs.append({ 'name': config['name'], From 9774bb46ced54748701d2e4861f5263a44ea83f0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 18 Nov 2022 16:33:06 -0500 Subject: [PATCH 300/409] Fixes #10973: Fix device links in VDC table --- docs/release-notes/version-3.4.md | 1 + netbox/dcim/tables/devices.py | 9 ++++++--- netbox/dcim/tables/template_code.py | 4 +--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 15cfdefbd..1d57660bd 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -11,6 +11,7 @@ * [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned * [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs * [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables +* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table * [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects ## v3.4-beta1 (2022-11-16) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 1b2ccf563..6a9c58eb1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -139,7 +139,8 @@ class PlatformTable(NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), - template_code=DEVICE_LINK + template_code=DEVICE_LINK, + linkify=True ) status = columns.ChoiceFieldColumn() region = tables.Column( @@ -220,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class DeviceImportTable(TenancyColumnsMixin, NetBoxTable): name = tables.TemplateColumn( - template_code=DEVICE_LINK + template_code=DEVICE_LINK, + linkify=True ) status = columns.ChoiceFieldColumn() site = tables.Column( @@ -897,7 +899,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): ) device = tables.TemplateColumn( order_by=('_name',), - template_code=DEVICE_LINK + template_code=DEVICE_LINK, + linkify=True ) status = columns.ChoiceFieldColumn() primary_ip = tables.Column( diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 9b8fb8fd6..4a44e33ba 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -21,9 +21,7 @@ DEVICE_WEIGHT = """ """ DEVICE_LINK = """ - - {{ record.name|default:'Unnamed device' }} - +{{ value|default:'Unnamed device' }} """ DEVICEBAY_STATUS = """ From e494d7bb222f472411436f83d4acc72c19d34297 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 21 Nov 2022 08:06:12 -0500 Subject: [PATCH 301/409] Fixes #10982: Catch NoReverseMatch exception when rendering tabs with no registered URL --- docs/release-notes/version-3.4.md | 1 + netbox/utilities/templatetags/tabs.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 1d57660bd..94939ff5e 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -13,6 +13,7 @@ * [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables * [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table * [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects +* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL ## v3.4-beta1 (2022-11-16) diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py index 65f52167d..3e5bf2127 100644 --- a/netbox/utilities/templatetags/tabs.py +++ b/netbox/utilities/templatetags/tabs.py @@ -1,5 +1,6 @@ from django import template from django.urls import reverse +from django.urls.exceptions import NoReverseMatch from django.utils.module_loading import import_string from netbox.registry import registry @@ -36,9 +37,14 @@ def model_view_tabs(context, instance): if attrs := tab.render(instance): viewname = get_viewname(instance, action=config['name']) active_tab = context.get('tab') + try: + url = reverse(viewname, args=[instance.pk]) + except NoReverseMatch: + # No URL has been registered for this view; skip + continue tabs.append({ 'name': config['name'], - 'url': reverse(viewname, args=[instance.pk]), + 'url': url, 'label': attrs['label'], 'badge': attrs['badge'], 'is_active': active_tab and active_tab == tab, From 150cb772fe519404857f48e894737f0570c4bc56 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 21 Nov 2022 08:38:44 -0500 Subject: [PATCH 302/409] Fixes #10984: Fix navigation menu expansion for plugin menus comprising multiple words --- docs/release-notes/version-3.4.md | 1 + netbox/extras/plugins/navigation.py | 4 ++++ netbox/extras/tests/dummy_plugin/navigation.py | 2 +- netbox/netbox/navigation/__init__.py | 4 ++++ netbox/utilities/templates/navigation/menu.html | 4 ++-- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 94939ff5e..35f938afd 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -14,6 +14,7 @@ * [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table * [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects * [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL +* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words ## v3.4-beta1 (2022-11-16) diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py index 193be6cfb..e667965b8 100644 --- a/netbox/extras/plugins/navigation.py +++ b/netbox/extras/plugins/navigation.py @@ -19,6 +19,10 @@ class PluginMenu: if icon_class is not None: self.icon_class = icon_class + @property + def name(self): + return self.label.replace(' ', '_') + class PluginMenuItem: """ diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py index 8aa161520..a9157b368 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -26,7 +26,7 @@ items = ( ) menu = PluginMenu( - label=_('Dummy'), + label=_('Dummy Plugin'), groups=(('Group 1', items),), ) menu_items = items diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index 7b5729843..a05b1c495 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -51,6 +51,10 @@ class Menu: icon_class: str groups: Sequence[MenuGroup] + @property + def name(self): + return self.label.replace(' ', '_') + # # Utility functions diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html index 33a476081..f7e3f3dcb 100644 --- a/netbox/utilities/templates/navigation/menu.html +++ b/netbox/utilities/templates/navigation/menu.html @@ -4,12 +4,12 @@ {% for menu, groups in nav_items %}