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/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 ObjectNewContactView(generic.ObjectEditView):
408
- queryset = Contact.objects.all()
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
- association = ContactAssociation(
432
- contact=obj,
433
- associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
434
- associated_object_id=request.POST.get("associated_object_id"),
435
- status=Status.objects.get(id=request.POST.get("status")),
436
- role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
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 ObjectNewTeamView(generic.ObjectEditView):
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 = ("display",)
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
- try:
1309
- job_class = job_model.job_class
1310
- except TypeError as exc:
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
- job_form = job_model.job_class.as_form(request.POST, request.FILES) if job_model.job_class is not None else None
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 job_model.job_class is None:
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.job_class.registered_name,
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=job_model.job_class.serialize_data(job_form.cleaned_data),
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 = job_model.job_class.prepare_job_kwargs(job_form.cleaned_data)
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
- **job_model.job_class.serialize_data(job_kwargs),
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 job_model.job_class is not None and hasattr(job_model.job_class, "template_name"):
1425
+ if job_class is not None and hasattr(job_class, "template_name"):
1475
1426
  try:
1476
- get_template(job_model.job_class.template_name)
1477
- template_name = job_model.job_class.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.job_class
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 = job_model.job_class.prepare_job_kwargs(scheduled_job.kwargs or {})
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
- **job_model.job_class.serialize_data(job_kwargs),
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] = var
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)}
@@ -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(detail=True, url_path="available-prefixes", methods=["get", "post"], filterset_class=None)
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(),
@@ -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
@@ -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 = (
@@ -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
@@ -137,3 +137,7 @@ img.copyright-logo {
137
137
  -webkit-mask-image: var(--md-admonition-icon--version-removed);
138
138
  mask-image: var(--md-admonition-icon--version-removed);
139
139
  }
140
+
141
+ .md-typeset .tabbed-set {
142
+ border: 0.5px solid gray;
143
+ }
@@ -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">,</span> <span class="n">suffix</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></code>
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">&para;</a></h2>
11924
11924