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/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
|
#
|
nautobot/extras/api/views.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
**
|
|
980
|
+
**job_class.serialize_data(job_kwargs),
|
|
970
981
|
)
|
|
971
982
|
serializer = serializers.JobResultSerializer(job_result, context={"request": request})
|
|
972
983
|
|
nautobot/extras/constants.py
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
741
|
-
if
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
762
|
+
refresh_job_code_from_repository(repository_record.slug, ignore_import_errors=False)
|
|
777
763
|
|
|
778
|
-
for
|
|
779
|
-
if not
|
|
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,
|
|
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 =
|
|
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
|
-
#
|
|
808
|
-
|
|
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:
|
nautobot/extras/forms/forms.py
CHANGED
|
@@ -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.
|
|
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):
|