nautobot 2.4.2__py3-none-any.whl → 2.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/circuits/templates/circuits/inc/circuit_termination.html +1 -1
- nautobot/circuits/tests/integration/test_circuit.py +135 -0
- nautobot/circuits/views.py +4 -1
- nautobot/cloud/api/views.py +3 -3
- nautobot/core/constants.py +0 -1
- nautobot/core/forms/__init__.py +2 -0
- nautobot/core/forms/forms.py +2 -1
- nautobot/core/forms/widgets.py +8 -0
- nautobot/core/management/commands/generate_performance_test_endpoints.py +268 -0
- nautobot/core/templates/generic/object_bulk_delete.html +1 -1
- nautobot/core/templates/generic/object_bulk_edit.html +1 -1
- nautobot/core/templates/generic/object_bulk_import.html +1 -1
- nautobot/core/templates/generic/object_create.html +5 -0
- nautobot/core/templates/generic/object_delete.html +1 -1
- nautobot/core/templates/generic/object_detail.html +1 -1
- nautobot/core/templates/generic/object_edit.html +1 -1
- nautobot/core/templates/inc/javascript.html +2 -0
- nautobot/core/templates/widgets/clearable_file.html +5 -0
- nautobot/core/templatetags/helpers.py +3 -3
- nautobot/core/testing/integration.py +37 -7
- nautobot/core/tests/test_commands.py +31 -0
- nautobot/core/tests/test_utils.py +17 -2
- nautobot/core/utils/lookup.py +12 -1
- nautobot/core/views/generic.py +9 -1
- nautobot/core/views/mixins.py +9 -1
- nautobot/dcim/api/views.py +11 -10
- nautobot/dcim/forms.py +3 -6
- nautobot/dcim/models/devices.py +1 -2
- nautobot/dcim/templates/dcim/cable_trace.html +4 -4
- nautobot/dcim/templates/dcim/consoleport.html +14 -4
- nautobot/dcim/templates/dcim/consoleserverport.html +14 -4
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +3 -3
- nautobot/dcim/templates/dcim/frontport.html +7 -2
- nautobot/dcim/templates/dcim/interface.html +9 -4
- nautobot/dcim/templates/dcim/powerfeed.html +8 -3
- nautobot/dcim/templates/dcim/poweroutlet.html +14 -4
- nautobot/dcim/templates/dcim/powerport.html +14 -4
- nautobot/dcim/templates/dcim/rearport.html +7 -2
- nautobot/dcim/tests/integration/test_fileinputpicker.py +87 -0
- nautobot/dcim/tests/test_models.py +1 -1
- nautobot/extras/api/views.py +2 -2
- nautobot/extras/forms/forms.py +4 -0
- nautobot/extras/jobs.py +8 -1
- nautobot/extras/templates/extras/job.html +1 -0
- nautobot/extras/tests/test_dynamicgroups.py +14 -0
- nautobot/extras/tests/test_views.py +197 -9
- nautobot/extras/utils.py +30 -0
- nautobot/extras/views.py +29 -14
- nautobot/ipam/api/views.py +3 -3
- nautobot/ipam/forms.py +2 -6
- nautobot/project-static/bootstrap-filestyle-1.2.3/bootstrap-filestyle.min.js +11 -0
- nautobot/project-static/docs/apps/index.html +1 -1
- nautobot/project-static/docs/apps/nautobot-apps.html +1 -1
- nautobot/project-static/docs/development/apps/api/models/graphql.html +9 -9
- nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +2 -2
- nautobot/project-static/docs/development/apps/api/setup.html +1 -1
- nautobot/project-static/docs/development/apps/migration/code-updates.html +6 -5
- nautobot/project-static/docs/development/apps/migration/dependency-updates.html +2 -2
- nautobot/project-static/docs/development/apps/migration/from-v1.html +3 -3
- nautobot/project-static/docs/development/core/best-practices.html +1 -1
- nautobot/project-static/docs/development/core/bootstrap-ui.html +1 -1
- nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +7 -7
- nautobot/project-static/docs/development/core/getting-started.html +2 -2
- nautobot/project-static/docs/development/core/index.html +1 -1
- nautobot/project-static/docs/development/core/minikube-dev-environment-for-k8s-jobs.html +3 -3
- nautobot/project-static/docs/development/core/model-checklist.html +1 -1
- nautobot/project-static/docs/development/core/navigation-menu.html +1 -1
- nautobot/project-static/docs/development/core/release-checklist.html +1 -1
- nautobot/project-static/docs/development/core/settings.html +1 -1
- nautobot/project-static/docs/development/core/style-guide.html +4 -4
- nautobot/project-static/docs/development/jobs/index.html +8 -1
- nautobot/project-static/docs/development/jobs/migration/from-v1.html +3 -2
- nautobot/project-static/docs/index.html +3 -2
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/overview/application_stack.html +2 -2
- nautobot/project-static/docs/release-notes/version-1.0.html +2 -2
- nautobot/project-static/docs/release-notes/version-1.1.html +2 -2
- nautobot/project-static/docs/release-notes/version-1.2.html +3 -3
- nautobot/project-static/docs/release-notes/version-1.3.html +1 -1
- nautobot/project-static/docs/release-notes/version-1.4.html +17 -17
- nautobot/project-static/docs/release-notes/version-1.5.html +8 -8
- nautobot/project-static/docs/release-notes/version-1.6.html +4 -4
- nautobot/project-static/docs/release-notes/version-2.0.html +10 -10
- nautobot/project-static/docs/release-notes/version-2.1.html +7 -7
- nautobot/project-static/docs/release-notes/version-2.2.html +1 -1
- nautobot/project-static/docs/release-notes/version-2.3.html +4 -4
- nautobot/project-static/docs/release-notes/version-2.4.html +188 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +290 -290
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/authentication/ldap.html +3 -3
- nautobot/project-static/docs/user-guide/administration/configuration/authentication/sso.html +4 -4
- nautobot/project-static/docs/user-guide/administration/configuration/redis.html +1 -1
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +3 -3
- nautobot/project-static/docs/user-guide/administration/guides/celery-queues.html +5 -5
- nautobot/project-static/docs/user-guide/administration/guides/docker.html +3 -3
- nautobot/project-static/docs/user-guide/administration/guides/health-checks.html +1 -1
- nautobot/project-static/docs/user-guide/administration/guides/prometheus-metrics.html +4 -4
- nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +15 -15
- nautobot/project-static/docs/user-guide/administration/guides/s3-django-storage.html +2 -2
- nautobot/project-static/docs/user-guide/administration/installation/app-install.html +1 -1
- nautobot/project-static/docs/user-guide/administration/installation/install_system.html +1 -1
- nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +6 -6
- nautobot/project-static/docs/user-guide/administration/installation/services.html +1 -1
- nautobot/project-static/docs/user-guide/administration/security/index.html +1 -1
- nautobot/project-static/docs/user-guide/administration/security/notices.html +1 -0
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-shell.html +1 -1
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +11 -8
- nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +12 -12
- nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
- nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +1 -1
- nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +3 -3
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobqueue.html +2 -2
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/kubernetes-job-support.html +6 -6
- nautobot/project-static/js/dropdown.js +28 -0
- nautobot/tenancy/forms.py +9 -0
- nautobot/tenancy/templates/tenancy/tenant_create.html +21 -0
- nautobot/tenancy/templates/tenancy/tenant_edit.html +2 -21
- nautobot/tenancy/templates/tenancy/tenantgroup.html +2 -44
- nautobot/tenancy/templates/tenancy/tenantgroup_retrieve.html +1 -0
- nautobot/tenancy/tests/test_views.py +5 -1
- nautobot/tenancy/urls.py +7 -79
- nautobot/tenancy/views.py +51 -80
- nautobot/wireless/api/serializers.py +6 -1
- nautobot/wireless/api/views.py +3 -3
- nautobot/wireless/tests/test_api.py +5 -0
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/METADATA +8 -8
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/RECORD +132 -123
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/NOTICE +0 -0
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/WHEEL +0 -0
- {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from django.urls import reverse
|
|
2
|
+
|
|
3
|
+
from nautobot.core.testing.integration import SeleniumTestCase, WebDriverWait
|
|
4
|
+
from nautobot.dcim.models import Location, LocationType
|
|
5
|
+
from nautobot.extras.models import Job, Status
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClearableFileInputTestCase(SeleniumTestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
super().setUp()
|
|
11
|
+
self.user.is_superuser = True
|
|
12
|
+
self.user.save()
|
|
13
|
+
self.login(self.user.username, self.password)
|
|
14
|
+
|
|
15
|
+
def tearDown(self):
|
|
16
|
+
self.logout()
|
|
17
|
+
super().tearDown()
|
|
18
|
+
|
|
19
|
+
def _assert_file_picker(self, uri_to_visit: str, page_loaded_confirmation: str, file_input_selector_id: str):
|
|
20
|
+
"""
|
|
21
|
+
Ensure clearable input file type has working clear and info display.
|
|
22
|
+
"""
|
|
23
|
+
self.browser.visit(f"{self.live_server_url}{uri_to_visit}")
|
|
24
|
+
WebDriverWait(self.browser, 10).until(lambda driver: driver.is_text_present(page_loaded_confirmation))
|
|
25
|
+
|
|
26
|
+
# Find the first file input button and scroll to it
|
|
27
|
+
front_image_button = self.browser.find_by_css("span.group-span-filestyle.input-group-btn").first
|
|
28
|
+
front_image_button.scroll_to()
|
|
29
|
+
|
|
30
|
+
# cancel button is NOT visible initially
|
|
31
|
+
self.assertFalse(self.browser.find_by_css("button.clear-button").first.visible)
|
|
32
|
+
|
|
33
|
+
# Test file text changes after selecting a file
|
|
34
|
+
file_selection_indicator_css = "div.bootstrap-filestyle input[type='text'].form-control"
|
|
35
|
+
self.assertEqual(self.browser.find_by_css(file_selection_indicator_css).first.value, "")
|
|
36
|
+
front_image_file_input = self.browser.find_by_id(file_input_selector_id).first
|
|
37
|
+
front_image_file_input.value = "/dev/null"
|
|
38
|
+
self.assertEqual(self.browser.find_by_css(file_selection_indicator_css).first.value, "null")
|
|
39
|
+
|
|
40
|
+
# clear button is now visible
|
|
41
|
+
clear_button = self.browser.find_by_css("button.clear-button").first
|
|
42
|
+
self.assertTrue(clear_button.visible)
|
|
43
|
+
|
|
44
|
+
# clicking clearbutton should hide the button, and wipe the file input value
|
|
45
|
+
clear_button.click()
|
|
46
|
+
self.assertFalse(clear_button.visible)
|
|
47
|
+
self.assertEqual(front_image_file_input.value, "")
|
|
48
|
+
|
|
49
|
+
def test_add_device_page(self):
|
|
50
|
+
"""
|
|
51
|
+
Confirm device type add page input is working correctly.
|
|
52
|
+
"""
|
|
53
|
+
self._assert_file_picker(
|
|
54
|
+
uri_to_visit=reverse("dcim:devicetype_add"),
|
|
55
|
+
page_loaded_confirmation="Device Type",
|
|
56
|
+
file_input_selector_id="id_front_image",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def test_job_runner_page(self):
|
|
60
|
+
"""
|
|
61
|
+
Confirm job run page file input is working correctly.
|
|
62
|
+
"""
|
|
63
|
+
example_job = Job.objects.get(name="Example File Input/Output job").pk
|
|
64
|
+
job_example_file_uri = reverse("extras:job_run", kwargs={"pk": example_job})
|
|
65
|
+
self._assert_file_picker(
|
|
66
|
+
uri_to_visit=job_example_file_uri,
|
|
67
|
+
page_loaded_confirmation="Example File",
|
|
68
|
+
file_input_selector_id="id_input_file",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def test_location_image_attachment_view(self):
|
|
72
|
+
"""
|
|
73
|
+
Confirm location image attachment page is working correctly.
|
|
74
|
+
"""
|
|
75
|
+
location_type, _ = LocationType.objects.get_or_create(name="Campus")
|
|
76
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
77
|
+
location, _ = Location.objects.get_or_create(
|
|
78
|
+
name="Test Location 1", location_type=location_type, status=location_status
|
|
79
|
+
)
|
|
80
|
+
location_image_attach_uri = reverse(
|
|
81
|
+
"dcim:location_add_image", kwargs={"object_id": location.id, "model": Location}
|
|
82
|
+
)
|
|
83
|
+
self._assert_file_picker(
|
|
84
|
+
uri_to_visit=location_image_attach_uri,
|
|
85
|
+
page_loaded_confirmation="Image attachment",
|
|
86
|
+
file_input_selector_id="id_image",
|
|
87
|
+
)
|
|
@@ -2790,7 +2790,7 @@ class ControllerTestCase(ModelTestCases.BaseModelTestCase):
|
|
|
2790
2790
|
controller.validated_save()
|
|
2791
2791
|
self.assertEqual(
|
|
2792
2792
|
error.exception.message_dict["location"][0],
|
|
2793
|
-
f'
|
|
2793
|
+
f'Controllers may not associate to locations of type "{location_type}".',
|
|
2794
2794
|
)
|
|
2795
2795
|
|
|
2796
2796
|
|
nautobot/extras/api/views.py
CHANGED
|
@@ -836,7 +836,7 @@ class JobQueueViewSet(NautobotModelViewSet):
|
|
|
836
836
|
filterset_class = filters.JobQueueFilterSet
|
|
837
837
|
|
|
838
838
|
|
|
839
|
-
class JobQueueAssignmentViewSet(
|
|
839
|
+
class JobQueueAssignmentViewSet(ModelViewSet):
|
|
840
840
|
"""
|
|
841
841
|
Manage job queue assignments through DELETE, GET, POST, PUT, and PATCH requests.
|
|
842
842
|
"""
|
|
@@ -1069,7 +1069,7 @@ class MetadataChoiceViewSet(ModelViewSet):
|
|
|
1069
1069
|
filterset_class = filters.MetadataChoiceFilterSet
|
|
1070
1070
|
|
|
1071
1071
|
|
|
1072
|
-
class ObjectMetadataViewSet(
|
|
1072
|
+
class ObjectMetadataViewSet(ModelViewSet):
|
|
1073
1073
|
queryset = ObjectMetadata.objects.all()
|
|
1074
1074
|
serializer_class = serializers.ObjectMetadataSerializer
|
|
1075
1075
|
filterset_class = filters.ObjectMetadataFilterSet
|
nautobot/extras/forms/forms.py
CHANGED
|
@@ -38,6 +38,7 @@ from nautobot.core.forms import (
|
|
|
38
38
|
)
|
|
39
39
|
from nautobot.core.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
|
|
40
40
|
from nautobot.core.forms.forms import ConfirmationForm
|
|
41
|
+
from nautobot.core.forms.widgets import ClearableFileInput
|
|
41
42
|
from nautobot.core.utils.deprecation import class_deprecated_in_favor_of
|
|
42
43
|
from nautobot.dcim.models import Device, DeviceRedundancyGroup, DeviceType, Location, Platform
|
|
43
44
|
from nautobot.extras.choices import (
|
|
@@ -985,6 +986,9 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
|
|
985
986
|
"name",
|
|
986
987
|
"image",
|
|
987
988
|
]
|
|
989
|
+
widgets = {
|
|
990
|
+
"image": ClearableFileInput,
|
|
991
|
+
}
|
|
988
992
|
|
|
989
993
|
|
|
990
994
|
#
|
nautobot/extras/jobs.py
CHANGED
|
@@ -37,6 +37,7 @@ from nautobot.core.forms import (
|
|
|
37
37
|
DynamicModelMultipleChoiceField,
|
|
38
38
|
JSONField,
|
|
39
39
|
)
|
|
40
|
+
from nautobot.core.forms.widgets import ClearableFileInput
|
|
40
41
|
from nautobot.core.utils.config import get_settings_or_config
|
|
41
42
|
from nautobot.core.utils.logging import sanitize
|
|
42
43
|
from nautobot.core.utils.lookup import get_model_from_name
|
|
@@ -1040,12 +1041,18 @@ class DatabaseFileField(forms.FileField):
|
|
|
1040
1041
|
widget = DBClearableFileInput
|
|
1041
1042
|
|
|
1042
1043
|
|
|
1044
|
+
class BootstrapStyleFileField(forms.FileField):
|
|
1045
|
+
"""File picker with UX bootstrap style and clearable checkbox."""
|
|
1046
|
+
|
|
1047
|
+
widget = ClearableFileInput
|
|
1048
|
+
|
|
1049
|
+
|
|
1043
1050
|
class FileVar(ScriptVariable):
|
|
1044
1051
|
"""
|
|
1045
1052
|
An uploaded file.
|
|
1046
1053
|
"""
|
|
1047
1054
|
|
|
1048
|
-
form_field =
|
|
1055
|
+
form_field = BootstrapStyleFileField
|
|
1049
1056
|
|
|
1050
1057
|
|
|
1051
1058
|
class IPAddressVar(ScriptVariable):
|
|
@@ -667,6 +667,20 @@ class DynamicGroupModelTest(DynamicGroupTestBase): # TODO: BaseModelTestCase mi
|
|
|
667
667
|
# Cleanup because we're using class-based fixtures in `setUpTestData()`
|
|
668
668
|
group.refresh_from_db()
|
|
669
669
|
|
|
670
|
+
def test_set_filter_on_ipaddress_dynamic_group(self):
|
|
671
|
+
"""
|
|
672
|
+
Test `DynamicGroup.set_filter()` for an IPAddress Dynamic Group.
|
|
673
|
+
https://github.com/nautobot/nautobot/issues/6805
|
|
674
|
+
"""
|
|
675
|
+
ipaddress_dg = DynamicGroup.objects.create(
|
|
676
|
+
name="IP Address Dynamic Group",
|
|
677
|
+
content_type=ContentType.objects.get_for_model(IPAddress),
|
|
678
|
+
description="IP Address Dynamic Group",
|
|
679
|
+
)
|
|
680
|
+
# Test the fact that set_filter correctly discard an empty PrefixQuerySet
|
|
681
|
+
ipaddress_dg.set_filter({"parent": Prefix.objects.none()})
|
|
682
|
+
self.assertEqual(ipaddress_dg.filter, {})
|
|
683
|
+
|
|
670
684
|
def test_add_child(self):
|
|
671
685
|
"""Test `DynamicGroup.add_child()`."""
|
|
672
686
|
self.parent.add_child(
|
|
@@ -820,7 +820,8 @@ class DynamicGroupTestCase(
|
|
|
820
820
|
return super()._get_queryset().filter(group_type=DynamicGroupTypeChoices.TYPE_DYNAMIC_FILTER) # TODO
|
|
821
821
|
|
|
822
822
|
def test_get_object_with_permission(self):
|
|
823
|
-
|
|
823
|
+
location_ct = ContentType.objects.get_for_model(Location)
|
|
824
|
+
instance = self._get_queryset().exclude(content_type=location_ct).first()
|
|
824
825
|
# Add view permissions for the group's members:
|
|
825
826
|
self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view"))
|
|
826
827
|
|
|
@@ -831,6 +832,18 @@ class DynamicGroupTestCase(
|
|
|
831
832
|
for member in instance.members:
|
|
832
833
|
self.assertIn(str(member.pk), response_body)
|
|
833
834
|
|
|
835
|
+
# Test accessing DynamicGroup detail view with a different content type, more specifically, TreeModel
|
|
836
|
+
# https://github.com/nautobot/nautobot/issues/6806
|
|
837
|
+
tree_model_dg = DynamicGroup.objects.create(name="DG 4", content_type=location_ct)
|
|
838
|
+
# Add view permissions for the group's members:
|
|
839
|
+
self.add_permissions(get_permission_for_model(tree_model_dg.content_type.model_class(), "view"))
|
|
840
|
+
response = self.client.get(tree_model_dg.get_absolute_url())
|
|
841
|
+
self.assertHttpStatus(response, 200)
|
|
842
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
843
|
+
# Check that the "members" table in the detail view includes all appropriate member objects
|
|
844
|
+
for member in tree_model_dg.members:
|
|
845
|
+
self.assertIn(str(member.pk), response_body)
|
|
846
|
+
|
|
834
847
|
def test_get_object_with_constrained_permission(self):
|
|
835
848
|
instance = self._get_queryset().first()
|
|
836
849
|
# Add view permission for one of the group's members but not the others:
|
|
@@ -1306,19 +1319,18 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1306
1319
|
|
|
1307
1320
|
model = SavedView
|
|
1308
1321
|
|
|
1309
|
-
def get_view_url_for_saved_view(self, saved_view, action="detail"):
|
|
1322
|
+
def get_view_url_for_saved_view(self, saved_view=None, action="detail"):
|
|
1310
1323
|
"""
|
|
1311
1324
|
Since saved view detail url redirects, we need to manually construct its detail url
|
|
1312
1325
|
to test the content of its response.
|
|
1313
1326
|
"""
|
|
1314
|
-
|
|
1315
|
-
pk = saved_view.pk
|
|
1327
|
+
url = ""
|
|
1316
1328
|
|
|
1317
|
-
if action == "detail":
|
|
1318
|
-
url = reverse(view) + f"?saved_view={pk}"
|
|
1319
|
-
elif action == "edit":
|
|
1329
|
+
if action == "detail" and saved_view:
|
|
1330
|
+
url = reverse(saved_view.view) + f"?saved_view={saved_view.pk}"
|
|
1331
|
+
elif action == "edit" and saved_view:
|
|
1320
1332
|
url = saved_view.get_absolute_url() + "update-config/"
|
|
1321
|
-
|
|
1333
|
+
elif action == "create":
|
|
1322
1334
|
url = reverse("extras:savedview_add")
|
|
1323
1335
|
|
|
1324
1336
|
return url
|
|
@@ -1411,7 +1423,14 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1411
1423
|
|
|
1412
1424
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1413
1425
|
def test_update_saved_view_as_owner(self):
|
|
1414
|
-
|
|
1426
|
+
view_name = "dcim:location_list"
|
|
1427
|
+
instance = SavedView.objects.create(
|
|
1428
|
+
name="Location Saved View",
|
|
1429
|
+
owner=self.user,
|
|
1430
|
+
view=view_name,
|
|
1431
|
+
is_global_default=True,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1415
1434
|
update_query_strings = ["per_page=12", "&status=active", "&name=new_name_filter", "&sort=name"]
|
|
1416
1435
|
update_url = self.get_view_url_for_saved_view(instance, "edit") + "?" + "".join(update_query_strings)
|
|
1417
1436
|
# Try update the saved view with the same user as the owner of the saved view
|
|
@@ -1543,6 +1562,62 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1543
1562
|
# Assert that Location List View got redirected to Saved View set as user default
|
|
1544
1563
|
self.assertBodyContains(response, "<strong>User Location Default View</strong>", html=True)
|
|
1545
1564
|
|
|
1565
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1566
|
+
def test_filtered_view_precedes_global_default(self):
|
|
1567
|
+
view_name = "dcim:location_list"
|
|
1568
|
+
# Global saved view that will show Floor type locations only.
|
|
1569
|
+
SavedView.objects.create(
|
|
1570
|
+
name="Global Location Default View",
|
|
1571
|
+
owner=self.user,
|
|
1572
|
+
view=view_name,
|
|
1573
|
+
is_global_default=True,
|
|
1574
|
+
config={
|
|
1575
|
+
"filter_params": {
|
|
1576
|
+
"location_type": ["Floor"],
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
)
|
|
1580
|
+
response = self.client.get(reverse(view_name) + "?location_type=Campus", follow=True)
|
|
1581
|
+
# Assert that the user is not redirected to the global default view
|
|
1582
|
+
# But instead redirected to the filtered view
|
|
1583
|
+
self.assertNotIn(
|
|
1584
|
+
"<strong>Global Location Default View</strong>",
|
|
1585
|
+
extract_page_body(response.content.decode(response.charset)),
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
# Floor type locations (Floor-<number>) should not be visible in the response
|
|
1589
|
+
self.assertNotIn(
|
|
1590
|
+
"Floor-",
|
|
1591
|
+
extract_page_body(response.content.decode(response.charset)),
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1595
|
+
def test_filtered_view_precedes_user_default(self):
|
|
1596
|
+
view_name = "dcim:location_list"
|
|
1597
|
+
# User saved view that will show Floor type locations only.
|
|
1598
|
+
sv = SavedView.objects.create(
|
|
1599
|
+
name="User Location Default View",
|
|
1600
|
+
owner=self.user,
|
|
1601
|
+
view=view_name,
|
|
1602
|
+
config={
|
|
1603
|
+
"filter_params": {
|
|
1604
|
+
"location_type": ["Floor"],
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
)
|
|
1608
|
+
UserSavedViewAssociation.objects.create(user=self.user, saved_view=sv, view_name=sv.view)
|
|
1609
|
+
response = self.client.get(reverse(view_name) + "?location_type=Campus", follow=True)
|
|
1610
|
+
# Assert that the user is not redirected to the user default view
|
|
1611
|
+
# But instead redirected to the filtered view
|
|
1612
|
+
self.assertNotIn(
|
|
1613
|
+
"<strong>User Location Default View</strong>", extract_page_body(response.content.decode(response.charset))
|
|
1614
|
+
)
|
|
1615
|
+
# Floor type locations (Floor-<number>) should not be visible in the response
|
|
1616
|
+
self.assertNotIn(
|
|
1617
|
+
"Floor-",
|
|
1618
|
+
extract_page_body(response.content.decode(response.charset)),
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1546
1621
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
1547
1622
|
def test_is_shared(self):
|
|
1548
1623
|
view_name = "dcim:location_list"
|
|
@@ -1568,6 +1643,119 @@ class SavedViewTest(ModelViewTestCase):
|
|
|
1568
1643
|
self.assertIn(str(sv_shared.pk), response_body, msg=response_body)
|
|
1569
1644
|
self.assertNotIn(str(sv_not_shared.pk), response_body, msg=response_body)
|
|
1570
1645
|
|
|
1646
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1647
|
+
def test_create_saved_views_contain_boolean_filter_params(self):
|
|
1648
|
+
"""
|
|
1649
|
+
Test the entire Save View workflow from creating a Saved View to rendering the View with boolean filter parameters.
|
|
1650
|
+
"""
|
|
1651
|
+
with self.subTest("Create job Saved View with boolean filter parameters"):
|
|
1652
|
+
view_name = "extras:job_list"
|
|
1653
|
+
app_label = view_name.split(":")[0]
|
|
1654
|
+
model_name = view_name.split(":")[1].split("_")[0]
|
|
1655
|
+
self.add_permissions(f"{app_label}.view_{model_name}")
|
|
1656
|
+
create_query_strings = [
|
|
1657
|
+
"&hidden=True",
|
|
1658
|
+
]
|
|
1659
|
+
create_url = self.get_view_url_for_saved_view(action="create")
|
|
1660
|
+
sv_name = "Hidden Jobs"
|
|
1661
|
+
request = {
|
|
1662
|
+
"path": create_url,
|
|
1663
|
+
"data": post_data({"name": sv_name, "view": f"{view_name}", "params": "".join(create_query_strings)}),
|
|
1664
|
+
}
|
|
1665
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
1666
|
+
instance = SavedView.objects.get(name=sv_name)
|
|
1667
|
+
hidden_job = Job.objects.get(name="Example hidden job")
|
|
1668
|
+
hidden_job.description = "I should not show in the UI!"
|
|
1669
|
+
hidden_job.save()
|
|
1670
|
+
self.assertEqual(instance.config["filter_params"]["hidden"], "True")
|
|
1671
|
+
response = self.client.get(reverse(view_name) + "?saved_view=" + str(instance.pk), follow=True)
|
|
1672
|
+
# Assert that Job List View rendered with the boolean filter parameter without error
|
|
1673
|
+
self.assertHttpStatus(response, 200)
|
|
1674
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1675
|
+
self.assertIn(str(instance.pk), response_body, msg=response_body)
|
|
1676
|
+
self.assertBodyContains(response, f"<strong>{sv_name}</strong>", html=True)
|
|
1677
|
+
# This is the description
|
|
1678
|
+
self.assertBodyContains(response, "I should not show in the UI!", html=True)
|
|
1679
|
+
|
|
1680
|
+
with self.subTest("Create device Saved View with boolean filter parameters"):
|
|
1681
|
+
view_name = "dcim:device_list"
|
|
1682
|
+
app_label = view_name.split(":")[0]
|
|
1683
|
+
model_name = view_name.split(":")[1].split("_")[0]
|
|
1684
|
+
self.add_permissions(f"{app_label}.view_{model_name}")
|
|
1685
|
+
create_query_strings = [
|
|
1686
|
+
"&per_page=12",
|
|
1687
|
+
"&has_primary_ip=True",
|
|
1688
|
+
"&sort=name",
|
|
1689
|
+
]
|
|
1690
|
+
create_url = self.get_view_url_for_saved_view(action="create")
|
|
1691
|
+
sv_name = "Devices with primary ips"
|
|
1692
|
+
request = {
|
|
1693
|
+
"path": create_url,
|
|
1694
|
+
"data": post_data({"name": sv_name, "view": f"{view_name}", "params": "".join(create_query_strings)}),
|
|
1695
|
+
}
|
|
1696
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
1697
|
+
instance = SavedView.objects.get(name=sv_name)
|
|
1698
|
+
self.assertEqual(instance.config["pagination_count"], 12)
|
|
1699
|
+
self.assertEqual(instance.config["filter_params"]["has_primary_ip"], "True")
|
|
1700
|
+
self.assertEqual(instance.config["sort_order"], ["name"])
|
|
1701
|
+
response = self.client.get(reverse(view_name) + "?saved_view=" + str(instance.pk), follow=True)
|
|
1702
|
+
# Assert that Job List View rendered with the boolean filter parameter without error
|
|
1703
|
+
self.assertHttpStatus(response, 200)
|
|
1704
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1705
|
+
self.assertIn(str(instance.pk), response_body, msg=response_body)
|
|
1706
|
+
self.assertBodyContains(response, f"<strong>{sv_name}</strong>", html=True)
|
|
1707
|
+
|
|
1708
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1709
|
+
def test_update_saved_view_contain_boolean_filter_params(self):
|
|
1710
|
+
with self.subTest("Update job Saved View with boolean filter parameters"):
|
|
1711
|
+
view_name = "extras:job_list"
|
|
1712
|
+
sv_name = "Non-hidden jobs"
|
|
1713
|
+
instance = SavedView.objects.create(
|
|
1714
|
+
name=sv_name,
|
|
1715
|
+
owner=self.user,
|
|
1716
|
+
view=view_name,
|
|
1717
|
+
)
|
|
1718
|
+
update_query_strings = ["hidden=False"]
|
|
1719
|
+
update_url = self.get_view_url_for_saved_view(instance, "edit") + "?" + "".join(update_query_strings)
|
|
1720
|
+
# Try update the saved view with the same user as the owner of the saved view
|
|
1721
|
+
instance.owner.is_active = True
|
|
1722
|
+
instance.owner.save()
|
|
1723
|
+
self.client.force_login(instance.owner)
|
|
1724
|
+
response = self.client.get(update_url)
|
|
1725
|
+
self.assertHttpStatus(response, 302)
|
|
1726
|
+
instance.refresh_from_db()
|
|
1727
|
+
self.assertEqual(instance.config["filter_params"]["hidden"], "False")
|
|
1728
|
+
response = self.client.get(reverse(view_name) + "?saved_view=" + str(instance.pk), follow=True)
|
|
1729
|
+
# Assert that Job List View rendered with the boolean filter parameter without error
|
|
1730
|
+
self.assertHttpStatus(response, 200)
|
|
1731
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1732
|
+
self.assertNotIn("Example hidden job", response_body, msg=response_body)
|
|
1733
|
+
self.assertBodyContains(response, f"<strong>{sv_name}</strong>", html=True)
|
|
1734
|
+
|
|
1735
|
+
with self.subTest("Update device Saved View with boolean filter parameters"):
|
|
1736
|
+
view_name = "dcim:device_list"
|
|
1737
|
+
sv_name = "Devices with no primary ips"
|
|
1738
|
+
instance = SavedView.objects.create(
|
|
1739
|
+
name=sv_name,
|
|
1740
|
+
owner=self.user,
|
|
1741
|
+
view=view_name,
|
|
1742
|
+
)
|
|
1743
|
+
update_query_strings = ["has_primary_ip=False"]
|
|
1744
|
+
update_url = self.get_view_url_for_saved_view(instance, "edit") + "?" + "".join(update_query_strings)
|
|
1745
|
+
# Try update the saved view with the same user as the owner of the saved view
|
|
1746
|
+
instance.owner.is_active = True
|
|
1747
|
+
instance.owner.save()
|
|
1748
|
+
self.client.force_login(instance.owner)
|
|
1749
|
+
response = self.client.get(update_url)
|
|
1750
|
+
self.assertHttpStatus(response, 302)
|
|
1751
|
+
instance.refresh_from_db()
|
|
1752
|
+
self.assertEqual(instance.config["filter_params"]["has_primary_ip"], "False")
|
|
1753
|
+
response = self.client.get(reverse(view_name) + "?saved_view=" + str(instance.pk), follow=True)
|
|
1754
|
+
# Assert that Job List View rendered with the boolean filter parameter without error
|
|
1755
|
+
self.assertHttpStatus(response, 200)
|
|
1756
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
1757
|
+
self.assertBodyContains(response, f"<strong>{sv_name}</strong>", html=True)
|
|
1758
|
+
|
|
1571
1759
|
|
|
1572
1760
|
# Not a full-fledged PrimaryObjectViewTestCase as there's no BulkEditView for Secrets
|
|
1573
1761
|
class SecretTestCase(
|
nautobot/extras/utils.py
CHANGED
|
@@ -22,9 +22,12 @@ import redis.exceptions
|
|
|
22
22
|
|
|
23
23
|
from nautobot.core.choices import ColorChoices
|
|
24
24
|
from nautobot.core.constants import CHARFIELD_MAX_LENGTH
|
|
25
|
+
from nautobot.core.exceptions import FilterSetFieldNotFound
|
|
25
26
|
from nautobot.core.models.managers import TagsManager
|
|
26
27
|
from nautobot.core.models.utils import find_models_with_matching_fields
|
|
27
28
|
from nautobot.core.utils.data import is_uuid
|
|
29
|
+
from nautobot.core.utils.lookup import get_filterset_for_model, get_model_for_view_name
|
|
30
|
+
from nautobot.core.utils.requests import is_single_choice_field
|
|
28
31
|
from nautobot.extras.choices import DynamicGroupTypeChoices, JobQueueTypeChoices, ObjectChangeActionChoices
|
|
29
32
|
from nautobot.extras.constants import (
|
|
30
33
|
CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL,
|
|
@@ -880,3 +883,30 @@ def bulk_delete_with_bulk_change_logging(qs, batch_size=1000):
|
|
|
880
883
|
finally:
|
|
881
884
|
change_context.defer_object_changes = False
|
|
882
885
|
change_context.reset_deferred_object_changes()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def fixup_filterset_query_params(param_dict, view_name, non_filter_params):
|
|
889
|
+
"""
|
|
890
|
+
Called before saving query filter parameters to a SavedView's config. This function will format
|
|
891
|
+
single value query parameters to be saved as a single values instead of lists of singles values.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
param_dict (dict): key-value pairs of query parameters.
|
|
895
|
+
view_name (str): The name of the view that the saved view is associated with. "dcim:location_list" for example.
|
|
896
|
+
non_filter_params (list): List of non-query parameters that should not be formatted.
|
|
897
|
+
"""
|
|
898
|
+
model = get_model_for_view_name(view_name)
|
|
899
|
+
try:
|
|
900
|
+
filterset_class = get_filterset_for_model(model)
|
|
901
|
+
except TypeError:
|
|
902
|
+
return param_dict
|
|
903
|
+
|
|
904
|
+
filterset = filterset_class()
|
|
905
|
+
|
|
906
|
+
for filter_field, value in param_dict.items():
|
|
907
|
+
try:
|
|
908
|
+
if filter_field not in non_filter_params and is_single_choice_field(filterset, filter_field):
|
|
909
|
+
param_dict[filter_field] = value[0]
|
|
910
|
+
except FilterSetFieldNotFound:
|
|
911
|
+
pass
|
|
912
|
+
return param_dict
|
nautobot/extras/views.py
CHANGED
|
@@ -26,6 +26,7 @@ from rest_framework.permissions import IsAuthenticated
|
|
|
26
26
|
|
|
27
27
|
from nautobot.core.constants import PAGINATE_COUNT_DEFAULT
|
|
28
28
|
from nautobot.core.events import publish_event
|
|
29
|
+
from nautobot.core.exceptions import FilterSetFieldNotFound
|
|
29
30
|
from nautobot.core.forms import restrict_form_fields
|
|
30
31
|
from nautobot.core.models.querysets import count_related
|
|
31
32
|
from nautobot.core.models.utils import pretty_print_query, serialize_object_v2
|
|
@@ -36,12 +37,13 @@ from nautobot.core.ui.object_detail import ObjectDetailContent, ObjectFieldsPane
|
|
|
36
37
|
from nautobot.core.utils.config import get_settings_or_config
|
|
37
38
|
from nautobot.core.utils.lookup import (
|
|
38
39
|
get_filterset_for_model,
|
|
40
|
+
get_model_for_view_name,
|
|
39
41
|
get_route_for_model,
|
|
40
42
|
get_table_class_string_from_view_name,
|
|
41
43
|
get_table_for_model,
|
|
42
44
|
)
|
|
43
45
|
from nautobot.core.utils.permissions import get_permission_for_model
|
|
44
|
-
from nautobot.core.utils.requests import normalize_querydict
|
|
46
|
+
from nautobot.core.utils.requests import is_single_choice_field, normalize_querydict
|
|
45
47
|
from nautobot.core.views import generic, viewsets
|
|
46
48
|
from nautobot.core.views.mixins import (
|
|
47
49
|
GetReturnURLMixin,
|
|
@@ -69,7 +71,7 @@ from nautobot.dcim.tables import (
|
|
|
69
71
|
VirtualDeviceContextTable,
|
|
70
72
|
)
|
|
71
73
|
from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
|
|
72
|
-
from nautobot.extras.utils import get_base_template, get_job_queue, get_worker_count
|
|
74
|
+
from nautobot.extras.utils import fixup_filterset_query_params, get_base_template, get_job_queue, get_worker_count
|
|
73
75
|
from nautobot.ipam.models import IPAddress, Prefix, VLAN
|
|
74
76
|
from nautobot.ipam.tables import IPAddressTable, PrefixTable, VLANTable
|
|
75
77
|
from nautobot.virtualization.models import VirtualMachine, VMInterface
|
|
@@ -720,8 +722,13 @@ class DynamicGroupView(generic.ObjectView):
|
|
|
720
722
|
|
|
721
723
|
if table_class is not None:
|
|
722
724
|
# Members table (for display on Members nav tab)
|
|
725
|
+
if hasattr(members, "without_tree_fields"):
|
|
726
|
+
members = members.without_tree_fields()
|
|
723
727
|
members_table = table_class(
|
|
724
|
-
members.restrict(request.user, "view"),
|
|
728
|
+
members.restrict(request.user, "view"),
|
|
729
|
+
orderable=False,
|
|
730
|
+
exclude=["dynamic_group_count"],
|
|
731
|
+
hide_hierarchy_ui=True,
|
|
725
732
|
)
|
|
726
733
|
paginate = {
|
|
727
734
|
"paginator_class": EnhancedPaginator,
|
|
@@ -1265,9 +1272,10 @@ class JobListView(generic.ObjectListView):
|
|
|
1265
1272
|
def alter_queryset(self, request):
|
|
1266
1273
|
queryset = super().alter_queryset(request)
|
|
1267
1274
|
# Default to hiding "hidden" and non-installed jobs
|
|
1268
|
-
|
|
1275
|
+
filter_params = self.get_filter_params(request)
|
|
1276
|
+
if "hidden" not in filter_params:
|
|
1269
1277
|
queryset = queryset.filter(hidden=False)
|
|
1270
|
-
if "installed" not in
|
|
1278
|
+
if "installed" not in filter_params:
|
|
1271
1279
|
queryset = queryset.filter(installed=True)
|
|
1272
1280
|
return queryset
|
|
1273
1281
|
|
|
@@ -1806,15 +1814,23 @@ class SavedViewUIViewSet(
|
|
|
1806
1814
|
if sort_order:
|
|
1807
1815
|
sv.config["sort_order"] = sort_order
|
|
1808
1816
|
|
|
1817
|
+
model = get_model_for_view_name(sv.view)
|
|
1818
|
+
filterset_class = get_filterset_for_model(model)
|
|
1819
|
+
filterset = filterset_class()
|
|
1809
1820
|
filter_params = {}
|
|
1810
1821
|
for key in request.GET:
|
|
1811
1822
|
if key in self.non_filter_params:
|
|
1812
1823
|
continue
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1824
|
+
try:
|
|
1825
|
+
if is_single_choice_field(filterset, key):
|
|
1826
|
+
filter_params[key] = request.GET.getlist(key)[0]
|
|
1827
|
+
except FilterSetFieldNotFound:
|
|
1828
|
+
continue
|
|
1829
|
+
try:
|
|
1830
|
+
if not is_single_choice_field(filterset, key):
|
|
1831
|
+
filter_params[key] = request.GET.getlist(key)
|
|
1832
|
+
except FilterSetFieldNotFound:
|
|
1833
|
+
continue
|
|
1818
1834
|
|
|
1819
1835
|
if filter_params:
|
|
1820
1836
|
sv.config["filter_params"] = filter_params
|
|
@@ -1839,14 +1855,14 @@ class SavedViewUIViewSet(
|
|
|
1839
1855
|
and the name of the new SavedView from request.POST to create a new SavedView.
|
|
1840
1856
|
"""
|
|
1841
1857
|
name = request.POST.get("name")
|
|
1858
|
+
view_name = request.POST.get("view")
|
|
1842
1859
|
is_shared = request.POST.get("is_shared", False)
|
|
1843
1860
|
if is_shared:
|
|
1844
1861
|
is_shared = True
|
|
1845
1862
|
params = request.POST.get("params", "")
|
|
1863
|
+
param_dict = fixup_filterset_query_params(parse_qs(params), view_name, self.non_filter_params)
|
|
1846
1864
|
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
single_value_params = ["saved_view", "table_changes_pending", "all_filters_removed", "q", "per_page"]
|
|
1865
|
+
single_value_params = ["saved_view", "table_changes_pending", "all_filters_removed", "per_page"]
|
|
1850
1866
|
for key in param_dict.keys():
|
|
1851
1867
|
if key in single_value_params:
|
|
1852
1868
|
param_dict[key] = param_dict[key][0]
|
|
@@ -1855,7 +1871,6 @@ class SavedViewUIViewSet(
|
|
|
1855
1871
|
derived_instance = None
|
|
1856
1872
|
if derived_view_pk:
|
|
1857
1873
|
derived_instance = self.get_queryset().get(pk=derived_view_pk)
|
|
1858
|
-
view_name = request.POST.get("view")
|
|
1859
1874
|
try:
|
|
1860
1875
|
reverse(view_name)
|
|
1861
1876
|
except NoReverseMatch:
|
nautobot/ipam/api/views.py
CHANGED
|
@@ -13,7 +13,7 @@ from nautobot.core.constants import MAX_PAGE_SIZE_DEFAULT, PAGINATE_COUNT_DEFAUL
|
|
|
13
13
|
from nautobot.core.models.querysets import count_related
|
|
14
14
|
from nautobot.core.utils.config import get_settings_or_config
|
|
15
15
|
from nautobot.dcim.models import Location
|
|
16
|
-
from nautobot.extras.api.views import NautobotModelViewSet
|
|
16
|
+
from nautobot.extras.api.views import ModelViewSet, NautobotModelViewSet
|
|
17
17
|
from nautobot.ipam import filters
|
|
18
18
|
from nautobot.ipam.api import serializers
|
|
19
19
|
from nautobot.ipam.models import (
|
|
@@ -323,7 +323,7 @@ class PrefixViewSet(NautobotModelViewSet):
|
|
|
323
323
|
return Response(serializer.data)
|
|
324
324
|
|
|
325
325
|
|
|
326
|
-
class PrefixLocationAssignmentViewSet(
|
|
326
|
+
class PrefixLocationAssignmentViewSet(ModelViewSet):
|
|
327
327
|
queryset = PrefixLocationAssignment.objects.all()
|
|
328
328
|
serializer_class = serializers.PrefixLocationAssignmentSerializer
|
|
329
329
|
filterset_class = filters.PrefixLocationAssignmentFilterSet
|
|
@@ -581,7 +581,7 @@ class VLANViewSet(NautobotModelViewSet):
|
|
|
581
581
|
raise self.LocationIncompatibleLegacyBehavior from e
|
|
582
582
|
|
|
583
583
|
|
|
584
|
-
class VLANLocationAssignmentViewSet(
|
|
584
|
+
class VLANLocationAssignmentViewSet(ModelViewSet):
|
|
585
585
|
queryset = VLANLocationAssignment.objects.all()
|
|
586
586
|
serializer_class = serializers.VLANLocationAssignmentSerializer
|
|
587
587
|
filterset_class = filters.VLANLocationAssignmentFilterSet
|
nautobot/ipam/forms.py
CHANGED
|
@@ -674,13 +674,9 @@ class IPAddressFilterForm(NautobotFilterForm, TenancyFilterForm, StatusModelFilt
|
|
|
674
674
|
"has_nat_inside",
|
|
675
675
|
]
|
|
676
676
|
q = forms.CharField(required=False, label="Search")
|
|
677
|
-
parent =
|
|
677
|
+
parent = DynamicModelMultipleChoiceField(
|
|
678
|
+
queryset=Prefix.objects.all(),
|
|
678
679
|
required=False,
|
|
679
|
-
widget=forms.TextInput(
|
|
680
|
-
attrs={
|
|
681
|
-
"placeholder": "Prefix",
|
|
682
|
-
}
|
|
683
|
-
),
|
|
684
680
|
label="Parent Prefix",
|
|
685
681
|
)
|
|
686
682
|
ip_version = forms.ChoiceField(
|