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.
- nautobot/core/celery/schedulers.py +1 -1
- nautobot/core/filters.py +48 -21
- nautobot/core/jobs/bulk_actions.py +56 -19
- nautobot/core/models/__init__.py +2 -0
- nautobot/core/tables.py +5 -1
- nautobot/core/testing/filters.py +25 -13
- nautobot/core/testing/integration.py +86 -4
- nautobot/core/tests/test_filters.py +209 -246
- nautobot/core/tests/test_jobs.py +250 -93
- nautobot/core/tests/test_models.py +9 -0
- nautobot/core/views/generic.py +80 -48
- nautobot/core/views/mixins.py +34 -6
- nautobot/dcim/api/serializers.py +2 -2
- nautobot/dcim/constants.py +6 -13
- nautobot/dcim/factory.py +6 -1
- nautobot/dcim/tests/integration/test_device_bulk_delete.py +189 -0
- nautobot/dcim/tests/integration/test_device_bulk_edit.py +181 -0
- nautobot/dcim/tests/test_api.py +0 -2
- nautobot/dcim/tests/test_models.py +42 -28
- nautobot/extras/forms/mixins.py +1 -1
- nautobot/extras/jobs.py +15 -6
- nautobot/extras/templatetags/job_buttons.py +4 -4
- nautobot/extras/tests/test_forms.py +13 -0
- nautobot/extras/tests/test_jobs.py +18 -13
- nautobot/extras/tests/test_models.py +6 -0
- nautobot/extras/tests/test_views.py +4 -3
- nautobot/ipam/tests/test_api.py +20 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +36 -1
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +108 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +288 -288
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/wireless/tests/test_views.py +22 -1
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/METADATA +2 -2
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/RECORD +40 -38
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/NOTICE +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/WHEEL +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/entry_points.txt +0 -0
nautobot/core/views/generic.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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,
|
nautobot/core/views/mixins.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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]
|
nautobot/dcim/api/serializers.py
CHANGED
|
@@ -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:
|
nautobot/dcim/constants.py
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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()
|