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.
- nautobot/apps/jobs.py +2 -0
- nautobot/core/api/utils.py +12 -9
- nautobot/core/apps/__init__.py +2 -2
- nautobot/core/celery/__init__.py +79 -68
- nautobot/core/celery/backends.py +9 -1
- nautobot/core/celery/control.py +4 -7
- nautobot/core/celery/schedulers.py +4 -2
- nautobot/core/celery/task.py +78 -5
- nautobot/core/graphql/schema.py +2 -1
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/templates/generic/object_list.html +3 -3
- nautobot/core/templatetags/helpers.py +66 -9
- nautobot/core/testing/__init__.py +6 -1
- nautobot/core/testing/api.py +12 -13
- nautobot/core/testing/mixins.py +2 -2
- nautobot/core/testing/views.py +50 -51
- nautobot/core/tests/test_api.py +23 -2
- nautobot/core/tests/test_templatetags_helpers.py +32 -0
- nautobot/core/tests/test_views.py +19 -0
- nautobot/core/tests/test_views_utils.py +22 -1
- nautobot/core/utils/module_loading.py +89 -0
- nautobot/core/views/utils.py +3 -2
- nautobot/dcim/choices.py +14 -0
- nautobot/dcim/forms.py +51 -1
- nautobot/dcim/models/device_components.py +9 -5
- nautobot/dcim/templates/dcim/location.html +32 -13
- nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
- nautobot/dcim/tests/test_views.py +137 -0
- nautobot/dcim/urls.py +5 -0
- nautobot/dcim/views.py +149 -1
- nautobot/extras/api/views.py +21 -10
- nautobot/extras/constants.py +3 -3
- nautobot/extras/datasources/git.py +47 -58
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/jobs.py +79 -146
- nautobot/extras/models/datasources.py +0 -2
- nautobot/extras/models/jobs.py +36 -18
- nautobot/extras/plugins/__init__.py +1 -20
- nautobot/extras/signals.py +6 -9
- nautobot/extras/test_jobs/__init__.py +8 -0
- nautobot/extras/test_jobs/dry_run.py +3 -2
- nautobot/extras/test_jobs/fail.py +43 -0
- nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
- nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
- nautobot/extras/test_jobs/pass.py +40 -0
- nautobot/extras/test_jobs/relative_import.py +11 -0
- nautobot/extras/tests/test_api.py +3 -0
- nautobot/extras/tests/test_datasources.py +125 -118
- nautobot/extras/tests/test_job_variables.py +57 -15
- nautobot/extras/tests/test_jobs.py +135 -1
- nautobot/extras/tests/test_models.py +26 -19
- nautobot/extras/tests/test_plugins.py +1 -3
- nautobot/extras/tests/test_views.py +2 -4
- nautobot/extras/views.py +47 -95
- nautobot/ipam/api/views.py +8 -1
- nautobot/ipam/graphql/types.py +11 -0
- nautobot/ipam/mixins.py +32 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/querysets.py +6 -1
- nautobot/ipam/tests/test_models.py +82 -0
- nautobot/project-static/docs/assets/extra.css +4 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
- nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
- nautobot/project-static/docs/development/core/application-registry.html +126 -84
- nautobot/project-static/docs/development/core/model-checklist.html +49 -1
- nautobot/project-static/docs/development/core/model-features.html +1 -1
- nautobot/project-static/docs/development/jobs/index.html +334 -58
- nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.2.html +237 -55
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +254 -254
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
- nautobot/project-static/js/forms.js +18 -11
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/RECORD +87 -81
- nautobot/extras/test_jobs/job_variables.py +0 -93
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
- {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",
|
nautobot/core/api/utils.py
CHANGED
|
@@ -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
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
nautobot/core/apps/__init__.py
CHANGED
|
@@ -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
|
|
921
|
+
from nautobot.core.celery import import_jobs
|
|
922
922
|
|
|
923
|
-
|
|
923
|
+
import_jobs()
|
|
924
924
|
|
|
925
925
|
|
|
926
926
|
class NautobotConstanceConfig(ConstanceConfig):
|
nautobot/core/celery/__init__.py
CHANGED
|
@@ -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
|
|
46
|
+
def import_jobs(sender=None, **kwargs):
|
|
57
47
|
"""
|
|
58
|
-
Import system Jobs into
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
155
|
-
multiprocess.MultiProcessCollector(
|
|
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=
|
|
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
|
-
"""
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
app.register_task(job)
|
|
224
|
+
if job.class_path not in registry["jobs"]:
|
|
225
|
+
registry["jobs"][job.class_path] = job
|
nautobot/core/celery/backends.py
CHANGED
|
@@ -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":
|
|
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),
|
nautobot/core/celery/control.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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)
|
nautobot/core/celery/task.py
CHANGED
|
@@ -1,12 +1,85 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
nautobot/core/graphql/schema.py
CHANGED
|
@@ -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
|
|
nautobot/core/jobs/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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">—</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
|
-
|
|
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)
|