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/extras/views.py
CHANGED
|
@@ -404,10 +404,8 @@ class ContactAssociationUIViewSet(
|
|
|
404
404
|
non_filter_params = ("export", "page", "per_page", "sort")
|
|
405
405
|
|
|
406
406
|
|
|
407
|
-
class
|
|
408
|
-
|
|
409
|
-
model_form = forms.ObjectNewContactForm
|
|
410
|
-
template_name = "extras/object_new_contact.html"
|
|
407
|
+
class ObjectContactTeamMixin:
|
|
408
|
+
"""Mixin that contains a custom post() method to create a new contact/team and assign it to an existing object"""
|
|
411
409
|
|
|
412
410
|
def post(self, request, *args, **kwargs):
|
|
413
411
|
obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
|
|
@@ -428,13 +426,22 @@ class ObjectNewContactView(generic.ObjectEditView):
|
|
|
428
426
|
if hasattr(form, "save_note") and callable(form.save_note):
|
|
429
427
|
form.save_note(instance=obj, user=request.user)
|
|
430
428
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
429
|
+
if isinstance(obj, Contact):
|
|
430
|
+
association = ContactAssociation(
|
|
431
|
+
contact=obj,
|
|
432
|
+
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
433
|
+
associated_object_id=request.POST.get("associated_object_id"),
|
|
434
|
+
status=Status.objects.get(id=request.POST.get("status")),
|
|
435
|
+
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
association = ContactAssociation(
|
|
439
|
+
team=obj,
|
|
440
|
+
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
441
|
+
associated_object_id=request.POST.get("associated_object_id"),
|
|
442
|
+
status=Status.objects.get(id=request.POST.get("status")),
|
|
443
|
+
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
444
|
+
)
|
|
438
445
|
association.validated_save()
|
|
439
446
|
self.successful_post(request, obj, object_created, logger)
|
|
440
447
|
|
|
@@ -474,75 +481,17 @@ class ObjectNewContactView(generic.ObjectEditView):
|
|
|
474
481
|
)
|
|
475
482
|
|
|
476
483
|
|
|
477
|
-
class
|
|
484
|
+
class ObjectNewContactView(ObjectContactTeamMixin, generic.ObjectEditView):
|
|
485
|
+
queryset = Contact.objects.all()
|
|
486
|
+
model_form = forms.ObjectNewContactForm
|
|
487
|
+
template_name = "extras/object_new_contact.html"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ObjectNewTeamView(ObjectContactTeamMixin, generic.ObjectEditView):
|
|
478
491
|
queryset = Team.objects.all()
|
|
479
492
|
model_form = forms.ObjectNewTeamForm
|
|
480
493
|
template_name = "extras/object_new_team.html"
|
|
481
494
|
|
|
482
|
-
def post(self, request, *args, **kwargs):
|
|
483
|
-
obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
|
|
484
|
-
form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
|
|
485
|
-
restrict_form_fields(form, request.user)
|
|
486
|
-
|
|
487
|
-
if form.is_valid():
|
|
488
|
-
logger.debug("Form validation was successful")
|
|
489
|
-
|
|
490
|
-
try:
|
|
491
|
-
with transaction.atomic():
|
|
492
|
-
object_created = not form.instance.present_in_database
|
|
493
|
-
obj = form.save()
|
|
494
|
-
|
|
495
|
-
# Check that the new object conforms with any assigned object-level permissions
|
|
496
|
-
self.queryset.get(pk=obj.pk)
|
|
497
|
-
|
|
498
|
-
if hasattr(form, "save_note") and callable(form.save_note):
|
|
499
|
-
form.save_note(instance=obj, user=request.user)
|
|
500
|
-
|
|
501
|
-
association = ContactAssociation(
|
|
502
|
-
team=obj,
|
|
503
|
-
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
504
|
-
associated_object_id=request.POST.get("associated_object_id"),
|
|
505
|
-
status=Status.objects.get(id=request.POST.get("status")),
|
|
506
|
-
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
507
|
-
)
|
|
508
|
-
association.validated_save()
|
|
509
|
-
self.successful_post(request, obj, object_created, logger)
|
|
510
|
-
|
|
511
|
-
if "_addanother" in request.POST:
|
|
512
|
-
# If the object has clone_fields, pre-populate a new instance of the form
|
|
513
|
-
if hasattr(obj, "clone_fields"):
|
|
514
|
-
url = f"{request.path}?{prepare_cloned_fields(obj)}"
|
|
515
|
-
return redirect(url)
|
|
516
|
-
|
|
517
|
-
return redirect(request.get_full_path())
|
|
518
|
-
|
|
519
|
-
return_url = form.cleaned_data.get("return_url")
|
|
520
|
-
if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
|
|
521
|
-
return redirect(iri_to_uri(return_url))
|
|
522
|
-
else:
|
|
523
|
-
return redirect(self.get_return_url(request, obj))
|
|
524
|
-
|
|
525
|
-
except ObjectDoesNotExist:
|
|
526
|
-
msg = "Object save failed due to object-level permissions violation"
|
|
527
|
-
logger.debug(msg)
|
|
528
|
-
form.add_error(None, msg)
|
|
529
|
-
|
|
530
|
-
else:
|
|
531
|
-
logger.debug("Form validation failed")
|
|
532
|
-
|
|
533
|
-
return render(
|
|
534
|
-
request,
|
|
535
|
-
self.template_name,
|
|
536
|
-
{
|
|
537
|
-
"obj": obj,
|
|
538
|
-
"obj_type": self.queryset.model._meta.verbose_name,
|
|
539
|
-
"form": form,
|
|
540
|
-
"return_url": self.get_return_url(request, obj),
|
|
541
|
-
"editing": obj.present_in_database,
|
|
542
|
-
**self.get_extra_context(request, obj),
|
|
543
|
-
},
|
|
544
|
-
)
|
|
545
|
-
|
|
546
495
|
|
|
547
496
|
class ObjectAssignContactOrTeamView(generic.ObjectEditView):
|
|
548
497
|
queryset = ContactAssociation.objects.all()
|
|
@@ -1250,7 +1199,10 @@ class JobListView(generic.ObjectListView):
|
|
|
1250
1199
|
filterset = filters.JobFilterSet
|
|
1251
1200
|
filterset_form = forms.JobFilterForm
|
|
1252
1201
|
action_buttons = ()
|
|
1253
|
-
non_filter_params = (
|
|
1202
|
+
non_filter_params = (
|
|
1203
|
+
*generic.ObjectListView.non_filter_params,
|
|
1204
|
+
"display",
|
|
1205
|
+
)
|
|
1254
1206
|
template_name = "extras/job_list.html"
|
|
1255
1207
|
|
|
1256
1208
|
def alter_queryset(self, request):
|
|
@@ -1305,11 +1257,9 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1305
1257
|
job_model = self._get_job_model_or_404(class_path, pk)
|
|
1306
1258
|
|
|
1307
1259
|
try:
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
# job_class may be None
|
|
1312
|
-
raise RuntimeError("Job code for this job is not currently installed or loadable") from exc
|
|
1260
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1261
|
+
if job_class is None:
|
|
1262
|
+
raise RuntimeError("Job code for this job is not currently installed or loadable")
|
|
1313
1263
|
initial = normalize_querydict(request.GET, form_class=job_class.as_form_class())
|
|
1314
1264
|
if "kwargs_from_job_result" in initial:
|
|
1315
1265
|
job_result_pk = initial.pop("kwargs_from_job_result")
|
|
@@ -1357,7 +1307,8 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1357
1307
|
def post(self, request, class_path=None, pk=None):
|
|
1358
1308
|
job_model = self._get_job_model_or_404(class_path, pk)
|
|
1359
1309
|
|
|
1360
|
-
|
|
1310
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1311
|
+
job_form = job_class.as_form(request.POST, request.FILES) if job_class is not None else None
|
|
1361
1312
|
schedule_form = forms.JobScheduleForm(request.POST)
|
|
1362
1313
|
task_queue = request.POST.get("_task_queue")
|
|
1363
1314
|
|
|
@@ -1370,7 +1321,7 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1370
1321
|
# Allow execution only if a worker process is running and the job is runnable.
|
|
1371
1322
|
if not get_worker_count(queue=task_queue):
|
|
1372
1323
|
messages.error(request, "Unable to run or schedule job: Celery worker process not running.")
|
|
1373
|
-
elif not job_model.installed or
|
|
1324
|
+
elif not job_model.installed or job_class is None:
|
|
1374
1325
|
messages.error(request, "Unable to run or schedule job: Job is not presently installed.")
|
|
1375
1326
|
elif not job_model.enabled:
|
|
1376
1327
|
messages.error(request, "Unable to run or schedule job: Job is not enabled to be run.")
|
|
@@ -1422,11 +1373,11 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1422
1373
|
celery_kwargs = {"nautobot_job_profile": profile, "queue": task_queue}
|
|
1423
1374
|
scheduled_job = ScheduledJob(
|
|
1424
1375
|
name=schedule_name,
|
|
1425
|
-
task=job_model.
|
|
1376
|
+
task=job_model.class_path,
|
|
1426
1377
|
job_model=job_model,
|
|
1427
1378
|
start_time=schedule_datetime,
|
|
1428
1379
|
description=f"Nautobot job {schedule_name} scheduled by {request.user} for {schedule_datetime}",
|
|
1429
|
-
kwargs=
|
|
1380
|
+
kwargs=job_class.serialize_data(job_form.cleaned_data),
|
|
1430
1381
|
celery_kwargs=celery_kwargs,
|
|
1431
1382
|
interval=schedule_type,
|
|
1432
1383
|
one_off=schedule_type == JobExecutionType.TYPE_FUTURE,
|
|
@@ -1446,13 +1397,13 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1446
1397
|
|
|
1447
1398
|
else:
|
|
1448
1399
|
# Enqueue job for immediate execution
|
|
1449
|
-
job_kwargs =
|
|
1400
|
+
job_kwargs = job_class.prepare_job_kwargs(job_form.cleaned_data)
|
|
1450
1401
|
job_result = JobResult.enqueue_job(
|
|
1451
1402
|
job_model,
|
|
1452
1403
|
request.user,
|
|
1453
1404
|
profile=profile,
|
|
1454
1405
|
task_queue=task_queue,
|
|
1455
|
-
**
|
|
1406
|
+
**job_class.serialize_data(job_kwargs),
|
|
1456
1407
|
)
|
|
1457
1408
|
|
|
1458
1409
|
if return_url:
|
|
@@ -1471,10 +1422,10 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1471
1422
|
return redirect(return_url)
|
|
1472
1423
|
|
|
1473
1424
|
template_name = "extras/job.html"
|
|
1474
|
-
if
|
|
1425
|
+
if job_class is not None and hasattr(job_class, "template_name"):
|
|
1475
1426
|
try:
|
|
1476
|
-
get_template(
|
|
1477
|
-
template_name =
|
|
1427
|
+
get_template(job_class.template_name)
|
|
1428
|
+
template_name = job_class.template_name
|
|
1478
1429
|
except TemplateDoesNotExist as err:
|
|
1479
1430
|
messages.error(request, f'Unable to render requested custom job template "{template_name}": {err}')
|
|
1480
1431
|
|
|
@@ -1555,7 +1506,7 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1555
1506
|
"""
|
|
1556
1507
|
job_model = instance.job_model
|
|
1557
1508
|
if job_model is not None:
|
|
1558
|
-
job_class = job_model.
|
|
1509
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1559
1510
|
else:
|
|
1560
1511
|
# 2.0 TODO: remove this fallback?
|
|
1561
1512
|
job_class = get_job(instance.job_class)
|
|
@@ -1591,6 +1542,7 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1591
1542
|
dry_run = "_dry_run" in post_data
|
|
1592
1543
|
|
|
1593
1544
|
job_model = scheduled_job.job_model
|
|
1545
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1594
1546
|
|
|
1595
1547
|
if dry_run:
|
|
1596
1548
|
# To dry-run a job, a user needs the same permissions that would be needed to run the job directly
|
|
@@ -1604,13 +1556,13 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1604
1556
|
messages.error(request, "This job does not support dryrun")
|
|
1605
1557
|
else:
|
|
1606
1558
|
# Immediately enqueue the job and send the user to the normal JobResult view
|
|
1607
|
-
job_kwargs =
|
|
1559
|
+
job_kwargs = job_class.prepare_job_kwargs(scheduled_job.kwargs or {})
|
|
1608
1560
|
job_kwargs["dryrun"] = True
|
|
1609
1561
|
job_result = JobResult.enqueue_job(
|
|
1610
1562
|
job_model,
|
|
1611
1563
|
request.user,
|
|
1612
1564
|
celery_kwargs=scheduled_job.celery_kwargs,
|
|
1613
|
-
**
|
|
1565
|
+
**job_class.serialize_data(job_kwargs),
|
|
1614
1566
|
)
|
|
1615
1567
|
|
|
1616
1568
|
return redirect("extras:jobresult", pk=job_result.pk)
|
|
@@ -1697,7 +1649,7 @@ class ScheduledJobView(generic.ObjectView):
|
|
|
1697
1649
|
for name, var in job_class._get_vars().items():
|
|
1698
1650
|
field = var.as_field()
|
|
1699
1651
|
if field.label:
|
|
1700
|
-
labels[name] =
|
|
1652
|
+
labels[name] = field.label
|
|
1701
1653
|
else:
|
|
1702
1654
|
labels[name] = pretty_name(name)
|
|
1703
1655
|
return {"labels": labels, "job_class_found": (job_class is not None)}
|
nautobot/ipam/api/views.py
CHANGED
|
@@ -160,7 +160,13 @@ class PrefixViewSet(NautobotModelViewSet):
|
|
|
160
160
|
|
|
161
161
|
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
|
162
162
|
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=False)})
|
|
163
|
-
@action(
|
|
163
|
+
@action(
|
|
164
|
+
detail=True,
|
|
165
|
+
name="Available Prefixes",
|
|
166
|
+
url_path="available-prefixes",
|
|
167
|
+
methods=["get", "post"],
|
|
168
|
+
filterset_class=None,
|
|
169
|
+
)
|
|
164
170
|
def available_prefixes(self, request, pk=None):
|
|
165
171
|
"""
|
|
166
172
|
A convenience method for returning available child prefixes within a parent.
|
|
@@ -237,6 +243,7 @@ class PrefixViewSet(NautobotModelViewSet):
|
|
|
237
243
|
)
|
|
238
244
|
@action(
|
|
239
245
|
detail=True,
|
|
246
|
+
name="Available IPs",
|
|
240
247
|
url_path="available-ips",
|
|
241
248
|
methods=["get", "post"],
|
|
242
249
|
queryset=IPAddress.objects.all(),
|
nautobot/ipam/graphql/types.py
CHANGED
|
@@ -26,6 +26,7 @@ class PrefixType(OptimizedNautobotObjectType):
|
|
|
26
26
|
prefix = graphene.String()
|
|
27
27
|
ip_version = graphene.Int()
|
|
28
28
|
dynamic_groups = graphene.List("nautobot.extras.graphql.types.DynamicGroupType")
|
|
29
|
+
location = graphene.Field("nautobot.dcim.graphql.types.LocationType")
|
|
29
30
|
|
|
30
31
|
class Meta:
|
|
31
32
|
model = models.Prefix
|
|
@@ -33,3 +34,13 @@ class PrefixType(OptimizedNautobotObjectType):
|
|
|
33
34
|
|
|
34
35
|
def resolve_dynamic_groups(self, args):
|
|
35
36
|
return DynamicGroup.objects.get_for_object(self, use_cache=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VLANType(OptimizedNautobotObjectType):
|
|
40
|
+
"""Graphql Type Object for VLAN model."""
|
|
41
|
+
|
|
42
|
+
location = graphene.Field("nautobot.dcim.graphql.types.LocationType")
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
model = models.VLAN
|
|
46
|
+
filterset_class = filters.VLANFilterSet
|
nautobot/ipam/mixins.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class LocationToLocationsQuerySetMixin:
|
|
2
|
+
"""
|
|
3
|
+
A mixin for Django QuerySets to support backward compatibility by converting
|
|
4
|
+
queries from a previously used 'location' field to the new
|
|
5
|
+
'locations'. This mixin intercepts `filter` and `exclude` calls
|
|
6
|
+
to transform references from 'location' to 'locations'.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def _convert_location_to_locations(self, kwargs):
|
|
10
|
+
"""Transforms query parameters that reference 'location' field into the corresponding 'locations' field."""
|
|
11
|
+
updated_kwargs = {}
|
|
12
|
+
for field, value in kwargs.items():
|
|
13
|
+
if field == "location":
|
|
14
|
+
# If there is no lookup expression, it means 'location' is queried directly,
|
|
15
|
+
# thus use 'locations__in' to accommodate the ManyToMany relationship
|
|
16
|
+
updated_kwargs["locations__in"] = [value]
|
|
17
|
+
elif field.startswith("location__"):
|
|
18
|
+
# If there is a lookup expression following 'location', prepend it with 'locations'
|
|
19
|
+
_, lookup_expr = field.split("location", maxsplit=1)
|
|
20
|
+
locations_field = f"locations{lookup_expr}".strip()
|
|
21
|
+
updated_kwargs[locations_field] = value
|
|
22
|
+
else:
|
|
23
|
+
updated_kwargs[field] = value
|
|
24
|
+
return updated_kwargs
|
|
25
|
+
|
|
26
|
+
def filter(self, *args, **kwargs):
|
|
27
|
+
kwargs = self._convert_location_to_locations(kwargs)
|
|
28
|
+
return super().filter(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
def exclude(self, *args, **kwargs):
|
|
31
|
+
kwargs = self._convert_location_to_locations(kwargs)
|
|
32
|
+
return super().exclude(*args, **kwargs)
|
nautobot/ipam/models.py
CHANGED
|
@@ -22,7 +22,7 @@ from nautobot.ipam import choices, constants
|
|
|
22
22
|
from nautobot.virtualization.models import VMInterface
|
|
23
23
|
|
|
24
24
|
from .fields import VarbinaryIPField
|
|
25
|
-
from .querysets import IPAddressQuerySet, PrefixQuerySet, RIRQuerySet
|
|
25
|
+
from .querysets import IPAddressQuerySet, PrefixQuerySet, RIRQuerySet, VLANQuerySet
|
|
26
26
|
from .validators import DNSValidator
|
|
27
27
|
|
|
28
28
|
__all__ = (
|
|
@@ -1380,6 +1380,7 @@ class VLAN(PrimaryModel):
|
|
|
1380
1380
|
]
|
|
1381
1381
|
|
|
1382
1382
|
natural_key_field_names = ["pk"]
|
|
1383
|
+
objects = BaseManager.from_queryset(VLANQuerySet)()
|
|
1383
1384
|
|
|
1384
1385
|
class Meta:
|
|
1385
1386
|
ordering = (
|
nautobot/ipam/querysets.py
CHANGED
|
@@ -6,6 +6,7 @@ import netaddr
|
|
|
6
6
|
|
|
7
7
|
from nautobot.core.models.querysets import RestrictedQuerySet
|
|
8
8
|
from nautobot.core.utils.data import merge_dicts_without_collision
|
|
9
|
+
from nautobot.ipam.mixins import LocationToLocationsQuerySetMixin
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class RIRQuerySet(RestrictedQuerySet):
|
|
@@ -194,7 +195,7 @@ class BaseNetworkQuerySet(RestrictedQuerySet):
|
|
|
194
195
|
return ip, last_ip
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
class PrefixQuerySet(BaseNetworkQuerySet):
|
|
198
|
+
class PrefixQuerySet(LocationToLocationsQuerySetMixin, BaseNetworkQuerySet):
|
|
198
199
|
"""Queryset for `Prefix` objects."""
|
|
199
200
|
|
|
200
201
|
def net_equals(self, *prefixes):
|
|
@@ -474,3 +475,7 @@ class IPAddressQuerySet(BaseNetworkQuerySet):
|
|
|
474
475
|
q |= Q(pk__in=pk_values)
|
|
475
476
|
|
|
476
477
|
return super().filter(q)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class VLANQuerySet(LocationToLocationsQuerySetMixin, RestrictedQuerySet):
|
|
481
|
+
"""Queryset for `VLAN` objects."""
|
|
@@ -295,6 +295,50 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
|
|
|
295
295
|
self.child1 = Prefix.objects.create(prefix="101.102.0.0/26", status=self.status, namespace=self.namespace)
|
|
296
296
|
self.child2 = Prefix.objects.create(prefix="101.102.0.64/26", status=self.status, namespace=self.namespace)
|
|
297
297
|
|
|
298
|
+
def test_location_queries(self):
|
|
299
|
+
locations = Location.objects.all()[:4]
|
|
300
|
+
for location in locations:
|
|
301
|
+
location.location_type.content_types.add(ContentType.objects.get_for_model(Prefix))
|
|
302
|
+
for i in range(10):
|
|
303
|
+
pfx = Prefix.objects.create(prefix=f"1.1.1.{4*i}/30", status=self.status, namespace=self.namespace)
|
|
304
|
+
if i > 4:
|
|
305
|
+
pfx.locations.set(locations)
|
|
306
|
+
|
|
307
|
+
with self.subTest("Assert filtering and excluding `location`"):
|
|
308
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
309
|
+
Prefix.objects.filter(location=locations[0]),
|
|
310
|
+
Prefix.objects.filter(locations__in=[locations[0]]),
|
|
311
|
+
)
|
|
312
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
313
|
+
Prefix.objects.exclude(location=locations[0]),
|
|
314
|
+
Prefix.objects.exclude(locations__in=[locations[0]]),
|
|
315
|
+
)
|
|
316
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
317
|
+
Prefix.objects.filter(location__in=[locations[0]]),
|
|
318
|
+
Prefix.objects.filter(locations__in=[locations[0]]),
|
|
319
|
+
)
|
|
320
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
321
|
+
Prefix.objects.exclude(location__in=[locations[0]]),
|
|
322
|
+
Prefix.objects.exclude(locations__in=[locations[0]]),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# We use `assertQuerysetEqualAndNotEmpty` for test validation. Including a nullable field could lead
|
|
326
|
+
# to flaky tests where querysets might return None, causing tests to fail. Therefore, we select
|
|
327
|
+
# fields that consistently contain values to ensure reliable filtering.
|
|
328
|
+
query_params = ["name", "location_type", "status"]
|
|
329
|
+
|
|
330
|
+
for field_name in query_params:
|
|
331
|
+
with self.subTest(f"Assert location__{field_name} query."):
|
|
332
|
+
value = getattr(locations[0], field_name)
|
|
333
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
334
|
+
Prefix.objects.filter(**{f"location__{field_name}": value}),
|
|
335
|
+
Prefix.objects.filter(**{f"locations__{field_name}": value}),
|
|
336
|
+
)
|
|
337
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
338
|
+
Prefix.objects.exclude(**{f"location__{field_name}": value}),
|
|
339
|
+
Prefix.objects.exclude(**{f"locations__{field_name}": value}),
|
|
340
|
+
)
|
|
341
|
+
|
|
298
342
|
def test_prefix_validation(self):
|
|
299
343
|
location_type = LocationType.objects.get(name="Room")
|
|
300
344
|
location = Location.objects.filter(location_type=location_type).first()
|
|
@@ -1201,6 +1245,44 @@ class TestVLAN(ModelTestCases.BaseModelTestCase):
|
|
|
1201
1245
|
location.vlans.add(vlan)
|
|
1202
1246
|
self.assertIn(f"{location} is a Floor and may not have VLANs associated to it.", str(cm.exception))
|
|
1203
1247
|
|
|
1248
|
+
def test_location_queries(self):
|
|
1249
|
+
location = VLAN.objects.filter(locations__isnull=False).first().locations.first()
|
|
1250
|
+
|
|
1251
|
+
with self.subTest("Assert filtering and excluding `location`"):
|
|
1252
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1253
|
+
VLAN.objects.filter(location=location),
|
|
1254
|
+
VLAN.objects.filter(locations__in=[location]),
|
|
1255
|
+
)
|
|
1256
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1257
|
+
VLAN.objects.exclude(location=location),
|
|
1258
|
+
VLAN.objects.exclude(locations__in=[location]),
|
|
1259
|
+
)
|
|
1260
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1261
|
+
VLAN.objects.filter(location__in=[location]),
|
|
1262
|
+
VLAN.objects.filter(locations__in=[location]),
|
|
1263
|
+
)
|
|
1264
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1265
|
+
VLAN.objects.exclude(location__in=[location]),
|
|
1266
|
+
VLAN.objects.exclude(locations__in=[location]),
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# We use `assertQuerysetEqualAndNotEmpty` for test validation. Including a nullable field could lead
|
|
1270
|
+
# to flaky tests where querysets might return None, causing tests to fail. Therefore, we select
|
|
1271
|
+
# fields that consistently contain values to ensure reliable filtering.
|
|
1272
|
+
query_params = ["name", "location_type", "status"]
|
|
1273
|
+
|
|
1274
|
+
for field_name in query_params:
|
|
1275
|
+
with self.subTest(f"Assert location__{field_name} query."):
|
|
1276
|
+
value = getattr(location, field_name)
|
|
1277
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1278
|
+
VLAN.objects.filter(**{f"location__{field_name}": value}),
|
|
1279
|
+
VLAN.objects.filter(**{f"locations__{field_name}": value}),
|
|
1280
|
+
)
|
|
1281
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
1282
|
+
VLAN.objects.exclude(**{f"location__{field_name}": value}),
|
|
1283
|
+
VLAN.objects.exclude(**{f"locations__{field_name}": value}),
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1204
1286
|
|
|
1205
1287
|
class TestVRF(ModelTestCases.BaseModelTestCase):
|
|
1206
1288
|
model = VRF
|
|
@@ -11918,7 +11918,7 @@ data any serializer fields that do not correspond to a specific model field</p>
|
|
|
11918
11918
|
|
|
11919
11919
|
|
|
11920
11920
|
<h2 id="nautobot.apps.api.get_view_name" class="doc doc-heading">
|
|
11921
|
-
<code class="highlight language-python"><span class="n">nautobot</span><span class="o">.</span><span class="n">apps</span><span class="o">.</span><span class="n">api</span><span class="o">.</span><span class="n">get_view_name</span><span class="p">(</span><span class="n">view</span><span class="p"
|
|
11921
|
+
<code class="highlight language-python"><span class="n">nautobot</span><span class="o">.</span><span class="n">apps</span><span class="o">.</span><span class="n">api</span><span class="o">.</span><span class="n">get_view_name</span><span class="p">(</span><span class="n">view</span><span class="p">)</span></code>
|
|
11922
11922
|
|
|
11923
11923
|
<a href="#nautobot.apps.api.get_view_name" class="headerlink" title="Permanent link">¶</a></h2>
|
|
11924
11924
|
|