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/apps/jobs.py CHANGED
@@ -12,6 +12,7 @@ from nautobot.extras.jobs import (
12
12
  enqueue_job_hooks,
13
13
  FileVar,
14
14
  get_job,
15
+ get_jobs,
15
16
  IntegerVar,
16
17
  IPAddressVar,
17
18
  IPAddressWithMaskVar,
@@ -40,6 +41,7 @@ __all__ = (
40
41
  "enqueue_job_hooks",
41
42
  "FileVar",
42
43
  "get_job",
44
+ "get_jobs",
43
45
  "GitRepositoryDryRun",
44
46
  "GitRepositorySync",
45
47
  "IntegerVar",
@@ -167,28 +167,31 @@ def is_api_request(request):
167
167
  return request.path_info.startswith(api_path)
168
168
 
169
169
 
170
- def get_view_name(view, suffix=None):
170
+ def get_view_name(view):
171
171
  """
172
172
  Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
173
173
  """
174
- if hasattr(view, "queryset"):
174
+ if hasattr(view, "name") and view.name:
175
+ return view.name
176
+ elif hasattr(view, "queryset"):
175
177
  # Determine the model name from the queryset.
176
- name = view.queryset.model._meta.verbose_name
178
+ if hasattr(view, "detail") and view.detail:
179
+ name = view.queryset.model._meta.verbose_name
180
+ else:
181
+ name = view.queryset.model._meta.verbose_name_plural
177
182
  name = " ".join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
178
183
 
179
184
  else:
180
185
  # Replicate DRF's built-in behavior.
181
- name = getattr(view, "name", None)
182
- if name is not None:
183
- return view.name
184
-
185
186
  name = view.__class__.__name__
186
187
  name = formatting.remove_trailing_string(name, "View")
187
188
  name = formatting.remove_trailing_string(name, "ViewSet")
188
189
  name = formatting.camelcase_to_spaces(name)
189
190
 
190
- if suffix:
191
- name += " " + suffix
191
+ # Suffix may be set by some Views, such as a ViewSet.
192
+ suffix = getattr(view, "suffix", None)
193
+ if suffix:
194
+ name += " " + suffix
192
195
 
193
196
  return name
194
197
 
@@ -918,9 +918,9 @@ class CoreConfig(NautobotConfig):
918
918
  super().ready()
919
919
 
920
920
  # Register jobs last after everything else has been done.
921
- from nautobot.core.celery import app, import_jobs_as_celery_tasks
921
+ from nautobot.core.celery import import_jobs
922
922
 
923
- import_jobs_as_celery_tasks(app, database_ready=False)
923
+ import_jobs()
924
924
 
925
925
 
926
926
  class NautobotConstanceConfig(ConstanceConfig):
@@ -1,16 +1,14 @@
1
- from importlib.util import find_spec
2
1
  import json
3
2
  import logging
4
3
  import os
5
4
  from pathlib import Path
6
- import pkgutil
7
5
  import shutil
8
- import sys
9
6
 
10
7
  from celery import Celery, shared_task, signals
11
8
  from celery.app.log import TaskFormatter
12
9
  from celery.utils.log import get_logger
13
10
  from django.conf import settings
11
+ from django.db.utils import ProgrammingError
14
12
  from django.utils.functional import SimpleLazyObject
15
13
  from django.utils.module_loading import import_string
16
14
  from kombu.serialization import register
@@ -19,6 +17,8 @@ from prometheus_client import CollectorRegistry, multiprocess, start_http_server
19
17
  from nautobot.core.celery.control import discard_git_repository, refresh_git_repository # noqa: F401 # unused-import
20
18
  from nautobot.core.celery.encoders import NautobotKombuJSONEncoder
21
19
  from nautobot.core.celery.log import NautobotDatabaseHandler
20
+ from nautobot.core.utils.module_loading import import_modules_privately
21
+ from nautobot.extras.registry import registry
22
22
 
23
23
  logger = logging.getLogger(__name__)
24
24
 
@@ -29,16 +29,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nautobot_config")
29
29
  class NautobotCelery(Celery):
30
30
  task_cls = "nautobot.core.celery.task:NautobotTask"
31
31
 
32
- def register_task(self, task, **options):
33
- """Override the default task name for job classes to allow app provided jobs to use the full module path."""
34
- from nautobot.extras.jobs import Job
35
-
36
- if issubclass(task, Job):
37
- task = task()
38
- task.name = task.registered_name
39
-
40
- return super().register_task(task, **options)
41
-
42
32
 
43
33
  app = NautobotCelery("nautobot")
44
34
 
@@ -53,60 +43,77 @@ app.autodiscover_tasks()
53
43
 
54
44
 
55
45
  @signals.import_modules.connect
56
- def import_jobs_as_celery_tasks(sender, database_ready=True, **kwargs):
46
+ def import_jobs(sender=None, **kwargs):
57
47
  """
58
- Import system Jobs into Celery as well as Jobs from JOBS_ROOT and GIT_ROOT.
48
+ Import system Jobs into Nautobot as well as Jobs from JOBS_ROOT and GIT_ROOT.
49
+
50
+ Note that app-provided jobs are automatically imported at startup time via NautobotAppConfig.ready()
51
+ """
52
+ import nautobot.core.jobs # noqa: F401
53
+
54
+ _import_jobs_from_jobs_root()
59
55
 
60
- Note that app-provided Jobs are automatically imported at startup time via NautobotAppConfig.ready()
56
+ try:
57
+ _import_jobs_from_git_repositories()
58
+ except ProgrammingError: # Database not ready yet, as may be the case on initial startup and migration
59
+ pass
60
+
61
+
62
+ def _import_jobs_from_jobs_root():
63
+ """
64
+ (Re)import all modules in settings.JOBS_ROOT.
61
65
  """
62
- logger.debug("Importing system Jobs")
63
- sender.loader.import_task_module("nautobot.core.jobs")
64
-
65
- jobs_root = settings.JOBS_ROOT
66
- if jobs_root and os.path.exists(jobs_root):
67
- if jobs_root not in sys.path:
68
- sys.path.append(jobs_root)
69
- for _, module_name, _ in pkgutil.iter_modules([jobs_root]):
70
- try:
71
- logger.debug("Importing Jobs from %s in JOBS_ROOT", module_name)
72
- existing_module = find_spec(module_name)
73
- if existing_module is not None:
74
- existing_module_path = os.path.realpath(existing_module.origin)
75
- jobs_root_path = os.path.realpath(jobs_root)
76
- if not existing_module_path.startswith(jobs_root_path):
77
- raise ImportError(
78
- f"JOBS_ROOT Jobs module {module_name} conflicts with existing module {existing_module_path}"
79
- )
80
- sender.loader.import_task_module(module_name)
81
- except Exception as exc:
82
- logger.exception(exc)
83
-
84
- git_root = settings.GIT_ROOT
85
- if git_root and os.path.exists(git_root):
86
- if git_root not in sys.path:
87
- sys.path.append(git_root)
88
-
89
- # We can't detect which Git directories we're *supposed* to auto-load Jobs from if we can't read GitRepository
90
- # records from the DB, unfortunately.
91
- # We work around this in JobModel.job_task to try later loading Git jobs on-the-fly if needed.
92
- if database_ready:
66
+ if not (settings.JOBS_ROOT and os.path.isdir(settings.JOBS_ROOT)):
67
+ return
68
+
69
+ # Flush any previously loaded non-system, non-App Jobs
70
+ for job_class_path in list(registry["jobs"]):
71
+ if job_class_path.startswith("nautobot."):
72
+ # System job
73
+ continue
74
+ if any(job_class_path.startswith(f"{app_name}.") for app_name in settings.PLUGINS):
75
+ # App provided job
76
+ continue
77
+ try:
93
78
  from nautobot.extras.models import GitRepository
94
79
 
95
- # Make sure there are no git clones in GIT_ROOT that *aren't* tracked by a GitRepository;
96
- # for example, maybe a GitRepository was deleted while this worker process wasn't running?
97
- for filename in os.listdir(git_root):
98
- filepath = os.path.join(git_root, filename)
99
- if (
100
- os.path.isdir(filepath)
101
- and os.path.isdir(os.path.join(filepath, ".git"))
102
- and not GitRepository.objects.filter(slug=filename).exists()
103
- ):
104
- logger.warning("Deleting unmanaged (leftover?) Git repository clone at %s", filepath)
105
- shutil.rmtree(filepath)
80
+ if any(
81
+ job_class_path.startswith(f"{repo.slug}.")
82
+ for repo in GitRepository.objects.filter(provided_contents__contains="extras.job")
83
+ ):
84
+ # Git provided job
85
+ continue
86
+ except ProgrammingError: # Database not ready yet, as may be the case on initial startup and migration
87
+ pass
88
+ # Else, it's presumably a JOBS_ROOT job
89
+ del registry["jobs"][job_class_path]
90
+
91
+ # Load all modules in JOBS_ROOT
92
+ import_modules_privately(path=os.path.realpath(settings.JOBS_ROOT))
93
+
94
+
95
+ def _import_jobs_from_git_repositories():
96
+ git_root = os.path.realpath(settings.GIT_ROOT)
97
+ if not (git_root and os.path.exists(git_root)):
98
+ return
99
+
100
+ from nautobot.extras.models import GitRepository
101
+
102
+ # Make sure there are no git clones in GIT_ROOT that *aren't* tracked by a GitRepository;
103
+ # for example, maybe a GitRepository was deleted while this worker process wasn't running?
104
+ for filename in os.listdir(git_root):
105
+ filepath = os.path.join(git_root, filename)
106
+ if (
107
+ os.path.isdir(filepath)
108
+ and os.path.isdir(os.path.join(filepath, ".git"))
109
+ and not GitRepository.objects.filter(slug=filename).exists()
110
+ ):
111
+ logger.warning("Deleting unmanaged (leftover?) Git repository clone at %s", filepath)
112
+ shutil.rmtree(filepath)
106
113
 
107
- # Make sure all GitRepository records that include Jobs have up-to-date git clones, and load their jobs
108
- for repo in GitRepository.objects.all():
109
- refresh_git_repository(state=None, repository_pk=repo.pk, head=repo.current_head)
114
+ # Make sure all GitRepository records that include Jobs have up-to-date git clones, and load their jobs
115
+ for repo in GitRepository.objects.filter(provided_contents__contains="extras.job"):
116
+ refresh_git_repository(state=None, repository_pk=repo.pk, head=repo.current_head)
110
117
 
111
118
 
112
119
  def add_nautobot_log_handler(logger_instance, log_format=None):
@@ -151,11 +158,11 @@ def setup_prometheus(**kwargs):
151
158
  multiprocess_coordination_directory.mkdir(parents=True, exist_ok=True)
152
159
 
153
160
  # Set up the collector registry
154
- registry = CollectorRegistry()
155
- multiprocess.MultiProcessCollector(registry, path=multiprocess_coordination_directory)
161
+ collector_registry = CollectorRegistry()
162
+ multiprocess.MultiProcessCollector(collector_registry, path=multiprocess_coordination_directory)
156
163
  for port in settings.CELERY_WORKER_PROMETHEUS_PORTS:
157
164
  try:
158
- start_http_server(port, registry=registry)
165
+ start_http_server(port, registry=collector_registry)
159
166
  break
160
167
  except OSError:
161
168
  continue
@@ -206,9 +213,13 @@ register("nautobot_json", _dumps, _loads, content_type="application/x-nautobot-j
206
213
  nautobot_task = shared_task
207
214
 
208
215
 
216
+ registry["jobs"] = {}
217
+
218
+
209
219
  def register_jobs(*jobs):
210
- """Helper method to register jobs with Celery"""
220
+ """
221
+ Method to register jobs - with Celery in Nautobot 2.0 through 2.2.2, with Nautobot itself in 2.2.3 and later.
222
+ """
211
223
  for job in jobs:
212
- # TODO: should we only register a job if it corresponds to a Job database record?
213
- logger.debug("Registering job %s.%s", job.__module__, job.__name__)
214
- app.register_task(job)
224
+ if job.class_path not in registry["jobs"]:
225
+ registry["jobs"][job.class_path] = job
@@ -37,6 +37,7 @@ class NautobotDatabaseBackend(DatabaseBackend):
37
37
  "worker": None,
38
38
  }
39
39
  if request and self.app.conf.find_value_for_key("extended", "result"):
40
+ task_name = getattr(request, "task", None)
40
41
  # do not encode args/kwargs as we store these in a JSONField instead of TextField
41
42
  task_args = getattr(request, "args", None)
42
43
  task_kwargs = getattr(request, "kwargs", None)
@@ -54,6 +55,13 @@ class NautobotDatabaseBackend(DatabaseBackend):
54
55
  if traceback is not None:
55
56
  traceback = sanitize(traceback)
56
57
 
58
+ # Preserve the JobResult data behavior from Nautobot 2.0 through 2.2 (wherein the Job itself was the task)
59
+ # by manipulating `task_name` and `task_args` to hide the fact that we are now calling
60
+ # `run_job.apply(args=[JobClass.class_path, ...])` instead of `JobClass.apply(args=[...])`.
61
+ if task_name == "nautobot.extras.jobs.run_job" and task_args:
62
+ task_name = task_args[0]
63
+ task_args = task_args[1:]
64
+
57
65
  extended_props.update(
58
66
  {
59
67
  "task_args": task_args,
@@ -61,7 +69,7 @@ class NautobotDatabaseBackend(DatabaseBackend):
61
69
  "celery_kwargs": celery_kwargs,
62
70
  "job_model_id": properties.get("nautobot_job_job_model_id", None),
63
71
  "scheduled_job_id": properties.get("nautobot_job_scheduled_job_id", None),
64
- "task_name": getattr(request, "task", None),
72
+ "task_name": task_name,
65
73
  "traceback": traceback,
66
74
  "user_id": properties.get("nautobot_job_user_id", None),
67
75
  "worker": getattr(request, "hostname", None),
@@ -13,14 +13,14 @@ def refresh_git_repository(state, repository_pk, head):
13
13
  """
14
14
  Celery worker control event to ensure that all active workers have the correct head for a given Git repository.
15
15
  """
16
- from nautobot.extras.datasources.git import ensure_git_repository, refresh_code_from_repository
16
+ from nautobot.extras.datasources.git import ensure_git_repository, refresh_job_code_from_repository
17
17
  from nautobot.extras.models import GitRepository
18
18
 
19
19
  try:
20
20
  repository = GitRepository.objects.get(pk=repository_pk)
21
21
  # Refresh the repository on disk
22
22
  ensure_git_repository(repository, head=head, logger=logger)
23
- refresh_code_from_repository(repository.slug, consumer=state.consumer if state is not None else None)
23
+ refresh_job_code_from_repository(repository.slug, ignore_import_errors=False)
24
24
 
25
25
  return {"ok": {"head": repository.current_head}}
26
26
  except Exception as exc:
@@ -33,12 +33,9 @@ def discard_git_repository(state, repository_slug):
33
33
  """
34
34
  Celery worker control even to ensure that all active workers unload a given Git repository and delete it from disk.
35
35
  """
36
- from nautobot.extras.datasources.git import refresh_code_from_repository
36
+ from nautobot.extras.datasources.git import refresh_job_code_from_repository
37
37
 
38
38
  filesystem_path = os.path.join(settings.GIT_ROOT, repository_slug)
39
39
  if os.path.isdir(filesystem_path):
40
40
  shutil.rmtree(filesystem_path)
41
- # Unload any code from this repository
42
- refresh_code_from_repository(
43
- repository_slug, consumer=state.consumer if state is not None else None, skip_reimport=True
44
- )
41
+ refresh_job_code_from_repository(repository_slug, skip_reimport=True)
@@ -22,11 +22,13 @@ class NautobotScheduleEntry(ModelEntry):
22
22
  """Initialize the model entry."""
23
23
  self.app = app or current_app._get_current_object()
24
24
  self.name = f"{model.name}_{model.pk}"
25
- self.task = model.task
25
+ self.task = "nautobot.extras.jobs.run_job"
26
26
  try:
27
27
  # Nautobot scheduled jobs pass args/kwargs as constructed objects,
28
28
  # but Celery built-in jobs such as celery.backend_cleanup pass them as JSON to be parsed
29
- self.args = model.args if isinstance(model.args, (tuple, list)) else loads(model.args or "[]")
29
+ self.args = [model.task] + (
30
+ model.args if isinstance(model.args, (tuple, list)) else loads(model.args or "[]")
31
+ )
30
32
  self.kwargs = model.kwargs if isinstance(model.kwargs, dict) else loads(model.kwargs or "{}")
31
33
  except (TypeError, ValueError) as exc:
32
34
  logger.exception("Removing schedule %s for argument deserialization error: %s", self.name, exc)
@@ -1,12 +1,85 @@
1
- from celery import Task
2
-
3
- from nautobot.extras.jobs import get_task_logger
4
-
5
- logger = get_task_logger(__name__)
1
+ from billiard.einfo import ExceptionInfo, ExceptionWithTraceback
2
+ from celery import states, Task
3
+ from celery.exceptions import Retry
4
+ from celery.result import EagerResult
5
+ from celery.utils.functional import maybe_list
6
+ from celery.utils.nodenames import gethostname
7
+ from kombu.utils.uuid import uuid
6
8
 
7
9
 
8
10
  class NautobotTask(Task):
9
11
  """Nautobot extensions to tasks for integrating with Job machinery."""
10
12
 
13
+ def apply(
14
+ self,
15
+ args=None,
16
+ kwargs=None,
17
+ link=None,
18
+ link_error=None,
19
+ task_id=None,
20
+ retries=None,
21
+ throw=None,
22
+ logfile=None,
23
+ loglevel=None,
24
+ headers=None,
25
+ **options,
26
+ ):
27
+ """Fix celery's Task.apply() method to propagate options to the task result just like apply_async does."""
28
+ # trace imports Task, so need to import inline.
29
+ from celery.app.trace import build_tracer
30
+
31
+ app = self._get_app()
32
+ args = args or ()
33
+ kwargs = kwargs or {}
34
+ task_id = task_id or uuid()
35
+ retries = retries or 0
36
+ if throw is None:
37
+ throw = app.conf.task_eager_propagates
38
+
39
+ # Make sure we get the task instance, not class.
40
+ task = app._tasks[self.name]
41
+
42
+ request = {
43
+ "id": task_id,
44
+ "retries": retries,
45
+ "is_eager": True,
46
+ "logfile": logfile,
47
+ "loglevel": loglevel or 0,
48
+ "hostname": gethostname(),
49
+ "callbacks": maybe_list(link),
50
+ "errbacks": maybe_list(link_error),
51
+ "headers": headers,
52
+ "ignore_result": options.get("ignore_result", False),
53
+ "delivery_info": {
54
+ "is_eager": True,
55
+ "exchange": options.get("exchange"),
56
+ "routing_key": options.get("routing_key"),
57
+ "priority": options.get("priority"),
58
+ },
59
+ "properties": options, # <------- this is the one line fix to the overloaded method
60
+ }
61
+ if "stamped_headers" in options:
62
+ request["stamped_headers"] = maybe_list(options["stamped_headers"])
63
+ request["stamps"] = {header: maybe_list(options.get(header, [])) for header in request["stamped_headers"]}
64
+
65
+ tb = None
66
+ tracer = build_tracer(
67
+ task.name,
68
+ task,
69
+ eager=True,
70
+ propagate=throw,
71
+ app=self._get_app(),
72
+ )
73
+ ret = tracer(task_id, args, kwargs, request)
74
+ retval = ret.retval
75
+ if isinstance(retval, ExceptionInfo):
76
+ retval, tb = retval.exception, retval.traceback
77
+ if isinstance(retval, ExceptionWithTraceback):
78
+ retval = retval.exc
79
+ if isinstance(retval, Retry) and retval.sig is not None:
80
+ return retval.sig.apply(retries=retries + 1)
81
+ state = states.SUCCESS if ret.info is None else ret.info.state
82
+ return EagerResult(task_id, retval, state, traceback=tb)
83
+
11
84
 
12
85
  Task = NautobotTask # So that the class path resolves.
@@ -45,7 +45,7 @@ from nautobot.extras.graphql.types import DynamicGroupType, TagType
45
45
  from nautobot.extras.models import ComputedField, CustomField, Relationship
46
46
  from nautobot.extras.registry import registry
47
47
  from nautobot.extras.utils import check_if_key_is_graphql_safe
48
- from nautobot.ipam.graphql.types import IPAddressType, PrefixType
48
+ from nautobot.ipam.graphql.types import IPAddressType, PrefixType, VLANType
49
49
  from nautobot.virtualization.graphql.types import VirtualMachineType, VMInterfaceType
50
50
 
51
51
  logger = logging.getLogger(__name__)
@@ -71,6 +71,7 @@ registry["graphql_types"]["extras.tag"] = TagType
71
71
  registry["graphql_types"]["extras.dynamicgroup"] = DynamicGroupType
72
72
  registry["graphql_types"]["ipam.ipaddress"] = IPAddressType
73
73
  registry["graphql_types"]["ipam.prefix"] = PrefixType
74
+ registry["graphql_types"]["ipam.vlan"] = VLANType
74
75
  registry["graphql_types"]["virtualization.virtualmachine"] = VirtualMachineType
75
76
  registry["graphql_types"]["virtualization.vminterface"] = VMInterfaceType
76
77
 
@@ -50,7 +50,8 @@ class GitRepositorySync(Job):
50
50
  # Given that the above succeeded, tell all workers (including ourself) to call ensure_git_repository()
51
51
  app.control.broadcast("refresh_git_repository", repository_pk=repository.pk, head=repository.current_head)
52
52
  finally:
53
- self.logger.info(f"Repository synchronization completed in {job_result.duration}")
53
+ if job_result.duration:
54
+ self.logger.info("Repository synchronization completed in %s", job_result.duration)
54
55
 
55
56
 
56
57
  class GitRepositoryDryRun(Job):
@@ -74,7 +74,7 @@
74
74
  class="remove-filter-param"
75
75
  title="Remove all items"
76
76
  data-field-type="parent"
77
- data-field-value={{ field.name }}
77
+ data-field-value="{{ field.name }}"
78
78
  >×</span>
79
79
  <ul class="filter-selection-rendered">
80
80
  {% for value in field.values %}
@@ -85,8 +85,8 @@
85
85
  <span
86
86
  class="filter-selection-choice-remove remove-filter-param"
87
87
  data-field-type="child"
88
- data-field-parent={{ field.name }}
89
- data-field-value={{ value.name }}
88
+ data-field-parent="{{ field.name }}"
89
+ data-field-value="{{ value.name }}"
90
90
  >×</span>{{ value.display }}
91
91
  </li>
92
92
  {% endfor %}
@@ -8,7 +8,7 @@ from django.conf import settings
8
8
  from django.contrib.staticfiles.finders import find
9
9
  from django.templatetags.static import static, StaticNode
10
10
  from django.urls import NoReverseMatch, reverse
11
- from django.utils.html import format_html
11
+ from django.utils.html import format_html, format_html_join
12
12
  from django.utils.safestring import mark_safe
13
13
  from django.utils.text import slugify as django_slugify
14
14
  from django_jinja import library
@@ -71,14 +71,7 @@ def hyperlinked_object(value, field="display"):
71
71
  >>> hyperlinked_object(location, "name")
72
72
  '<a href="/dcim/locations/leaf/">Leaf</a>'
73
73
  """
74
- if value is None:
75
- return placeholder(value)
76
- display = getattr(value, field) if hasattr(value, field) else str(value)
77
- if hasattr(value, "get_absolute_url"):
78
- if hasattr(value, "description") and value.description:
79
- return format_html('<a href="{}" title="{}">{}</a>', value.get_absolute_url(), value.description, display)
80
- return format_html('<a href="{}">{}</a>', value.get_absolute_url(), display)
81
- return format_html("{}", display)
74
+ return _build_hyperlink(value, field)
82
75
 
83
76
 
84
77
  @library.filter()
@@ -822,3 +815,67 @@ def queryset_to_pks(obj):
822
815
  result = list(obj.values_list("pk", flat=True)) if obj else []
823
816
  result = [str(entry) for entry in result]
824
817
  return ",".join(result)
818
+
819
+
820
+ @library.filter()
821
+ @register.filter()
822
+ def hyperlinked_object_target_new_tab(value, field="display"):
823
+ """Render and link to a Django model instance, if any, or render a placeholder if not.
824
+
825
+ Similar to the hyperlinked_object filter, but passes attributes needed to open the link in new tab.
826
+
827
+ Uses the specified object field if available, otherwise uses the string representation of the object.
828
+ If the object defines `get_absolute_url()` this will be used to hyperlink the displayed object;
829
+ additionally if there is an `object.description` this will be used as the title of the hyperlink.
830
+
831
+ Args:
832
+ value (Union[django.db.models.Model, None]): Instance of a Django model or None.
833
+ field (Optional[str]): Name of the field to use for the display value. Defaults to "display".
834
+
835
+ Returns:
836
+ (str): String representation of the value (hyperlinked if it defines get_absolute_url()) or a placeholder.
837
+
838
+ Examples:
839
+ >>> hyperlinked_object_target_new_tab(device)
840
+ '<a href="/dcim/devices/3faafe8c-bdd6-4317-88dc-f791e6988caa/" target="_blank" rel="noreferrer">Device 1</a>'
841
+ >>> hyperlinked_object_target_new_tab(device_role)
842
+ '<a href="/dcim/device-roles/router/" title="Devices that are routers, not switches" target="_blank" rel="noreferrer">Router</a>'
843
+ >>> hyperlinked_object_target_new_tab(None)
844
+ '<span class="text-muted">&mdash;</span>'
845
+ >>> hyperlinked_object_target_new_tab("Hello")
846
+ 'Hello'
847
+ >>> hyperlinked_object_target_new_tab(location)
848
+ '<a href="/dcim/locations/leaf/" target="_blank" rel="noreferrer">Root → Intermediate → Leaf</a>'
849
+ >>> hyperlinked_object_target_new_tab(location, "name")
850
+ '<a href="/dcim/locations/leaf/" target="_blank" rel="noreferrer">Leaf</a>'
851
+ """
852
+ return _build_hyperlink(value, field, target="_blank", rel="noreferrer")
853
+
854
+
855
+ def _build_hyperlink(value, field="", target="", rel=""):
856
+ """Internal function used by filters to build hyperlinks.
857
+
858
+ Args:
859
+ value (Union[django.db.models.Model, None]): Instance of a Django model or None.
860
+ field (Optional[str]): Name of the field to use for the display value. Defaults to "display".
861
+ target (Optional[str]): Location to open the linked document. Defaults to "" which is _self.
862
+ rel (Optional[str]): Relationship between current document and linked document. Defaults to "".
863
+
864
+ Returns:
865
+ (str): String representation of the value (hyperlinked if it defines get_absolute_url()) or a placeholder.
866
+ """
867
+ if value is None:
868
+ return placeholder(value)
869
+
870
+ attributes = {}
871
+ display = getattr(value, field) if hasattr(value, field) else str(value)
872
+ if hasattr(value, "get_absolute_url"):
873
+ attributes["href"] = value.get_absolute_url()
874
+ if hasattr(value, "description") and value.description:
875
+ attributes["title"] = value.description
876
+ if target:
877
+ attributes["target"] = target
878
+ if rel:
879
+ attributes["rel"] = rel
880
+ return format_html("<a {}>{}</a>", format_html_join(" ", '{}="{}"', attributes.items()), display)
881
+ return format_html("{}", display)
@@ -102,7 +102,12 @@ def get_job_class_and_model(module, name, source="local"):
102
102
  (JobClassInfo): Named 2-tuple of (job_class, job_model)
103
103
  """
104
104
  job_class = get_job(f"{module}.{name}")
105
- job_model = Job.objects.get(module_name=module, job_class_name=name)
105
+ try:
106
+ job_model = Job.objects.get(module_name=module, job_class_name=name)
107
+ except Job.DoesNotExist:
108
+ raise RuntimeError(
109
+ f"Job database record for {module}.{name} not found. Known jobs are: {list(Job.objects.all())}"
110
+ )
106
111
  job_model.enabled = True
107
112
  job_model.validated_save()
108
113
  return JobClassInfo(job_class, job_model)