django-searchkit 0.1__py3-none-any.whl → 1.0__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.
- build/lib/example/example/__init__.py +0 -0
- build/lib/example/example/admin.py +16 -0
- build/lib/example/example/asgi.py +16 -0
- build/lib/example/example/management/__init__.py +0 -0
- build/lib/example/example/management/commands/__init__.py +0 -0
- build/lib/example/example/management/commands/createtestdata.py +62 -0
- build/lib/example/example/migrations/0001_initial.py +48 -0
- build/lib/example/example/migrations/__init__.py +0 -0
- build/lib/example/example/models.py +38 -0
- build/lib/example/example/settings.py +125 -0
- build/lib/example/example/urls.py +23 -0
- build/lib/example/example/wsgi.py +16 -0
- build/lib/searchkit/__init__.py +0 -0
- build/lib/searchkit/__version__.py +16 -0
- build/lib/searchkit/admin.py +30 -0
- build/lib/searchkit/apps.py +6 -0
- build/lib/searchkit/filters.py +31 -0
- build/lib/searchkit/forms/__init__.py +3 -0
- build/lib/searchkit/forms/fields.py +55 -0
- build/lib/searchkit/forms/search.py +42 -0
- build/lib/searchkit/forms/searchkit.py +189 -0
- build/lib/searchkit/forms/utils.py +149 -0
- build/lib/searchkit/migrations/0001_initial.py +30 -0
- build/lib/searchkit/migrations/__init__.py +0 -0
- build/lib/searchkit/models.py +23 -0
- build/lib/searchkit/templatetags/__init__.py +0 -0
- build/lib/searchkit/templatetags/searchkit.py +47 -0
- build/lib/searchkit/tests.py +250 -0
- build/lib/searchkit/urls.py +8 -0
- build/lib/searchkit/views.py +30 -0
- {django_searchkit-0.1.dist-info → django_searchkit-1.0.dist-info}/METADATA +44 -8
- django_searchkit-1.0.dist-info/RECORD +66 -0
- {django_searchkit-0.1.dist-info → django_searchkit-1.0.dist-info}/WHEEL +1 -1
- {django_searchkit-0.1.dist-info → django_searchkit-1.0.dist-info}/top_level.txt +1 -0
- searchkit/__version__.py +1 -1
- searchkit/forms/__init__.py +2 -1
- searchkit/forms/search.py +0 -3
- searchkit/forms/searchkit.py +11 -14
- searchkit/tests.py +121 -68
- django_searchkit-0.1.dist-info/RECORD +0 -36
- {django_searchkit-0.1.dist-info → django_searchkit-1.0.dist-info}/licenses/LICENCE +0 -0
- {django_searchkit-0.1.dist-info → django_searchkit-1.0.dist-info}/zip-safe +0 -0
@@ -0,0 +1,189 @@
|
|
1
|
+
from collections import OrderedDict
|
2
|
+
from django import forms
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
4
|
+
from django.utils.functional import cached_property
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
6
|
+
from .utils import CSS_CLASSES, FIELD_PLAN, OPERATOR_DESCRIPTION
|
7
|
+
from .utils import SUPPORTED_FIELDS, SUPPORTED_RELATIONS
|
8
|
+
|
9
|
+
|
10
|
+
# FIXME: Make this a setting
|
11
|
+
RELATION_DEPTH = 3
|
12
|
+
|
13
|
+
|
14
|
+
class SearchkitForm(CSS_CLASSES, forms.Form):
|
15
|
+
"""
|
16
|
+
Searchkit form representing a model field lookup based on the field name,
|
17
|
+
the operator and one or two values.
|
18
|
+
|
19
|
+
The unbound form is composed of an index field (the count of the searchkit
|
20
|
+
form) and a choice field offering the names of the model fields.
|
21
|
+
|
22
|
+
The bound form is dynamically extended by the operator field or the operator and
|
23
|
+
the value field depending on the provided data
|
24
|
+
|
25
|
+
See the FIELD_PLAN variable for the logic of building the form.
|
26
|
+
"""
|
27
|
+
def __init__(self, model, *args, **kwargs):
|
28
|
+
super().__init__(*args, **kwargs)
|
29
|
+
self.model = model
|
30
|
+
self.model_field = None
|
31
|
+
self.field_plan = None
|
32
|
+
self.operator = None
|
33
|
+
self._add_field_name_field()
|
34
|
+
lookup = self._preload_clean_data('field')
|
35
|
+
self.model_field = self._get_model_field(lookup)
|
36
|
+
self.field_plan = next(iter([p for t, p in FIELD_PLAN.items() if t(self.model_field)]))
|
37
|
+
self._add_operator_field()
|
38
|
+
self.operator = self._preload_clean_data('operator')
|
39
|
+
self._add_value_field()
|
40
|
+
|
41
|
+
@cached_property
|
42
|
+
def unprefixed_data(self):
|
43
|
+
data = dict()
|
44
|
+
for key, value in self.data.items():
|
45
|
+
if key.startswith(self.prefix):
|
46
|
+
data[key[len(self.prefix) + 1:]] = value
|
47
|
+
return data
|
48
|
+
|
49
|
+
def _preload_clean_data(self, field_name):
|
50
|
+
# Try the initial value first since it is already cleaned.
|
51
|
+
if self.initial and field_name in self.initial:
|
52
|
+
return self.initial[field_name]
|
53
|
+
# Otherwise look up the data dict.
|
54
|
+
elif field_name in self.unprefixed_data:
|
55
|
+
try:
|
56
|
+
# Do we have a valid value?
|
57
|
+
return self.fields[field_name].clean(self.unprefixed_data[field_name])
|
58
|
+
except forms.ValidationError:
|
59
|
+
return self.fields[field_name].choices[0][0]
|
60
|
+
else:
|
61
|
+
# At last simply return the first option which will be the selected
|
62
|
+
# one.
|
63
|
+
return self.fields[field_name].choices[0][0]
|
64
|
+
|
65
|
+
def _get_model_field(self, lookup):
|
66
|
+
path = lookup.split('__')
|
67
|
+
model = self.model
|
68
|
+
for index, field_name in enumerate(path, 1):
|
69
|
+
field = model._meta.get_field(field_name)
|
70
|
+
if index < len(path):
|
71
|
+
model = field.remote_field.model
|
72
|
+
return field
|
73
|
+
|
74
|
+
def _get_model_field_choices(self, model, fields=[]):
|
75
|
+
choices = list()
|
76
|
+
for model_field in model._meta.fields:
|
77
|
+
if any(isinstance(model_field, f) for f in SUPPORTED_FIELDS):
|
78
|
+
lookup = '__'.join([f.name for f in [*fields, model_field]])
|
79
|
+
label = ' -> '.join(f.verbose_name for f in [*fields, model_field])
|
80
|
+
choices.append((lookup, label))
|
81
|
+
if len(fields) < RELATION_DEPTH:
|
82
|
+
for model_field in model._meta.fields:
|
83
|
+
if any(isinstance(model_field, f) for f in SUPPORTED_RELATIONS):
|
84
|
+
related_model = model_field.remote_field.model
|
85
|
+
fields = [*fields, model_field]
|
86
|
+
choices += self._get_model_field_choices(related_model, fields)
|
87
|
+
return choices
|
88
|
+
|
89
|
+
def _add_field_name_field(self):
|
90
|
+
initial = self.initial.get('field')
|
91
|
+
choices = self._get_model_field_choices(self.model)
|
92
|
+
field = forms.ChoiceField(label=_('Model field'), choices=choices, initial=initial)
|
93
|
+
field.widget.attrs.update({"class": CSS_CLASSES.reload_on_change_css_class})
|
94
|
+
self.fields['field'] = field
|
95
|
+
|
96
|
+
def _add_operator_field(self):
|
97
|
+
initial = self.initial.get('operator')
|
98
|
+
choices = [(o, OPERATOR_DESCRIPTION[o]) for o in self.field_plan.keys()]
|
99
|
+
field = forms.ChoiceField(label=_('Operator'), choices=choices, initial=initial)
|
100
|
+
field.widget.attrs.update({"class": CSS_CLASSES.reload_on_change_css_class})
|
101
|
+
self.fields['operator'] = field
|
102
|
+
|
103
|
+
def _add_value_field(self):
|
104
|
+
initial = self.initial.get('value')
|
105
|
+
field_class = self.field_plan[self.operator][0]
|
106
|
+
if getattr(field_class, 'choices', None) and getattr(self.model_field, 'choices', None):
|
107
|
+
field = field_class(choices=self.model_field.choices, initial=initial)
|
108
|
+
else:
|
109
|
+
field = field_class()
|
110
|
+
self.fields['value'] = field
|
111
|
+
|
112
|
+
|
113
|
+
class ContentTypeForm(CSS_CLASSES, forms.Form):
|
114
|
+
"""
|
115
|
+
Form to select a content type.
|
116
|
+
"""
|
117
|
+
contenttype = forms.ModelChoiceField(
|
118
|
+
queryset=ContentType.objects.all(), # FIXME: Limit choices to models that can be filtered.
|
119
|
+
label=_('Model'),
|
120
|
+
empty_label=_('Select a Model'),
|
121
|
+
widget=forms.Select(attrs={"class": CSS_CLASSES.reload_on_change_css_class}),
|
122
|
+
)
|
123
|
+
|
124
|
+
class Media:
|
125
|
+
js = [
|
126
|
+
'admin/js/vendor/jquery/jquery.min.js',
|
127
|
+
'admin/js/jquery.init.js',
|
128
|
+
"searchkit/searchkit.js"
|
129
|
+
]
|
130
|
+
|
131
|
+
|
132
|
+
class BaseSearchkitFormset(CSS_CLASSES, forms.BaseFormSet):
|
133
|
+
"""
|
134
|
+
Formset holding all searchkit forms.
|
135
|
+
"""
|
136
|
+
template_name = "searchkit/searchkit.html"
|
137
|
+
template_name_div = "searchkit/searchkit.html"
|
138
|
+
default_prefix = 'searchkit'
|
139
|
+
form = SearchkitForm
|
140
|
+
contenttype_form_class = ContentTypeForm
|
141
|
+
|
142
|
+
def __init__(self, *args, **kwargs):
|
143
|
+
self.model = kwargs.pop('model', None)
|
144
|
+
super().__init__(*args, **kwargs)
|
145
|
+
self.contenttype_form = self.get_conttenttype_form(kwargs)
|
146
|
+
if not self.model and self.contenttype_form.is_valid():
|
147
|
+
self.model = self.contenttype_form.cleaned_data.get('contenttype').model_class()
|
148
|
+
if self.initial:
|
149
|
+
self.extra = 0
|
150
|
+
|
151
|
+
def get_conttenttype_form(self, kwargs):
|
152
|
+
ct_kwargs = dict()
|
153
|
+
ct_kwargs['data'] = self.data or None
|
154
|
+
ct_kwargs['prefix'] = self.prefix
|
155
|
+
if self.model:
|
156
|
+
contenttype = ContentType.objects.get_for_model(self.model)
|
157
|
+
ct_kwargs['initial'] = dict(contenttype=contenttype)
|
158
|
+
return self.contenttype_form_class(**ct_kwargs)
|
159
|
+
|
160
|
+
def get_form_kwargs(self, index):
|
161
|
+
kwargs = self.form_kwargs.copy()
|
162
|
+
kwargs['model'] = self.model
|
163
|
+
return kwargs
|
164
|
+
|
165
|
+
def add_prefix(self, index):
|
166
|
+
if self.model:
|
167
|
+
return "%s-%s-%s" % (self.prefix, self.model._meta.model_name, index)
|
168
|
+
|
169
|
+
@classmethod
|
170
|
+
def get_default_prefix(cls):
|
171
|
+
return cls.default_prefix
|
172
|
+
|
173
|
+
@cached_property
|
174
|
+
def forms(self):
|
175
|
+
# We won't render any forms if we got no model.
|
176
|
+
return super().forms if self.model else []
|
177
|
+
|
178
|
+
@property
|
179
|
+
def media(self):
|
180
|
+
return self.contenttype_form.media
|
181
|
+
|
182
|
+
def is_valid(self):
|
183
|
+
return self.contenttype_form.is_valid() and self.forms and super().is_valid()
|
184
|
+
|
185
|
+
|
186
|
+
SearchkitFormSet = forms.formset_factory(
|
187
|
+
form=SearchkitForm,
|
188
|
+
formset=BaseSearchkitFormset,
|
189
|
+
)
|
@@ -0,0 +1,149 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from django import forms
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
4
|
+
from collections import OrderedDict
|
5
|
+
from searchkit.forms import fields as searchkit_fields
|
6
|
+
|
7
|
+
|
8
|
+
OPERATOR_DESCRIPTION = {
|
9
|
+
'exact': _('is exact'),
|
10
|
+
'contains': _('contains'),
|
11
|
+
'startswith': _('starts with'),
|
12
|
+
'endswith': _('ends with'),
|
13
|
+
'regex': _('matches regular expression'),
|
14
|
+
'gt': _('is greater than'),
|
15
|
+
'gte': _('is greater than or equal'),
|
16
|
+
'lt': _('is lower than'),
|
17
|
+
'lte': _('is lower than or equal'),
|
18
|
+
'range': _('is between'),
|
19
|
+
'in': _('is one of'),
|
20
|
+
}
|
21
|
+
|
22
|
+
|
23
|
+
SUPPORTED_FIELDS = [
|
24
|
+
models.BooleanField,
|
25
|
+
models.CharField,
|
26
|
+
models.IntegerField,
|
27
|
+
models.FloatField,
|
28
|
+
models.DecimalField,
|
29
|
+
models.DateField,
|
30
|
+
models.DateTimeField,
|
31
|
+
]
|
32
|
+
SUPPORTED_RELATIONS = [
|
33
|
+
models.ForeignKey,
|
34
|
+
models.OneToOneField,
|
35
|
+
]
|
36
|
+
|
37
|
+
|
38
|
+
FIELD_PLAN = OrderedDict((
|
39
|
+
(
|
40
|
+
lambda f: isinstance(f, models.BooleanField),
|
41
|
+
{
|
42
|
+
'exact': (forms.NullBooleanField,),
|
43
|
+
}
|
44
|
+
),
|
45
|
+
(
|
46
|
+
lambda f: isinstance(f, models.CharField) and f.choices,
|
47
|
+
{
|
48
|
+
'exact': (forms.ChoiceField,),
|
49
|
+
'contains': (forms.CharField,),
|
50
|
+
'startswith': (forms.CharField,),
|
51
|
+
'endswith': (forms.CharField,),
|
52
|
+
'regex': (forms.CharField,),
|
53
|
+
'in': (forms.MultipleChoiceField,),
|
54
|
+
}
|
55
|
+
),
|
56
|
+
(
|
57
|
+
lambda f: isinstance(f, models.CharField),
|
58
|
+
{
|
59
|
+
'exact': (forms.CharField,),
|
60
|
+
'contains': (forms.CharField,),
|
61
|
+
'startswith': (forms.CharField,),
|
62
|
+
'endswith': (forms.CharField,),
|
63
|
+
'regex': (forms.CharField,),
|
64
|
+
}
|
65
|
+
),
|
66
|
+
(
|
67
|
+
lambda f: isinstance(f, models.IntegerField) and f.choices,
|
68
|
+
{
|
69
|
+
'exact': (forms.ChoiceField,),
|
70
|
+
'contains': (forms.IntegerField,),
|
71
|
+
'startswith': (forms.IntegerField,),
|
72
|
+
'endswith': (forms.IntegerField,),
|
73
|
+
'regex': (forms.IntegerField,),
|
74
|
+
'in': (forms.MultipleChoiceField,),
|
75
|
+
}
|
76
|
+
),
|
77
|
+
(
|
78
|
+
lambda f: isinstance(f, models.IntegerField),
|
79
|
+
{
|
80
|
+
'exact': (forms.IntegerField,),
|
81
|
+
'gt': (forms.IntegerField,),
|
82
|
+
'gte': (forms.IntegerField,),
|
83
|
+
'lt': (forms.IntegerField,),
|
84
|
+
'lte': (forms.IntegerField,),
|
85
|
+
'range': (searchkit_fields.IntegerRangeField,),
|
86
|
+
}
|
87
|
+
),
|
88
|
+
(
|
89
|
+
lambda f: isinstance(f, models.FloatField),
|
90
|
+
{
|
91
|
+
'exact': (forms.FloatField,),
|
92
|
+
'gt': (forms.FloatField,),
|
93
|
+
'gte': (forms.FloatField,),
|
94
|
+
'lt': (forms.FloatField,),
|
95
|
+
'lte': (forms.FloatField,),
|
96
|
+
'range': (searchkit_fields.IntegerRangeField,),
|
97
|
+
}
|
98
|
+
),
|
99
|
+
(
|
100
|
+
lambda f: isinstance(f, models.DecimalField),
|
101
|
+
{
|
102
|
+
'exact': (forms.DecimalField,),
|
103
|
+
'gt': (forms.DecimalField,),
|
104
|
+
'gte': (forms.DecimalField,),
|
105
|
+
'lt': (forms.DecimalField,),
|
106
|
+
'lte': (forms.DecimalField,),
|
107
|
+
'range': (searchkit_fields.IntegerRangeField,),
|
108
|
+
}
|
109
|
+
),
|
110
|
+
(
|
111
|
+
lambda f: isinstance(f, models.DateTimeField),
|
112
|
+
{
|
113
|
+
'exact': (forms.DateTimeField,),
|
114
|
+
'gt': (forms.DateTimeField,),
|
115
|
+
'gte': (forms.DateTimeField,),
|
116
|
+
'lt': (forms.DateTimeField,),
|
117
|
+
'lte': (forms.DateTimeField,),
|
118
|
+
'range': (searchkit_fields.DateTimeRangeField,),
|
119
|
+
}
|
120
|
+
),
|
121
|
+
(
|
122
|
+
lambda f: isinstance(f, models.DateField),
|
123
|
+
{
|
124
|
+
'exact': (forms.DateField,),
|
125
|
+
'gt': (forms.DateField,),
|
126
|
+
'gte': (forms.DateField,),
|
127
|
+
'lt': (forms.DateField,),
|
128
|
+
'lte': (forms.DateField,),
|
129
|
+
'range': (searchkit_fields.DateRangeField,),
|
130
|
+
}
|
131
|
+
),
|
132
|
+
))
|
133
|
+
|
134
|
+
|
135
|
+
class CSS_CLASSES:
|
136
|
+
reload_on_change_css_class = "searchkit-reload-on-change"
|
137
|
+
reload_on_click_css_class = "searchkit-reload-on-click"
|
138
|
+
|
139
|
+
|
140
|
+
def get_filter_rules(formset):
|
141
|
+
"""
|
142
|
+
Build filter rules out of the cleaned data of the formset.
|
143
|
+
:param formset: Formset to extract filter rules from.
|
144
|
+
:return: OrderedDict with filter rule pairs: field__operator: value
|
145
|
+
"""
|
146
|
+
lookups = OrderedDict()
|
147
|
+
for data in formset.cleaned_data:
|
148
|
+
lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
|
149
|
+
return lookups
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Generated by Django 5.1.3 on 2025-05-22 19:04
|
2
|
+
|
3
|
+
import django.db.models.deletion
|
4
|
+
import picklefield.fields
|
5
|
+
from django.db import migrations, models
|
6
|
+
|
7
|
+
|
8
|
+
class Migration(migrations.Migration):
|
9
|
+
|
10
|
+
initial = True
|
11
|
+
|
12
|
+
dependencies = [
|
13
|
+
('contenttypes', '0002_remove_content_type_name'),
|
14
|
+
]
|
15
|
+
|
16
|
+
operations = [
|
17
|
+
migrations.CreateModel(
|
18
|
+
name='SearchkitSearch',
|
19
|
+
fields=[
|
20
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
21
|
+
('name', models.CharField(max_length=255, verbose_name='Search name')),
|
22
|
+
('data', picklefield.fields.PickledObjectField(editable=False, verbose_name='Serialized filter rule data')),
|
23
|
+
('created_date', models.DateTimeField(auto_now_add=True)),
|
24
|
+
('contenttype', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Model')),
|
25
|
+
],
|
26
|
+
options={
|
27
|
+
'unique_together': {('name', 'contenttype')},
|
28
|
+
},
|
29
|
+
),
|
30
|
+
]
|
File without changes
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from collections import OrderedDict
|
2
|
+
from picklefield.fields import PickledObjectField
|
3
|
+
from django.db import models
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
6
|
+
|
7
|
+
|
8
|
+
# Create your models here.
|
9
|
+
# TODO: Use pickled cleaned data with a char field.
|
10
|
+
class SearchkitSearch(models.Model):
|
11
|
+
name = models.CharField(_('Search name'), max_length=255)
|
12
|
+
contenttype = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_('Model'))
|
13
|
+
data = PickledObjectField(_('Serialized filter rule data'))
|
14
|
+
created_date = models.DateTimeField(auto_now_add=True)
|
15
|
+
|
16
|
+
class Meta:
|
17
|
+
unique_together = ('name', 'contenttype')
|
18
|
+
|
19
|
+
def get_filter_rules(self):
|
20
|
+
lookups = OrderedDict()
|
21
|
+
for data in self.data:
|
22
|
+
lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
|
23
|
+
return lookups
|
File without changes
|
@@ -0,0 +1,47 @@
|
|
1
|
+
from django.template import Library
|
2
|
+
from django.urls import reverse
|
3
|
+
from django.contrib.admin.helpers import Fieldset
|
4
|
+
from ..forms.utils import CSS_CLASSES
|
5
|
+
|
6
|
+
|
7
|
+
register = Library()
|
8
|
+
|
9
|
+
|
10
|
+
@register.simple_tag
|
11
|
+
def searchkit_url(formset):
|
12
|
+
"""
|
13
|
+
Return url to reload the formset.
|
14
|
+
"""
|
15
|
+
app_label = formset.model._meta.app_label
|
16
|
+
model_name = formset.model._meta.model_name
|
17
|
+
url = reverse('searchkit_form', args=[app_label, model_name])
|
18
|
+
return url
|
19
|
+
|
20
|
+
@register.simple_tag
|
21
|
+
def on_change_class():
|
22
|
+
"""
|
23
|
+
Return css class for on change handler.
|
24
|
+
"""
|
25
|
+
return CSS_CLASSES.reload_on_change_css_class
|
26
|
+
|
27
|
+
@register.simple_tag
|
28
|
+
def on_click_class():
|
29
|
+
"""
|
30
|
+
Return css class for on click handler.
|
31
|
+
"""
|
32
|
+
return CSS_CLASSES.reload_on_click_css_class
|
33
|
+
|
34
|
+
@register.inclusion_tag("admin/includes/fieldset.html")
|
35
|
+
def as_fieldset(form, heading_level=2, prefix='', id_prefix=0, id_suffix='', **fieldset_kwargs):
|
36
|
+
"""
|
37
|
+
Create and render a fieldset for form.
|
38
|
+
"""
|
39
|
+
fieldset = Fieldset(form, fields=form.fields, **fieldset_kwargs)
|
40
|
+
context = dict(
|
41
|
+
fieldset=fieldset,
|
42
|
+
heading_level=heading_level,
|
43
|
+
prefix=prefix,
|
44
|
+
id_prefix=id_prefix,
|
45
|
+
id_suffix=id_suffix,
|
46
|
+
)
|
47
|
+
return context
|
@@ -0,0 +1,250 @@
|
|
1
|
+
from django.test import TestCase
|
2
|
+
from django.contrib.contenttypes.models import ContentType
|
3
|
+
from django import forms
|
4
|
+
from example.models import ModelA
|
5
|
+
from searchkit.forms.utils import FIELD_PLAN
|
6
|
+
from searchkit.forms.utils import SUPPORTED_FIELDS
|
7
|
+
from searchkit.forms.utils import SUPPORTED_RELATIONS
|
8
|
+
from searchkit.forms import SearchkitSearchForm
|
9
|
+
from searchkit.forms import SearchkitForm
|
10
|
+
from searchkit.forms import SearchkitFormSet
|
11
|
+
|
12
|
+
|
13
|
+
INITIAL_DATA = [
|
14
|
+
dict(
|
15
|
+
field='model_b__chars',
|
16
|
+
operator='exact',
|
17
|
+
value='anytext',
|
18
|
+
),
|
19
|
+
dict(
|
20
|
+
field='integer',
|
21
|
+
operator='range',
|
22
|
+
value=[1, 123],
|
23
|
+
),
|
24
|
+
dict(
|
25
|
+
field='float',
|
26
|
+
operator='exact',
|
27
|
+
value='0.3',
|
28
|
+
),
|
29
|
+
dict(
|
30
|
+
field='decimal',
|
31
|
+
operator='exact',
|
32
|
+
value='1.23',
|
33
|
+
),
|
34
|
+
dict(
|
35
|
+
field='date',
|
36
|
+
operator='exact',
|
37
|
+
value='2025-05-14',
|
38
|
+
),
|
39
|
+
dict(
|
40
|
+
field='datetime',
|
41
|
+
operator='exact',
|
42
|
+
value='2025-05-14 08:45',
|
43
|
+
)
|
44
|
+
]
|
45
|
+
|
46
|
+
add_prefix = lambda i: SearchkitFormSet(model=ModelA).add_prefix(i)
|
47
|
+
DEFAULT_PREFIX = SearchkitFormSet.get_default_prefix()
|
48
|
+
FORM_DATA = {
|
49
|
+
'name': 'test search', # The name of the search.
|
50
|
+
f'{DEFAULT_PREFIX}-TOTAL_FORMS': '6', # Data for the managment form.
|
51
|
+
f'{DEFAULT_PREFIX}-INITIAL_FORMS': '1', # Data for the managment form.
|
52
|
+
f'{DEFAULT_PREFIX}-contenttype': f'{ContentType.objects.get_for_model(ModelA).pk}',
|
53
|
+
f'{add_prefix(1)}-value_0': '1', # Data for the range operator.
|
54
|
+
f'{add_prefix(1)}-value_1': '123', # Data for the range operator.
|
55
|
+
}
|
56
|
+
for i, data in enumerate(INITIAL_DATA, 0):
|
57
|
+
prefix = SearchkitFormSet(model=ModelA).add_prefix(i)
|
58
|
+
for key, value in data.items():
|
59
|
+
FORM_DATA.update({f'{prefix}-{key}': value})
|
60
|
+
|
61
|
+
|
62
|
+
class CheckFormMixin:
|
63
|
+
"""
|
64
|
+
Mixin to check the form fields and their choices.
|
65
|
+
"""
|
66
|
+
def check_form(self, form):
|
67
|
+
# Three fields should be generated on instantiation.
|
68
|
+
self.assertIn('field', form.fields)
|
69
|
+
self.assertIn('operator', form.fields)
|
70
|
+
self.assertIn('value', form.fields)
|
71
|
+
self.assertEqual(len(form.fields), 3)
|
72
|
+
|
73
|
+
# Check choices of the model_field.
|
74
|
+
form_model_field = form.fields['field']
|
75
|
+
self.assertTrue(form_model_field.choices)
|
76
|
+
options = [c[0] for c in form_model_field.choices]
|
77
|
+
for model_field in ModelA._meta.fields:
|
78
|
+
if isinstance(model_field, tuple(SUPPORTED_FIELDS)):
|
79
|
+
self.assertIn(model_field.name, options)
|
80
|
+
|
81
|
+
# Check choices for relational lookups.
|
82
|
+
for model_field in ModelA._meta.fields:
|
83
|
+
if isinstance(model_field, tuple(SUPPORTED_RELATIONS)):
|
84
|
+
remote_fields = model_field.remote_field.model._meta.fields
|
85
|
+
for remote_field in remote_fields:
|
86
|
+
if isinstance(model_field, tuple(SUPPORTED_FIELDS)):
|
87
|
+
lookup_path = f'{model_field.name}__{remote_field.name}'
|
88
|
+
self.assertIn(lookup_path, options)
|
89
|
+
|
90
|
+
# Check the field_plan choosen based on the model_field.
|
91
|
+
field_plan = next(iter([p for t, p in FIELD_PLAN.items() if t(form.model_field)]))
|
92
|
+
self.assertEqual(form.field_plan, field_plan)
|
93
|
+
|
94
|
+
# Check choices of the operator field based on the field_plan.
|
95
|
+
operator_field = form.fields['operator']
|
96
|
+
self.assertTrue(operator_field.choices)
|
97
|
+
self.assertEqual(len(operator_field.choices), len(form.field_plan))
|
98
|
+
for operator in form.field_plan.keys():
|
99
|
+
self.assertIn(operator, [c[0] for c in operator_field.choices])
|
100
|
+
|
101
|
+
|
102
|
+
class SearchkitFormTestCase(CheckFormMixin, TestCase):
|
103
|
+
|
104
|
+
def test_blank_searchkitform(self):
|
105
|
+
form = SearchkitForm(ModelA, prefix=add_prefix(0))
|
106
|
+
self.check_form(form)
|
107
|
+
|
108
|
+
# Form should not be bound or valid.
|
109
|
+
self.assertFalse(form.is_bound)
|
110
|
+
self.assertFalse(form.is_valid())
|
111
|
+
|
112
|
+
def test_searchkitform_with_invalid_model_field_data(self):
|
113
|
+
data = {
|
114
|
+
f'{add_prefix(0)}-field': 'foobar',
|
115
|
+
}
|
116
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
117
|
+
self.check_form(form)
|
118
|
+
|
119
|
+
# Form should be invalid.
|
120
|
+
self.assertFalse(form.is_valid())
|
121
|
+
|
122
|
+
# Check error message in html.
|
123
|
+
errors = ['Select a valid choice. foobar is not one of the available choices.']
|
124
|
+
self.assertIn(errors, form.errors.values())
|
125
|
+
|
126
|
+
def test_searchkitform_with_valid_model_field_data(self):
|
127
|
+
data = {
|
128
|
+
f'{add_prefix(0)}-field': 'integer',
|
129
|
+
}
|
130
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
131
|
+
self.check_form(form)
|
132
|
+
|
133
|
+
# Form should be invalid since no value data is provieded.
|
134
|
+
self.assertFalse(form.is_valid())
|
135
|
+
|
136
|
+
def test_searchkitform_with_invalid_operator_data(self):
|
137
|
+
data = {
|
138
|
+
f'{add_prefix(0)}-field': 'integer',
|
139
|
+
f'{add_prefix(0)}-operator': 'foobar',
|
140
|
+
}
|
141
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
142
|
+
self.check_form(form)
|
143
|
+
|
144
|
+
# Form should be invalid.
|
145
|
+
self.assertFalse(form.is_valid())
|
146
|
+
|
147
|
+
# Check error message in html.
|
148
|
+
errors = ['Select a valid choice. foobar is not one of the available choices.']
|
149
|
+
self.assertIn(errors, form.errors.values())
|
150
|
+
|
151
|
+
def test_searchkitform_with_valid_operator_data(self):
|
152
|
+
data = {
|
153
|
+
f'{add_prefix(0)}-field': 'integer',
|
154
|
+
f'{add_prefix(0)}-operator': 'exact',
|
155
|
+
}
|
156
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
157
|
+
self.check_form(form)
|
158
|
+
|
159
|
+
# Form should be invalid since no value data is provieded.
|
160
|
+
self.assertFalse(form.is_valid())
|
161
|
+
|
162
|
+
def test_searchkitform_with_valid_data(self):
|
163
|
+
data = {
|
164
|
+
f'{add_prefix(0)}-field': 'integer',
|
165
|
+
f'{add_prefix(0)}-operator': 'exact',
|
166
|
+
f'{add_prefix(0)}-value': '123',
|
167
|
+
}
|
168
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
169
|
+
self.check_form(form)
|
170
|
+
|
171
|
+
# Form should be valid.
|
172
|
+
self.assertTrue(form.is_valid())
|
173
|
+
|
174
|
+
def test_searchkitform_with_invalid_data(self):
|
175
|
+
data = {
|
176
|
+
f'{add_prefix(0)}-field': 'integer',
|
177
|
+
f'{add_prefix(0)}-operator': 'exact',
|
178
|
+
f'{add_prefix(0)}-value': 'foobar',
|
179
|
+
}
|
180
|
+
form = SearchkitForm(ModelA, data, prefix=add_prefix(0))
|
181
|
+
self.check_form(form)
|
182
|
+
|
183
|
+
# Form should be invalid.
|
184
|
+
self.assertFalse(form.is_valid())
|
185
|
+
|
186
|
+
# Check error message in html.
|
187
|
+
errors = ['Enter a whole number.']
|
188
|
+
self.assertIn(errors, form.errors.values())
|
189
|
+
|
190
|
+
|
191
|
+
class SearchkitFormSetTestCase(CheckFormMixin, TestCase):
|
192
|
+
def test_blank_searchkitform(self):
|
193
|
+
# Instantiating the formset neither with a model instance nor with model
|
194
|
+
# related data or initial data should result in a formset without forms,
|
195
|
+
# that is invalid and unbound.
|
196
|
+
formset = SearchkitFormSet()
|
197
|
+
self.assertFalse(formset.is_bound)
|
198
|
+
self.assertFalse(formset.is_valid())
|
199
|
+
|
200
|
+
def test_searchkit_formset_with_valid_data(self):
|
201
|
+
formset = SearchkitFormSet(FORM_DATA)
|
202
|
+
self.assertTrue(formset.is_valid())
|
203
|
+
|
204
|
+
def test_searchkit_formset_with_invalid_data(self):
|
205
|
+
data = FORM_DATA.copy()
|
206
|
+
del data[f'{add_prefix(0)}-value']
|
207
|
+
formset = SearchkitFormSet(data, model=ModelA)
|
208
|
+
self.assertFalse(formset.is_valid())
|
209
|
+
|
210
|
+
# Check error message in html.
|
211
|
+
errors = ['This field is required.']
|
212
|
+
self.assertIn(errors, formset.forms[0].errors.values())
|
213
|
+
|
214
|
+
def test_searchkit_formset_with_initial_data(self):
|
215
|
+
formset = SearchkitFormSet(initial=INITIAL_DATA, model=ModelA)
|
216
|
+
self.assertFalse(formset.is_bound)
|
217
|
+
self.assertFalse(formset.is_valid())
|
218
|
+
self.assertEqual(len(formset.forms), len(INITIAL_DATA))
|
219
|
+
for i, form in enumerate(formset.forms):
|
220
|
+
self.assertEqual(form.initial, INITIAL_DATA[i])
|
221
|
+
self.check_form(form)
|
222
|
+
|
223
|
+
|
224
|
+
class SearchkitSearchFormTestCase(TestCase):
|
225
|
+
def test_searchkit_search_form_without_data(self):
|
226
|
+
form = SearchkitSearchForm()
|
227
|
+
self.assertFalse(form.is_bound)
|
228
|
+
self.assertFalse(form.is_valid())
|
229
|
+
self.assertIsInstance(form.formset, SearchkitFormSet)
|
230
|
+
self.assertEqual(form.formset.model, None)
|
231
|
+
|
232
|
+
def test_searchkit_search_form_with_data(self):
|
233
|
+
form = SearchkitSearchForm(FORM_DATA)
|
234
|
+
self.assertTrue(form.is_bound)
|
235
|
+
self.assertTrue(form.is_valid())
|
236
|
+
self.assertIsInstance(form.formset, SearchkitFormSet)
|
237
|
+
self.assertEqual(form.formset.model, ModelA)
|
238
|
+
self.assertEqual(form.instance.data, form.formset.cleaned_data)
|
239
|
+
|
240
|
+
# Saving the instance works.
|
241
|
+
form.instance.save()
|
242
|
+
self.assertTrue(form.instance.pk)
|
243
|
+
|
244
|
+
# Using the instance data as filter rules works.
|
245
|
+
filter_rules = form.instance.get_filter_rules()
|
246
|
+
self.assertEqual(len(filter_rules), len(INITIAL_DATA))
|
247
|
+
for data in INITIAL_DATA:
|
248
|
+
self.assertIn(f"{data['field']}__{data['operator']}", filter_rules)
|
249
|
+
queryset = form.formset.model.objects.filter(**filter_rules)
|
250
|
+
self.assertTrue(queryset.model == ModelA)
|