nautobot 2.4.0__py3-none-any.whl → 2.4.1__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.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (40) hide show
  1. nautobot/core/celery/schedulers.py +1 -1
  2. nautobot/core/filters.py +48 -21
  3. nautobot/core/jobs/bulk_actions.py +56 -19
  4. nautobot/core/models/__init__.py +2 -0
  5. nautobot/core/tables.py +5 -1
  6. nautobot/core/testing/filters.py +25 -13
  7. nautobot/core/testing/integration.py +86 -4
  8. nautobot/core/tests/test_filters.py +209 -246
  9. nautobot/core/tests/test_jobs.py +250 -93
  10. nautobot/core/tests/test_models.py +9 -0
  11. nautobot/core/views/generic.py +80 -48
  12. nautobot/core/views/mixins.py +34 -6
  13. nautobot/dcim/api/serializers.py +2 -2
  14. nautobot/dcim/constants.py +6 -13
  15. nautobot/dcim/factory.py +6 -1
  16. nautobot/dcim/tests/integration/test_device_bulk_delete.py +189 -0
  17. nautobot/dcim/tests/integration/test_device_bulk_edit.py +181 -0
  18. nautobot/dcim/tests/test_api.py +0 -2
  19. nautobot/dcim/tests/test_models.py +42 -28
  20. nautobot/extras/forms/mixins.py +1 -1
  21. nautobot/extras/jobs.py +15 -6
  22. nautobot/extras/templatetags/job_buttons.py +4 -4
  23. nautobot/extras/tests/test_forms.py +13 -0
  24. nautobot/extras/tests/test_jobs.py +18 -13
  25. nautobot/extras/tests/test_models.py +6 -0
  26. nautobot/extras/tests/test_views.py +4 -3
  27. nautobot/ipam/tests/test_api.py +20 -0
  28. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +36 -1
  29. nautobot/project-static/docs/objects.inv +0 -0
  30. nautobot/project-static/docs/release-notes/version-2.4.html +108 -0
  31. nautobot/project-static/docs/search/search_index.json +1 -1
  32. nautobot/project-static/docs/sitemap.xml +288 -288
  33. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  34. nautobot/wireless/tests/test_views.py +22 -1
  35. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/METADATA +2 -2
  36. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/RECORD +40 -38
  37. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/LICENSE.txt +0 -0
  38. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/NOTICE +0 -0
  39. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/WHEEL +0 -0
  40. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/entry_points.txt +0 -0
@@ -78,7 +78,7 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
78
78
  template_name: Name of the template to use
79
79
  """
80
80
 
81
- queryset: ClassVar[QuerySet]
81
+ queryset: ClassVar[Optional[QuerySet]] = None # TODO: required, declared Optional only to avoid breaking change
82
82
  template_name: ClassVar[Optional[str]] = None
83
83
  object_detail_content = None
84
84
 
@@ -141,7 +141,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
141
141
  non_filter_params: List of query parameters that are **not** used for queryset filtering
142
142
  """
143
143
 
144
- queryset: QuerySet
144
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
145
145
  filterset: Optional[type[FilterSet]] = None
146
146
  filterset_form: Optional[type[Form]] = None
147
147
  table: Optional[type[Table]] = None
@@ -413,8 +413,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
413
413
  template_name: The name of the template
414
414
  """
415
415
 
416
- queryset: QuerySet
417
- model_form: type[Form]
416
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
417
+ model_form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
418
418
  template_name = "generic/object_create.html"
419
419
 
420
420
  def get_required_permission(self):
@@ -458,7 +458,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
458
458
  obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
459
459
 
460
460
  initial_data = normalize_querydict(request.GET, form_class=self.model_form)
461
- form = self.model_form(instance=obj, initial=initial_data)
461
+ if self.model_form is None:
462
+ raise RuntimeError("self.model_form must not be None")
463
+ form = self.model_form(instance=obj, initial=initial_data) # pylint: disable=not-callable
462
464
  restrict_form_fields(form, request.user)
463
465
 
464
466
  return render(
@@ -488,7 +490,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
488
490
  def post(self, request, *args, **kwargs):
489
491
  logger = logging.getLogger(__name__ + ".ObjectEditView")
490
492
  obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
491
- form = self.model_form(
493
+ if self.model_form is None:
494
+ raise RuntimeError("self.model_form must not be None")
495
+ form = self.model_form( # pylint: disable=not-callable
492
496
  data=request.POST,
493
497
  files=request.FILES,
494
498
  initial=normalize_querydict(request.GET, form_class=self.model_form),
@@ -556,7 +560,7 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
556
560
  template_name: The name of the template
557
561
  """
558
562
 
559
- queryset: QuerySet
563
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
560
564
  template_name = "generic/object_delete.html"
561
565
 
562
566
  def get_required_permission(self):
@@ -636,9 +640,9 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
636
640
  template_name: The name of the template
637
641
  """
638
642
 
639
- queryset: QuerySet
640
- form: type[Form]
641
- model_form: type[Form]
643
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
644
+ form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
645
+ model_form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
642
646
  pattern_target = ""
643
647
  template_name = None
644
648
 
@@ -648,12 +652,14 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
648
652
  def get(self, request):
649
653
  # Set initial values for visible form fields from query args
650
654
  initial = {}
655
+ if self.form is None or self.model_form is None:
656
+ raise RuntimeError("self.form and self.model_form must not be None")
651
657
  for field in getattr(self.model_form._meta, "fields", []):
652
658
  if request.GET.get(field):
653
659
  initial[field] = request.GET[field]
654
660
 
655
- form = self.form()
656
- model_form = self.model_form(initial=initial)
661
+ form = self.form() # pylint: disable=not-callable
662
+ model_form = self.model_form(initial=initial) # pylint: disable=not-callable
657
663
 
658
664
  return render(
659
665
  request,
@@ -668,9 +674,11 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
668
674
 
669
675
  def post(self, request):
670
676
  logger = logging.getLogger(__name__ + ".BulkCreateView")
677
+ if self.queryset is None or self.form is None or self.model_form is None:
678
+ raise RuntimeError("self.queryset, self.form, and self.model_form must not be None")
671
679
  model = self.queryset.model
672
- form = self.form(request.POST)
673
- model_form = self.model_form(request.POST)
680
+ form = self.form(request.POST) # pylint: disable=not-callable
681
+ model_form = self.model_form(request.POST) # pylint: disable=not-callable
674
682
 
675
683
  if form.is_valid():
676
684
  logger.debug("Form validation was successful")
@@ -683,7 +691,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
683
691
  for value in pattern:
684
692
  # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
685
693
  # copy of the POST QueryDict so that we can update the target field value.
686
- model_form = self.model_form(request.POST.copy())
694
+ model_form = self.model_form(request.POST.copy()) # pylint: disable=not-callable
687
695
  model_form.data[self.pattern_target] = value
688
696
 
689
697
  # Validate each new object independently.
@@ -745,8 +753,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
745
753
  template_name: The name of the template
746
754
  """
747
755
 
748
- queryset: QuerySet
749
- model_form: type[Form]
756
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
757
+ model_form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
750
758
  related_object_forms = {}
751
759
  template_name = "generic/object_import.html"
752
760
 
@@ -770,12 +778,15 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
770
778
  logger = logging.getLogger(__name__ + ".ObjectImportView")
771
779
  form = ImportForm(request.POST)
772
780
 
781
+ if self.model_form is None or self.queryset is None:
782
+ raise RuntimeError("self.model_form and self.queryset must not be None")
783
+
773
784
  if form.is_valid():
774
785
  logger.debug("Import form validation was successful")
775
786
 
776
787
  # Initialize model form
777
788
  data = form.cleaned_data["data"]
778
- model_form = self.model_form(data)
789
+ model_form = self.model_form(data) # pylint: disable=not-callable
779
790
  restrict_form_fields(model_form, request.user)
780
791
 
781
792
  # Assign default values for any fields which were not specified. We have to do this manually because passing
@@ -890,8 +901,8 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): #
890
901
  template_name: The name of the template
891
902
  """
892
903
 
893
- queryset: QuerySet
894
- table: type[Table]
904
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
905
+ table: Optional[type[Table]] = None # TODO: required, declared Optional only to avoid a breaking change
895
906
  template_name = "generic/object_bulk_import.html"
896
907
 
897
908
  def __init__(self, *args, **kwargs):
@@ -935,6 +946,9 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): #
935
946
  if form.is_valid():
936
947
  logger.debug("Form validation was successful")
937
948
 
949
+ if self.queryset is None or self.table is None:
950
+ raise RuntimeError("self.queryset and self.table must not be None")
951
+
938
952
  try:
939
953
  # Iterate through CSV data and bind each row to a new model form instance.
940
954
  with transaction.atomic():
@@ -945,7 +959,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): #
945
959
  raise ObjectDoesNotExist
946
960
 
947
961
  # Compile a table containing the imported objects
948
- obj_table = self.table(new_objs)
962
+ obj_table = self.table(new_objs) # pylint: disable=not-callable
949
963
 
950
964
  if new_objs:
951
965
  msg = f"Imported {len(new_objs)} {new_objs[0]._meta.verbose_name_plural}"
@@ -996,10 +1010,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditAnd
996
1010
  template_name: The name of the template
997
1011
  """
998
1012
 
999
- queryset: QuerySet
1013
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
1000
1014
  filterset: Optional[type[FilterSet]] = None
1001
- table: type[Table]
1002
- form: type[Form]
1015
+ table: Optional[type[Table]] = None # TODO: required, declared Optional only to avoid a breaking change
1016
+ form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
1003
1017
  template_name = "generic/object_bulk_edit.html"
1004
1018
 
1005
1019
  def get_required_permission(self):
@@ -1018,6 +1032,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditAnd
1018
1032
 
1019
1033
  def post(self, request, **kwargs):
1020
1034
  logger = logging.getLogger(__name__ + ".BulkEditView")
1035
+ if self.queryset is None or self.form is None or self.table is None:
1036
+ raise RuntimeError("self.queryset, self.form, and self.table must not be None")
1021
1037
  model = self.queryset.model
1022
1038
  edit_all = request.POST.get("_all")
1023
1039
 
@@ -1030,7 +1046,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditAnd
1030
1046
  queryset = self.queryset.filter(pk__in=pk_list)
1031
1047
 
1032
1048
  if "_apply" in request.POST:
1033
- form = self.form(model, request.POST, edit_all=edit_all)
1049
+ form = self.form(model, request.POST, edit_all=edit_all) # pylint: disable=not-callable
1034
1050
  restrict_form_fields(form, request.user)
1035
1051
 
1036
1052
  if form.is_valid():
@@ -1051,13 +1067,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditAnd
1051
1067
  elif "device_type" in request.GET:
1052
1068
  initial_data["device_type"] = request.GET.get("device_type")
1053
1069
 
1054
- form = self.form(model, initial=initial_data, edit_all=edit_all)
1070
+ form = self.form(model, initial=initial_data, edit_all=edit_all) # pylint: disable=not-callable
1055
1071
  restrict_form_fields(form, request.user)
1056
1072
 
1057
1073
  # Retrieve objects being edited
1058
1074
  table = None
1059
1075
  if not edit_all:
1060
- table = self.table(queryset, orderable=False)
1076
+ table = self.table(queryset, orderable=False) # pylint: disable=not-callable
1061
1077
  if not table.rows:
1062
1078
  messages.warning(request, f"No {model._meta.verbose_name_plural} were selected.")
1063
1079
  return redirect(self.get_return_url(request))
@@ -1084,7 +1100,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
1084
1100
  An extendable view for renaming objects in bulk.
1085
1101
  """
1086
1102
 
1087
- queryset: QuerySet
1103
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
1088
1104
  template_name = "generic/object_bulk_rename.html"
1089
1105
 
1090
1106
  def __init__(self, *args, **kwargs):
@@ -1190,9 +1206,9 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditA
1190
1206
  template_name: The name of the template
1191
1207
  """
1192
1208
 
1193
- queryset: QuerySet
1209
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
1194
1210
  filterset: Optional[type[FilterSet]] = None
1195
- table: type[Table]
1211
+ table: Optional[type[Table]] = None # TODO: required, declared Optional only to avoid a breaking change
1196
1212
  form: Optional[type[Form]] = None
1197
1213
  template_name = "generic/object_bulk_delete.html"
1198
1214
 
@@ -1204,6 +1220,8 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditA
1204
1220
 
1205
1221
  def post(self, request, **kwargs):
1206
1222
  logger = logging.getLogger(f"{__name__}.BulkDeleteView")
1223
+ if self.queryset is None or self.table is None:
1224
+ raise RuntimeError("self.queryset and self.table must not be None")
1207
1225
  model = self.queryset.model
1208
1226
  delete_all = request.POST.get("_all")
1209
1227
 
@@ -1240,7 +1258,7 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, BulkEditA
1240
1258
  # Retrieve objects being deleted
1241
1259
  table = None
1242
1260
  if not delete_all:
1243
- table = self.table(queryset, orderable=False)
1261
+ table = self.table(queryset, orderable=False) # pylint: disable=not-callable
1244
1262
  if not table.rows:
1245
1263
  messages.warning(
1246
1264
  request,
@@ -1293,17 +1311,19 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
1293
1311
  Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
1294
1312
  """
1295
1313
 
1296
- queryset: QuerySet
1297
- form: type[Form]
1298
- model_form: type[Form]
1314
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
1315
+ form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
1316
+ model_form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
1299
1317
  template_name = "dcim/device_component_add.html"
1300
1318
 
1301
1319
  def get_required_permission(self):
1302
1320
  return get_permission_for_model(self.queryset.model, "add")
1303
1321
 
1304
1322
  def get(self, request):
1305
- form = self.form(initial=normalize_querydict(request.GET, form_class=self.form))
1306
- model_form = self.model_form(request.GET)
1323
+ if self.form is None or self.model_form is None:
1324
+ raise RuntimeError("self.form and self.model_form must not be None")
1325
+ form = self.form(initial=normalize_querydict(request.GET, form_class=self.form)) # pylint: disable=not-callable
1326
+ model_form = self.model_form(request.GET) # pylint: disable=not-callable
1307
1327
 
1308
1328
  return render(
1309
1329
  request,
@@ -1318,8 +1338,10 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
1318
1338
 
1319
1339
  def post(self, request):
1320
1340
  logger = logging.getLogger(__name__ + ".ComponentCreateView")
1321
- form = self.form(request.POST, initial=normalize_querydict(request.GET, form_class=self.form))
1322
- model_form = self.model_form(request.POST, initial=normalize_querydict(request.GET, form_class=self.model_form))
1341
+ if self.form is None or self.model_form is None or self.queryset is None:
1342
+ raise RuntimeError("self.form, self.model_form, and self.queryset must not be None")
1343
+ form = self.form(request.POST, initial=normalize_querydict(request.GET, form_class=self.form)) # pylint: disable=not-callable
1344
+ model_form = self.model_form(request.POST, initial=normalize_querydict(request.GET, form_class=self.model_form)) # pylint: disable=not-callable
1323
1345
 
1324
1346
  if form.is_valid():
1325
1347
  new_components = []
@@ -1334,7 +1356,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
1334
1356
  data["label"] = label
1335
1357
  if hasattr(form, "get_iterative_data"):
1336
1358
  data.update(form.get_iterative_data(i))
1337
- component_form = self.model_form(
1359
+ component_form = self.model_form( # pylint: disable=not-callable
1338
1360
  data, initial=normalize_querydict(request.GET, form_class=self.model_form)
1339
1361
  )
1340
1362
 
@@ -1395,13 +1417,13 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
1395
1417
  Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
1396
1418
  """
1397
1419
 
1398
- parent_model: type[Model]
1420
+ parent_model: Optional[type[Model]] = None # TODO: required, declared Optional only to avoid a breaking change
1399
1421
  parent_field = None
1400
- form: type[Form]
1401
- queryset: QuerySet
1402
- model_form: type[Form]
1422
+ form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
1423
+ queryset: Optional[QuerySet] = None # TODO: required, declared Optional only to avoid a breaking change
1424
+ model_form: Optional[type[Form]] = None # TODO: required, declared Optional only to avoid a breaking change
1403
1425
  filterset: Optional[type[FilterSet]] = None
1404
- table: type[Table]
1426
+ table: Optional[type[Table]] = None # TODO: required, declared Optional only to avoid a breaking change
1405
1427
  template_name = "generic/object_bulk_add_component.html"
1406
1428
 
1407
1429
  def get_required_permission(self):
@@ -1409,6 +1431,16 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
1409
1431
 
1410
1432
  def post(self, request):
1411
1433
  logger = logging.getLogger(__name__ + ".BulkComponentCreateView")
1434
+ if (
1435
+ self.form is None
1436
+ or self.model_form is None
1437
+ or self.parent_model is None
1438
+ or self.queryset is None
1439
+ or self.table is None
1440
+ ):
1441
+ raise RuntimeError(
1442
+ "self.form, self.model_form, self.parent_model, self.queryset, and self.table must not be None"
1443
+ )
1412
1444
  parent_model_name = self.parent_model._meta.verbose_name_plural
1413
1445
  model_name = self.queryset.model._meta.verbose_name_plural
1414
1446
  model = self.queryset.model
@@ -1426,10 +1458,10 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
1426
1458
  f"No {self.parent_model._meta.verbose_name_plural} were selected.",
1427
1459
  )
1428
1460
  return redirect(self.get_return_url(request))
1429
- table = self.table(selected_objects)
1461
+ table = self.table(selected_objects) # pylint: disable=not-callable
1430
1462
 
1431
1463
  if "_create" in request.POST:
1432
- form = self.form(model, request.POST)
1464
+ form = self.form(model, request.POST) # pylint: disable=not-callable
1433
1465
 
1434
1466
  if form.is_valid():
1435
1467
  logger.debug("Form validation was successful")
@@ -1451,7 +1483,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
1451
1483
  "label": label,
1452
1484
  }
1453
1485
  component_data.update(data)
1454
- component_form = self.model_form(component_data)
1486
+ component_form = self.model_form(component_data) # pylint: disable=not-callable
1455
1487
  if component_form.is_valid():
1456
1488
  instance = component_form.save()
1457
1489
  logger.debug(f"Created {instance} on {instance.parent}")
@@ -1493,7 +1525,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
1493
1525
  logger.debug("Form validation failed")
1494
1526
 
1495
1527
  else:
1496
- form = self.form(model, initial={"pk": pk_list})
1528
+ form = self.form(model, initial={"pk": pk_list}) # pylint: disable=not-callable
1497
1529
 
1498
1530
  return render(
1499
1531
  request,
@@ -31,6 +31,7 @@ from rest_framework.parsers import FormParser, MultiPartParser
31
31
  from rest_framework.response import Response
32
32
  from rest_framework.viewsets import GenericViewSet
33
33
 
34
+ from nautobot.core import exceptions as core_exceptions
34
35
  from nautobot.core.api.views import BulkDestroyModelMixin, BulkUpdateModelMixin
35
36
  from nautobot.core.forms import (
36
37
  BootstrapMixin,
@@ -40,7 +41,7 @@ from nautobot.core.forms import (
40
41
  restrict_form_fields,
41
42
  )
42
43
  from nautobot.core.jobs import BulkDeleteObjects, BulkEditObjects
43
- from nautobot.core.utils import lookup, permissions
44
+ from nautobot.core.utils import filtering, lookup, permissions
44
45
  from nautobot.core.utils.requests import get_filterable_params_from_filter_params, normalize_querydict
45
46
  from nautobot.core.views.renderers import NautobotHTMLRenderer
46
47
  from nautobot.core.views.utils import (
@@ -925,6 +926,8 @@ class BulkEditAndBulkDeleteModelMixin:
925
926
  UI mixin to bulk destroy and bulk edit all model instances.
926
927
  """
927
928
 
929
+ logger = logging.getLogger(__name__)
930
+
928
931
  def _get_bulk_edit_delete_all_queryset(self, request):
929
932
  """
930
933
  Retrieve the queryset of model instances to be bulk-deleted or bulk-deleted, filtered based on request parameters.
@@ -957,7 +960,19 @@ class BulkEditAndBulkDeleteModelMixin:
957
960
  if filterset_class := lookup.get_filterset_for_model(model):
958
961
  filter_query_params = normalize_querydict(request.GET, filterset=filterset_class())
959
962
  else:
960
- filter_query_params = None
963
+ filter_query_params = {}
964
+
965
+ # Discarding non-filter query params
966
+ new_filter_query_params = {}
967
+
968
+ for key, value in filter_query_params.items():
969
+ try:
970
+ filtering.get_filterset_field(filterset_class(), key)
971
+ new_filter_query_params[key] = value
972
+ except core_exceptions.FilterSetFieldNotFound:
973
+ self.logger.debug(f"Query parameter `{key}` not found in `{filterset_class}`, discarding it")
974
+
975
+ filter_query_params = new_filter_query_params
961
976
 
962
977
  job_form = BulkDeleteObjects.as_form(
963
978
  data={
@@ -984,7 +999,20 @@ class BulkEditAndBulkDeleteModelMixin:
984
999
  if filterset_class := lookup.get_filterset_for_model(model):
985
1000
  filter_query_params = normalize_querydict(request.GET, filterset=filterset_class())
986
1001
  else:
987
- filter_query_params = None
1002
+ filter_query_params = {}
1003
+
1004
+ # Discarding non-filter query params
1005
+ new_filter_query_params = {}
1006
+
1007
+ for key, value in filter_query_params.items():
1008
+ try:
1009
+ filtering.get_filterset_field(filterset_class(), key)
1010
+ new_filter_query_params[key] = value
1011
+ except core_exceptions.FilterSetFieldNotFound:
1012
+ self.logger.debug(f"Query parameter `{key}` not found in `{filterset_class}`, discarding it")
1013
+
1014
+ filter_query_params = new_filter_query_params
1015
+
988
1016
  job_form = BulkEditObjects.as_form(
989
1017
  data={
990
1018
  "form_data": form_data,
@@ -1164,7 +1192,7 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin, Bulk
1164
1192
  for field in form.fields
1165
1193
  if field not in form_custom_fields + form_relationships + ["pk"] + ["object_note"]
1166
1194
  ]
1167
- nullified_fields = request.POST.getlist("_nullify")
1195
+ nullified_fields = request.POST.getlist("_nullify") or []
1168
1196
  with deferred_change_logging_for_bulk_operation():
1169
1197
  updated_objects = []
1170
1198
  edit_all = self.request.POST.get("_all")
@@ -1183,7 +1211,7 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin, Bulk
1183
1211
  # This form field is used to modify a field rather than set its value directly
1184
1212
  model_field = None
1185
1213
  # Handle nullification
1186
- if name in form.nullable_fields and name in nullified_fields:
1214
+ if name in form.nullable_fields and nullified_fields and name in nullified_fields:
1187
1215
  if isinstance(model_field, ManyToManyField):
1188
1216
  getattr(obj, name).set([])
1189
1217
  else:
@@ -1197,7 +1225,7 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin, Bulk
1197
1225
  setattr(obj, name, form.cleaned_data[name])
1198
1226
  # Update custom fields
1199
1227
  for field_name in form_custom_fields:
1200
- if field_name in form.nullable_fields and field_name in nullified_fields:
1228
+ if field_name in form.nullable_fields and nullified_fields and field_name in nullified_fields:
1201
1229
  obj.cf[remove_prefix_from_cf_key(field_name)] = None
1202
1230
  elif form.cleaned_data.get(field_name) not in (None, "", []):
1203
1231
  obj.cf[remove_prefix_from_cf_key(field_name)] = form.cleaned_data[field_name]
@@ -981,7 +981,7 @@ class DeviceTypeToSoftwareImageFileSerializer(ValidatedModelSerializer):
981
981
 
982
982
  class ControllerSerializer(TaggedModelSerializerMixin, NautobotModelSerializer):
983
983
  capabilities = serializers.ListField(
984
- child=ChoiceField(choices=ControllerCapabilitiesChoices, required=False), allow_empty=True
984
+ child=ChoiceField(choices=ControllerCapabilitiesChoices, required=False), allow_empty=True, required=False
985
985
  )
986
986
 
987
987
  class Meta:
@@ -991,7 +991,7 @@ class ControllerSerializer(TaggedModelSerializerMixin, NautobotModelSerializer):
991
991
 
992
992
  class ControllerManagedDeviceGroupSerializer(TaggedModelSerializerMixin, NautobotModelSerializer):
993
993
  capabilities = serializers.ListField(
994
- child=ChoiceField(choices=ControllerCapabilitiesChoices, required=False), allow_empty=True
994
+ child=ChoiceField(choices=ControllerCapabilitiesChoices, required=False), allow_empty=True, required=False
995
995
  )
996
996
 
997
997
  class Meta:
@@ -27,19 +27,12 @@ REARPORT_POSITIONS_MAX = 1024
27
27
  INTERFACE_MTU_MIN = 1
28
28
  INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
29
29
 
30
- VIRTUAL_IFACE_TYPES = [
31
- InterfaceTypeChoices.TYPE_VIRTUAL,
32
- InterfaceTypeChoices.TYPE_BRIDGE,
33
- InterfaceTypeChoices.TYPE_LAG,
34
- ]
35
-
36
- WIRELESS_IFACE_TYPES = [
37
- InterfaceTypeChoices.TYPE_80211A,
38
- InterfaceTypeChoices.TYPE_80211G,
39
- InterfaceTypeChoices.TYPE_80211N,
40
- InterfaceTypeChoices.TYPE_80211AC,
41
- InterfaceTypeChoices.TYPE_80211AD,
42
- ]
30
+ interface_type_by_category = {}
31
+ for category_name, category_item_tuples in InterfaceTypeChoices.CHOICES:
32
+ interface_type_by_category[category_name] = [item_tuple[0] for item_tuple in category_item_tuples]
33
+
34
+ WIRELESS_IFACE_TYPES = interface_type_by_category["Wireless"]
35
+ VIRTUAL_IFACE_TYPES = interface_type_by_category["Virtual interfaces"]
43
36
 
44
37
  NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
45
38
 
nautobot/dcim/factory.py CHANGED
@@ -201,7 +201,12 @@ class DeviceFactory(PrimaryModelFactory):
201
201
  if extracted:
202
202
  self.software_image_files.set(extracted)
203
203
  else:
204
- self.software_image_files.set(get_random_instances(SoftwareImageFile))
204
+ self.software_image_files.set(
205
+ get_random_instances(
206
+ SoftwareImageFile.objects.filter(default_image=True)
207
+ | SoftwareImageFile.objects.filter(device_types=self.device_type)
208
+ )
209
+ )
205
210
 
206
211
  # TODO to be done after these model factories are done.
207
212
  # has_cluster = NautobotBoolIterator()