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.

Files changed (132) hide show
  1. nautobot/circuits/templates/circuits/inc/circuit_termination.html +1 -1
  2. nautobot/circuits/tests/integration/test_circuit.py +135 -0
  3. nautobot/circuits/views.py +4 -1
  4. nautobot/cloud/api/views.py +3 -3
  5. nautobot/core/constants.py +0 -1
  6. nautobot/core/forms/__init__.py +2 -0
  7. nautobot/core/forms/forms.py +2 -1
  8. nautobot/core/forms/widgets.py +8 -0
  9. nautobot/core/management/commands/generate_performance_test_endpoints.py +268 -0
  10. nautobot/core/templates/generic/object_bulk_delete.html +1 -1
  11. nautobot/core/templates/generic/object_bulk_edit.html +1 -1
  12. nautobot/core/templates/generic/object_bulk_import.html +1 -1
  13. nautobot/core/templates/generic/object_create.html +5 -0
  14. nautobot/core/templates/generic/object_delete.html +1 -1
  15. nautobot/core/templates/generic/object_detail.html +1 -1
  16. nautobot/core/templates/generic/object_edit.html +1 -1
  17. nautobot/core/templates/inc/javascript.html +2 -0
  18. nautobot/core/templates/widgets/clearable_file.html +5 -0
  19. nautobot/core/templatetags/helpers.py +3 -3
  20. nautobot/core/testing/integration.py +37 -7
  21. nautobot/core/tests/test_commands.py +31 -0
  22. nautobot/core/tests/test_utils.py +17 -2
  23. nautobot/core/utils/lookup.py +12 -1
  24. nautobot/core/views/generic.py +9 -1
  25. nautobot/core/views/mixins.py +9 -1
  26. nautobot/dcim/api/views.py +11 -10
  27. nautobot/dcim/forms.py +3 -6
  28. nautobot/dcim/models/devices.py +1 -2
  29. nautobot/dcim/templates/dcim/cable_trace.html +4 -4
  30. nautobot/dcim/templates/dcim/consoleport.html +14 -4
  31. nautobot/dcim/templates/dcim/consoleserverport.html +14 -4
  32. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +3 -3
  33. nautobot/dcim/templates/dcim/frontport.html +7 -2
  34. nautobot/dcim/templates/dcim/interface.html +9 -4
  35. nautobot/dcim/templates/dcim/powerfeed.html +8 -3
  36. nautobot/dcim/templates/dcim/poweroutlet.html +14 -4
  37. nautobot/dcim/templates/dcim/powerport.html +14 -4
  38. nautobot/dcim/templates/dcim/rearport.html +7 -2
  39. nautobot/dcim/tests/integration/test_fileinputpicker.py +87 -0
  40. nautobot/dcim/tests/test_models.py +1 -1
  41. nautobot/extras/api/views.py +2 -2
  42. nautobot/extras/forms/forms.py +4 -0
  43. nautobot/extras/jobs.py +8 -1
  44. nautobot/extras/templates/extras/job.html +1 -0
  45. nautobot/extras/tests/test_dynamicgroups.py +14 -0
  46. nautobot/extras/tests/test_views.py +197 -9
  47. nautobot/extras/utils.py +30 -0
  48. nautobot/extras/views.py +29 -14
  49. nautobot/ipam/api/views.py +3 -3
  50. nautobot/ipam/forms.py +2 -6
  51. nautobot/project-static/bootstrap-filestyle-1.2.3/bootstrap-filestyle.min.js +11 -0
  52. nautobot/project-static/docs/apps/index.html +1 -1
  53. nautobot/project-static/docs/apps/nautobot-apps.html +1 -1
  54. nautobot/project-static/docs/development/apps/api/models/graphql.html +9 -9
  55. nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +2 -2
  56. nautobot/project-static/docs/development/apps/api/setup.html +1 -1
  57. nautobot/project-static/docs/development/apps/migration/code-updates.html +6 -5
  58. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +2 -2
  59. nautobot/project-static/docs/development/apps/migration/from-v1.html +3 -3
  60. nautobot/project-static/docs/development/core/best-practices.html +1 -1
  61. nautobot/project-static/docs/development/core/bootstrap-ui.html +1 -1
  62. nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +7 -7
  63. nautobot/project-static/docs/development/core/getting-started.html +2 -2
  64. nautobot/project-static/docs/development/core/index.html +1 -1
  65. nautobot/project-static/docs/development/core/minikube-dev-environment-for-k8s-jobs.html +3 -3
  66. nautobot/project-static/docs/development/core/model-checklist.html +1 -1
  67. nautobot/project-static/docs/development/core/navigation-menu.html +1 -1
  68. nautobot/project-static/docs/development/core/release-checklist.html +1 -1
  69. nautobot/project-static/docs/development/core/settings.html +1 -1
  70. nautobot/project-static/docs/development/core/style-guide.html +4 -4
  71. nautobot/project-static/docs/development/jobs/index.html +8 -1
  72. nautobot/project-static/docs/development/jobs/migration/from-v1.html +3 -2
  73. nautobot/project-static/docs/index.html +3 -2
  74. nautobot/project-static/docs/objects.inv +0 -0
  75. nautobot/project-static/docs/overview/application_stack.html +2 -2
  76. nautobot/project-static/docs/release-notes/version-1.0.html +2 -2
  77. nautobot/project-static/docs/release-notes/version-1.1.html +2 -2
  78. nautobot/project-static/docs/release-notes/version-1.2.html +3 -3
  79. nautobot/project-static/docs/release-notes/version-1.3.html +1 -1
  80. nautobot/project-static/docs/release-notes/version-1.4.html +17 -17
  81. nautobot/project-static/docs/release-notes/version-1.5.html +8 -8
  82. nautobot/project-static/docs/release-notes/version-1.6.html +4 -4
  83. nautobot/project-static/docs/release-notes/version-2.0.html +10 -10
  84. nautobot/project-static/docs/release-notes/version-2.1.html +7 -7
  85. nautobot/project-static/docs/release-notes/version-2.2.html +1 -1
  86. nautobot/project-static/docs/release-notes/version-2.3.html +4 -4
  87. nautobot/project-static/docs/release-notes/version-2.4.html +188 -0
  88. nautobot/project-static/docs/search/search_index.json +1 -1
  89. nautobot/project-static/docs/sitemap.xml +290 -290
  90. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  91. nautobot/project-static/docs/user-guide/administration/configuration/authentication/ldap.html +3 -3
  92. nautobot/project-static/docs/user-guide/administration/configuration/authentication/sso.html +4 -4
  93. nautobot/project-static/docs/user-guide/administration/configuration/redis.html +1 -1
  94. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +3 -3
  95. nautobot/project-static/docs/user-guide/administration/guides/celery-queues.html +5 -5
  96. nautobot/project-static/docs/user-guide/administration/guides/docker.html +3 -3
  97. nautobot/project-static/docs/user-guide/administration/guides/health-checks.html +1 -1
  98. nautobot/project-static/docs/user-guide/administration/guides/prometheus-metrics.html +4 -4
  99. nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +15 -15
  100. nautobot/project-static/docs/user-guide/administration/guides/s3-django-storage.html +2 -2
  101. nautobot/project-static/docs/user-guide/administration/installation/app-install.html +1 -1
  102. nautobot/project-static/docs/user-guide/administration/installation/install_system.html +1 -1
  103. nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +6 -6
  104. nautobot/project-static/docs/user-guide/administration/installation/services.html +1 -1
  105. nautobot/project-static/docs/user-guide/administration/security/index.html +1 -1
  106. nautobot/project-static/docs/user-guide/administration/security/notices.html +1 -0
  107. nautobot/project-static/docs/user-guide/administration/tools/nautobot-shell.html +1 -1
  108. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +11 -8
  109. nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +12 -12
  110. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
  111. nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +1 -1
  112. nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +3 -3
  113. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobqueue.html +2 -2
  114. nautobot/project-static/docs/user-guide/platform-functionality/jobs/kubernetes-job-support.html +6 -6
  115. nautobot/project-static/js/dropdown.js +28 -0
  116. nautobot/tenancy/forms.py +9 -0
  117. nautobot/tenancy/templates/tenancy/tenant_create.html +21 -0
  118. nautobot/tenancy/templates/tenancy/tenant_edit.html +2 -21
  119. nautobot/tenancy/templates/tenancy/tenantgroup.html +2 -44
  120. nautobot/tenancy/templates/tenancy/tenantgroup_retrieve.html +1 -0
  121. nautobot/tenancy/tests/test_views.py +5 -1
  122. nautobot/tenancy/urls.py +7 -79
  123. nautobot/tenancy/views.py +51 -80
  124. nautobot/wireless/api/serializers.py +6 -1
  125. nautobot/wireless/api/views.py +3 -3
  126. nautobot/wireless/tests/test_api.py +5 -0
  127. {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/METADATA +8 -8
  128. {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/RECORD +132 -123
  129. {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/LICENSE.txt +0 -0
  130. {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/NOTICE +0 -0
  131. {nautobot-2.4.2.dist-info → nautobot-2.4.3.dist-info}/WHEEL +0 -0
  132. {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'Devices may not associate to locations of type "{location_type}".',
2793
+ f'Controllers may not associate to locations of type "{location_type}".',
2794
2794
  )
2795
2795
 
2796
2796
 
@@ -836,7 +836,7 @@ class JobQueueViewSet(NautobotModelViewSet):
836
836
  filterset_class = filters.JobQueueFilterSet
837
837
 
838
838
 
839
- class JobQueueAssignmentViewSet(NautobotModelViewSet):
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(NautobotModelViewSet):
1072
+ class ObjectMetadataViewSet(ModelViewSet):
1073
1073
  queryset = ObjectMetadata.objects.all()
1074
1074
  serializer_class = serializers.ObjectMetadataSerializer
1075
1075
  filterset_class = filters.ObjectMetadataFilterSet
@@ -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 = DatabaseFileField
1055
+ form_field = BootstrapStyleFileField
1049
1056
 
1050
1057
 
1051
1058
  class IPAddressVar(ScriptVariable):
@@ -192,4 +192,5 @@
192
192
  toggleExecutionType();
193
193
  });
194
194
  </script>
195
+ {{ job_form.media }}
195
196
  {% endblock %}
@@ -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
- instance = self._get_queryset().first()
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
- view = saved_view.view
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
- else:
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
- instance = self._get_queryset().first()
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"), orderable=False, exclude=["dynamic_group_count"]
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
- if "hidden" not in request.GET:
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 request.GET:
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
- # TODO: this is fragile, other single-value filters will also be unhappy if given a list
1814
- if key == "q":
1815
- filter_params[key] = request.GET.get(key)
1816
- else:
1817
- filter_params[key] = request.GET.getlist(key)
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
- param_dict = parse_qs(params)
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:
@@ -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(NautobotModelViewSet):
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(NautobotModelViewSet):
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 = forms.CharField(
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(