nautobot 2.2.2__py3-none-any.whl → 2.2.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.
Files changed (88) hide show
  1. nautobot/apps/jobs.py +2 -0
  2. nautobot/core/api/utils.py +12 -9
  3. nautobot/core/apps/__init__.py +2 -2
  4. nautobot/core/celery/__init__.py +79 -68
  5. nautobot/core/celery/backends.py +9 -1
  6. nautobot/core/celery/control.py +4 -7
  7. nautobot/core/celery/schedulers.py +4 -2
  8. nautobot/core/celery/task.py +78 -5
  9. nautobot/core/graphql/schema.py +2 -1
  10. nautobot/core/jobs/__init__.py +2 -1
  11. nautobot/core/templates/generic/object_list.html +3 -3
  12. nautobot/core/templatetags/helpers.py +66 -9
  13. nautobot/core/testing/__init__.py +6 -1
  14. nautobot/core/testing/api.py +12 -13
  15. nautobot/core/testing/mixins.py +2 -2
  16. nautobot/core/testing/views.py +50 -51
  17. nautobot/core/tests/test_api.py +23 -2
  18. nautobot/core/tests/test_templatetags_helpers.py +32 -0
  19. nautobot/core/tests/test_views.py +19 -0
  20. nautobot/core/tests/test_views_utils.py +22 -1
  21. nautobot/core/utils/module_loading.py +89 -0
  22. nautobot/core/views/utils.py +3 -2
  23. nautobot/dcim/choices.py +14 -0
  24. nautobot/dcim/forms.py +51 -1
  25. nautobot/dcim/models/device_components.py +9 -5
  26. nautobot/dcim/templates/dcim/location.html +32 -13
  27. nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
  28. nautobot/dcim/tests/test_views.py +137 -0
  29. nautobot/dcim/urls.py +5 -0
  30. nautobot/dcim/views.py +149 -1
  31. nautobot/extras/api/views.py +21 -10
  32. nautobot/extras/constants.py +3 -3
  33. nautobot/extras/datasources/git.py +47 -58
  34. nautobot/extras/forms/forms.py +3 -1
  35. nautobot/extras/jobs.py +79 -146
  36. nautobot/extras/models/datasources.py +0 -2
  37. nautobot/extras/models/jobs.py +36 -18
  38. nautobot/extras/plugins/__init__.py +1 -20
  39. nautobot/extras/signals.py +6 -9
  40. nautobot/extras/test_jobs/__init__.py +8 -0
  41. nautobot/extras/test_jobs/dry_run.py +3 -2
  42. nautobot/extras/test_jobs/fail.py +43 -0
  43. nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
  44. nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
  45. nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
  46. nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
  47. nautobot/extras/test_jobs/pass.py +40 -0
  48. nautobot/extras/test_jobs/relative_import.py +11 -0
  49. nautobot/extras/tests/test_api.py +3 -0
  50. nautobot/extras/tests/test_datasources.py +125 -118
  51. nautobot/extras/tests/test_job_variables.py +57 -15
  52. nautobot/extras/tests/test_jobs.py +135 -1
  53. nautobot/extras/tests/test_models.py +26 -19
  54. nautobot/extras/tests/test_plugins.py +1 -3
  55. nautobot/extras/tests/test_views.py +2 -4
  56. nautobot/extras/views.py +47 -95
  57. nautobot/ipam/api/views.py +8 -1
  58. nautobot/ipam/graphql/types.py +11 -0
  59. nautobot/ipam/mixins.py +32 -0
  60. nautobot/ipam/models.py +2 -1
  61. nautobot/ipam/querysets.py +6 -1
  62. nautobot/ipam/tests/test_models.py +82 -0
  63. nautobot/project-static/docs/assets/extra.css +4 -0
  64. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
  65. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
  66. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
  67. nautobot/project-static/docs/development/core/application-registry.html +126 -84
  68. nautobot/project-static/docs/development/core/model-checklist.html +49 -1
  69. nautobot/project-static/docs/development/core/model-features.html +1 -1
  70. nautobot/project-static/docs/development/jobs/index.html +334 -58
  71. nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
  72. nautobot/project-static/docs/objects.inv +0 -0
  73. nautobot/project-static/docs/release-notes/version-2.2.html +237 -55
  74. nautobot/project-static/docs/search/search_index.json +1 -1
  75. nautobot/project-static/docs/sitemap.xml +254 -254
  76. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  77. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
  78. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
  79. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
  80. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
  81. nautobot/project-static/js/forms.js +18 -11
  82. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
  83. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/RECORD +87 -81
  84. nautobot/extras/test_jobs/job_variables.py +0 -93
  85. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
  86. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
  87. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
  88. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/entry_points.txt +0 -0
nautobot/dcim/views.py CHANGED
@@ -1,8 +1,10 @@
1
1
  from collections import OrderedDict
2
+ import logging
2
3
  import uuid
3
4
 
4
5
  from django.contrib import messages
5
6
  from django.contrib.contenttypes.models import ContentType
7
+ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
6
8
  from django.core.paginator import EmptyPage, PageNotAnInteger
7
9
  from django.db import transaction
8
10
  from django.db.models import F, Prefetch
@@ -12,15 +14,19 @@ from django.forms import (
12
14
  MultipleHiddenInput,
13
15
  )
14
16
  from django.shortcuts import get_object_or_404, HttpResponse, redirect, render
17
+ from django.utils.encoding import iri_to_uri
15
18
  from django.utils.functional import cached_property
16
19
  from django.utils.html import format_html
20
+ from django.utils.http import url_has_allowed_host_and_scheme
17
21
  from django.views.generic import View
18
22
  from django_tables2 import RequestConfig
19
23
 
20
24
  from nautobot.circuits.models import Circuit
21
- from nautobot.core.forms import ConfirmationForm
25
+ from nautobot.core.forms import ConfirmationForm, restrict_form_fields
22
26
  from nautobot.core.models.querysets import count_related
27
+ from nautobot.core.templatetags.helpers import has_perms
23
28
  from nautobot.core.utils.permissions import get_permission_for_model
29
+ from nautobot.core.utils.requests import normalize_querydict
24
30
  from nautobot.core.views import generic
25
31
  from nautobot.core.views.mixins import (
26
32
  GetReturnURLMixin,
@@ -30,7 +36,10 @@ from nautobot.core.views.mixins import (
30
36
  )
31
37
  from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
32
38
  from nautobot.core.views.viewsets import NautobotUIViewSet
39
+ from nautobot.dcim.choices import LocationDataToContactActionChoices
40
+ from nautobot.dcim.forms import LocationMigrateDataToContactForm
33
41
  from nautobot.dcim.utils import get_all_network_driver_mappings, get_network_driver_mapping_tool_names
42
+ from nautobot.extras.models import Contact, ContactAssociation, Role, Status, Team
34
43
  from nautobot.extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectDynamicGroupsView
35
44
  from nautobot.ipam.models import IPAddress, Prefix, Service, VLAN
36
45
  from nautobot.ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable, VRFDeviceAssignmentTable
@@ -83,6 +92,8 @@ from .models import (
83
92
  VirtualChassis,
84
93
  )
85
94
 
95
+ logger = logging.getLogger(__name__)
96
+
86
97
 
87
98
  class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
88
99
  """
@@ -283,6 +294,9 @@ class LocationView(generic.ObjectView):
283
294
  "children_table": children_table,
284
295
  "rack_groups": rack_groups,
285
296
  "stats": stats,
297
+ "contact_association_permission": ["extras.add_contactassociation"],
298
+ # show the button if any of these fields have non-empty value.
299
+ "show_convert_to_contact_button": instance.contact_name or instance.contact_phone or instance.contact_email,
286
300
  }
287
301
 
288
302
 
@@ -314,6 +328,140 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
314
328
  table = tables.LocationTable
315
329
 
316
330
 
331
+ class MigrateLocationDataToContactView(generic.ObjectEditView):
332
+ queryset = Location.objects.all()
333
+ model_form = LocationMigrateDataToContactForm
334
+ template_name = "dcim/location_migrate_data_to_contact.html"
335
+
336
+ def get(self, request, *args, **kwargs):
337
+ obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
338
+
339
+ initial_data = normalize_querydict(request.GET, form_class=self.model_form)
340
+ # remove status from the location itself
341
+ initial_data["status"] = None
342
+ initial_data["location"] = obj.pk
343
+
344
+ # populate contact tab fields initial data
345
+ initial_data["name"] = obj.contact_name
346
+ initial_data["phone"] = obj.contact_phone
347
+ initial_data["email"] = obj.contact_email
348
+ form = self.model_form(instance=obj, initial=initial_data)
349
+ restrict_form_fields(form, request.user)
350
+ return render(
351
+ request,
352
+ self.template_name,
353
+ {
354
+ "obj": obj,
355
+ "obj_type": self.queryset.model._meta.verbose_name,
356
+ "form": form,
357
+ "return_url": self.get_return_url(request, obj),
358
+ "editing": obj.present_in_database,
359
+ "active_tab": "assign",
360
+ **self.get_extra_context(request, obj),
361
+ },
362
+ )
363
+
364
+ def post(self, request, *args, **kwargs):
365
+ obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
366
+ form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
367
+ restrict_form_fields(form, request.user)
368
+
369
+ associated_object_id = obj.pk
370
+ associated_object_content_type = ContentType.objects.get_for_model(Location)
371
+ action = request.POST.get("action")
372
+ try:
373
+ with transaction.atomic():
374
+ if not has_perms(request.user, ["extras.add_contactassociation"]):
375
+ raise PermissionDenied(
376
+ "ObjectPermission extras.add_contactassociation is needed to perform this action"
377
+ )
378
+ contact = None
379
+ team = None
380
+ if action == LocationDataToContactActionChoices.CREATE_AND_ASSIGN_NEW_CONTACT:
381
+ if not has_perms(request.user, ["extras.add_contact"]):
382
+ raise PermissionDenied("ObjectPermission extras.add_contact is needed to perform this action")
383
+ contact = Contact(
384
+ name=request.POST.get("name"),
385
+ phone=request.POST.get("phone"),
386
+ email=request.POST.get("email"),
387
+ )
388
+ contact.validated_save()
389
+ # Trigger permission check
390
+ Contact.objects.restrict(request.user, "view").get(pk=contact.pk)
391
+ elif action == LocationDataToContactActionChoices.CREATE_AND_ASSIGN_NEW_TEAM:
392
+ if not has_perms(request.user, ["extras.add_team"]):
393
+ raise PermissionDenied("ObjectPermission extras.add_team is needed to perform this action")
394
+ team = Team(
395
+ name=request.POST.get("name"),
396
+ phone=request.POST.get("phone"),
397
+ email=request.POST.get("email"),
398
+ )
399
+ team.validated_save()
400
+ # Trigger permission check
401
+ Team.objects.restrict(request.user, "view").get(pk=team.pk)
402
+ elif action == LocationDataToContactActionChoices.ASSOCIATE_EXISTING_CONTACT:
403
+ contact = Contact.objects.restrict(request.user, "view").get(pk=request.POST.get("contact"))
404
+ elif action == LocationDataToContactActionChoices.ASSOCIATE_EXISTING_TEAM:
405
+ team = Team.objects.restrict(request.user, "view").get(pk=request.POST.get("team"))
406
+ else:
407
+ raise ValueError(f"Invalid action {action} passed from the form")
408
+
409
+ association = ContactAssociation(
410
+ contact=contact,
411
+ team=team,
412
+ associated_object_type=associated_object_content_type,
413
+ associated_object_id=associated_object_id,
414
+ status=Status.objects.get(pk=request.POST.get("status")),
415
+ role=Role.objects.get(pk=request.POST.get("role")),
416
+ )
417
+ association.validated_save()
418
+ # Trigger permission check
419
+ ContactAssociation.objects.restrict(request.user, "view").get(pk=association.pk)
420
+
421
+ # Clear out contact fields from location
422
+ location = self.get_object(kwargs)
423
+ location.contact_name = ""
424
+ location.contact_phone = ""
425
+ location.contact_email = ""
426
+ location.validated_save()
427
+
428
+ object_created = not form.instance.present_in_database
429
+
430
+ self.successful_post(request, obj, object_created, logger)
431
+
432
+ return_url = request.POST.get("return_url")
433
+ if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
434
+ return redirect(iri_to_uri(return_url))
435
+ else:
436
+ return redirect(self.get_return_url(request, obj))
437
+
438
+ except ObjectDoesNotExist:
439
+ msg = "Object save failed due to object-level permissions violation"
440
+ logger.debug(msg)
441
+ form.add_error(None, msg)
442
+ except PermissionDenied as e:
443
+ msg = e
444
+ logger.debug(msg)
445
+ form.add_error(None, msg)
446
+ except ValueError:
447
+ msg = f"Invalid action {action} passed from the form"
448
+ logger.debug(msg)
449
+ form.add_error(None, msg)
450
+
451
+ return render(
452
+ request,
453
+ self.template_name,
454
+ {
455
+ "obj": obj,
456
+ "obj_type": self.queryset.model._meta.verbose_name,
457
+ "form": form,
458
+ "return_url": self.get_return_url(request, obj),
459
+ "editing": obj.present_in_database,
460
+ **self.get_extra_context(request, obj),
461
+ },
462
+ )
463
+
464
+
317
465
  #
318
466
  # Rack groups
319
467
  #
@@ -33,6 +33,7 @@ from nautobot.core.models.querysets import count_related
33
33
  from nautobot.extras import filters
34
34
  from nautobot.extras.choices import JobExecutionType
35
35
  from nautobot.extras.filters import RoleFilterSet
36
+ from nautobot.extras.jobs import get_job
36
37
  from nautobot.extras.models import (
37
38
  ComputedField,
38
39
  ConfigContext,
@@ -484,7 +485,7 @@ def _create_schedule(serializer, data, job_model, user, approval_required, task_
484
485
  # scheduled for.
485
486
  scheduled_job = ScheduledJob(
486
487
  name=name,
487
- task=job_model.job_class.registered_name,
488
+ task=job_model.class_path,
488
489
  job_model=job_model,
489
490
  start_time=time,
490
491
  description=f"Nautobot job {name} scheduled by {user} for {time}",
@@ -520,7 +521,7 @@ class JobViewSetBase(
520
521
  def variables(self, request, *args, **kwargs):
521
522
  """Get details of the input variables that may/must be specified to run a particular Job."""
522
523
  job_model = self.get_object()
523
- job_class = job_model.job_class
524
+ job_class = get_job(job_model.class_path, reload=True)
524
525
  if job_class is None:
525
526
  raise Http404
526
527
  variables_dict = job_class._get_vars()
@@ -602,14 +603,15 @@ class JobViewSetBase(
602
603
  "One of these two flags must be removed before this job can be scheduled or run."
603
604
  )
604
605
 
605
- job_class = job_model.job_class
606
+ job_class = get_job(job_model.class_path, reload=True)
606
607
  if job_class is None:
607
608
  raise MethodNotAllowed(
608
609
  request.method, detail="This job's source code could not be located and cannot be run"
609
610
  )
610
611
 
611
612
  valid_queues = job_model.task_queues if job_model.task_queues else [settings.CELERY_TASK_DEFAULT_QUEUE]
612
- # Get a default queue from either the job model's specified task queue or system default to fall back on if request doesn't provide one
613
+ # Get a default queue from either the job model's specified task queue or
614
+ # the system default to fall back on if request doesn't provide one
613
615
  default_valid_queue = valid_queues[0]
614
616
 
615
617
  # We need to call request.data for both cases as this is what pulls and caches the request data
@@ -621,7 +623,8 @@ class JobViewSetBase(
621
623
  # - Job Form data (for submission to the job itself)
622
624
  # - Schedule data
623
625
  # - Desired task queue
624
- # Depending on request content type (largely for backwards compatibility) the keys at which these are found are different
626
+ # Depending on request content type (largely for backwards compatibility) the keys at which these are found
627
+ # are different
625
628
  if "multipart/form-data" in request.content_type:
626
629
  data = request._data.dict() # .data will return data and files, we just want the data
627
630
  files = request.FILES
@@ -639,7 +642,8 @@ class JobViewSetBase(
639
642
  for non_job_key in non_job_keys:
640
643
  data.pop(non_job_key, None)
641
644
 
642
- # List of keys in serializer that are effectively exploded versions of the schedule dictionary from JobInputSerializer
645
+ # List of keys in serializer that are effectively exploded versions of the schedule dictionary
646
+ # from JobInputSerializer
643
647
  schedule_keys = ("_schedule_name", "_schedule_start_time", "_schedule_interval", "_schedule_crontab")
644
648
 
645
649
  # Assign the key from the validated_data output to dictionary without prefixed "_schedule_"
@@ -666,7 +670,7 @@ class JobViewSetBase(
666
670
  cleaned_data = None
667
671
  try:
668
672
  cleaned_data = job_class.validate_data(data, files=files)
669
- cleaned_data = job_model.job_class.prepare_job_kwargs(cleaned_data)
673
+ cleaned_data = job_class.prepare_job_kwargs(cleaned_data)
670
674
 
671
675
  except FormsValidationError as e:
672
676
  # message_dict can only be accessed if ValidationError got a dict
@@ -948,7 +952,13 @@ class ScheduledJobViewSet(ReadOnlyModelViewSet):
948
952
  responses={"200": serializers.JobResultSerializer},
949
953
  request=None,
950
954
  )
951
- @action(detail=True, url_path="dry-run", methods=["post"], permission_classes=[ScheduledJobViewPermissions])
955
+ @action(
956
+ detail=True,
957
+ name="Dry Run",
958
+ url_path="dry-run",
959
+ methods=["post"],
960
+ permission_classes=[ScheduledJobViewPermissions],
961
+ )
952
962
  def dry_run(self, request, pk):
953
963
  scheduled_job = get_object_or_404(ScheduledJob, pk=pk)
954
964
  job_model = scheduled_job.job_model
@@ -960,13 +970,14 @@ class ScheduledJobViewSet(ReadOnlyModelViewSet):
960
970
  raise PermissionDenied("You do not have permission to run this job.")
961
971
 
962
972
  # Immediately enqueue the job
963
- job_kwargs = job_model.job_class.prepare_job_kwargs(scheduled_job.kwargs.get("data", {}))
973
+ job_class = get_job(job_model.class_path, reload=True)
974
+ job_kwargs = job_class.prepare_job_kwargs(scheduled_job.kwargs or {})
964
975
  job_kwargs["dryrun"] = True
965
976
  job_result = JobResult.enqueue_job(
966
977
  job_model,
967
978
  request.user,
968
979
  celery_kwargs=scheduled_job.celery_kwargs or {},
969
- **job_model.job_class.serialize_data(job_kwargs),
980
+ **job_class.serialize_data(job_kwargs),
970
981
  )
971
982
  serializer = serializers.JobResultSerializer(job_result, context={"request": request})
972
983
 
@@ -5,16 +5,16 @@ HTTP_CONTENT_TYPE_JSON = "application/json"
5
5
  EXTRAS_FEATURES = [
6
6
  "cable_terminations",
7
7
  "config_context_owners",
8
- "custom_fields",
8
+ "custom_fields", # Deprecated - see nautobot.extras.utils.populate_model_features_registry
9
9
  "custom_links",
10
10
  "custom_validators",
11
11
  "dynamic_groups",
12
12
  "export_template_owners",
13
13
  "export_templates",
14
14
  "graphql",
15
- "job_results",
15
+ "job_results", # No longer used
16
16
  "locations",
17
- "relationships",
17
+ "relationships", # Deprecated - see nautobot.extras.utils.populate_model_features_registry
18
18
  "statuses",
19
19
  "webhooks",
20
20
  ]
@@ -7,7 +7,6 @@ import mimetypes
7
7
  import os
8
8
  from pathlib import Path
9
9
  import re
10
- import sys
11
10
  from urllib.parse import quote
12
11
 
13
12
  from django.conf import settings
@@ -17,9 +16,9 @@ from django.db import transaction
17
16
  from git import InvalidGitRepositoryError, Repo
18
17
  import yaml
19
18
 
20
- from nautobot.core.celery import app as celery_app
21
19
  from nautobot.core.utils.git import GitRepo
22
- from nautobot.dcim.models import Device, DeviceType, Location, Platform
20
+ from nautobot.core.utils.module_loading import import_modules_privately
21
+ from nautobot.dcim.models import Device, DeviceRedundancyGroup, DeviceType, Location, Platform
23
22
  from nautobot.extras.choices import (
24
23
  LogLevelChoices,
25
24
  SecretsGroupAccessTypeChoices,
@@ -36,7 +35,7 @@ from nautobot.extras.models import (
36
35
  Role,
37
36
  Tag,
38
37
  )
39
- from nautobot.extras.registry import DatasourceContent, register_datasource_contents
38
+ from nautobot.extras.registry import DatasourceContent, register_datasource_contents, registry
40
39
  from nautobot.extras.utils import refresh_job_model_from_job_class
41
40
  from nautobot.tenancy.models import Tenant, TenantGroup
42
41
  from nautobot.virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -186,7 +185,7 @@ def ensure_git_repository(repository_record, logger=None, head=None): # pylint:
186
185
  def git_repository_dry_run(repository_record, logger): # pylint: disable=redefined-outer-name
187
186
  """Log the difference between local branch and remote branch files.
188
187
  Args:
189
- repository_record (GitRepository): The GitRepostiory instance to diff.
188
+ repository_record (GitRepository): The GitRepository instance to diff.
190
189
  logger (logging.Logger): Logger to log results to.
191
190
  """
192
191
  from_url, to_path, from_branch = get_repo_from_url_to_path_and_from_branch(repository_record)
@@ -272,6 +271,7 @@ def update_git_config_contexts(repository_record, job_result):
272
271
  "tenants",
273
272
  "tags",
274
273
  "dynamic_groups",
274
+ "device_redundancy_groups",
275
275
  ):
276
276
  if os.path.isdir(os.path.join(repository_record.filesystem_path, filter_type)):
277
277
  msg = (
@@ -402,6 +402,7 @@ def import_config_context(context_data, repository_record, job_result):
402
402
  ("tenants", Tenant),
403
403
  ("tags", Tag),
404
404
  ("dynamic_groups", DynamicGroup),
405
+ ("device_redundancy_groups", DeviceRedundancyGroup),
405
406
  ]:
406
407
  relations[key] = []
407
408
  for object_data in context_metadata.get(key, ()):
@@ -714,56 +715,41 @@ def delete_git_config_context_schemas(repository_record, job_result, preserve=()
714
715
  #
715
716
 
716
717
 
717
- def refresh_code_from_repository(repository_slug, consumer=None, skip_reimport=False):
718
+ def refresh_job_code_from_repository(repository_slug, skip_reimport=False, ignore_import_errors=True):
718
719
  """
719
- After cloning/updating a GitRepository on disk, call this function to reload and reregister the repo's Python code.
720
+ After cloning/updating/deleting a GitRepository on disk, call this function to reload and reregister its Python.
720
721
 
721
722
  Args:
722
- repository_slug (str): Repository directory in GIT_ROOT that was refreshed.
723
- consumer (celery.worker.Consumer): Celery Consumer to update as well
723
+ repository_slug (str): Repository directory in GIT_ROOT that was updated or deleted.
724
724
  skip_reimport (bool): If True, unload existing code from this repository but do not re-import it.
725
+ ignore_import_errors (bool): If True, any exceptions raised in the import will be caught and logged.
726
+ If False, exceptions will be re-raised after logging.
725
727
  """
726
- if settings.GIT_ROOT not in sys.path:
727
- sys.path.append(settings.GIT_ROOT)
728
-
729
- app = consumer.app if consumer is not None else celery_app
730
- # TODO: This is ugly, but when app.use_fast_trace_task is set (true by default), Celery calls
731
- # celery.app.trace.fast_trace_task(...) which assumes that all tasks are cached and have a valid `__trace__()`
732
- # function defined. In theory consumer.update_strategies() (below) should ensure this, but it doesn't
733
- # go far enough (possibly a discrepancy between the main worker process and the prefork executors?)
734
- # as we can and do still encounter errors where `task.__trace__` is unexpectedly None.
735
- # For now, simply disabling use_fast_trace_task forces the task trace function to be rebuilt each time,
736
- # which avoids the issue at the cost of very slight overhead.
737
- app.use_fast_trace_task = False
738
-
739
728
  # Unload any previous version of this module and its submodules if present
740
- for module_name in list(sys.modules):
741
- if module_name == repository_slug or module_name.startswith(f"{repository_slug}."):
742
- logger.debug("Unloading module %s", module_name)
743
- if module_name in app.loader.task_modules:
744
- app.loader.task_modules.remove(module_name)
745
- if module_name in sys.modules:
746
- del sys.modules[module_name]
747
-
748
- # Unregister any previous Celery tasks from this module
749
- for task_name in list(app.tasks):
750
- if task_name.startswith(f"{repository_slug}."):
751
- logger.debug("Unregistering Celery task %s", task_name)
752
- app.tasks.unregister(task_name)
753
- if consumer is not None and task_name in consumer.strategies:
754
- del consumer.strategies[task_name]
755
-
756
- if not skip_reimport:
757
- try:
758
- repository = GitRepository.objects.get(slug=repository_slug)
759
- if "extras.job" in repository.provided_contents:
760
- # Re-import Celery tasks from this module
761
- logger.debug("Importing Jobs from %s.jobs in GIT_ROOT", repository_slug)
762
- app.loader.import_task_module(f"{repository_slug}.jobs")
763
- if consumer is not None:
764
- consumer.update_strategies()
765
- except GitRepository.DoesNotExist as exc:
766
- logger.error("Unable to reload Jobs from %s.jobs: %s", repository_slug, exc)
729
+ for job_class_path in list(registry["jobs"]):
730
+ if job_class_path.startswith(f"{repository_slug}."):
731
+ del registry["jobs"][job_class_path]
732
+
733
+ if skip_reimport:
734
+ return
735
+
736
+ try:
737
+ repository = GitRepository.objects.get(slug=repository_slug)
738
+ if "extras.job" in repository.provided_contents:
739
+ if not (
740
+ os.path.isdir(os.path.join(repository.filesystem_path, "jobs"))
741
+ or os.path.isfile(os.path.join(repository.filesystem_path, "jobs.py"))
742
+ ):
743
+ logger.error("No `jobs` submodule found in Git repository %s", repository)
744
+ if not ignore_import_errors:
745
+ raise FileNotFoundError(f"No `jobs` submodule found in Git repository {repository}")
746
+ else:
747
+ import_modules_privately(
748
+ settings.GIT_ROOT, module_path=[repository_slug, "jobs"], ignore_import_errors=ignore_import_errors
749
+ )
750
+ except GitRepository.DoesNotExist as exc:
751
+ logger.error("Unable to reload Jobs from %s.jobs: %s", repository_slug, exc)
752
+ if not ignore_import_errors:
767
753
  raise
768
754
 
769
755
 
@@ -773,13 +759,13 @@ def refresh_git_jobs(repository_record, job_result, delete=False):
773
759
  if "extras.job" in repository_record.provided_contents and not delete:
774
760
  found_jobs = False
775
761
  try:
776
- refresh_code_from_repository(repository_record.slug)
762
+ refresh_job_code_from_repository(repository_record.slug, ignore_import_errors=False)
777
763
 
778
- for task_name, task in celery_app.tasks.items():
779
- if not task_name.startswith(f"{repository_record.slug}."):
764
+ for job_class_path, job_class in registry["jobs"].items():
765
+ if not job_class_path.startswith(f"{repository_record.slug}.jobs."):
780
766
  continue
781
767
  found_jobs = True
782
- job_model, created = refresh_job_model_from_job_class(Job, task.__class__)
768
+ job_model, created = refresh_job_model_from_job_class(Job, job_class)
783
769
 
784
770
  if job_model is None:
785
771
  msg = "Failed to create Job record; check Nautobot logs for details"
@@ -788,15 +774,18 @@ def refresh_git_jobs(repository_record, job_result, delete=False):
788
774
  continue
789
775
 
790
776
  if created:
791
- message = "Created Job record"
777
+ message = f"Created Job record for {job_class_path}"
792
778
  else:
793
- message = "Refreshed Job record"
779
+ message = f"Refreshed Job record for {job_class_path}"
794
780
  logger.info(message)
795
781
  job_result.log(message=message, obj=job_model, grouping="jobs", level_choice=LogLevelChoices.LOG_INFO)
796
782
  installed_jobs.append(job_model)
797
783
 
798
784
  if not found_jobs:
799
- msg = "No jobs were registered on loading the `jobs` submodule. Did you miss a `register_jobs()` call?"
785
+ msg = (
786
+ f"No jobs were registered on loading the `{repository_record.slug}.jobs` submodule. "
787
+ "Did you miss a `register_jobs()` call? Or was there a syntax error or similar in your code?"
788
+ )
800
789
  logger.warning(msg)
801
790
  job_result.log(msg, grouping="jobs", level_choice=LogLevelChoices.LOG_WARNING)
802
791
  except Exception as exc:
@@ -804,8 +793,8 @@ def refresh_git_jobs(repository_record, job_result, delete=False):
804
793
  logger.error(msg)
805
794
  job_result.log(msg, grouping="jobs", level_choice=LogLevelChoices.LOG_ERROR)
806
795
  else:
807
- # Unload code from this repository, do not reimport it
808
- refresh_code_from_repository(repository_record.slug, skip_reimport=True)
796
+ # Flush this repository's job classes
797
+ refresh_job_code_from_repository(repository_record.slug, skip_reimport=True)
809
798
 
810
799
  for job_model in Job.objects.filter(module_name__startswith=f"{repository_record.slug}."):
811
800
  if job_model.installed and job_model not in installed_jobs:
@@ -844,8 +844,10 @@ class JobEditForm(NautobotModelForm):
844
844
  """
845
845
  For all overridable fields, if they aren't marked as overridden, revert them to the underlying value if known.
846
846
  """
847
+ from nautobot.extras.jobs import get_job # avoid circular import
848
+
847
849
  cleaned_data = super().clean() or self.cleaned_data
848
- job_class = self.instance.job_class
850
+ job_class = get_job(self.instance.class_path, reload=True)
849
851
  if job_class is not None:
850
852
  for field_name in JOB_OVERRIDABLE_FIELDS:
851
853
  if not cleaned_data.get(f"{field_name}_override", False):