nautobot 2.4.0__py3-none-any.whl → 2.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/core/celery/schedulers.py +1 -1
- nautobot/core/filters.py +48 -21
- nautobot/core/jobs/bulk_actions.py +56 -19
- nautobot/core/models/__init__.py +2 -0
- nautobot/core/tables.py +5 -1
- nautobot/core/testing/filters.py +25 -13
- nautobot/core/testing/integration.py +86 -4
- nautobot/core/tests/test_filters.py +209 -246
- nautobot/core/tests/test_jobs.py +250 -93
- nautobot/core/tests/test_models.py +9 -0
- nautobot/core/views/generic.py +80 -48
- nautobot/core/views/mixins.py +34 -6
- nautobot/dcim/api/serializers.py +2 -2
- nautobot/dcim/constants.py +6 -13
- nautobot/dcim/factory.py +6 -1
- nautobot/dcim/tests/integration/test_device_bulk_delete.py +189 -0
- nautobot/dcim/tests/integration/test_device_bulk_edit.py +181 -0
- nautobot/dcim/tests/test_api.py +0 -2
- nautobot/dcim/tests/test_models.py +42 -28
- nautobot/extras/forms/mixins.py +1 -1
- nautobot/extras/jobs.py +15 -6
- nautobot/extras/templatetags/job_buttons.py +4 -4
- nautobot/extras/tests/test_forms.py +13 -0
- nautobot/extras/tests/test_jobs.py +18 -13
- nautobot/extras/tests/test_models.py +6 -0
- nautobot/extras/tests/test_views.py +4 -3
- nautobot/ipam/tests/test_api.py +20 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +36 -1
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +108 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +288 -288
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/wireless/tests/test_views.py +22 -1
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/METADATA +2 -2
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/RECORD +40 -38
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/NOTICE +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/WHEEL +0 -0
- {nautobot-2.4.0.dist-info → nautobot-2.4.1.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
279
|
+
deleted_count = deleted_info.get(model._meta.label, 0)
|
|
244
280
|
except ProtectedError as err:
|
|
245
|
-
|
|
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)
|
nautobot/core/models/__init__.py
CHANGED
|
@@ -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(
|
|
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:
|
nautobot/core/testing/filters.py
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|