nautobot 2.4.0__py3-none-any.whl → 2.4.1__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 (40) hide show
  1. nautobot/core/celery/schedulers.py +1 -1
  2. nautobot/core/filters.py +48 -21
  3. nautobot/core/jobs/bulk_actions.py +56 -19
  4. nautobot/core/models/__init__.py +2 -0
  5. nautobot/core/tables.py +5 -1
  6. nautobot/core/testing/filters.py +25 -13
  7. nautobot/core/testing/integration.py +86 -4
  8. nautobot/core/tests/test_filters.py +209 -246
  9. nautobot/core/tests/test_jobs.py +250 -93
  10. nautobot/core/tests/test_models.py +9 -0
  11. nautobot/core/views/generic.py +80 -48
  12. nautobot/core/views/mixins.py +34 -6
  13. nautobot/dcim/api/serializers.py +2 -2
  14. nautobot/dcim/constants.py +6 -13
  15. nautobot/dcim/factory.py +6 -1
  16. nautobot/dcim/tests/integration/test_device_bulk_delete.py +189 -0
  17. nautobot/dcim/tests/integration/test_device_bulk_edit.py +181 -0
  18. nautobot/dcim/tests/test_api.py +0 -2
  19. nautobot/dcim/tests/test_models.py +42 -28
  20. nautobot/extras/forms/mixins.py +1 -1
  21. nautobot/extras/jobs.py +15 -6
  22. nautobot/extras/templatetags/job_buttons.py +4 -4
  23. nautobot/extras/tests/test_forms.py +13 -0
  24. nautobot/extras/tests/test_jobs.py +18 -13
  25. nautobot/extras/tests/test_models.py +6 -0
  26. nautobot/extras/tests/test_views.py +4 -3
  27. nautobot/ipam/tests/test_api.py +20 -0
  28. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +36 -1
  29. nautobot/project-static/docs/objects.inv +0 -0
  30. nautobot/project-static/docs/release-notes/version-2.4.html +108 -0
  31. nautobot/project-static/docs/search/search_index.json +1 -1
  32. nautobot/project-static/docs/sitemap.xml +288 -288
  33. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  34. nautobot/wireless/tests/test_views.py +22 -1
  35. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/METADATA +2 -2
  36. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/RECORD +40 -38
  37. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/LICENSE.txt +0 -0
  38. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/NOTICE +0 -0
  39. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/WHEEL +0 -0
  40. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,189 @@
1
+ from django.urls import reverse
2
+
3
+ from nautobot.core.testing.integration import BulkOperationsMixin, ObjectsListMixin, SeleniumTestCase
4
+
5
+
6
+ class BulkDeleteDeviceTestCase(SeleniumTestCase, ObjectsListMixin, BulkOperationsMixin):
7
+ """
8
+ Test devices bulk delete.
9
+ """
10
+
11
+ def setUp(self):
12
+ super().setUp()
13
+
14
+ self.user.is_superuser = True
15
+ self.user.save()
16
+ self.login(self.user.username, self.password)
17
+
18
+ # Manufacturer
19
+ self.click_navbar_entry("Devices", "Manufacturers")
20
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_list"))
21
+ self.click_list_view_add_button()
22
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_add"))
23
+ self.browser.fill("name", "Test Manufacturer 1")
24
+ self.click_edit_form_create_button()
25
+
26
+ # Device Type
27
+ self.click_navbar_entry("Devices", "Device Types")
28
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_list"))
29
+ self.click_list_view_add_button()
30
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_add"))
31
+ self.fill_select2_field("manufacturer", "Test Manufacturer 1")
32
+ self.browser.fill("model", "Test Device Type 1")
33
+ self.click_edit_form_create_button()
34
+
35
+ # LocationType
36
+ self.click_navbar_entry("Organization", "Location Types")
37
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_list"))
38
+ self.click_list_view_add_button()
39
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_add"))
40
+ self.fill_select2_multiselect_field("content_types", "dcim | device")
41
+ self.browser.fill("name", "Test Location Type 1")
42
+ self.click_edit_form_create_button()
43
+
44
+ # Location 1
45
+ self.click_navbar_entry("Organization", "Locations")
46
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_list"))
47
+ self.click_list_view_add_button()
48
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_add"))
49
+ self.fill_select2_field("location_type", "Test Location Type 1")
50
+ self.fill_select2_field("status", "") # pick first status
51
+ self.browser.fill("name", "Test Location 1")
52
+ self.click_edit_form_create_button()
53
+
54
+ # Location 2
55
+ self.click_navbar_entry("Organization", "Locations")
56
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_list"))
57
+ self.click_list_view_add_button()
58
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_add"))
59
+ self.fill_select2_field("location_type", "Test Location Type 1")
60
+ self.fill_select2_field("status", "") # pick first status
61
+ self.browser.fill("name", "Test Location 2")
62
+ self.click_edit_form_create_button()
63
+
64
+ # Role
65
+ self.click_navbar_entry("Organization", "Roles")
66
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_list"))
67
+ self.click_list_view_add_button()
68
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_add"))
69
+ self.browser.fill("name", "Test Role 1")
70
+ self.fill_select2_multiselect_field("content_types", "dcim | device")
71
+ self.click_edit_form_create_button()
72
+
73
+ def _create_device(self, name, location="Test Location 1"):
74
+ self.click_navbar_entry("Devices", "Devices")
75
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
76
+ self.click_list_view_add_button()
77
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_add"))
78
+ self.browser.fill("name", name)
79
+ self.fill_select2_field("role", "Test Role 1")
80
+ self.fill_select2_field("device_type", "Test Device Type 1")
81
+ self.fill_select2_field("location", location)
82
+ self.fill_select2_field("status", "") # pick first status
83
+ self.click_edit_form_create_button()
84
+
85
+ def tearDown(self):
86
+ self.logout()
87
+ super().tearDown()
88
+
89
+ def _go_to_devices_list(self):
90
+ self.click_navbar_entry("Devices", "Devices")
91
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
92
+
93
+ def test_bulk_delete_require_selection(self):
94
+ self._go_to_devices_list()
95
+
96
+ # Click "delete selected" without selecting anything
97
+ self.click_bulk_delete()
98
+
99
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
100
+ self.assertTrue(self.browser.is_text_present("No devices were selected for deletion.", wait_time=5))
101
+
102
+ def test_bulk_delete_all_devices(self):
103
+ # Create device for test
104
+ self._create_device("Test Device Integration Test 1")
105
+ self._create_device("Test Device Integration Test 2")
106
+ self._go_to_devices_list()
107
+
108
+ # Select all devices and delete them
109
+ self.select_all_items()
110
+ self.click_bulk_delete()
111
+ self.confirm_bulk_delete_operation()
112
+
113
+ # Verify job output
114
+ self.assertIsBulkDeleteJob()
115
+ job_status = self.wait_for_job_result()
116
+
117
+ self.assertEqual(job_status, "Completed")
118
+
119
+ self._go_to_devices_list()
120
+ self.assertEqual(self.objects_list_visible_items, 0)
121
+
122
+ def test_bulk_delete_one_device(self):
123
+ # Create device for test
124
+ self._create_device("Test Device Integration Test 1")
125
+ self._create_device("Test Device Integration Test 2")
126
+ self._go_to_devices_list()
127
+
128
+ # Select one device and delete it
129
+ self.select_one_item()
130
+ self.click_bulk_delete()
131
+ self.browser.find_by_xpath('//button[@name="_confirm" and @type="submit"]').click()
132
+
133
+ # Verify job output
134
+ self.assertIsBulkDeleteJob()
135
+ job_status = self.wait_for_job_result()
136
+
137
+ self.assertEqual(job_status, "Completed")
138
+
139
+ self._go_to_devices_list()
140
+ self.assertEqual(self.objects_list_visible_items, 1)
141
+
142
+ def test_bulk_delete_all_filtered_devices(self):
143
+ # Create device for test
144
+ self._create_device("Test Device Integration Test 1")
145
+ self._create_device("Test Device Integration Test 2")
146
+ self._create_device("Test Device Integration Test 3", location="Test Location 2")
147
+ self._go_to_devices_list()
148
+
149
+ # Filter devices
150
+ self.apply_filter("location", "Test Location 2")
151
+
152
+ # Select all devices and delete them
153
+ self.select_all_items()
154
+ self.click_bulk_delete()
155
+ self.confirm_bulk_delete_operation()
156
+
157
+ # Verify job output
158
+ self.assertIsBulkDeleteJob()
159
+ job_status = self.wait_for_job_result()
160
+
161
+ self.assertEqual(job_status, "Completed")
162
+
163
+ self._go_to_devices_list()
164
+ self.assertEqual(self.objects_list_visible_items, 2)
165
+
166
+ def test_bulk_delete_one_filtered_devices(self):
167
+ # Create device for test
168
+ self._create_device("Test Device Integration Test 1")
169
+ self._create_device("Test Device Integration Test 2")
170
+ self._create_device("Test Device Integration Test 3", location="Test Location 2")
171
+ self._create_device("Test Device Integration Test 4", location="Test Location 2")
172
+ self._go_to_devices_list()
173
+
174
+ # Filter devices
175
+ self.apply_filter("location", "Test Location 2")
176
+
177
+ # Select one device and delete it
178
+ self.select_one_item()
179
+ self.click_bulk_delete()
180
+ self.confirm_bulk_delete_operation()
181
+
182
+ # Verify job output
183
+ self.assertIsBulkDeleteJob()
184
+ job_status = self.wait_for_job_result()
185
+
186
+ self.assertEqual(job_status, "Completed")
187
+
188
+ self._go_to_devices_list()
189
+ self.assertEqual(self.objects_list_visible_items, 3)
@@ -0,0 +1,181 @@
1
+ from django.urls import reverse
2
+
3
+ from nautobot.core.testing.integration import BulkOperationsMixin, ObjectsListMixin, SeleniumTestCase
4
+
5
+
6
+ class BulkEditDeviceTestCase(SeleniumTestCase, ObjectsListMixin, BulkOperationsMixin):
7
+ """
8
+ Test devices bulk delete.
9
+ """
10
+
11
+ def setUp(self):
12
+ super().setUp()
13
+
14
+ self.user.is_superuser = True
15
+ self.user.save()
16
+ self.login(self.user.username, self.password)
17
+
18
+ # Manufacturer
19
+ self.click_navbar_entry("Devices", "Manufacturers")
20
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_list"))
21
+ self.click_list_view_add_button()
22
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_add"))
23
+ self.browser.fill("name", "Test Manufacturer 1")
24
+ self.click_edit_form_create_button()
25
+
26
+ # Device Type
27
+ self.click_navbar_entry("Devices", "Device Types")
28
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_list"))
29
+ self.click_list_view_add_button()
30
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_add"))
31
+ self.fill_select2_field("manufacturer", "Test Manufacturer 1")
32
+ self.browser.fill("model", "Test Device Type 1")
33
+ self.click_edit_form_create_button()
34
+
35
+ # LocationType
36
+ self.click_navbar_entry("Organization", "Location Types")
37
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_list"))
38
+ self.click_list_view_add_button()
39
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_add"))
40
+ self.fill_select2_multiselect_field("content_types", "dcim | device")
41
+ self.browser.fill("name", "Test Location Type 1")
42
+ self.click_edit_form_create_button()
43
+
44
+ # Location 1
45
+ self.click_navbar_entry("Organization", "Locations")
46
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_list"))
47
+ self.click_list_view_add_button()
48
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_add"))
49
+ self.fill_select2_field("location_type", "Test Location Type 1")
50
+ self.fill_select2_field("status", "") # pick first status
51
+ self.browser.fill("name", "Test Location 1")
52
+ self.click_edit_form_create_button()
53
+
54
+ # Location 2
55
+ self.click_navbar_entry("Organization", "Locations")
56
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_list"))
57
+ self.click_list_view_add_button()
58
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_add"))
59
+ self.fill_select2_field("location_type", "Test Location Type 1")
60
+ self.fill_select2_field("status", "") # pick first status
61
+ self.browser.fill("name", "Test Location 2")
62
+ self.click_edit_form_create_button()
63
+
64
+ # Role
65
+ self.click_navbar_entry("Organization", "Roles")
66
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_list"))
67
+ self.click_list_view_add_button()
68
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_add"))
69
+ self.browser.fill("name", "Test Role 1")
70
+ self.fill_select2_multiselect_field("content_types", "dcim | device")
71
+ self.click_edit_form_create_button()
72
+
73
+ def _create_device(self, name, location="Test Location 1"):
74
+ self.click_navbar_entry("Devices", "Devices")
75
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
76
+ self.click_list_view_add_button()
77
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_add"))
78
+ self.browser.fill("name", name)
79
+ self.fill_select2_field("role", "Test Role 1")
80
+ self.fill_select2_field("device_type", "Test Device Type 1")
81
+ self.fill_select2_field("location", location)
82
+ self.fill_select2_field("status", "") # pick first status
83
+ self.click_edit_form_create_button()
84
+
85
+ def tearDown(self):
86
+ self.logout()
87
+ super().tearDown()
88
+
89
+ def _go_to_devices_list(self):
90
+ self.click_navbar_entry("Devices", "Devices")
91
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
92
+
93
+ def test_bulk_edit_require_selection(self):
94
+ self._go_to_devices_list()
95
+
96
+ # Click "edit selected" without selecting anything
97
+ self.click_bulk_edit()
98
+
99
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
100
+ self.assertTrue(self.browser.is_text_present("No devices were selected.", wait_time=5))
101
+
102
+ def test_bulk_edit_all_devices(self):
103
+ # Create device for test
104
+ self._create_device("Test Device Integration Test 1")
105
+ self._create_device("Test Device Integration Test 2")
106
+ self._go_to_devices_list()
107
+
108
+ # Select all devices and edit them
109
+ self.select_all_items()
110
+ self.click_bulk_edit()
111
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_bulk_edit"))
112
+
113
+ # Submit bulk edit form without any changes
114
+ self.submit_bulk_edit_operation()
115
+
116
+ # Verify job output
117
+ self.assertIsBulkEditJob()
118
+ self.assertJobStatusIsCompleted()
119
+
120
+ def test_bulk_edit_one_device(self):
121
+ # Create device for test
122
+ self._create_device("Test Device Integration Test 1")
123
+ self._create_device("Test Device Integration Test 2")
124
+ self._go_to_devices_list()
125
+
126
+ # Select one device and edit it
127
+ self.select_one_item()
128
+ self.click_bulk_edit()
129
+ self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_bulk_edit"))
130
+
131
+ # Submit bulk edit form without any changes
132
+ self.submit_bulk_edit_operation()
133
+
134
+ # Verify job output
135
+ self.assertIsBulkEditJob()
136
+ self.assertJobStatusIsCompleted()
137
+
138
+ def test_bulk_edit_all_filtered_devices(self):
139
+ # Create device for test
140
+ self._create_device("Test Device Integration Test 1")
141
+ self._create_device("Test Device Integration Test 2")
142
+ self._create_device("Test Device Integration Test 3", location="Test Location 2")
143
+ self._go_to_devices_list()
144
+
145
+ # Filter devices
146
+ self.apply_filter("location", "Test Location 2")
147
+
148
+ # Select all devices and edit them
149
+ self.select_all_items()
150
+ self.click_bulk_edit()
151
+ self.assertIn(self.live_server_url + reverse("dcim:device_bulk_edit"), self.browser.url)
152
+
153
+ # Submit bulk edit form without any changes
154
+ self.submit_bulk_edit_operation()
155
+
156
+ # Verify job output
157
+ self.assertIsBulkEditJob()
158
+ self.assertJobStatusIsCompleted()
159
+
160
+ def test_bulk_edit_one_filtered_devices(self):
161
+ # Create device for test
162
+ self._create_device("Test Device Integration Test 1")
163
+ self._create_device("Test Device Integration Test 2")
164
+ self._create_device("Test Device Integration Test 3", location="Test Location 2")
165
+ self._create_device("Test Device Integration Test 4", location="Test Location 2")
166
+ self._go_to_devices_list()
167
+
168
+ # Filter devices
169
+ self.apply_filter("location", "Test Location 2")
170
+
171
+ # Select one device and edit it
172
+ self.select_all_items()
173
+ self.click_bulk_edit()
174
+ self.assertIn(self.live_server_url + reverse("dcim:device_bulk_edit"), self.browser.url)
175
+
176
+ # Submit bulk edit form without any changes
177
+ self.submit_bulk_edit_operation()
178
+
179
+ # Verify job output
180
+ self.assertIsBulkEditJob()
181
+ self.assertJobStatusIsCompleted()
@@ -3366,7 +3366,6 @@ class ControllerTestCase(APIViewTestCases.APIViewTestCase):
3366
3366
  "status": statuses[1].pk,
3367
3367
  "role": roles[1].pk,
3368
3368
  "location": locations[1].pk,
3369
- "capabilities": [],
3370
3369
  },
3371
3370
  {
3372
3371
  "name": "Controller 3",
@@ -3402,7 +3401,6 @@ class ControllerManagedDeviceGroupTestCase(APIViewTestCases.APIViewTestCase):
3402
3401
  "name": "ControllerManagedDeviceGroup 2",
3403
3402
  "controller": controllers[1].pk,
3404
3403
  "weight": 150,
3405
- "capabilities": [],
3406
3404
  },
3407
3405
  {
3408
3406
  "name": "ControllerManagedDeviceGroup 3",
@@ -2185,45 +2185,59 @@ class CableTestCase(ModelTestCases.BaseModelTestCase):
2185
2185
  """
2186
2186
  A cable cannot terminate to a virtual interface
2187
2187
  """
2188
+ virtual_interface_choices = self.interface_choices["Virtual interfaces"]
2189
+
2188
2190
  self.cable.delete()
2189
2191
  interface_status = Status.objects.get_for_model(Interface).first()
2190
- virtual_interface = Interface.objects.create(
2191
- device=self.device1,
2192
- name="V1",
2193
- type=InterfaceTypeChoices.TYPE_VIRTUAL,
2194
- status=interface_status,
2195
- )
2196
- cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
2197
- with self.assertRaises(ValidationError) as cm:
2198
- cable.clean()
2199
2192
 
2200
- virtual_interface_choices = self.interface_choices["Virtual interfaces"]
2201
- self.assertIn(
2202
- f"Cables cannot be terminated to {virtual_interface_choices[InterfaceTypeChoices.TYPE_VIRTUAL]} interfaces",
2203
- str(cm.exception),
2204
- )
2193
+ for virtual_interface_type in virtual_interface_choices:
2194
+ virtual_interface = Interface.objects.create(
2195
+ device=self.device1,
2196
+ name=f"V-{virtual_interface_type}",
2197
+ type=virtual_interface_type,
2198
+ status=interface_status,
2199
+ )
2200
+ with self.assertRaises(
2201
+ ValidationError,
2202
+ msg=f"Virtual interface type '{virtual_interface_choices[virtual_interface_type]}' should not accept a cable.",
2203
+ ) as cm:
2204
+ Cable(
2205
+ termination_a=self.interface2,
2206
+ termination_b=virtual_interface,
2207
+ ).clean()
2208
+
2209
+ self.assertIn(
2210
+ f"Cables cannot be terminated to {virtual_interface_choices[virtual_interface_type]} interfaces",
2211
+ str(cm.exception),
2212
+ )
2205
2213
 
2206
2214
  def test_cable_cannot_terminate_to_a_wireless_interface(self):
2207
2215
  """
2208
2216
  A cable cannot terminate to a wireless interface
2209
2217
  """
2218
+ wireless_interface_choices = self.interface_choices["Wireless"]
2219
+
2210
2220
  self.cable.delete()
2211
2221
  interface_status = Status.objects.get_for_model(Interface).first()
2212
- wireless_interface = Interface.objects.create(
2213
- device=self.device1,
2214
- name="W1",
2215
- type=InterfaceTypeChoices.TYPE_80211A,
2216
- status=interface_status,
2217
- )
2218
- cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
2219
- with self.assertRaises(ValidationError) as cm:
2220
- cable.clean()
2221
2222
 
2222
- wireless_interface_choices = self.interface_choices["Wireless"]
2223
- self.assertIn(
2224
- f"Cables cannot be terminated to {wireless_interface_choices[InterfaceTypeChoices.TYPE_80211A]} interfaces",
2225
- str(cm.exception),
2226
- )
2223
+ for wireless_interface_type in wireless_interface_choices:
2224
+ wireless_interface = Interface.objects.create(
2225
+ device=self.device1,
2226
+ name=f"W-{wireless_interface_type}",
2227
+ type=wireless_interface_type,
2228
+ status=interface_status,
2229
+ )
2230
+
2231
+ with self.assertRaises(
2232
+ ValidationError,
2233
+ msg=f"Wireless interface type '{wireless_interface_choices[wireless_interface_type]}' should not accept a cable.",
2234
+ ) as cm:
2235
+ Cable(termination_a=self.interface2, termination_b=wireless_interface).clean()
2236
+
2237
+ self.assertIn(
2238
+ f"Cables cannot be terminated to {wireless_interface_choices[wireless_interface_type]} interfaces",
2239
+ str(cm.exception),
2240
+ )
2227
2241
 
2228
2242
  def test_create_cable_with_missing_status_connected(self):
2229
2243
  """Test for https://github.com/nautobot/nautobot/issues/2081"""
@@ -327,7 +327,7 @@ class RelationshipModelBulkEditFormMixin(BulkEditForm):
327
327
  field_name,
328
328
  instance,
329
329
  )
330
- if field_name in self.nullable_fields and field_name in nullified_fields:
330
+ if field_name in self.nullable_fields and nullified_fields and field_name in nullified_fields:
331
331
  logger.debug("Deleting existing relationship associations for %s on %s", relationship, instance)
332
332
  relationshipassociation_queryset.delete()
333
333
  elif field_name in self.cleaned_data:
nautobot/extras/jobs.py CHANGED
@@ -534,7 +534,10 @@ class BaseJob:
534
534
  job_model = JobModel.objects.get(module_name=cls.__module__, job_class_name=cls.__name__)
535
535
  is_singleton = job_model.is_singleton
536
536
  except JobModel.DoesNotExist:
537
+ logger.warning("No Job instance found in the database corresponding to %s", cls.class_path)
538
+ job_model = None
537
539
  is_singleton = cls.is_singleton
540
+
538
541
  if is_singleton:
539
542
  form.fields["_ignore_singleton_lock"] = forms.BooleanField(
540
543
  required=False,
@@ -543,10 +546,12 @@ class BaseJob:
543
546
  help_text="Allow this singleton job to run even when another instance is already running",
544
547
  )
545
548
 
546
- job_model = JobModel.objects.get_for_class_path(cls.class_path)
547
- dryrun_default = job_model.dryrun_default if job_model.dryrun_default_override else cls.dryrun_default
548
- job_queue_queryset = JobQueue.objects.filter(jobs=job_model)
549
- job_queue_params = {"jobs": [job_model.pk]}
549
+ if job_model is not None:
550
+ job_queue_queryset = JobQueue.objects.filter(jobs=job_model)
551
+ job_queue_params = {"jobs": [job_model.pk]}
552
+ else:
553
+ job_queue_queryset = JobQueue.objects.all()
554
+ job_queue_params = {}
550
555
 
551
556
  # Initialize job_queue choices
552
557
  form.fields["_job_queue"] = DynamicModelChoiceField(
@@ -556,8 +561,12 @@ class BaseJob:
556
561
  help_text="The job queue to route this job to",
557
562
  label="Job queue",
558
563
  )
559
- # Populate the job queue field on the JobRun Form
560
- form.fields["_job_queue"].initial = job_model.default_job_queue.pk
564
+
565
+ dryrun_default = cls.dryrun_default
566
+ if job_model is not None:
567
+ form.fields["_job_queue"].initial = job_model.default_job_queue.pk
568
+ if job_model.dryrun_default_override:
569
+ dryrun_default = job_model.dryrun_default
561
570
 
562
571
  if cls.supports_dryrun and (not initial or "dryrun" not in initial):
563
572
  # Set initial "dryrun" checkbox state based on the Meta parameter
@@ -8,7 +8,7 @@ from django.utils.html import format_html
8
8
  from django.utils.safestring import mark_safe
9
9
 
10
10
  from nautobot.core.utils.data import render_jinja2
11
- from nautobot.extras.models import Job, JobButton
11
+ from nautobot.extras.models import Job, JobButton, JobQueue
12
12
 
13
13
  register = template.Library()
14
14
 
@@ -108,17 +108,17 @@ def _render_job_button_for_obj(job_button, obj, context, content_type):
108
108
  # Disable buttons if the user doesn't have permission to run the underlying Job.
109
109
  has_run_perm = Job.objects.check_perms(context["user"], instance=job_button.job, action="run")
110
110
  try:
111
- job_queues = list(job_button.job.job_queues.all().values_list("name", flat=True))
111
+ job_queues = job_button.job.job_queues.all()
112
112
  _job_queue = job_queues[0]
113
113
  except IndexError:
114
- _job_queue = settings.CELERY_TASK_DEFAULT_QUEUE
114
+ _job_queue = JobQueue.objects.get(name=settings.CELERY_TASK_DEFAULT_QUEUE)
115
115
  hidden_inputs = format_html(
116
116
  HIDDEN_INPUTS,
117
117
  csrf_token=context["csrf_token"],
118
118
  object_pk=obj.pk,
119
119
  object_model_name=f"{content_type.app_label}.{content_type.model}",
120
120
  redirect_path=context["request"].path,
121
- job_queue=_job_queue,
121
+ job_queue=_job_queue.pk,
122
122
  )
123
123
  template_args = {
124
124
  "button_id": job_button.pk,
@@ -910,6 +910,19 @@ class RelationshipModelBulkEditFormMixinTestCase(TestCase):
910
910
  ras = RelationshipAssociation.objects.filter(relationship=self.rel_mtom_s)
911
911
  self.assertEqual(1, ras.count())
912
912
 
913
+ def test_form_save_relationship_with_nullified_fields_is_none(self):
914
+ """Test save_relationships with nullified_fields=None."""
915
+ form = LocationBulkEditForm(
916
+ model=dcim_models.Location,
917
+ data={
918
+ "pks": [self.locations[0].pk],
919
+ "add_cr_multiplexing__destination": [ipaddress.pk for ipaddress in self.ipaddresses],
920
+ "add_cr_peer_locations__peer": [self.locations[1].pk],
921
+ },
922
+ )
923
+ form.is_valid()
924
+ form.save_relationships(instance=self.locations[0], nullified_fields=None)
925
+
913
926
  def test_location_form_remove_mtom(self):
914
927
  """Test removal of relationship-associations for many-to-many relationships."""
915
928
  RelationshipAssociation.objects.create(