nautobot 2.3.10__py3-none-any.whl → 2.3.11__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/apps/utils.py +2 -0
- nautobot/cloud/tables.py +1 -0
- nautobot/core/forms/forms.py +5 -1
- nautobot/core/tables.py +88 -22
- nautobot/core/templates/generic/object_bulk_destroy.html +12 -3
- nautobot/core/templates/generic/object_bulk_update.html +4 -2
- nautobot/core/templates/generic/object_create.html +1 -1
- nautobot/core/templates/rest_framework/api.html +3 -0
- nautobot/core/testing/api.py +3 -1
- nautobot/core/testing/integration.py +64 -0
- nautobot/core/testing/views.py +33 -27
- nautobot/core/tests/integration/test_app_navbar.py +3 -3
- nautobot/core/tests/integration/test_navbar.py +1 -1
- nautobot/core/tests/test_csv.py +3 -0
- nautobot/core/tests/test_utils.py +25 -5
- nautobot/core/utils/lookup.py +35 -0
- nautobot/core/views/generic.py +50 -39
- nautobot/core/views/mixins.py +97 -43
- nautobot/core/views/renderers.py +8 -5
- nautobot/dcim/tables/devices.py +3 -0
- nautobot/dcim/templates/dcim/device_component_add.html +8 -8
- nautobot/dcim/templates/dcim/virtualchassis_add_member.html +2 -2
- nautobot/dcim/templates/dcim/virtualchassis_edit.html +2 -2
- nautobot/dcim/tests/integration/test_create_device.py +86 -0
- nautobot/extras/tests/test_relationships.py +1 -0
- nautobot/extras/views.py +1 -0
- nautobot/ipam/factory.py +3 -0
- nautobot/ipam/filters.py +5 -0
- nautobot/ipam/forms.py +17 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/signals.py +2 -2
- nautobot/ipam/tables.py +3 -3
- nautobot/ipam/templates/ipam/ipaddress_assign.html +2 -2
- nautobot/ipam/tests/test_models.py +113 -1
- nautobot/ipam/tests/test_views.py +39 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +131 -6
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +175 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +94 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +4 -4
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +293 -138
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +270 -270
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +39 -0
- nautobot/virtualization/forms.py +24 -0
- nautobot/virtualization/templates/virtualization/vminterface_edit.html +1 -0
- nautobot/virtualization/tests/test_views.py +7 -2
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/METADATA +1 -1
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/RECORD +54 -53
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/NOTICE +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/WHEEL +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/entry_points.txt +0 -0
|
@@ -21,6 +21,7 @@ from nautobot.extras import models as extras_models, utils as extras_utils
|
|
|
21
21
|
from nautobot.extras.choices import ObjectChangeActionChoices, RelationshipTypeChoices
|
|
22
22
|
from nautobot.extras.models import ObjectChange
|
|
23
23
|
from nautobot.extras.registry import registry
|
|
24
|
+
from nautobot.ipam import models as ipam_models
|
|
24
25
|
|
|
25
26
|
from example_app.models import ExampleModel
|
|
26
27
|
|
|
@@ -164,7 +165,7 @@ class GetFooForModelTest(TestCase):
|
|
|
164
165
|
|
|
165
166
|
def test_get_filterset_for_model(self):
|
|
166
167
|
"""
|
|
167
|
-
Test
|
|
168
|
+
Test that `get_filterset_for_model` returns the right FilterSet for various inputs.
|
|
168
169
|
"""
|
|
169
170
|
self.assertEqual(lookup.get_filterset_for_model("dcim.device"), dcim_filters.DeviceFilterSet)
|
|
170
171
|
self.assertEqual(lookup.get_filterset_for_model(dcim_models.Device), dcim_filters.DeviceFilterSet)
|
|
@@ -173,7 +174,7 @@ class GetFooForModelTest(TestCase):
|
|
|
173
174
|
|
|
174
175
|
def test_get_form_for_model(self):
|
|
175
176
|
"""
|
|
176
|
-
Test
|
|
177
|
+
Test that `get_form_for_model` returns the right Form for various inputs.
|
|
177
178
|
"""
|
|
178
179
|
self.assertEqual(lookup.get_form_for_model("dcim.device", "Filter"), dcim_forms.DeviceFilterForm)
|
|
179
180
|
self.assertEqual(lookup.get_form_for_model(dcim_models.Device, "Filter"), dcim_forms.DeviceFilterForm)
|
|
@@ -184,9 +185,28 @@ class GetFooForModelTest(TestCase):
|
|
|
184
185
|
self.assertEqual(lookup.get_form_for_model("dcim.location"), dcim_forms.LocationForm)
|
|
185
186
|
self.assertEqual(lookup.get_form_for_model(dcim_models.Location), dcim_forms.LocationForm)
|
|
186
187
|
|
|
188
|
+
def test_get_related_field_for_models(self):
|
|
189
|
+
"""
|
|
190
|
+
Test that `get_related_field_for_models` returns the appropriate field for various inputs.
|
|
191
|
+
"""
|
|
192
|
+
# No direct relation found
|
|
193
|
+
self.assertIsNone(lookup.get_related_field_for_models(dcim_models.Device, dcim_models.LocationType))
|
|
194
|
+
# ForeignKey and reverse
|
|
195
|
+
self.assertEqual(lookup.get_related_field_for_models(dcim_models.Device, dcim_models.Location).name, "location")
|
|
196
|
+
self.assertEqual(lookup.get_related_field_for_models(dcim_models.Location, dcim_models.Device).name, "devices")
|
|
197
|
+
# ManyToMany and reverse
|
|
198
|
+
self.assertEqual(
|
|
199
|
+
lookup.get_related_field_for_models(ipam_models.Prefix, dcim_models.Location).name, "locations"
|
|
200
|
+
)
|
|
201
|
+
self.assertEqual(lookup.get_related_field_for_models(dcim_models.Location, ipam_models.Prefix).name, "prefixes")
|
|
202
|
+
# Multiple candidate fields
|
|
203
|
+
with self.assertRaises(AttributeError):
|
|
204
|
+
# both primary_ip4 and primary_ip6 are candidates
|
|
205
|
+
lookup.get_related_field_for_models(dcim_models.Device, ipam_models.IPAddress)
|
|
206
|
+
|
|
187
207
|
def test_get_route_for_model(self):
|
|
188
208
|
"""
|
|
189
|
-
Test
|
|
209
|
+
Test that `get_route_for_model` returns the appropriate URL route name for various inputs.
|
|
190
210
|
"""
|
|
191
211
|
# UI
|
|
192
212
|
self.assertEqual(lookup.get_route_for_model("dcim.device", "list"), "dcim:device_list")
|
|
@@ -221,7 +241,7 @@ class GetFooForModelTest(TestCase):
|
|
|
221
241
|
|
|
222
242
|
def test_get_table_for_model(self):
|
|
223
243
|
"""
|
|
224
|
-
Test
|
|
244
|
+
Test that `get_table_for_model` returns the appropriate Table for various inputs.
|
|
225
245
|
"""
|
|
226
246
|
self.assertEqual(lookup.get_table_for_model("dcim.device"), tables.DeviceTable)
|
|
227
247
|
self.assertEqual(lookup.get_table_for_model(dcim_models.Device), tables.DeviceTable)
|
|
@@ -237,7 +257,7 @@ class GetFooForModelTest(TestCase):
|
|
|
237
257
|
|
|
238
258
|
def test_get_model_for_view_name(self):
|
|
239
259
|
"""
|
|
240
|
-
Test
|
|
260
|
+
Test that `get_model_for_view_name` returns the appropriate Model, if the colon separated view name provided.
|
|
241
261
|
"""
|
|
242
262
|
with self.subTest("Test core view."):
|
|
243
263
|
self.assertEqual(lookup.get_model_for_view_name("dcim:device_list"), dcim_models.Device)
|
nautobot/core/utils/lookup.py
CHANGED
|
@@ -177,6 +177,41 @@ def get_form_for_model(model, form_prefix=""):
|
|
|
177
177
|
return get_related_class_for_model(model, module_name="forms", object_suffix=object_suffix)
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
def get_related_field_for_models(from_model, to_model):
|
|
181
|
+
"""
|
|
182
|
+
Find the field on `from_model` that is a relation to `to_model`.
|
|
183
|
+
|
|
184
|
+
If no such field is found, returns None.
|
|
185
|
+
If more than one such field is found, raises an AttributeError.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
from_model (BaseModel): The model class that should contain the relevant field or relation.
|
|
189
|
+
to_model (BaseModel): The model class that we're looking for as the destination.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> get_related_field_for_models(Device, Location)
|
|
193
|
+
<django.db.models.fields.related.ForeignKey: location>
|
|
194
|
+
>>> get_related_field_for_models(Location, Device)
|
|
195
|
+
<ManyToOneRel: dcim.device>
|
|
196
|
+
>>> get_related_field_for_models(Prefix, Location)
|
|
197
|
+
<django.db.models.fields.related.ManyToManyField: locations>
|
|
198
|
+
>>> get_related_field_for_models(Location, Prefix)
|
|
199
|
+
<ManyToManyRel: ipam.prefix>
|
|
200
|
+
>>> get_related_field_for_models(Device, IPAddress)
|
|
201
|
+
AttributeError: Device has more than one relation to IPAddress: primary_ip4, primary_ip6
|
|
202
|
+
"""
|
|
203
|
+
matching_field = None
|
|
204
|
+
for field in from_model._meta.get_fields():
|
|
205
|
+
if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == to_model:
|
|
206
|
+
if matching_field is not None:
|
|
207
|
+
raise AttributeError(
|
|
208
|
+
f"{from_model.__name__} has more than one relation to {to_model.__name__}: "
|
|
209
|
+
f"{matching_field.name}, {field.name}"
|
|
210
|
+
)
|
|
211
|
+
matching_field = field
|
|
212
|
+
return matching_field
|
|
213
|
+
|
|
214
|
+
|
|
180
215
|
def get_table_for_model(model):
|
|
181
216
|
"""Return the `Table` class associated with a given `model`.
|
|
182
217
|
|
nautobot/core/views/generic.py
CHANGED
|
@@ -46,7 +46,7 @@ from nautobot.core.utils.requests import (
|
|
|
46
46
|
get_filterable_params_from_filter_params,
|
|
47
47
|
normalize_querydict,
|
|
48
48
|
)
|
|
49
|
-
from nautobot.core.views.mixins import GetReturnURLMixin, ObjectPermissionRequiredMixin
|
|
49
|
+
from nautobot.core.views.mixins import EditAndDeleteAllModelMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin
|
|
50
50
|
from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
|
|
51
51
|
from nautobot.core.views.utils import (
|
|
52
52
|
check_filter_for_display,
|
|
@@ -979,7 +979,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): #
|
|
|
979
979
|
)
|
|
980
980
|
|
|
981
981
|
|
|
982
|
-
class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
982
|
+
class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, EditAndDeleteAllModelMixin, View):
|
|
983
983
|
"""
|
|
984
984
|
Edit objects in bulk.
|
|
985
985
|
|
|
@@ -1013,18 +1013,18 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1013
1013
|
def post(self, request, **kwargs):
|
|
1014
1014
|
logger = logging.getLogger(__name__ + ".BulkEditView")
|
|
1015
1015
|
model = self.queryset.model
|
|
1016
|
+
edit_all = request.POST.get("_all")
|
|
1016
1017
|
|
|
1017
1018
|
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
|
|
1018
|
-
if
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
else:
|
|
1022
|
-
pk_list = list(model.objects.all().values_list("pk", flat=True))
|
|
1019
|
+
if edit_all:
|
|
1020
|
+
pk_list = []
|
|
1021
|
+
queryset = self._get_bulk_edit_delete_all_queryset(request)
|
|
1023
1022
|
else:
|
|
1024
1023
|
pk_list = request.POST.getlist("pk")
|
|
1024
|
+
queryset = self.queryset.filter(pk__in=pk_list)
|
|
1025
1025
|
|
|
1026
1026
|
if "_apply" in request.POST:
|
|
1027
|
-
form = self.form(model, request.POST)
|
|
1027
|
+
form = self.form(model, request.POST, edit_all=edit_all)
|
|
1028
1028
|
restrict_form_fields(form, request.user)
|
|
1029
1029
|
|
|
1030
1030
|
if form.is_valid():
|
|
@@ -1041,7 +1041,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1041
1041
|
try:
|
|
1042
1042
|
with deferred_change_logging_for_bulk_operation():
|
|
1043
1043
|
updated_objects = []
|
|
1044
|
-
|
|
1044
|
+
queryset = queryset if edit_all else queryset.filter(pk__in=form.cleaned_data["pk"])
|
|
1045
|
+
for obj in queryset:
|
|
1045
1046
|
obj = self.alter_obj(obj, request, [], kwargs)
|
|
1046
1047
|
|
|
1047
1048
|
# Update standard fields. If a field is listed in _nullify, delete its value.
|
|
@@ -1130,23 +1131,26 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1130
1131
|
elif "device_type" in request.GET:
|
|
1131
1132
|
initial_data["device_type"] = request.GET.get("device_type")
|
|
1132
1133
|
|
|
1133
|
-
form = self.form(model, initial=initial_data)
|
|
1134
|
+
form = self.form(model, initial=initial_data, edit_all=edit_all)
|
|
1134
1135
|
restrict_form_fields(form, request.user)
|
|
1135
1136
|
|
|
1136
1137
|
# Retrieve objects being edited
|
|
1137
|
-
table =
|
|
1138
|
-
if not
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1138
|
+
table = None
|
|
1139
|
+
if not edit_all:
|
|
1140
|
+
table = self.table(queryset, orderable=False)
|
|
1141
|
+
if not table.rows:
|
|
1142
|
+
messages.warning(request, f"No {model._meta.verbose_name_plural} were selected.")
|
|
1143
|
+
return redirect(self.get_return_url(request))
|
|
1144
|
+
# Hide actions column if present
|
|
1145
|
+
if "actions" in table.columns:
|
|
1146
|
+
table.columns.hide("actions")
|
|
1144
1147
|
|
|
1145
1148
|
context = {
|
|
1146
1149
|
"form": form,
|
|
1147
1150
|
"table": table,
|
|
1148
1151
|
"obj_type_plural": model._meta.verbose_name_plural,
|
|
1149
1152
|
"return_url": self.get_return_url(request),
|
|
1153
|
+
"objs_count": queryset.count(),
|
|
1150
1154
|
}
|
|
1151
1155
|
context.update(self.extra_context())
|
|
1152
1156
|
return render(request, self.template_name, context)
|
|
@@ -1255,7 +1259,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1255
1259
|
return ""
|
|
1256
1260
|
|
|
1257
1261
|
|
|
1258
|
-
class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
1262
|
+
class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, EditAndDeleteAllModelMixin, View):
|
|
1259
1263
|
"""
|
|
1260
1264
|
Delete objects in bulk.
|
|
1261
1265
|
|
|
@@ -1278,18 +1282,37 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1278
1282
|
def get(self, request):
|
|
1279
1283
|
return redirect(self.get_return_url(request))
|
|
1280
1284
|
|
|
1281
|
-
def
|
|
1285
|
+
def _perform_delete_operation(self, request, queryset, model):
|
|
1282
1286
|
logger = logging.getLogger(__name__ + ".BulkDeleteView")
|
|
1287
|
+
self.perform_pre_delete(request, queryset)
|
|
1288
|
+
try:
|
|
1289
|
+
_, deleted_info = bulk_delete_with_bulk_change_logging(queryset)
|
|
1290
|
+
deleted_count = deleted_info[model._meta.label]
|
|
1291
|
+
except ProtectedError as e:
|
|
1292
|
+
logger.info("Caught ProtectedError while attempting to delete objects")
|
|
1293
|
+
handle_protectederror(queryset, request, e)
|
|
1294
|
+
return redirect(self.get_return_url(request))
|
|
1295
|
+
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
|
1296
|
+
logger.info(msg)
|
|
1297
|
+
messages.success(request, msg)
|
|
1298
|
+
return redirect(self.get_return_url(request))
|
|
1299
|
+
|
|
1300
|
+
def post(self, request, **kwargs):
|
|
1301
|
+
logger = logging.getLogger(f"{__name__}.BulkDeleteView")
|
|
1283
1302
|
model = self.queryset.model
|
|
1284
1303
|
|
|
1285
1304
|
# Are we deleting *all* objects in the queryset or just a selected subset?
|
|
1286
1305
|
if request.POST.get("_all"):
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1306
|
+
queryset = self._get_bulk_edit_delete_all_queryset(request)
|
|
1307
|
+
|
|
1308
|
+
if "_confirm" in request.POST:
|
|
1309
|
+
return self._perform_delete_operation(request, queryset, model)
|
|
1310
|
+
|
|
1311
|
+
context = self._bulk_delete_all_context(request, queryset)
|
|
1312
|
+
context.update(self.extra_context())
|
|
1313
|
+
return render(request, self.template_name, context)
|
|
1314
|
+
|
|
1315
|
+
pk_list = request.POST.getlist("pk")
|
|
1293
1316
|
|
|
1294
1317
|
form_cls = self.get_form()
|
|
1295
1318
|
|
|
@@ -1300,20 +1323,7 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1300
1323
|
|
|
1301
1324
|
# Delete objects
|
|
1302
1325
|
queryset = self.queryset.filter(pk__in=pk_list)
|
|
1303
|
-
|
|
1304
|
-
self.perform_pre_delete(request, queryset)
|
|
1305
|
-
try:
|
|
1306
|
-
_, deleted_info = bulk_delete_with_bulk_change_logging(queryset)
|
|
1307
|
-
deleted_count = deleted_info[model._meta.label]
|
|
1308
|
-
except ProtectedError as e:
|
|
1309
|
-
logger.info("Caught ProtectedError while attempting to delete objects")
|
|
1310
|
-
handle_protectederror(queryset, request, e)
|
|
1311
|
-
return redirect(self.get_return_url(request))
|
|
1312
|
-
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
|
1313
|
-
logger.info(msg)
|
|
1314
|
-
messages.success(request, msg)
|
|
1315
|
-
return redirect(self.get_return_url(request))
|
|
1316
|
-
|
|
1326
|
+
return self._perform_delete_operation(request, queryset, model)
|
|
1317
1327
|
else:
|
|
1318
1328
|
logger.debug("Form validation failed")
|
|
1319
1329
|
|
|
@@ -1342,6 +1352,7 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1342
1352
|
"obj_type_plural": model._meta.verbose_name_plural,
|
|
1343
1353
|
"table": table,
|
|
1344
1354
|
"return_url": self.get_return_url(request),
|
|
1355
|
+
"total_objs_to_delete": len(table.rows),
|
|
1345
1356
|
}
|
|
1346
1357
|
context.update(self.extra_context())
|
|
1347
1358
|
return render(request, self.template_name, context)
|
nautobot/core/views/mixins.py
CHANGED
|
@@ -414,7 +414,7 @@ class NautobotViewSetMixin(GenericViewSet, AccessMixin, GetReturnURLMixin, FormV
|
|
|
414
414
|
else:
|
|
415
415
|
# render the form with the error message.
|
|
416
416
|
data = {}
|
|
417
|
-
if self.action in ["bulk_update", "bulk_destroy"]:
|
|
417
|
+
if not request.POST.get("_all") and self.action in ["bulk_update", "bulk_destroy"]:
|
|
418
418
|
pk_list = self.pk_list
|
|
419
419
|
table_class = self.get_table_class()
|
|
420
420
|
table = table_class(queryset.filter(pk__in=pk_list), orderable=False)
|
|
@@ -578,6 +578,9 @@ class NautobotViewSetMixin(GenericViewSet, AccessMixin, GetReturnURLMixin, FormV
|
|
|
578
578
|
if not form_class:
|
|
579
579
|
if self.action == "bulk_destroy":
|
|
580
580
|
queryset = self.get_queryset()
|
|
581
|
+
bulk_delete_all = bool(self.request.POST.get("_all"))
|
|
582
|
+
if bulk_delete_all:
|
|
583
|
+
return ConfirmationForm
|
|
581
584
|
|
|
582
585
|
class BulkDestroyForm(ConfirmationForm):
|
|
583
586
|
pk = ModelMultipleChoiceField(queryset=queryset, widget=MultipleHiddenInput)
|
|
@@ -909,7 +912,48 @@ class ObjectEditViewMixin(NautobotViewSetMixin, mixins.CreateModelMixin, mixins.
|
|
|
909
912
|
return self.form_invalid(form)
|
|
910
913
|
|
|
911
914
|
|
|
912
|
-
class
|
|
915
|
+
class EditAndDeleteAllModelMixin:
|
|
916
|
+
"""
|
|
917
|
+
UI mixin to bulk destroy all and bulk edit all model instances.
|
|
918
|
+
"""
|
|
919
|
+
|
|
920
|
+
def _get_bulk_edit_delete_all_queryset(self, request):
|
|
921
|
+
"""
|
|
922
|
+
Retrieve the queryset of model instances to be bulk-deleted or bulk-deleted, filtered based on request parameters.
|
|
923
|
+
|
|
924
|
+
This method handles the retrieval of a queryset of model instances that match the specified
|
|
925
|
+
filter criteria in the request parameters, allowing a bulk delete operation to be performed
|
|
926
|
+
on all matching instances.
|
|
927
|
+
"""
|
|
928
|
+
model = self.queryset.model
|
|
929
|
+
|
|
930
|
+
# This Mixin is currently been used by both NautobotUIViewSet ObjectBulkDestroyViewMixin, ObjectBulkUpdateViewMixin
|
|
931
|
+
# BulkEditView, and BulkDeleteView which uses different keys for accessing filterset
|
|
932
|
+
filterset_class = getattr(self, "filterset", None)
|
|
933
|
+
if filterset_class is None:
|
|
934
|
+
filterset_class = getattr(self, "filterset_class", None)
|
|
935
|
+
|
|
936
|
+
if request.GET and filterset_class is not None:
|
|
937
|
+
queryset = filterset_class(request.GET, model.objects.all()).qs
|
|
938
|
+
# We take this approach because filterset.qs has already applied .distinct(),
|
|
939
|
+
# and performing a .delete directly on a queryset with .distinct applied is not allowed.
|
|
940
|
+
queryset = self.queryset.filter(pk__in=queryset)
|
|
941
|
+
else:
|
|
942
|
+
queryset = model.objects.all()
|
|
943
|
+
return queryset
|
|
944
|
+
|
|
945
|
+
def _bulk_delete_all_context(self, request, queryset):
|
|
946
|
+
model = queryset.model
|
|
947
|
+
return {
|
|
948
|
+
"obj_type_plural": model._meta.verbose_name_plural,
|
|
949
|
+
"return_url": self.get_return_url(request),
|
|
950
|
+
"total_objs_to_delete": queryset.count(),
|
|
951
|
+
"delete_all": True,
|
|
952
|
+
"table": None,
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin, EditAndDeleteAllModelMixin):
|
|
913
957
|
"""
|
|
914
958
|
UI mixin to bulk destroy model instances.
|
|
915
959
|
"""
|
|
@@ -923,7 +967,10 @@ class ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin):
|
|
|
923
967
|
queryset = self.get_queryset()
|
|
924
968
|
model = queryset.model
|
|
925
969
|
# Delete objects
|
|
926
|
-
|
|
970
|
+
if self.request.POST.get("_all"):
|
|
971
|
+
queryset = self._get_bulk_edit_delete_all_queryset(self.request)
|
|
972
|
+
else:
|
|
973
|
+
queryset = queryset.filter(pk__in=pk_list)
|
|
927
974
|
|
|
928
975
|
try:
|
|
929
976
|
with transaction.atomic():
|
|
@@ -951,35 +998,33 @@ class ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin):
|
|
|
951
998
|
request.POST "_confirm": Function to validate the table form/BulkDestroyConfirmationForm and to perform the action of bulk destroy. Render the form with errors if exceptions are raised.
|
|
952
999
|
"""
|
|
953
1000
|
queryset = self.get_queryset()
|
|
954
|
-
|
|
1001
|
+
delete_all = bool(request.POST.get("_all"))
|
|
1002
|
+
data = {}
|
|
955
1003
|
# Are we deleting *all* objects in the queryset or just a selected subset?
|
|
956
|
-
if
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
self.filterset_class(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True)
|
|
960
|
-
)
|
|
961
|
-
else:
|
|
962
|
-
self.pk_list = list(model.objects.all().values_list("pk", flat=True))
|
|
1004
|
+
if delete_all:
|
|
1005
|
+
queryset = self._get_bulk_edit_delete_all_queryset(self.request)
|
|
1006
|
+
data = self._bulk_delete_all_context(request, queryset)
|
|
963
1007
|
else:
|
|
964
1008
|
self.pk_list = list(request.POST.getlist("pk"))
|
|
1009
|
+
|
|
965
1010
|
form_class = self.get_form_class(**kwargs)
|
|
966
|
-
data = {}
|
|
967
1011
|
if "_confirm" in request.POST:
|
|
968
1012
|
form = form_class(request.POST, initial=normalize_querydict(request.GET, form_class=form_class))
|
|
969
1013
|
if form.is_valid():
|
|
970
1014
|
return self.form_valid(form)
|
|
971
1015
|
else:
|
|
972
1016
|
return self.form_invalid(form)
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1017
|
+
if not delete_all:
|
|
1018
|
+
table_class = self.get_table_class()
|
|
1019
|
+
table = table_class(queryset.filter(pk__in=self.pk_list), orderable=False)
|
|
1020
|
+
if not table.rows:
|
|
1021
|
+
messages.warning(
|
|
1022
|
+
request,
|
|
1023
|
+
f"No {queryset.model._meta.verbose_name_plural} were selected for deletion.",
|
|
1024
|
+
)
|
|
1025
|
+
return redirect(self.get_return_url(request))
|
|
981
1026
|
|
|
982
|
-
|
|
1027
|
+
data.update({"table": table})
|
|
983
1028
|
return Response(data)
|
|
984
1029
|
|
|
985
1030
|
|
|
@@ -1037,7 +1082,7 @@ class ObjectBulkCreateViewMixin(NautobotViewSetMixin): # 3.0 TODO: remove, unus
|
|
|
1037
1082
|
return self.form_invalid(form)
|
|
1038
1083
|
|
|
1039
1084
|
|
|
1040
|
-
class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin):
|
|
1085
|
+
class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin, EditAndDeleteAllModelMixin):
|
|
1041
1086
|
"""
|
|
1042
1087
|
UI mixin to bulk update model instances.
|
|
1043
1088
|
"""
|
|
@@ -1062,7 +1107,13 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin):
|
|
|
1062
1107
|
nullified_fields = request.POST.getlist("_nullify")
|
|
1063
1108
|
with deferred_change_logging_for_bulk_operation():
|
|
1064
1109
|
updated_objects = []
|
|
1065
|
-
|
|
1110
|
+
edit_all = self.request.POST.get("_all")
|
|
1111
|
+
|
|
1112
|
+
if edit_all:
|
|
1113
|
+
queryset = self._get_bulk_edit_delete_all_queryset(self.request)
|
|
1114
|
+
else:
|
|
1115
|
+
queryset = queryset.filter(pk__in=form.cleaned_data["pk"])
|
|
1116
|
+
for obj in queryset:
|
|
1066
1117
|
self.obj = obj
|
|
1067
1118
|
# Update standard fields. If a field is listed in _nullify, delete its value.
|
|
1068
1119
|
for name in standard_fields:
|
|
@@ -1133,38 +1184,41 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin):
|
|
|
1133
1184
|
request.POST "_edit": Function to render the user selection of objects in a table form/BulkUpdateForm via Response that is passed to NautobotHTMLRenderer.
|
|
1134
1185
|
request.POST "_apply": Function to validate the table form/BulkUpdateForm and to perform the action of bulk update. Render the form with errors if exceptions are raised.
|
|
1135
1186
|
"""
|
|
1136
|
-
|
|
1137
|
-
model = queryset.model
|
|
1187
|
+
edit_all = request.POST.get("_all")
|
|
1138
1188
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
self.pk_list = list(
|
|
1143
|
-
self.filterset_class(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True)
|
|
1144
|
-
)
|
|
1145
|
-
else:
|
|
1146
|
-
self.pk_list = list(model.objects.all().values_list("pk", flat=True))
|
|
1189
|
+
if edit_all:
|
|
1190
|
+
self.pk_list = None
|
|
1191
|
+
queryset = self._get_bulk_edit_delete_all_queryset(request)
|
|
1147
1192
|
else:
|
|
1148
1193
|
self.pk_list = list(request.POST.getlist("pk"))
|
|
1194
|
+
queryset = self.get_queryset().filter(pk__in=self.pk_list)
|
|
1195
|
+
|
|
1149
1196
|
data = {}
|
|
1150
1197
|
form_class = self.get_form_class()
|
|
1151
1198
|
if "_apply" in request.POST:
|
|
1152
1199
|
self.kwargs = kwargs
|
|
1153
|
-
form = form_class(queryset.model, request.POST)
|
|
1200
|
+
form = form_class(queryset.model, request.POST, edit_all=edit_all)
|
|
1154
1201
|
restrict_form_fields(form, request.user)
|
|
1155
1202
|
if form.is_valid():
|
|
1156
1203
|
return self.form_valid(form)
|
|
1157
1204
|
else:
|
|
1158
1205
|
return self.form_invalid(form)
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1206
|
+
table = None
|
|
1207
|
+
if not edit_all:
|
|
1208
|
+
table_class = self.get_table_class()
|
|
1209
|
+
table = table_class(queryset, orderable=False)
|
|
1210
|
+
if not table.rows:
|
|
1211
|
+
messages.warning(
|
|
1212
|
+
request,
|
|
1213
|
+
f"No {queryset.model._meta.verbose_name_plural} were selected to update.",
|
|
1214
|
+
)
|
|
1215
|
+
return redirect(self.get_return_url(request))
|
|
1216
|
+
data.update(
|
|
1217
|
+
{
|
|
1218
|
+
"table": table,
|
|
1219
|
+
"objs_count": queryset.count(),
|
|
1220
|
+
}
|
|
1221
|
+
)
|
|
1168
1222
|
return Response(data)
|
|
1169
1223
|
|
|
1170
1224
|
|
nautobot/core/views/renderers.py
CHANGED
|
@@ -249,19 +249,22 @@ class NautobotHTMLRenderer(renderers.BrowsableAPIRenderer):
|
|
|
249
249
|
"return_url": return_url,
|
|
250
250
|
}
|
|
251
251
|
form = form_class(initial=initial)
|
|
252
|
-
|
|
252
|
+
delete_all = request.POST.get("_all")
|
|
253
|
+
if not delete_all:
|
|
254
|
+
table = self.construct_table(view, pk_list=pk_list)
|
|
253
255
|
elif view.action == "bulk_create": # 3.0 TODO: remove, replaced by ImportObjects system Job
|
|
254
256
|
form = view.get_form()
|
|
255
257
|
if request.data:
|
|
256
258
|
table = data.get("table")
|
|
257
259
|
elif view.action == "bulk_update":
|
|
260
|
+
edit_all = request.POST.get("_all")
|
|
258
261
|
pk_list = getattr(view, "pk_list", [])
|
|
259
|
-
if pk_list:
|
|
262
|
+
if pk_list or edit_all:
|
|
260
263
|
initial_data = {"pk": pk_list}
|
|
261
|
-
form = form_class(model, initial=initial_data)
|
|
262
|
-
|
|
264
|
+
form = form_class(model, initial=initial_data, edit_all=edit_all)
|
|
263
265
|
restrict_form_fields(form, request.user)
|
|
264
|
-
|
|
266
|
+
if not edit_all:
|
|
267
|
+
table = self.construct_table(view, pk_list=pk_list)
|
|
265
268
|
elif view.action == "notes":
|
|
266
269
|
initial_data = {
|
|
267
270
|
"assigned_object_type": content_type,
|
nautobot/dcim/tables/devices.py
CHANGED
|
@@ -1277,6 +1277,9 @@ class SoftwareImageFileTable(StatusTableMixin, BaseTable):
|
|
|
1277
1277
|
class SoftwareVersionTable(StatusTableMixin, BaseTable):
|
|
1278
1278
|
pk = ToggleColumn()
|
|
1279
1279
|
version = tables.Column(linkify=True)
|
|
1280
|
+
platform = tables.Column(linkify=True)
|
|
1281
|
+
release_date = tables.DateColumn()
|
|
1282
|
+
end_of_support_date = tables.DateColumn()
|
|
1280
1283
|
software_image_file_count = LinkedCountColumn(
|
|
1281
1284
|
viewname="dcim:softwareimagefile_list",
|
|
1282
1285
|
url_params={"software_version": "pk"},
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<form action="" method="post" class="form form-horizontal">
|
|
8
8
|
{% csrf_token %}
|
|
9
9
|
<div class="row">
|
|
10
|
-
<div class="col-md-
|
|
10
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
|
11
11
|
{% if form.non_field_errors %}
|
|
12
12
|
<div class="panel panel-danger">
|
|
13
13
|
<div class="panel-heading"><strong>Errors</strong></div>
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
{% include 'inc/extras_features_edit_form_fields.html' with form=model_form %}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
</
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="row">
|
|
31
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 text-right">
|
|
32
|
+
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
|
33
|
+
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
|
34
|
+
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
|
35
35
|
</div>
|
|
36
36
|
</div>
|
|
37
37
|
</form>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
|
6
6
|
{% csrf_token %}
|
|
7
7
|
<div class="row">
|
|
8
|
-
<div class="col-md-
|
|
8
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
|
9
9
|
<h3>{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}</h3>
|
|
10
10
|
{% if membership_form.non_field_errors %}
|
|
11
11
|
<div class="panel panel-danger">
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
<div class="row">
|
|
28
|
-
<div class="col-md-
|
|
28
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 text-right">
|
|
29
29
|
<button type="submit" name="_save" class="btn btn-primary">Save</button>
|
|
30
30
|
<button type="submit" name="_addanother" class="btn btn-primary">Add Another</button>
|
|
31
31
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{{ pk_form.pk }}
|
|
9
9
|
{{ formset.management_form }}
|
|
10
10
|
<div class="row">
|
|
11
|
-
<div class="col-
|
|
11
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
|
12
12
|
<h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
|
|
13
13
|
{% if vc_form.non_field_errors %}
|
|
14
14
|
<div class="panel panel-danger">
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
</div>
|
|
85
85
|
</div>
|
|
86
86
|
<div class="row">
|
|
87
|
-
<div class="col-
|
|
87
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 text-right">
|
|
88
88
|
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
|
89
89
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
|
90
90
|
</div>
|