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.

Files changed (54) hide show
  1. nautobot/apps/utils.py +2 -0
  2. nautobot/cloud/tables.py +1 -0
  3. nautobot/core/forms/forms.py +5 -1
  4. nautobot/core/tables.py +88 -22
  5. nautobot/core/templates/generic/object_bulk_destroy.html +12 -3
  6. nautobot/core/templates/generic/object_bulk_update.html +4 -2
  7. nautobot/core/templates/generic/object_create.html +1 -1
  8. nautobot/core/templates/rest_framework/api.html +3 -0
  9. nautobot/core/testing/api.py +3 -1
  10. nautobot/core/testing/integration.py +64 -0
  11. nautobot/core/testing/views.py +33 -27
  12. nautobot/core/tests/integration/test_app_navbar.py +3 -3
  13. nautobot/core/tests/integration/test_navbar.py +1 -1
  14. nautobot/core/tests/test_csv.py +3 -0
  15. nautobot/core/tests/test_utils.py +25 -5
  16. nautobot/core/utils/lookup.py +35 -0
  17. nautobot/core/views/generic.py +50 -39
  18. nautobot/core/views/mixins.py +97 -43
  19. nautobot/core/views/renderers.py +8 -5
  20. nautobot/dcim/tables/devices.py +3 -0
  21. nautobot/dcim/templates/dcim/device_component_add.html +8 -8
  22. nautobot/dcim/templates/dcim/virtualchassis_add_member.html +2 -2
  23. nautobot/dcim/templates/dcim/virtualchassis_edit.html +2 -2
  24. nautobot/dcim/tests/integration/test_create_device.py +86 -0
  25. nautobot/extras/tests/test_relationships.py +1 -0
  26. nautobot/extras/views.py +1 -0
  27. nautobot/ipam/factory.py +3 -0
  28. nautobot/ipam/filters.py +5 -0
  29. nautobot/ipam/forms.py +17 -0
  30. nautobot/ipam/models.py +2 -1
  31. nautobot/ipam/signals.py +2 -2
  32. nautobot/ipam/tables.py +3 -3
  33. nautobot/ipam/templates/ipam/ipaddress_assign.html +2 -2
  34. nautobot/ipam/tests/test_models.py +113 -1
  35. nautobot/ipam/tests/test_views.py +39 -5
  36. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +131 -6
  37. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +175 -0
  38. nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +94 -0
  39. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +4 -4
  40. nautobot/project-static/docs/objects.inv +0 -0
  41. nautobot/project-static/docs/release-notes/version-2.3.html +293 -138
  42. nautobot/project-static/docs/search/search_index.json +1 -1
  43. nautobot/project-static/docs/sitemap.xml +270 -270
  44. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  45. nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +39 -0
  46. nautobot/virtualization/forms.py +24 -0
  47. nautobot/virtualization/templates/virtualization/vminterface_edit.html +1 -0
  48. nautobot/virtualization/tests/test_views.py +7 -2
  49. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/METADATA +1 -1
  50. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/RECORD +54 -53
  51. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/LICENSE.txt +0 -0
  52. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/NOTICE +0 -0
  53. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/WHEEL +0 -0
  54. {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 the util function `get_filterset_for_model` returns the appropriate FilterSet, if model (as dotted string or class) provided.
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 the util function `get_form_for_model` returns the appropriate Form, if form type and model (as dotted string or class) provided.
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 the util function `get_route_for_model` returns the appropriate URL route name, if model (as dotted string or class) provided.
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 the util function `get_table_for_model` returns the appropriate Table, if model (as dotted string or class) provided.
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 the util function `get_model_for_view_name` returns the appropriate Model, if the colon separated view name provided.
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)
@@ -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
 
@@ -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 request.POST.get("_all"):
1019
- if self.filterset is not None:
1020
- pk_list = list(self.filterset(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True))
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
- for obj in self.queryset.filter(pk__in=form.cleaned_data["pk"]):
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 = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
1138
- if not table.rows:
1139
- messages.warning(request, f"No {model._meta.verbose_name_plural} were selected.")
1140
- return redirect(self.get_return_url(request))
1141
- # Hide actions column if present
1142
- if "actions" in table.columns:
1143
- table.columns.hide("actions")
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 post(self, request, **kwargs):
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
- if self.filterset is not None:
1288
- pk_list = list(self.filterset(request.GET, model.objects.only("pk")).qs.values_list("pk", flat=True))
1289
- else:
1290
- pk_list = list(model.objects.all().values_list("pk", flat=True))
1291
- else:
1292
- pk_list = request.POST.getlist("pk")
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)
@@ -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 ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin):
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
- queryset = queryset.filter(pk__in=pk_list)
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
- model = queryset.model
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 request.POST.get("_all"):
957
- if self.filterset_class is not None:
958
- self.pk_list = list(
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
- table_class = self.get_table_class()
974
- table = table_class(queryset.filter(pk__in=self.pk_list), orderable=False)
975
- if not table.rows:
976
- messages.warning(
977
- request,
978
- f"No {queryset.model._meta.verbose_name_plural} were selected for deletion.",
979
- )
980
- return redirect(self.get_return_url(request))
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
- data.update({"table": table})
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
- for obj in queryset.filter(pk__in=form.cleaned_data["pk"]):
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
- queryset = self.get_queryset()
1137
- model = queryset.model
1187
+ edit_all = request.POST.get("_all")
1138
1188
 
1139
- # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
1140
- if request.POST.get("_all"):
1141
- if self.filterset_class is not None:
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
- table_class = self.get_table_class()
1160
- table = table_class(queryset.filter(pk__in=self.pk_list), orderable=False)
1161
- if not table.rows:
1162
- messages.warning(
1163
- request,
1164
- f"No {queryset.model._meta.verbose_name_plural} were selected to update.",
1165
- )
1166
- return redirect(self.get_return_url(request))
1167
- data.update({"table": table})
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
 
@@ -249,19 +249,22 @@ class NautobotHTMLRenderer(renderers.BrowsableAPIRenderer):
249
249
  "return_url": return_url,
250
250
  }
251
251
  form = form_class(initial=initial)
252
- table = self.construct_table(view, pk_list=pk_list)
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
- table = self.construct_table(view, pk_list=pk_list)
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,
@@ -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-6 col-md-offset-3">
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
- <div class="form-group">
29
- <div class="col-md-9 col-md-offset-3 text-right">
30
- <button type="submit" name="_create" class="btn btn-primary">Create</button>
31
- <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
32
- <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
33
- </div>
34
- </div>
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-6 col-md-offset-3">
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-6 col-md-offset-3 text-right">
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-md-8 col-md-offset-2">
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-md-8 col-md-offset-2 text-right">
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>