nautobot 2.4.0__py3-none-any.whl → 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (40) hide show
  1. nautobot/core/celery/schedulers.py +1 -1
  2. nautobot/core/filters.py +48 -21
  3. nautobot/core/jobs/bulk_actions.py +56 -19
  4. nautobot/core/models/__init__.py +2 -0
  5. nautobot/core/tables.py +5 -1
  6. nautobot/core/testing/filters.py +25 -13
  7. nautobot/core/testing/integration.py +86 -4
  8. nautobot/core/tests/test_filters.py +209 -246
  9. nautobot/core/tests/test_jobs.py +250 -93
  10. nautobot/core/tests/test_models.py +9 -0
  11. nautobot/core/views/generic.py +80 -48
  12. nautobot/core/views/mixins.py +34 -6
  13. nautobot/dcim/api/serializers.py +2 -2
  14. nautobot/dcim/constants.py +6 -13
  15. nautobot/dcim/factory.py +6 -1
  16. nautobot/dcim/tests/integration/test_device_bulk_delete.py +189 -0
  17. nautobot/dcim/tests/integration/test_device_bulk_edit.py +181 -0
  18. nautobot/dcim/tests/test_api.py +0 -2
  19. nautobot/dcim/tests/test_models.py +42 -28
  20. nautobot/extras/forms/mixins.py +1 -1
  21. nautobot/extras/jobs.py +15 -6
  22. nautobot/extras/templatetags/job_buttons.py +4 -4
  23. nautobot/extras/tests/test_forms.py +13 -0
  24. nautobot/extras/tests/test_jobs.py +18 -13
  25. nautobot/extras/tests/test_models.py +6 -0
  26. nautobot/extras/tests/test_views.py +4 -3
  27. nautobot/ipam/tests/test_api.py +20 -0
  28. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +36 -1
  29. nautobot/project-static/docs/objects.inv +0 -0
  30. nautobot/project-static/docs/release-notes/version-2.4.html +108 -0
  31. nautobot/project-static/docs/search/search_index.json +1 -1
  32. nautobot/project-static/docs/sitemap.xml +288 -288
  33. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  34. nautobot/wireless/tests/test_views.py +22 -1
  35. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/METADATA +2 -2
  36. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/RECORD +40 -38
  37. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/LICENSE.txt +0 -0
  38. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/NOTICE +0 -0
  39. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/WHEEL +0 -0
  40. {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/entry_points.txt +0 -0
@@ -128,7 +128,7 @@ class NautobotDatabaseScheduler(DatabaseScheduler):
128
128
  scheduled_job = entry.model
129
129
  job_queue = scheduled_job.job_queue
130
130
  # Distinguish between Celery and Kubernetes job queues
131
- if job_queue.queue_type == JobQueueTypeChoices.TYPE_KUBERNETES:
131
+ if job_queue is not None and job_queue.queue_type == JobQueueTypeChoices.TYPE_KUBERNETES:
132
132
  job_result = JobResult.objects.create(
133
133
  name=scheduled_job.job_model.name,
134
134
  job_model=scheduled_job.job_model,
nautobot/core/filters.py CHANGED
@@ -13,6 +13,7 @@ from django_filters.constants import EMPTY_VALUES
13
13
  from django_filters.utils import get_model_field, label_for_filter, resolve_field, verbose_lookup_expr
14
14
  from drf_spectacular.types import OpenApiTypes
15
15
  from drf_spectacular.utils import extend_schema_field
16
+ import timezone_field
16
17
 
17
18
  from nautobot.core import constants, forms
18
19
  from nautobot.core.forms import widgets
@@ -612,9 +613,12 @@ class BaseFilterSet(django_filters.FilterSet):
612
613
  models.UUIDField: {"filter_class": MultiValueUUIDFilter},
613
614
  core_fields.MACAddressCharField: {"filter_class": MultiValueMACAddressFilter},
614
615
  core_fields.TagsField: {"filter_class": TagFilter},
616
+ timezone_field.TimeZoneField: {"filter_class": MultiValueCharFilter},
615
617
  }
616
618
  )
617
619
 
620
+ USE_CHAR_FILTER_FOR_LOOKUPS = [django_filters.MultipleChoiceFilter]
621
+
618
622
  @staticmethod
619
623
  def _get_filter_lookup_dict(existing_filter):
620
624
  # Choose the lookup expression map based on the filter type
@@ -678,8 +682,7 @@ class BaseFilterSet(django_filters.FilterSet):
678
682
  return magic_filters
679
683
 
680
684
  # Get properties of the existing filter for later use
681
- field_name = filter_field.field_name
682
- field = get_model_field(cls._meta.model, field_name) # pylint: disable=no-member
685
+ field = get_model_field(cls._meta.model, filter_field.field_name) # pylint: disable=no-member
683
686
 
684
687
  # If there isn't a model field, return.
685
688
  if field is None:
@@ -696,25 +699,7 @@ class BaseFilterSet(django_filters.FilterSet):
696
699
  new_filter_name = f"{filter_name}__{lookup_name}"
697
700
 
698
701
  try:
699
- if filter_name in cls.declared_filters and lookup_expr not in {"isnull"}: # pylint: disable=no-member
700
- # The filter field has been explicitly defined on the filterset class so we must manually
701
- # create the new filter with the same type because there is no guarantee the defined type
702
- # is the same as the default type for the field. This does not apply if the filter
703
- # should retain the original lookup_expr type, such as `isnull` using a boolean field on a
704
- # char or date object.
705
- resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
706
- new_filter = type(filter_field)(
707
- field_name=field_name,
708
- lookup_expr=lookup_expr,
709
- label=filter_field.label,
710
- exclude=filter_field.exclude,
711
- distinct=filter_field.distinct,
712
- **filter_field.extra,
713
- )
714
- else:
715
- # The filter field is listed in Meta.fields so we can safely rely on default behavior
716
- # Will raise FieldLookupError if the lookup is invalid
717
- new_filter = cls.filter_for_field(field, field_name, lookup_expr)
702
+ new_filter = cls._get_new_filter(filter_field, field, filter_name, lookup_expr)
718
703
  except django_filters.exceptions.FieldLookupError:
719
704
  # The filter could not be created because the lookup expression is not supported on the field
720
705
  continue
@@ -743,6 +728,45 @@ class BaseFilterSet(django_filters.FilterSet):
743
728
 
744
729
  return magic_filters
745
730
 
731
+ @classmethod
732
+ def _should_use_char_filter_for_lookups(cls, filter_field):
733
+ return type(filter_field) in cls.USE_CHAR_FILTER_FOR_LOOKUPS
734
+
735
+ @classmethod
736
+ def _get_new_filter(cls, filter_field, field, filter_name, lookup_expr):
737
+ if cls._should_use_char_filter_for_lookups(filter_field):
738
+ # For some cases like `MultiValueChoiceFilter(django_filters.MultipleChoiceFilter)`
739
+ # we want to have choices field with no lookups and standard char field for lookups filtering.
740
+ # Using a `choice` field for lookups blocks us from using `__re`, `__iew` or other "partial" filters.
741
+ resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
742
+ return MultiValueCharFilter(
743
+ field_name=filter_field.field_name,
744
+ lookup_expr=lookup_expr,
745
+ label=filter_field.label,
746
+ exclude=filter_field.exclude,
747
+ distinct=filter_field.distinct,
748
+ )
749
+
750
+ if filter_name in cls.declared_filters and lookup_expr not in {"isnull"}: # pylint: disable=no-member
751
+ # The filter field has been explicitly defined on the filterset class so we must manually
752
+ # create the new filter with the same type because there is no guarantee the defined type
753
+ # is the same as the default type for the field. This does not apply if the filter
754
+ # should retain the original lookup_expr type, such as `isnull` using a boolean field on a
755
+ # char or date object.
756
+ resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
757
+ return type(filter_field)(
758
+ field_name=filter_field.field_name,
759
+ lookup_expr=lookup_expr,
760
+ label=filter_field.label,
761
+ exclude=filter_field.exclude,
762
+ distinct=filter_field.distinct,
763
+ **filter_field.extra,
764
+ )
765
+
766
+ # The filter field is listed in Meta.fields so we can safely rely on default behavior
767
+ # Will raise FieldLookupError if the lookup is invalid
768
+ return cls.filter_for_field(field, filter_field.field_name, lookup_expr)
769
+
746
770
  @classmethod
747
771
  def add_filter(cls, new_filter_name, new_filter_field):
748
772
  """
@@ -850,7 +874,10 @@ class BaseFilterSet(django_filters.FilterSet):
850
874
  Note: Any CharField or IntegerField with choices set is a ChoiceField.
851
875
  """
852
876
  if lookup_type == "exact" and getattr(field, "choices", None):
877
+ if isinstance(field, timezone_field.TimeZoneField):
878
+ return django_filters.MultipleChoiceFilter, {"choices": ((str(v), n) for v, n in field.choices)}
853
879
  return django_filters.MultipleChoiceFilter, {"choices": field.choices}
880
+
854
881
  return super().filter_for_lookup(field, lookup_type)
855
882
 
856
883
  def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
@@ -7,7 +7,9 @@ from django.core.exceptions import (
7
7
  )
8
8
  from django.db.models import ManyToManyField, ProtectedError
9
9
 
10
+ from nautobot.core import exceptions
10
11
  from nautobot.core.forms.utils import restrict_form_fields
12
+ from nautobot.core.utils.filtering import get_filterset_field
11
13
  from nautobot.core.utils.lookup import get_filterset_for_model, get_form_for_model
12
14
  from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
13
15
  from nautobot.extras.jobs import (
@@ -42,15 +44,33 @@ class BulkEditObjects(Job):
42
44
 
43
45
  def _update_objects(self, model, form, filter_query_params, edit_all, nullified_fields):
44
46
  with deferred_change_logging_for_bulk_operation():
47
+ base_queryset = model.objects.restrict(self.user, "change")
45
48
  updated_objects_pk = []
46
49
  filterset_cls = get_filterset_for_model(model)
47
50
 
48
- if filter_query_params and not filterset_cls:
49
- self.logger.error(f"Filterset not found for {model}")
50
- raise RunJobTaskFailed(f"Filter query provided but {model} do not have a filterset.")
51
+ if filter_query_params:
52
+ if not filterset_cls:
53
+ self.logger.error(f"Filterset not found for {model._meta.verbose_name}")
54
+ raise RunJobTaskFailed(
55
+ f"Filter query provided but {model._meta.verbose_name} does not have a filterset."
56
+ )
57
+
58
+ # Discarding non-filter query params
59
+ new_filter_query_params = {}
60
+
61
+ for key, value in filter_query_params.items():
62
+ try:
63
+ get_filterset_field(filterset_cls(), key)
64
+ new_filter_query_params[key] = value
65
+ except exceptions.FilterSetFieldNotFound:
66
+ self.logger.debug(
67
+ f"Query parameter `{key}` not found in filterset for `{filterset_cls}`, discarding it"
68
+ )
69
+
70
+ filter_query_params = new_filter_query_params
51
71
 
52
72
  if edit_all:
53
- if filterset_cls and filter_query_params:
73
+ if filter_query_params:
54
74
  queryset = filterset_cls(filter_query_params).qs.restrict(self.user, "change")
55
75
  else:
56
76
  queryset = model.objects.restrict(self.user, "change")
@@ -93,7 +113,7 @@ class BulkEditObjects(Job):
93
113
 
94
114
  # Update custom fields
95
115
  for field_name in form_custom_fields:
96
- if field_name in form.nullable_fields and field_name in nullified_fields:
116
+ if field_name in form.nullable_fields and nullified_fields and field_name in nullified_fields:
97
117
  obj.cf[remove_prefix_from_cf_key(field_name)] = None
98
118
  elif form.cleaned_data.get(field_name) not in (None, "", []):
99
119
  obj.cf[remove_prefix_from_cf_key(field_name)] = form.cleaned_data[field_name]
@@ -111,7 +131,7 @@ class BulkEditObjects(Job):
111
131
  form.save_note(instance=obj, user=self.user)
112
132
  total_updated_objs = len(updated_objects_pk)
113
133
  # Enforce object-level permissions
114
- if queryset.filter(pk__in=updated_objects_pk).count() != total_updated_objs:
134
+ if base_queryset.filter(pk__in=updated_objects_pk).count() != total_updated_objs:
115
135
  raise ObjectDoesNotExist
116
136
  return total_updated_objs
117
137
 
@@ -201,33 +221,49 @@ class BulkDeleteObjects(Job):
201
221
  )
202
222
  raise RunJobTaskFailed("Model not found")
203
223
 
204
- if pk_list and (delete_all or filter_query_params):
205
- self.logger.error(
206
- "You can either delete objs within `pk_list` provided or `delete_all` with `filter_query_params` if needed."
207
- )
208
- raise RunJobTaskFailed("Both `pk_list` and `delete_all` can not both be set.")
209
-
210
224
  filterset_cls = get_filterset_for_model(model)
211
225
 
212
- if filter_query_params and not filterset_cls:
213
- self.logger.error(f"Filterset not found for {model}")
214
- raise RunJobTaskFailed(f"Filter query provided but {model} do not have a filterset.")
226
+ if filter_query_params:
227
+ if not filterset_cls:
228
+ self.logger.error(f"Filterset not found for {model._meta.verbose_name}")
229
+ raise RunJobTaskFailed(
230
+ f"Filter query provided but {model._meta.verbose_name} does not have a filterset."
231
+ )
232
+
233
+ # Discarding non-filter query params
234
+ new_filter_query_params = {}
235
+
236
+ for key, value in filter_query_params.items():
237
+ try:
238
+ get_filterset_field(filterset_cls(), key)
239
+ new_filter_query_params[key] = value
240
+ except exceptions.FilterSetFieldNotFound:
241
+ self.logger.debug(f"Query parameter `{key}` not found in `{filterset_cls}`, discarding it")
242
+
243
+ filter_query_params = new_filter_query_params
215
244
 
216
245
  if delete_all:
217
- if filterset_cls and filter_query_params:
246
+ # Case for selecting all objects (or all filtered objects) to delete
247
+ if filter_query_params:
248
+ # If there is filter query params, we need to apply it to the queryset
218
249
  queryset = filterset_cls(filter_query_params).qs.restrict(self.user, "delete")
219
250
  # We take this approach because filterset.qs has already applied .distinct(),
220
251
  # and performing a .delete directly on a queryset with .distinct applied is not allowed.
221
252
  queryset = model.objects.filter(pk__in=queryset)
222
253
  else:
254
+ # If there is not, we can just restrict the queryset to the user's permissions
223
255
  queryset = model.objects.restrict(self.user, "delete")
224
- else:
256
+ elif pk_list:
257
+ # Case for selecting specific objects to delete, delete_all overrides this option
225
258
  queryset = model.objects.restrict(self.user, "delete").filter(pk__in=pk_list)
226
259
  if queryset.count() < len(pk_list):
227
260
  # We do not check ObjectPermission error for `delete_all` case because user is trying to
228
261
  # delete all objs they have permission to which would not raise any issue.
229
262
  self.logger.error("You do not have permissions to delete some of the objects provided in `pk_list`.")
230
263
  raise RunJobTaskFailed("Caught ObjectPermission error while attempting to delete objects")
264
+ elif not pk_list and not delete_all:
265
+ self.logger.error("You must select at least one object instance to delete.")
266
+ raise RunJobTaskFailed("Either `pk_list` or `delete_all` is required.")
231
267
 
232
268
  verbose_name_plural = model._meta.verbose_name_plural
233
269
 
@@ -240,9 +276,10 @@ class BulkDeleteObjects(Job):
240
276
  try:
241
277
  self.logger.info(f"Deleting {queryset.count()} {verbose_name_plural}...")
242
278
  _, deleted_info = bulk_delete_with_bulk_change_logging(queryset)
243
- deleted_count = deleted_info[model._meta.label]
279
+ deleted_count = deleted_info.get(model._meta.label, 0)
244
280
  except ProtectedError as err:
245
- self.logger.error(f"Caught ProtectedError while attempting to delete objects: {err}")
281
+ # TODO this error message needs to be cleaner, ideally using a variant of handle_protectederror
282
+ self.logger.error(f"Caught ProtectedError while attempting to delete objects: `{err}`")
246
283
  raise RunJobTaskFailed("Caught ProtectedError while attempting to delete objects")
247
284
  msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
248
285
  self.logger.info(msg)
@@ -248,6 +248,8 @@ class BaseModel(models.Model):
248
248
  Unlike `get_natural_key_def()`, this doesn't auto-exclude all AutoField and BigAutoField fields,
249
249
  but instead explicitly discounts the `id` field (only) as a candidate.
250
250
  """
251
+ if cls != cls._meta.concrete_model:
252
+ return cls._meta.concrete_model.natural_key_field_lookups
251
253
  # First, figure out which local fields comprise the natural key:
252
254
  natural_key_field_names = []
253
255
  if hasattr(cls, "natural_key_field_names"):
nautobot/core/tables.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import contextlib
2
2
  import logging
3
3
 
4
+ from django.conf import settings
4
5
  from django.contrib.auth.models import AnonymousUser
5
6
  from django.contrib.contenttypes.fields import GenericForeignKey
6
7
  from django.core.exceptions import FieldDoesNotExist, FieldError
@@ -557,7 +558,10 @@ class LinkedCountColumn(django_tables2.Column):
557
558
  related_record = related_records[0]
558
559
  url = reverse(self.viewname, kwargs=self.view_kwargs)
559
560
  if self.url_params:
560
- url += "?" + urlencode({k: getattr(record, v) for k, v in self.url_params.items()})
561
+ url += "?" + urlencode(
562
+ # Replace None values with `FILTERS_NULL_CHOICE_VALUE` to handle URL parameters correctly
563
+ {k: (getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE) for k, v in self.url_params.items()}
564
+ )
561
565
  if value > 1:
562
566
  return format_html('<a href="{}" class="badge">{}</a>', url, value)
563
567
  if related_record is not None:
@@ -1,6 +1,6 @@
1
1
  import random
2
2
  import string
3
- from typing import ClassVar, Iterable
3
+ from typing import ClassVar, Iterable, Optional
4
4
 
5
5
  from django.contrib.contenttypes.models import ContentType
6
6
  from django.db.models import Count, Q, QuerySet
@@ -30,7 +30,7 @@ class FilterTestCases:
30
30
  class BaseFilterTestCase(views.TestCase):
31
31
  """Base class for testing of FilterSets."""
32
32
 
33
- queryset: ClassVar[QuerySet]
33
+ queryset: ClassVar[Optional[QuerySet]] = None # TODO: declared as Optional only to avoid a breaking change
34
34
 
35
35
  def get_filterset_test_values(self, field_name, queryset=None):
36
36
  """Returns a list of distinct values from the requested queryset field to use in filterset tests.
@@ -73,7 +73,7 @@ class FilterTestCases:
73
73
  class FilterTestCase(BaseFilterTestCase):
74
74
  """Add common tests for all FilterSets."""
75
75
 
76
- filterset: ClassVar[type[FilterSet]]
76
+ filterset: ClassVar[Optional[type[FilterSet]]] = None # TODO: declared Optional only to avoid breaking change
77
77
 
78
78
  # filter predicate fields that should be excluded from q test case
79
79
  exclude_q_filter_predicates = []
@@ -85,7 +85,7 @@ class FilterTestCases:
85
85
  # ["filter1"],
86
86
  # ["filter2", "field2__name"],
87
87
  # ]
88
- generic_filter_tests: ClassVar[Iterable]
88
+ generic_filter_tests: ClassVar[Iterable] = ()
89
89
 
90
90
  def setUp(self):
91
91
  for attr in ["queryset", "filterset", "generic_filter_tests"]:
@@ -95,34 +95,38 @@ class FilterTestCases:
95
95
 
96
96
  def get_q_filter(self):
97
97
  """Helper method to return q filter."""
98
+ self.assertIsNotNone(self.filterset)
98
99
  return self.filterset.declared_filters["q"].filter_predicates
99
100
 
100
101
  def test_id(self):
101
102
  """Verify that the filterset supports filtering by id with only lookup `__n`."""
103
+ self.assertIsNotNone(self.filterset)
104
+
102
105
  with self.subTest("Assert `id`"):
103
106
  params = {"id": list(self.queryset.values_list("pk", flat=True)[:2])}
104
107
  expected_queryset = self.queryset.filter(id__in=params["id"])
105
- filterset = self.filterset(params, self.queryset)
108
+ filterset = self.filterset(params, self.queryset) # pylint: disable=not-callable # see assertion above
106
109
  self.assertTrue(filterset.is_valid())
107
110
  self.assertQuerysetEqualAndNotEmpty(filterset.qs.order_by("id"), expected_queryset.order_by("id"))
108
111
 
109
112
  with self.subTest("Assert negate lookup"):
110
113
  params = {"id__n": list(self.queryset.values_list("pk", flat=True)[:2])}
111
114
  expected_queryset = self.queryset.exclude(id__in=params["id__n"])
112
- filterset = self.filterset(params, self.queryset)
115
+ filterset = self.filterset(params, self.queryset) # pylint: disable=not-callable # see assertion above
113
116
  self.assertTrue(filterset.is_valid())
114
117
  self.assertQuerysetEqualAndNotEmpty(filterset.qs.order_by("id"), expected_queryset.order_by("id"))
115
118
 
116
119
  with self.subTest("Assert invalid lookup"):
117
120
  params = {"id__in": list(self.queryset.values_list("pk", flat=True)[:2])}
118
- filterset = self.filterset(params, self.queryset)
121
+ filterset = self.filterset(params, self.queryset) # pylint: disable=not-callable # see assertion above
119
122
  self.assertFalse(filterset.is_valid())
120
123
  self.assertIn("Unknown filter field", filterset.errors.as_text())
121
124
 
122
125
  def test_invalid_filter(self):
123
126
  """Verify that the filterset reports as invalid when initialized with an unsupported filter parameter."""
124
127
  params = {"ice_cream_flavor": ["chocolate"]}
125
- self.assertFalse(self.filterset(params, self.queryset).is_valid())
128
+ self.assertIsNotNone(self.filterset)
129
+ self.assertFalse(self.filterset(params, self.queryset).is_valid()) # pylint: disable=not-callable
126
130
 
127
131
  def test_filters_generic(self):
128
132
  """Test all multiple choice filters declared in `self.generic_filter_tests`.
@@ -190,13 +194,16 @@ class FilterTestCases:
190
194
  status=Status.objects.get_for_model(ContactAssociation).last(),
191
195
  )
192
196
 
197
+ if self.generic_filter_tests:
198
+ self.assertIsNotNone(self.filterset)
199
+
193
200
  for test in self.generic_filter_tests:
194
201
  filter_name = test[0]
195
202
  field_name = test[-1] # default to filter_name if a second list item was not supplied
196
203
  with self.subTest(f"{self.filterset.__name__} filter {filter_name} ({field_name})"):
197
204
  test_data = self.get_filterset_test_values(field_name)
198
205
  params = {filter_name: test_data}
199
- filterset_result = self.filterset(params, self.queryset).qs
206
+ filterset_result = self.filterset(params, self.queryset).qs # pylint: disable=not-callable
200
207
  qs_result = self.queryset.filter(**{f"{field_name}__in": test_data}).distinct()
201
208
  self.assertQuerysetEqualAndNotEmpty(filterset_result, qs_result, ordered=False)
202
209
 
@@ -207,6 +214,7 @@ class FilterTestCases:
207
214
  This test asserts that `filter=True` matches `self.queryset.filter(field__isnull=False)` and
208
215
  that `filter=False` matches `self.queryset.filter(field__isnull=True)`.
209
216
  """
217
+ self.assertIsNotNone(self.filterset)
210
218
  for filter_name, filter_object in self.filterset.get_filters().items():
211
219
  if not isinstance(filter_object, RelatedMembershipBooleanFilter):
212
220
  continue
@@ -214,11 +222,11 @@ class FilterTestCases:
214
222
  continue
215
223
  field_name = filter_object.field_name
216
224
  with self.subTest(f"{self.filterset.__name__} RelatedMembershipBooleanFilter {filter_name} (True)"):
217
- filterset_result = self.filterset({filter_name: True}, self.queryset).qs
225
+ filterset_result = self.filterset({filter_name: True}, self.queryset).qs # pylint: disable=not-callable
218
226
  qs_result = self.queryset.filter(**{f"{field_name}__isnull": filter_object.exclude}).distinct()
219
227
  self.assertQuerysetEqualAndNotEmpty(filterset_result, qs_result)
220
228
  with self.subTest(f"{self.filterset.__name__} RelatedMembershipBooleanFilter {filter_name} (False)"):
221
- filterset_result = self.filterset({filter_name: False}, self.queryset).qs
229
+ filterset_result = self.filterset({filter_name: False}, self.queryset).qs # pylint: disable=not-callable
222
230
  qs_result = self.queryset.exclude(**{f"{field_name}__isnull": filter_object.exclude}).distinct()
223
231
  self.assertQuerysetEqualAndNotEmpty(filterset_result, qs_result)
224
232
 
@@ -227,6 +235,8 @@ class FilterTestCases:
227
235
  if not issubclass(self.queryset.model, PrimaryModel):
228
236
  self.skipTest("Not a PrimaryModel")
229
237
 
238
+ self.assertIsNotNone(self.filterset)
239
+
230
240
  # Find an instance with at least two tags (should be common given our factory design)
231
241
  for instance in list(self.queryset):
232
242
  if len(instance.tags.all()) >= 2:
@@ -243,7 +253,7 @@ class FilterTestCases:
243
253
  self.queryset.first().tags.add(test_tags_filter_a, test_tags_filter_b)
244
254
  tags = [test_tags_filter_a, test_tags_filter_b]
245
255
  params = {"tags": [tags[0].name, tags[1].pk]}
246
- filterset_result = self.filterset(params, self.queryset).qs
256
+ filterset_result = self.filterset(params, self.queryset).qs # pylint: disable=not-callable
247
257
  # Tags is an AND filter not an OR filter
248
258
  qs_result = self.queryset.filter(tags=tags[0]).filter(tags=tags[1]).distinct()
249
259
  self.assertQuerysetEqualAndNotEmpty(filterset_result, qs_result)
@@ -294,6 +304,8 @@ class FilterTestCases:
294
304
  """
295
305
  self._assert_valid_filter_predicates(obj, obj_field_name)
296
306
 
307
+ self.assertIsNotNone(self.filterset)
308
+
297
309
  # Generic test only supports CharField or TextFields, skip all other types
298
310
  obj_field = obj._meta.get_field(obj_field_name)
299
311
  if not isinstance(obj_field, (CharField, TextField)):
@@ -313,7 +325,7 @@ class FilterTestCases:
313
325
  lookup = randomized_attr_value[1:].upper()
314
326
  model_queryset = self.queryset.filter(**{f"{filter_field_name}__icontains": lookup})
315
327
  params = {"q": lookup}
316
- filterset_result = self.filterset(params, self.queryset)
328
+ filterset_result = self.filterset(params, self.queryset) # pylint: disable=not-callable
317
329
 
318
330
  self.assertTrue(filterset_result.is_valid())
319
331
  self.assertQuerysetEqualAndNotEmpty(
@@ -6,7 +6,9 @@ from django.test import override_settings, tag
6
6
  from django.urls import reverse
7
7
  from django.utils.functional import classproperty
8
8
  from selenium.webdriver.common.keys import Keys
9
+ from selenium.webdriver.support.wait import WebDriverWait
9
10
  from splinter.browser import Browser
11
+ from splinter.exceptions import ElementDoesNotExist
10
12
 
11
13
  from nautobot.core import testing
12
14
 
@@ -20,6 +22,71 @@ SELENIUM_HOST = os.getenv("NAUTOBOT_SELENIUM_HOST", "host.docker.internal")
20
22
  LOGIN_URL = reverse(settings.LOGIN_URL)
21
23
 
22
24
 
25
+ class ObjectsListMixin:
26
+ """
27
+ Helper class for easier testing and navigating on standard Nautobot objects list page.
28
+ """
29
+
30
+ def select_all_items(self):
31
+ self.browser.find_by_xpath('//*[@id="object_list_form"]//input[@class="toggle"]').click()
32
+
33
+ def select_one_item(self):
34
+ self.browser.find_by_xpath('//*[@id="object_list_form"]//input[@name="pk"]').click()
35
+
36
+ def click_bulk_delete(self):
37
+ self.browser.find_by_xpath(
38
+ '//*[@id="object_list_form"]//button[@type="submit"]/following-sibling::button[1]'
39
+ ).click()
40
+ self.browser.find_by_xpath('//*[@id="object_list_form"]//button[@name="_delete"]').click()
41
+
42
+ def click_bulk_edit(self):
43
+ self.browser.find_by_xpath('//*[@id="object_list_form"]//button[@type="submit"]').click()
44
+
45
+ @property
46
+ def objects_list_visible_items(self):
47
+ objects_table_container = self.browser.find_by_xpath('//*[@id="object_list_form"]/div[1]/div')
48
+ try:
49
+ objects_table = objects_table_container.find_by_tag("tbody")
50
+ return len(objects_table.find_by_tag("tr"))
51
+ except ElementDoesNotExist:
52
+ return 0
53
+
54
+ def apply_filter(self, field, value):
55
+ self.browser.find_by_xpath('//*[@id="id__filterbtn"]').click()
56
+ self.fill_filters_select2_field(field, value)
57
+ self.browser.find_by_xpath('//*[@id="default-filter"]//button[@type="submit"]').click()
58
+
59
+
60
+ class BulkOperationsMixin:
61
+ def confirm_bulk_delete_operation(self):
62
+ self.browser.find_by_xpath('//button[@name="_confirm" and @type="submit"]').click()
63
+
64
+ def submit_bulk_edit_operation(self):
65
+ self.browser.find_by_xpath("//button[@name='_apply']", wait_time=5).click()
66
+
67
+ def wait_for_job_result(self):
68
+ end_statuses = ["Completed", "Failed"]
69
+ WebDriverWait(self.browser, 30).until(
70
+ lambda driver: driver.find_by_id("pending-result-label").text in end_statuses
71
+ )
72
+
73
+ return self.browser.find_by_id("pending-result-label").text
74
+
75
+ def verify_job_description(self, expected_job_description):
76
+ job_description = self.browser.find_by_xpath('//td[text()="Job Description"]/following-sibling::td[1]').text
77
+ self.assertEqual(job_description, expected_job_description)
78
+
79
+ def assertIsBulkDeleteJob(self):
80
+ self.verify_job_description("Bulk delete objects.")
81
+
82
+ def assertIsBulkEditJob(self):
83
+ self.verify_job_description("Bulk edit objects.")
84
+
85
+ def assertJobStatusIsCompleted(self):
86
+ job_status = self.wait_for_job_result()
87
+ self.assertEqual(job_status, "Completed")
88
+
89
+
23
90
  # In CI, sometimes the FQDN of SELENIUM_HOST gets used, other times it seems to be just the hostname?
24
91
  @override_settings(ALLOWED_HOSTS=["nautobot.example.com", SELENIUM_HOST, SELENIUM_HOST.split(".")[0]])
25
92
  @tag("integration")
@@ -112,21 +179,36 @@ class SeleniumTestCase(StaticLiveServerTestCase, testing.NautobotTestCaseMixin):
112
179
  # Wait for body element to appear
113
180
  self.assertTrue(self.browser.is_element_present_by_tag("body", wait_time=5), "Page failed to load")
114
181
 
115
- def fill_select2_field(self, field_name, value):
182
+ def _fill_select2_field(self, field_name, value, search_box_class=None):
116
183
  """
117
184
  Helper function to fill a Select2 single selection field.
118
185
  """
186
+ if search_box_class is None:
187
+ search_box_class = "select2-search select2-search--dropdown"
188
+
119
189
  self.browser.find_by_xpath(f"//select[@id='id_{field_name}']//following-sibling::span").click()
120
- search_box = self.browser.find_by_xpath(
121
- "//*[@class='select2-search select2-search--dropdown']//input", wait_time=5
122
- )
190
+ search_box = self.browser.find_by_xpath(f"//*[@class='{search_box_class}']//input", wait_time=5)
123
191
  for _ in search_box.first.type(value, slowly=True):
124
192
  pass
125
193
 
126
194
  # wait for "searching" to disappear
127
195
  self.browser.is_element_not_present_by_css(".loading-results", wait_time=5)
196
+ return search_box
197
+
198
+ def fill_select2_field(self, field_name, value):
199
+ """
200
+ Helper function to fill a Select2 single selection field on add/edit forms.
201
+ """
202
+ search_box = self._fill_select2_field(field_name, value)
128
203
  search_box.first.type(Keys.ENTER)
129
204
 
205
+ def fill_filters_select2_field(self, field_name, value):
206
+ """
207
+ Helper function to fill a Select2 single selection field on filters modals.
208
+ """
209
+ self._fill_select2_field(field_name, value, search_box_class="select2-search select2-search--inline")
210
+ self.browser.find_by_xpath(f"//li[@class='select2-results__option' and text()='{value}']").click()
211
+
130
212
  def fill_select2_multiselect_field(self, field_name, value):
131
213
  """
132
214
  Helper function to fill a Select2 multi-selection field.