nautobot 3.0.3__py3-none-any.whl → 3.0.4__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.
Files changed (57) hide show
  1. nautobot/core/authentication.py +0 -1
  2. nautobot/core/celery/schedulers.py +1 -3
  3. nautobot/core/cli/__init__.py +81 -39
  4. nautobot/core/settings.yaml +12 -4
  5. nautobot/core/tests/test_cli.py +120 -1
  6. nautobot/dcim/forms.py +1 -0
  7. nautobot/dcim/tables/devices.py +2 -1
  8. nautobot/dcim/templates/dcim/platform_create.html +3 -4
  9. nautobot/extras/models/jobs.py +7 -1
  10. nautobot/extras/signals.py +143 -113
  11. nautobot/extras/tests/test_utils.py +116 -1
  12. nautobot/extras/utils.py +18 -16
  13. nautobot/extras/views.py +2 -14
  14. nautobot/ipam/apps.py +1 -0
  15. nautobot/project-static/docs/development/core/release-checklist.html +2 -0
  16. nautobot/project-static/docs/release-notes/version-3.0.html +235 -0
  17. nautobot/project-static/docs/search/search_index.json +1 -1
  18. nautobot/project-static/docs/sitemap.xml +329 -329
  19. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  20. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +11 -4
  21. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +11 -4
  22. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +21 -9
  23. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant-dark.png +0 -0
  24. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant-light.png +0 -0
  25. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device-dark.png +0 -0
  26. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device-light.png +0 -0
  27. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2-dark.png +0 -0
  28. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2-light.png +0 -0
  29. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans-dark.png +0 -0
  30. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans-light.png +0 -0
  31. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2-dark.png +0 -0
  32. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2-light.png +0 -0
  33. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page-dark.png +0 -0
  34. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page-light.png +0 -0
  35. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface-dark.png +0 -0
  36. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface-light.png +0 -0
  37. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2-dark.png +0 -0
  38. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2-light.png +0 -0
  39. nautobot/tenancy/tables.py +1 -1
  40. nautobot/ui/package-lock.json +36 -36
  41. nautobot/ui/package.json +3 -3
  42. nautobot/users/models.py +33 -0
  43. nautobot/users/tests/test_models.py +83 -0
  44. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/METADATA +4 -4
  45. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/RECORD +49 -41
  46. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant.png +0 -0
  47. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device.png +0 -0
  48. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2.png +0 -0
  49. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans.png +0 -0
  50. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2.png +0 -0
  51. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page.png +0 -0
  52. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface.png +0 -0
  53. nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2.png +0 -0
  54. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/LICENSE.txt +0 -0
  55. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/NOTICE +0 -0
  56. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/WHEEL +0 -0
  57. {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/entry_points.txt +0 -0
@@ -8,6 +8,7 @@ import uuid
8
8
 
9
9
  from db_file_storage.model_utils import delete_file
10
10
  from db_file_storage.storage import DatabaseFileStorage
11
+ from django.conf import settings
11
12
  from django.contrib.contenttypes.models import ContentType
12
13
  from django.core.cache import cache
13
14
  from django.core.exceptions import ValidationError
@@ -19,6 +20,7 @@ from django.utils import timezone
19
20
  from django_prometheus.models import model_deletes, model_inserts, model_updates
20
21
  import redis.exceptions
21
22
 
23
+ from nautobot.core.branching import BranchContext
22
24
  from nautobot.core.celery import app, import_jobs
23
25
  from nautobot.core.models import BaseModel
24
26
  from nautobot.core.utils.cache import construct_cache_key
@@ -214,6 +216,29 @@ def invalidate_gitrepository_provided_contents_cache(sender, **kwargs):
214
216
  cache.delete_pattern(f"{cache_key}(*)")
215
217
 
216
218
 
219
+ def _object_change_branch_name(instance):
220
+ """
221
+ Get the version-control branch name (if any) that needs to be switched to for ObjectChanges on a given instance.
222
+ """
223
+ if "nautobot_version_control" not in settings.PLUGINS:
224
+ return None
225
+
226
+ # When modifying non-version-controlled models, which only get committed to DOLT_DEFAULT_BRANCH,
227
+ # we need to ensure that the corresponding ObjectChange also is created there, even if we're otherwise working
228
+ # in a non-default branch at the moment. Failing to do so would result in a Dolt error on transaction commit:
229
+ # "Cannot commit changes on more than one branch / database"
230
+ from nautobot_version_control.constants import DOLT_DEFAULT_BRANCH # pylint: disable=import-error
231
+ from nautobot_version_control.utils import ( # pylint: disable=import-error
232
+ active_branch,
233
+ is_version_controlled_model,
234
+ )
235
+
236
+ if is_version_controlled_model(instance.__class__) or active_branch() == DOLT_DEFAULT_BRANCH:
237
+ return None # no need to switch branches
238
+
239
+ return DOLT_DEFAULT_BRANCH # need to switch temporarily to the default `main` branch for this record
240
+
241
+
217
242
  @receiver(post_save)
218
243
  @receiver(m2m_changed)
219
244
  def _handle_changed_object(sender, instance, raw=False, **kwargs):
@@ -242,60 +267,63 @@ def _handle_changed_object(sender, instance, raw=False, **kwargs):
242
267
 
243
268
  # Record an ObjectChange if applicable
244
269
  if hasattr(instance, "to_objectchange"):
270
+ branch_name = _object_change_branch_name(instance)
245
271
  user = change_context.get_user(instance)
246
- # save a copy of this instance's field cache so it can be restored after serialization
247
- # to prevent unexpected behavior when chaining multiple signal handlers
248
- original_cache = instance._state.fields_cache.copy()
249
-
250
- changed_object_type = ContentType.objects.get_for_model(instance)
251
- changed_object_id = instance.id
252
272
 
253
- # Generate a unique identifier for this change to stash in the change context
254
- # This is used for deferred change logging and for looking up related changes without querying the database
255
- unique_object_change_id = None
256
- if user is not None:
257
- unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
258
- else:
259
- unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}"
260
-
261
- # If a change already exists for this change_id, user, and object, update it instead of creating a new one.
262
- # If the object was deleted then recreated with the same pk (don't do this), change the action to update.
263
- if unique_object_change_id in change_context.deferred_object_changes:
264
- related_changes = ObjectChange.objects.filter(
265
- changed_object_type=changed_object_type,
266
- changed_object_id=changed_object_id,
267
- user=user,
268
- request_id=change_context.change_id,
269
- )
270
-
271
- # Skip the database check when deferring object changes
272
- if not change_context.defer_object_changes and related_changes.exists():
273
- objectchange = instance.to_objectchange(action)
274
- if objectchange is not None:
275
- most_recent_change = related_changes.order_by("-time").first()
276
- if most_recent_change.action == ObjectChangeActionChoices.ACTION_DELETE:
277
- most_recent_change.action = ObjectChangeActionChoices.ACTION_UPDATE
278
- most_recent_change.object_data = objectchange.object_data
279
- most_recent_change.object_data_v2 = objectchange.object_data_v2
280
- most_recent_change.save()
273
+ with BranchContext(branch_name=branch_name, user=user, autocommit=False):
274
+ # save a copy of this instance's field cache so it can be restored after serialization
275
+ # to prevent unexpected behavior when chaining multiple signal handlers
276
+ original_cache = instance._state.fields_cache.copy()
277
+
278
+ changed_object_type = ContentType.objects.get_for_model(instance)
279
+ changed_object_id = instance.id
280
+
281
+ # Generate a unique identifier for this change to stash in the change context
282
+ # This is used for deferred change logging and for looking up related changes without querying the database
283
+ unique_object_change_id = None
284
+ if user is not None:
285
+ unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
286
+ else:
287
+ unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}"
288
+
289
+ # If a change already exists for this change_id, user, and object, update it instead of creating a new one.
290
+ # If the object was deleted then recreated with the same pk (don't do this), change the action to update.
291
+ if unique_object_change_id in change_context.deferred_object_changes:
292
+ related_changes = ObjectChange.objects.filter(
293
+ changed_object_type=changed_object_type,
294
+ changed_object_id=changed_object_id,
295
+ user=user,
296
+ request_id=change_context.change_id,
297
+ )
298
+
299
+ # Skip the database check when deferring object changes
300
+ if not change_context.defer_object_changes and related_changes.exists():
301
+ objectchange = instance.to_objectchange(action)
302
+ if objectchange is not None:
303
+ most_recent_change = related_changes.order_by("-time").first()
304
+ if most_recent_change.action == ObjectChangeActionChoices.ACTION_DELETE:
305
+ most_recent_change.action = ObjectChangeActionChoices.ACTION_UPDATE
306
+ most_recent_change.object_data = objectchange.object_data
307
+ most_recent_change.object_data_v2 = objectchange.object_data_v2
308
+ most_recent_change.save()
281
309
 
282
- else:
283
- change_context.deferred_object_changes[unique_object_change_id] = [
284
- {"action": action, "instance": instance, "user": user}
285
- ]
286
- if not change_context.defer_object_changes:
287
- objectchange = instance.to_objectchange(action)
288
- if objectchange is not None:
289
- objectchange.user = user
290
- objectchange.request_id = change_context.change_id
291
- objectchange.change_context = change_context.context
292
- objectchange.change_context_detail = change_context.context_detail[
293
- :CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
294
- ]
295
- objectchange.save()
296
-
297
- # restore field cache
298
- instance._state.fields_cache = original_cache
310
+ else:
311
+ change_context.deferred_object_changes[unique_object_change_id] = [
312
+ {"action": action, "instance": instance, "user": user}
313
+ ]
314
+ if not change_context.defer_object_changes:
315
+ objectchange = instance.to_objectchange(action)
316
+ if objectchange is not None:
317
+ objectchange.user = user
318
+ objectchange.request_id = change_context.change_id
319
+ objectchange.change_context = change_context.context
320
+ objectchange.change_context_detail = change_context.context_detail[
321
+ :CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
322
+ ]
323
+ objectchange.save()
324
+
325
+ # restore field cache
326
+ instance._state.fields_cache = original_cache
299
327
 
300
328
  # Increment metric counters
301
329
  if action == ObjectChangeActionChoices.ACTION_CREATE:
@@ -326,71 +354,73 @@ def _handle_deleted_object(sender, instance, **kwargs):
326
354
 
327
355
  # Record an ObjectChange if applicable
328
356
  if hasattr(instance, "to_objectchange"):
357
+ branch_name = _object_change_branch_name(instance)
329
358
  user = change_context.get_user(instance)
330
359
 
331
- # save a copy of this instance's field cache so it can be restored after serialization
332
- # to prevent unexpected behavior when chaining multiple signal handlers
333
- original_cache = instance._state.fields_cache.copy()
334
-
335
- changed_object_type = ContentType.objects.get_for_model(instance)
336
- changed_object_id = instance.id
337
-
338
- # Generate a unique identifier for this change to stash in the change context
339
- # This is used for deferred change logging and for looking up related changes without querying the database
340
- unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
341
- save_new_objectchange = True
342
-
343
- # if a change already exists for this change_id, user, and object, update it instead of creating a new one
344
- # except in the case that the object was created and deleted in the same change_id
345
- # we don't want to create a delete change for an object that never existed
346
- if unique_object_change_id in change_context.deferred_object_changes:
347
- cached_related_change = change_context.deferred_object_changes[unique_object_change_id][-1]
348
- if cached_related_change["action"] != ObjectChangeActionChoices.ACTION_CREATE:
349
- cached_related_change["action"] = ObjectChangeActionChoices.ACTION_DELETE
350
- save_new_objectchange = False
351
-
352
- related_changes = ObjectChange.objects.filter(
353
- changed_object_type=changed_object_type,
354
- changed_object_id=changed_object_id,
355
- user=user,
356
- request_id=change_context.change_id,
357
- )
360
+ with BranchContext(branch_name=branch_name, user=user, autocommit=False):
361
+ # save a copy of this instance's field cache so it can be restored after serialization
362
+ # to prevent unexpected behavior when chaining multiple signal handlers
363
+ original_cache = instance._state.fields_cache.copy()
358
364
 
359
- # Skip the database check when deferring object changes
360
- if not change_context.defer_object_changes and related_changes.exists():
361
- objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
362
- if objectchange is not None:
363
- most_recent_change = related_changes.order_by("-time").first()
364
- if most_recent_change.action != ObjectChangeActionChoices.ACTION_CREATE:
365
- most_recent_change.action = ObjectChangeActionChoices.ACTION_DELETE
366
- most_recent_change.object_data = objectchange.object_data
367
- most_recent_change.object_data_v2 = objectchange.object_data_v2
368
- most_recent_change.save()
369
- save_new_objectchange = False
370
-
371
- if save_new_objectchange:
372
- change_context.deferred_object_changes.setdefault(unique_object_change_id, []).append(
373
- {
374
- "action": ObjectChangeActionChoices.ACTION_DELETE,
375
- "instance": instance,
376
- "user": user,
377
- "changed_object_id": changed_object_id,
378
- "changed_object_type": changed_object_type,
379
- }
380
- )
381
- if not change_context.defer_object_changes:
382
- objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
383
- if objectchange is not None:
384
- objectchange.user = user
385
- objectchange.request_id = change_context.change_id
386
- objectchange.change_context = change_context.context
387
- objectchange.change_context_detail = change_context.context_detail[
388
- :CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
389
- ]
390
- objectchange.save()
391
-
392
- # restore field cache
393
- instance._state.fields_cache = original_cache
365
+ changed_object_type = ContentType.objects.get_for_model(instance)
366
+ changed_object_id = instance.id
367
+
368
+ # Generate a unique identifier for this change to stash in the change context
369
+ # This is used for deferred change logging and for looking up related changes without querying the database
370
+ unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
371
+ save_new_objectchange = True
372
+
373
+ # if a change already exists for this change_id, user, and object, update it instead of creating a new one
374
+ # except in the case that the object was created and deleted in the same change_id
375
+ # we don't want to create a delete change for an object that never existed
376
+ if unique_object_change_id in change_context.deferred_object_changes:
377
+ cached_related_change = change_context.deferred_object_changes[unique_object_change_id][-1]
378
+ if cached_related_change["action"] != ObjectChangeActionChoices.ACTION_CREATE:
379
+ cached_related_change["action"] = ObjectChangeActionChoices.ACTION_DELETE
380
+ save_new_objectchange = False
381
+
382
+ related_changes = ObjectChange.objects.filter(
383
+ changed_object_type=changed_object_type,
384
+ changed_object_id=changed_object_id,
385
+ user=user,
386
+ request_id=change_context.change_id,
387
+ )
388
+
389
+ # Skip the database check when deferring object changes
390
+ if not change_context.defer_object_changes and related_changes.exists():
391
+ objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
392
+ if objectchange is not None:
393
+ most_recent_change = related_changes.order_by("-time").first()
394
+ if most_recent_change.action != ObjectChangeActionChoices.ACTION_CREATE:
395
+ most_recent_change.action = ObjectChangeActionChoices.ACTION_DELETE
396
+ most_recent_change.object_data = objectchange.object_data
397
+ most_recent_change.object_data_v2 = objectchange.object_data_v2
398
+ most_recent_change.save()
399
+ save_new_objectchange = False
400
+
401
+ if save_new_objectchange:
402
+ change_context.deferred_object_changes.setdefault(unique_object_change_id, []).append(
403
+ {
404
+ "action": ObjectChangeActionChoices.ACTION_DELETE,
405
+ "instance": instance,
406
+ "user": user,
407
+ "changed_object_id": changed_object_id,
408
+ "changed_object_type": changed_object_type,
409
+ }
410
+ )
411
+ if not change_context.defer_object_changes:
412
+ objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
413
+ if objectchange is not None:
414
+ objectchange.user = user
415
+ objectchange.request_id = change_context.change_id
416
+ objectchange.change_context = change_context.context
417
+ objectchange.change_context_detail = change_context.context_detail[
418
+ :CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
419
+ ]
420
+ objectchange.save()
421
+
422
+ # restore field cache
423
+ instance._state.fields_cache = original_cache
394
424
 
395
425
  # Increment metric counters
396
426
  model_deletes.labels(instance._meta.model_name).inc()
@@ -2,22 +2,26 @@ from unittest import mock
2
2
  import uuid
3
3
 
4
4
  from django.core.cache import cache
5
+ from django.test import override_settings
5
6
 
6
7
  from nautobot.core.testing import TestCase
7
8
  from nautobot.dcim.models import Cable, Device, PowerPort
8
9
  from nautobot.extras.choices import JobQueueTypeChoices
9
- from nautobot.extras.models import JobQueue
10
+ from nautobot.extras.models import JobQueue, JobResult
10
11
  from nautobot.extras.registry import registry
11
12
  from nautobot.extras.utils import (
12
13
  get_base_template,
13
14
  get_celery_queues,
14
15
  get_worker_count,
15
16
  populate_model_features_registry,
17
+ run_kubernetes_job_and_return_job_result,
16
18
  )
17
19
  from nautobot.users.models import Token
18
20
 
19
21
 
20
22
  class UtilsTestCase(TestCase):
23
+ databases = ("default", "job_logs")
24
+
21
25
  def test_get_base_template(self):
22
26
  with self.subTest("explicitly specified base_template always wins"):
23
27
  self.assertEqual(get_base_template("dcim/device/base.html", Device), "dcim/device/base.html")
@@ -125,3 +129,114 @@ class UtilsTestCase(TestCase):
125
129
  original_custom_fields_registry,
126
130
  "Registry should be restored to original state",
127
131
  )
132
+
133
+ @override_settings(
134
+ KUBERNETES_JOB_POD_NAME="test-pod",
135
+ KUBERNETES_JOB_POD_NAMESPACE="test-namespace",
136
+ KUBERNETES_JOB_MANIFEST={
137
+ "metadata": {"name": "test-job"},
138
+ "spec": {
139
+ "template": {
140
+ "spec": {
141
+ "containers": [
142
+ {
143
+ "command": [],
144
+ }
145
+ ]
146
+ }
147
+ }
148
+ },
149
+ },
150
+ KUBERNETES_SSL_CA_CERT_PATH="/path/to/ca.crt",
151
+ KUBERNETES_TOKEN_PATH="/path/to/token", # noqa: S106
152
+ KUBERNETES_DEFAULT_SERVICE_ADDRESS="https://kubernetes.default.svc",
153
+ )
154
+ @mock.patch("nautobot.extras.utils.transaction.on_commit")
155
+ @mock.patch("builtins.open", new_callable=mock.mock_open, read_data="test-token\n")
156
+ @mock.patch("nautobot.extras.utils.kubernetes.client.BatchV1Api")
157
+ @mock.patch("nautobot.extras.utils.kubernetes.client.ApiClient")
158
+ @mock.patch("nautobot.extras.utils.kubernetes.client.Configuration")
159
+ def test_run_kubernetes_job_and_return_job_result(
160
+ self,
161
+ mock_configuration,
162
+ mock_api_client,
163
+ mock_batch_api,
164
+ mock_open,
165
+ mock_on_commit,
166
+ ):
167
+ """Test run_kubernetes_job_and_return_job_result function."""
168
+ # Setup test data
169
+ job_result = JobResult.objects.create(
170
+ name="Test Job",
171
+ user=self.user,
172
+ )
173
+ # Mock the log method to avoid database writes during test
174
+ job_result.log = mock.Mock()
175
+ job_kwargs = '{"key": "value"}'
176
+
177
+ # Setup kubernetes client mocks
178
+ mock_config_instance = mock.MagicMock()
179
+ mock_config_instance.api_key_prefix = {}
180
+ mock_config_instance.api_key = {}
181
+ mock_configuration.return_value = mock_config_instance
182
+
183
+ mock_api_client_instance = mock.Mock()
184
+ mock_api_client.return_value.__enter__.return_value = mock_api_client_instance
185
+ mock_api_client.return_value.__exit__.return_value = None
186
+
187
+ mock_api_instance = mock.Mock()
188
+ mock_batch_api.return_value = mock_api_instance
189
+
190
+ # Capture the callback passed to transaction.on_commit
191
+ commit_callback = None
192
+
193
+ def capture_callback(callback):
194
+ nonlocal commit_callback
195
+ commit_callback = callback
196
+ # Execute immediately for testing
197
+ callback()
198
+
199
+ mock_on_commit.side_effect = capture_callback
200
+
201
+ # Execute the function
202
+ result = run_kubernetes_job_and_return_job_result(job_result, job_kwargs)
203
+
204
+ # Verify job_result was updated and saved
205
+ job_result.refresh_from_db()
206
+ self.assertEqual(job_result.task_kwargs, job_kwargs)
207
+ self.assertEqual(result, job_result)
208
+
209
+ # Verify transaction.on_commit was called
210
+ mock_on_commit.assert_called_once()
211
+ self.assertIsNotNone(commit_callback)
212
+
213
+ # Verify kubernetes configuration was set up correctly
214
+ mock_configuration.assert_called_once()
215
+ self.assertEqual(mock_config_instance.host, "https://kubernetes.default.svc")
216
+ self.assertEqual(mock_config_instance.ssl_ca_cert, "/path/to/ca.crt")
217
+ self.assertEqual(mock_config_instance.api_key_prefix["authorization"], "Bearer")
218
+ self.assertEqual(mock_config_instance.api_key["authorization"], "test-token")
219
+
220
+ # Verify ApiClient was used as context manager
221
+ mock_api_client.assert_called_once_with(mock_config_instance)
222
+
223
+ # Verify BatchV1Api was created with the api_client_instance
224
+ mock_batch_api.assert_called_once_with(mock_api_client_instance)
225
+
226
+ # Verify the pod manifest was modified correctly
227
+ mock_api_instance.create_namespaced_job.assert_called_once()
228
+ create_call = mock_api_instance.create_namespaced_job.call_args
229
+ body = create_call[1]["body"]
230
+ self.assertEqual(body["metadata"]["name"], f"nautobot-job-{job_result.pk}")
231
+ self.assertEqual(
232
+ body["spec"]["template"]["spec"]["containers"][0]["command"],
233
+ ["nautobot-server", "runjob_with_job_result", str(job_result.pk)],
234
+ )
235
+ self.assertEqual(create_call[1]["namespace"], "test-namespace")
236
+
237
+ # Verify token file was opened
238
+ mock_open.assert_called_once_with("/path/to/token", "r", encoding="utf-8")
239
+
240
+ # Verify job_result.log was called (checking for log messages)
241
+ self.assertEqual(job_result.log.call_count, 1)
242
+ self.assertIn("Creating job pod", str(job_result.log.call_args_list[0]))
nautobot/extras/utils.py CHANGED
@@ -668,7 +668,7 @@ def refresh_job_model_from_job_class(job_model_class, job_class, job_queue_class
668
668
  return (job_model, created)
669
669
 
670
670
 
671
- def run_kubernetes_job_and_return_job_result(job_queue, job_result, job_kwargs):
671
+ def run_kubernetes_job_and_return_job_result(job_result, job_kwargs):
672
672
  """
673
673
  Pass the job to a kubernetes pod and execute it there.
674
674
  """
@@ -678,17 +678,6 @@ def run_kubernetes_job_and_return_job_result(job_queue, job_result, job_kwargs):
678
678
  pod_ssl_ca_cert = settings.KUBERNETES_SSL_CA_CERT_PATH
679
679
  pod_token = settings.KUBERNETES_TOKEN_PATH
680
680
 
681
- configuration = kubernetes.client.Configuration()
682
- configuration.host = settings.KUBERNETES_DEFAULT_SERVICE_ADDRESS
683
- configuration.ssl_ca_cert = pod_ssl_ca_cert
684
- with open(pod_token, "r") as token_file:
685
- token = token_file.read().strip()
686
- # configure API Key authorization: BearerToken
687
- configuration.api_key_prefix["authorization"] = "Bearer"
688
- configuration.api_key["authorization"] = token
689
- with kubernetes.client.ApiClient(configuration) as api_client:
690
- api_instance = kubernetes.client.BatchV1Api(api_client)
691
-
692
681
  job_result.task_kwargs = job_kwargs
693
682
  job_result.save()
694
683
  pod_manifest["metadata"]["name"] = "nautobot-job-" + str(job_result.pk)
@@ -697,10 +686,23 @@ def run_kubernetes_job_and_return_job_result(job_queue, job_result, job_kwargs):
697
686
  "runjob_with_job_result",
698
687
  f"{job_result.pk}",
699
688
  ]
700
- job_result.log(f"Creating job pod {pod_name} in namespace {pod_namespace}")
701
- api_instance.create_namespaced_job(body=pod_manifest, namespace=pod_namespace)
702
- job_result.log(f"Reading job pod {pod_name} in namespace {pod_namespace}")
703
- api_instance.read_namespaced_job(name="nautobot-job-" + str(job_result.pk), namespace=pod_namespace)
689
+
690
+ def create_kubernetes_job():
691
+ """Create and read the Kubernetes job after the transaction commits."""
692
+ configuration = kubernetes.client.Configuration()
693
+ configuration.host = settings.KUBERNETES_DEFAULT_SERVICE_ADDRESS
694
+ configuration.ssl_ca_cert = pod_ssl_ca_cert
695
+ with open(pod_token, "r", encoding="utf-8") as token_file:
696
+ token = token_file.read().strip()
697
+ # configure API Key authorization: BearerToken
698
+ configuration.api_key_prefix["authorization"] = "Bearer"
699
+ configuration.api_key["authorization"] = token
700
+ with kubernetes.client.ApiClient(configuration) as api_client:
701
+ api_instance = kubernetes.client.BatchV1Api(api_client)
702
+ job_result.log(f"Creating job pod {pod_name} in namespace {pod_namespace}")
703
+ api_instance.create_namespaced_job(body=pod_manifest, namespace=pod_namespace)
704
+
705
+ transaction.on_commit(create_kubernetes_job)
704
706
  return job_result
705
707
 
706
708
 
nautobot/extras/views.py CHANGED
@@ -820,7 +820,6 @@ class ComputedFieldUIViewSet(NautobotUIViewSet):
820
820
  serializer_class = serializers.ComputedFieldSerializer
821
821
  table_class = tables.ComputedFieldTable
822
822
  queryset = ComputedField.objects.all()
823
- action_buttons = ("add",)
824
823
  object_detail_content = object_detail.ObjectDetailContent(
825
824
  panels=(
826
825
  object_detail.ObjectFieldsPanel(
@@ -1246,7 +1245,6 @@ class CustomFieldUIViewSet(NautobotUIViewSet):
1246
1245
  form_class = forms.CustomFieldForm
1247
1246
  table_class = tables.CustomFieldTable
1248
1247
  template_name = "extras/customfield_update.html"
1249
- action_buttons = ("add",)
1250
1248
 
1251
1249
  class CustomFieldObjectFieldsPanel(object_detail.ObjectFieldsPanel):
1252
1250
  def render_value(self, key, value, context):
@@ -1392,7 +1390,6 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
1392
1390
  queryset = DynamicGroup.objects.all()
1393
1391
  serializer_class = serializers.DynamicGroupSerializer
1394
1392
  table_class = tables.DynamicGroupTable
1395
- action_buttons = ("add",)
1396
1393
 
1397
1394
  def get_extra_context(self, request, instance):
1398
1395
  context = super().get_extra_context(request, instance)
@@ -1896,23 +1893,14 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
1896
1893
  #
1897
1894
 
1898
1895
 
1899
- class GraphQLQueryUIViewSet(
1900
- ObjectDetailViewMixin,
1901
- ObjectListViewMixin,
1902
- ObjectEditViewMixin,
1903
- ObjectDestroyViewMixin,
1904
- ObjectBulkDestroyViewMixin,
1905
- ObjectChangeLogViewMixin,
1906
- ObjectDataComplianceViewMixin,
1907
- ObjectNotesViewMixin,
1908
- ):
1896
+ class GraphQLQueryUIViewSet(NautobotUIViewSet):
1909
1897
  filterset_form_class = forms.GraphQLQueryFilterForm
1910
1898
  queryset = GraphQLQuery.objects.all()
1911
1899
  form_class = forms.GraphQLQueryForm
1912
1900
  filterset_class = filters.GraphQLQueryFilterSet
1913
1901
  serializer_class = serializers.GraphQLQuerySerializer
1914
1902
  table_class = tables.GraphQLQueryTable
1915
- action_buttons = ("add",)
1903
+ action_buttons = ("add", "export", "import")
1916
1904
 
1917
1905
  object_detail_content = object_detail.ObjectDetailContent(
1918
1906
  panels=(
nautobot/ipam/apps.py CHANGED
@@ -9,6 +9,7 @@ class IPAMConfig(NautobotConfig):
9
9
  "ipaddress",
10
10
  "namespace",
11
11
  "prefix",
12
+ "service",
12
13
  "vlan",
13
14
  "vrf",
14
15
  ]
@@ -13736,6 +13736,8 @@
13736
13736
  </div>
13737
13737
  <h3 id="sync-changes-into-next">Sync Changes into <code>next</code><a class="headerlink" href="#sync-changes-into-next" title="Permanent link">¶</a></h3>
13738
13738
  <p>After the main-to-develop pull request is merged into <code>develop</code>, create a new branch off of <code>next</code> (typically named <code>develop-to-next-post-&lt;x.y.z&gt;</code>) and <code>git merge develop</code>. Resolve any merge conflicts as appropriate (if you're lucky, there may only be one, a version number clash in <code>pyproject.toml</code>), then open a pull request to <code>next</code>.</p>
13739
+ <p>When resolving conflicts, always keep the Nautobot app version number from the <strong>current branch</strong> (not the incoming one). Once conflicts are resolved, open a pull request to <code>next</code>.</p>
13740
+ <p>During the pull request, the CI pipeline may fail at the <strong>changelog</strong> step. This is expected in this case and should not be a cause for concern (the error can be safely ignored).</p>
13739
13741
  <div class="admonition important">
13740
13742
  <p class="admonition-title">Important</p>
13741
13743
  <p>Do not squash merge this branch into <code>next</code>. Make sure to select <code>Create a merge commit</code> when merging in GitHub.</p>