django-searchkit 1.2__py3-none-any.whl → 1.3__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/build/lib/build/lib/build/lib/example/example/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/example/example/admin.py +16 -0
- build/lib/build/lib/build/lib/build/lib/example/example/asgi.py +16 -0
- build/lib/build/lib/build/lib/build/lib/example/example/management/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/example/example/management/commands/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/example/example/management/commands/createtestdata.py +62 -0
- build/lib/build/lib/build/lib/build/lib/example/example/migrations/0001_initial.py +48 -0
- build/lib/build/lib/build/lib/build/lib/example/example/migrations/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/example/example/models.py +38 -0
- build/lib/build/lib/build/lib/build/lib/example/example/settings.py +125 -0
- build/lib/build/lib/build/lib/build/lib/example/example/urls.py +23 -0
- build/lib/build/lib/build/lib/build/lib/example/example/wsgi.py +16 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/__version__.py +16 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/admin.py +44 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/apps.py +6 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/filters.py +36 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/forms/__init__.py +5 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/forms/fields.py +56 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/forms/search.py +61 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/forms/searchkit.py +177 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/forms/utils.py +154 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/migrations/0001_initial.py +30 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/migrations/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/models.py +21 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/templatetags/__init__.py +0 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/templatetags/searchkit.py +20 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/tests.py +400 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/urls.py +7 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/utils.py +13 -0
- build/lib/build/lib/build/lib/build/lib/searchkit/views.py +23 -0
- build/lib/build/lib/build/lib/searchkit/__version__.py +1 -1
- build/lib/build/lib/build/lib/searchkit/admin.py +15 -1
- build/lib/build/lib/build/lib/searchkit/filters.py +10 -2
- build/lib/build/lib/build/lib/searchkit/forms/fields.py +5 -4
- build/lib/build/lib/build/lib/searchkit/forms/search.py +0 -1
- build/lib/build/lib/build/lib/searchkit/forms/searchkit.py +36 -13
- build/lib/build/lib/build/lib/searchkit/forms/utils.py +50 -74
- build/lib/build/lib/build/lib/searchkit/tests.py +1 -1
- build/lib/build/lib/build/lib/searchkit/utils.py +13 -0
- build/lib/build/lib/example/example/admin.py +1 -1
- build/lib/build/lib/searchkit/__version__.py +1 -1
- build/lib/build/lib/searchkit/admin.py +15 -1
- build/lib/build/lib/searchkit/filters.py +10 -2
- build/lib/build/lib/searchkit/forms/fields.py +5 -4
- build/lib/build/lib/searchkit/forms/search.py +0 -1
- build/lib/build/lib/searchkit/forms/searchkit.py +36 -13
- build/lib/build/lib/searchkit/forms/utils.py +50 -74
- build/lib/build/lib/searchkit/tests.py +1 -1
- build/lib/build/lib/searchkit/utils.py +13 -0
- build/lib/searchkit/__version__.py +1 -1
- build/lib/searchkit/admin.py +15 -1
- build/lib/searchkit/filters.py +10 -2
- build/lib/searchkit/forms/fields.py +5 -4
- build/lib/searchkit/forms/search.py +0 -1
- build/lib/searchkit/forms/searchkit.py +36 -13
- build/lib/searchkit/forms/utils.py +50 -74
- build/lib/searchkit/tests.py +1 -1
- build/lib/searchkit/utils.py +13 -0
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/METADATA +1 -1
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/RECORD +75 -39
- searchkit/__version__.py +1 -1
- searchkit/admin.py +15 -1
- searchkit/filters.py +10 -2
- searchkit/forms/fields.py +5 -4
- searchkit/forms/search.py +0 -1
- searchkit/forms/searchkit.py +36 -13
- searchkit/forms/utils.py +50 -74
- searchkit/tests.py +1 -1
- searchkit/utils.py +13 -0
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/WHEEL +0 -0
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/licenses/LICENCE +0 -0
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/top_level.txt +0 -0
- {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/zip-safe +0 -0
@@ -0,0 +1,400 @@
|
|
1
|
+
from urllib.parse import urlencode
|
2
|
+
from django.test import TestCase
|
3
|
+
from django.contrib.contenttypes.models import ContentType
|
4
|
+
from django.contrib.auth.models import User
|
5
|
+
from django.urls import reverse
|
6
|
+
from example.models import ModelA
|
7
|
+
from example.management.commands.createtestdata import Command as CreateTestData
|
8
|
+
from searchkit.forms.utils import FIELD_PLAN
|
9
|
+
from searchkit.forms.utils import SUPPORTED_FIELDS
|
10
|
+
from searchkit.forms.utils import ModelTree
|
11
|
+
from searchkit.forms import SearchForm
|
12
|
+
from searchkit.forms import SearchkitModelForm
|
13
|
+
from searchkit.forms import BaseSearchkitFormSet
|
14
|
+
from searchkit.forms import searchkit_formset_factory
|
15
|
+
from searchkit.models import Search
|
16
|
+
from searchkit import __version__
|
17
|
+
|
18
|
+
|
19
|
+
SearchkitFormSet = searchkit_formset_factory(model=ModelA)
|
20
|
+
SearchkitForm = SearchkitFormSet.form
|
21
|
+
|
22
|
+
|
23
|
+
INITIAL_DATA = [
|
24
|
+
dict(
|
25
|
+
field='model_b__chars',
|
26
|
+
operator='contains',
|
27
|
+
value='anytext',
|
28
|
+
),
|
29
|
+
dict(
|
30
|
+
field='integer',
|
31
|
+
operator='range',
|
32
|
+
value=[1, 123],
|
33
|
+
),
|
34
|
+
dict(
|
35
|
+
field='float',
|
36
|
+
operator='gt',
|
37
|
+
value='0.3',
|
38
|
+
),
|
39
|
+
dict(
|
40
|
+
field='decimal',
|
41
|
+
operator='lte',
|
42
|
+
value='1.23',
|
43
|
+
),
|
44
|
+
dict(
|
45
|
+
field='date',
|
46
|
+
operator='exact',
|
47
|
+
value='2025-05-14',
|
48
|
+
),
|
49
|
+
dict(
|
50
|
+
field='datetime',
|
51
|
+
operator='exact',
|
52
|
+
value=['2025-05-14', '08:45'],
|
53
|
+
)
|
54
|
+
]
|
55
|
+
|
56
|
+
add_prefix = lambda i: SearchkitFormSet().add_prefix(i)
|
57
|
+
contenttype = ContentType.objects.get_for_model(ModelA)
|
58
|
+
DEFAULT_PREFIX = SearchkitFormSet.get_default_prefix()
|
59
|
+
|
60
|
+
def get_form_data(initial_data=INITIAL_DATA):
|
61
|
+
count = len(initial_data)
|
62
|
+
data = {
|
63
|
+
'name': 'test search', # The name of the search.
|
64
|
+
'searchkit_model': f'{contenttype.pk}', # Data for the searchkit-model form.
|
65
|
+
f'{DEFAULT_PREFIX}-TOTAL_FORMS': f'{count}', # Data for the managment form.
|
66
|
+
f'{DEFAULT_PREFIX}-INITIAL_FORMS': f'{count}', # Data for the managment form.
|
67
|
+
}
|
68
|
+
for i, d in enumerate(initial_data):
|
69
|
+
prefix = SearchkitFormSet().add_prefix(i)
|
70
|
+
for key, value in d.items():
|
71
|
+
if isinstance(value, list):
|
72
|
+
for i, v in enumerate(value):
|
73
|
+
data.update({f'{prefix}-{key}_{i}': v})
|
74
|
+
else:
|
75
|
+
data.update({f'{prefix}-{key}': value})
|
76
|
+
return data
|
77
|
+
|
78
|
+
FORM_DATA = get_form_data()
|
79
|
+
|
80
|
+
|
81
|
+
class CheckFormMixin:
|
82
|
+
"""
|
83
|
+
Mixin to check the form fields and their choices.
|
84
|
+
"""
|
85
|
+
def check_form(self, form):
|
86
|
+
# Three fields should be generated on instantiation.
|
87
|
+
self.assertIn('field', form.fields)
|
88
|
+
self.assertIn('operator', form.fields)
|
89
|
+
self.assertIn('value', form.fields)
|
90
|
+
self.assertEqual(len(form.fields), 3)
|
91
|
+
|
92
|
+
# Check field choices for the model.
|
93
|
+
form_model_field = form.fields['field']
|
94
|
+
self.assertTrue(form_model_field.choices)
|
95
|
+
options = [c[0] for c in form_model_field.choices]
|
96
|
+
tree = ModelTree(ModelA)
|
97
|
+
for node in tree.iterate():
|
98
|
+
for model_field in node.model._meta.fields:
|
99
|
+
if not any(isinstance(model_field, f) for f in SUPPORTED_FIELDS):
|
100
|
+
continue
|
101
|
+
if node.is_root:
|
102
|
+
self.assertIn(model_field.name, options)
|
103
|
+
else:
|
104
|
+
self.assertIn(f'{node.field_path}__{model_field.name}', options)
|
105
|
+
|
106
|
+
# Check the field_plan choosen based on the model_field.
|
107
|
+
field_plan = next(iter([p for t, p in FIELD_PLAN.items() if t(form.model_field)]))
|
108
|
+
self.assertEqual(form.field_plan, field_plan)
|
109
|
+
|
110
|
+
# Check choices of the operator field based on the field_plan.
|
111
|
+
operator_field = form.fields['operator']
|
112
|
+
self.assertTrue(operator_field.choices)
|
113
|
+
self.assertEqual(len(operator_field.choices), len(form.field_plan))
|
114
|
+
for operator in form.field_plan.keys():
|
115
|
+
self.assertIn(operator, [c[0] for c in operator_field.choices])
|
116
|
+
|
117
|
+
|
118
|
+
class SearchkitFormTestCase(CheckFormMixin, TestCase):
|
119
|
+
|
120
|
+
def test_blank_searchkitform(self):
|
121
|
+
form = SearchkitForm(prefix=add_prefix(0))
|
122
|
+
self.check_form(form)
|
123
|
+
|
124
|
+
# Form should not be bound or valid.
|
125
|
+
self.assertFalse(form.is_bound)
|
126
|
+
self.assertFalse(form.is_valid())
|
127
|
+
|
128
|
+
def test_searchkitform_with_invalid_model_field_data(self):
|
129
|
+
data = {
|
130
|
+
f'{add_prefix(0)}-field': 'foobar',
|
131
|
+
}
|
132
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
133
|
+
self.check_form(form)
|
134
|
+
|
135
|
+
# Form should be invalid.
|
136
|
+
self.assertFalse(form.is_valid())
|
137
|
+
|
138
|
+
# Check error message in html.
|
139
|
+
errors = ['Select a valid choice. foobar is not one of the available choices.']
|
140
|
+
self.assertIn(errors, form.errors.values())
|
141
|
+
|
142
|
+
def test_searchkitform_with_valid_model_field_data(self):
|
143
|
+
data = {
|
144
|
+
f'{add_prefix(0)}-field': 'integer',
|
145
|
+
}
|
146
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
147
|
+
self.check_form(form)
|
148
|
+
|
149
|
+
# Form should be invalid since no value data is provieded.
|
150
|
+
self.assertFalse(form.is_valid())
|
151
|
+
|
152
|
+
def test_searchkitform_with_invalid_operator_data(self):
|
153
|
+
data = {
|
154
|
+
f'{add_prefix(0)}-field': 'integer',
|
155
|
+
f'{add_prefix(0)}-operator': 'foobar',
|
156
|
+
}
|
157
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
158
|
+
self.check_form(form)
|
159
|
+
|
160
|
+
# Form should be invalid.
|
161
|
+
self.assertFalse(form.is_valid())
|
162
|
+
|
163
|
+
# Check error message in html.
|
164
|
+
errors = ['Select a valid choice. foobar is not one of the available choices.']
|
165
|
+
self.assertIn(errors, form.errors.values())
|
166
|
+
|
167
|
+
def test_searchkitform_with_valid_operator_data(self):
|
168
|
+
data = {
|
169
|
+
f'{add_prefix(0)}-field': 'integer',
|
170
|
+
f'{add_prefix(0)}-operator': 'exact',
|
171
|
+
}
|
172
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
173
|
+
self.check_form(form)
|
174
|
+
|
175
|
+
# Form should be invalid since no value data is provieded.
|
176
|
+
self.assertFalse(form.is_valid())
|
177
|
+
|
178
|
+
def test_searchkitform_with_valid_data(self):
|
179
|
+
data = {
|
180
|
+
f'{add_prefix(0)}-field': 'integer',
|
181
|
+
f'{add_prefix(0)}-operator': 'exact',
|
182
|
+
f'{add_prefix(0)}-value': '123',
|
183
|
+
}
|
184
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
185
|
+
self.check_form(form)
|
186
|
+
|
187
|
+
# Form should be valid.
|
188
|
+
self.assertTrue(form.is_valid())
|
189
|
+
|
190
|
+
def test_searchkitform_with_invalid_data(self):
|
191
|
+
data = {
|
192
|
+
f'{add_prefix(0)}-field': 'integer',
|
193
|
+
f'{add_prefix(0)}-operator': 'exact',
|
194
|
+
f'{add_prefix(0)}-value': 'foobar',
|
195
|
+
}
|
196
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
197
|
+
self.check_form(form)
|
198
|
+
|
199
|
+
# Form should be invalid.
|
200
|
+
self.assertFalse(form.is_valid())
|
201
|
+
|
202
|
+
# Check error message in html.
|
203
|
+
errors = ['Enter a whole number.']
|
204
|
+
self.assertIn(errors, form.errors.values())
|
205
|
+
|
206
|
+
|
207
|
+
class SearchkitFormSetTestCase(CheckFormMixin, TestCase):
|
208
|
+
def test_blank_searchkitform(self):
|
209
|
+
# Instantiating the formset neither with a model instance nor with model
|
210
|
+
# related data or initial data should result in a formset without forms,
|
211
|
+
# that is invalid and unbound.
|
212
|
+
formset = SearchkitFormSet()
|
213
|
+
self.assertFalse(formset.is_bound)
|
214
|
+
self.assertFalse(formset.is_valid())
|
215
|
+
|
216
|
+
def test_searchkit_formset_with_valid_data(self):
|
217
|
+
formset = SearchkitFormSet(FORM_DATA)
|
218
|
+
self.assertTrue(formset.is_valid())
|
219
|
+
|
220
|
+
def test_searchkit_formset_with_invalid_data(self):
|
221
|
+
data = FORM_DATA.copy()
|
222
|
+
del data[f'{add_prefix(0)}-value']
|
223
|
+
formset = SearchkitFormSet(data)
|
224
|
+
self.assertFalse(formset.is_valid())
|
225
|
+
|
226
|
+
# Check error message in html.
|
227
|
+
errors = ['This field is required.']
|
228
|
+
self.assertIn(errors, formset.forms[0].errors.values())
|
229
|
+
|
230
|
+
def test_searchkit_formset_with_initial_data(self):
|
231
|
+
formset_class = searchkit_formset_factory(model=ModelA, extra=0)
|
232
|
+
formset = formset_class(initial=INITIAL_DATA)
|
233
|
+
self.assertFalse(formset.is_bound)
|
234
|
+
self.assertFalse(formset.is_valid())
|
235
|
+
self.assertEqual(len(formset.forms), len(INITIAL_DATA))
|
236
|
+
for i, form in enumerate(formset.forms):
|
237
|
+
self.assertEqual(form.initial, INITIAL_DATA[i])
|
238
|
+
self.check_form(form)
|
239
|
+
|
240
|
+
|
241
|
+
class SearchkitSearchFormTestCase(TestCase):
|
242
|
+
def test_searchkit_search_form_without_data(self):
|
243
|
+
form = SearchForm()
|
244
|
+
self.assertFalse(form.is_bound)
|
245
|
+
self.assertFalse(form.is_valid())
|
246
|
+
self.assertIsInstance(form.formset, BaseSearchkitFormSet)
|
247
|
+
self.assertEqual(form.formset.model, None)
|
248
|
+
|
249
|
+
def test_searchkit_search_form_with_data(self):
|
250
|
+
form = SearchForm(FORM_DATA)
|
251
|
+
self.assertTrue(form.is_bound)
|
252
|
+
self.assertTrue(form.is_valid())
|
253
|
+
self.assertIsInstance(form.formset, BaseSearchkitFormSet)
|
254
|
+
self.assertEqual(form.formset.model, ModelA)
|
255
|
+
self.assertEqual(form.instance.data, form.formset.cleaned_data)
|
256
|
+
|
257
|
+
# Saving the instance works.
|
258
|
+
form.instance.save()
|
259
|
+
self.assertTrue(form.instance.pk)
|
260
|
+
|
261
|
+
# Using the instance data as filter rules works.
|
262
|
+
filter_rules = form.instance.as_lookups()
|
263
|
+
self.assertEqual(len(filter_rules), len(INITIAL_DATA))
|
264
|
+
for data in INITIAL_DATA:
|
265
|
+
self.assertIn(f"{data['field']}__{data['operator']}", filter_rules)
|
266
|
+
|
267
|
+
|
268
|
+
class SearchkitModelFormTestCase(TestCase):
|
269
|
+
def test_searchkit_model_form_choices(self):
|
270
|
+
form = SearchkitModelForm()
|
271
|
+
labels = [c[1] for c in form.fields['searchkit_model'].choices]
|
272
|
+
self.assertEqual(len(labels), 3)
|
273
|
+
self.assertEqual('select a model', labels[0].lower())
|
274
|
+
self.assertEqual('example | model a', labels[1].lower())
|
275
|
+
self.assertEqual('example | model b', labels[2].lower())
|
276
|
+
|
277
|
+
|
278
|
+
class AdminBackendTest(TestCase):
|
279
|
+
@classmethod
|
280
|
+
def setUpTestData(cls):
|
281
|
+
CreateTestData().handle()
|
282
|
+
|
283
|
+
def setUp(self):
|
284
|
+
admin = User.objects.get(username='admin')
|
285
|
+
self.client.force_login(admin)
|
286
|
+
|
287
|
+
def test_search_form(self):
|
288
|
+
url = reverse('admin:searchkit_search_add')
|
289
|
+
resp = self.client.get(url)
|
290
|
+
self.assertEqual(resp.status_code, 200)
|
291
|
+
select = b'<select name="searchkit_model" class="searchkit-reload-on-change" data-total-forms="1" required id="id_searchkit_model">'
|
292
|
+
for snippet in select.split(b' '):
|
293
|
+
self.assertIn(snippet, resp.content)
|
294
|
+
|
295
|
+
def test_search_form_with_initial(self):
|
296
|
+
url = reverse('admin:searchkit_search_add') + '?searchkit_model=1'
|
297
|
+
resp = self.client.get(url)
|
298
|
+
self.assertEqual(resp.status_code, 200)
|
299
|
+
select = '<select name="searchkit_model" class="searchkit-reload-on-change" data-total-forms="1" required id="id_searchkit_model">'
|
300
|
+
for snippet in select.split(' '):
|
301
|
+
self.assertIn(snippet, str(resp.content))
|
302
|
+
self.assertIn('<option value="1" selected>', str(resp.content))
|
303
|
+
self.assertIn('name="searchkit-example-modela-0-field"', str(resp.content))
|
304
|
+
|
305
|
+
def test_add_search(self):
|
306
|
+
# Create a search object via the admin backend.
|
307
|
+
url = reverse('admin:searchkit_search_add')
|
308
|
+
data = FORM_DATA.copy()
|
309
|
+
data['_save_and_apply'] = True
|
310
|
+
resp = self.client.post(url, data, follow=True)
|
311
|
+
self.assertEqual(resp.status_code, 200)
|
312
|
+
self.assertEqual(len(Search.objects.all()), 1)
|
313
|
+
|
314
|
+
# Change it via backend.
|
315
|
+
url = reverse('admin:searchkit_search_change', args=(1,))
|
316
|
+
data['name'] = 'Changed name'
|
317
|
+
data['searchkit-example-modela-0-field'] = 'boolean'
|
318
|
+
data['searchkit-example-modela-0-operator'] = 'exact'
|
319
|
+
data['searchkit-example-modela-0-value'] = 'true'
|
320
|
+
resp = self.client.post(url, data, follow=True)
|
321
|
+
self.assertEqual(resp.status_code, 200)
|
322
|
+
self.assertEqual(Search.objects.get(pk=1).name, data['name'])
|
323
|
+
|
324
|
+
# Will the search be listed in the admin filter?
|
325
|
+
url = reverse('admin:example_modela_changelist')
|
326
|
+
resp = self.client.get(url)
|
327
|
+
self.assertEqual(resp.status_code, 200)
|
328
|
+
self.assertIn('href="?search=1"', str(resp.content))
|
329
|
+
self.assertIn(data['name'], str(resp.content))
|
330
|
+
|
331
|
+
|
332
|
+
class SearchViewTest(TestCase):
|
333
|
+
|
334
|
+
def setUp(self):
|
335
|
+
self.initial = [
|
336
|
+
dict(
|
337
|
+
field='integer',
|
338
|
+
operator='exact',
|
339
|
+
value=1,
|
340
|
+
)
|
341
|
+
]
|
342
|
+
self.initial_range = [
|
343
|
+
dict(
|
344
|
+
field='integer',
|
345
|
+
operator='range',
|
346
|
+
value=[1,3],
|
347
|
+
)
|
348
|
+
]
|
349
|
+
|
350
|
+
def test_search_view_invalid_data(self):
|
351
|
+
initial = self.initial.copy()
|
352
|
+
initial[0]['value'] = 'no integer'
|
353
|
+
data = get_form_data(initial)
|
354
|
+
url_params = urlencode(data)
|
355
|
+
base_url = reverse('searchkit_form')
|
356
|
+
url = f'{base_url}?{url_params}'
|
357
|
+
resp = self.client.get(url)
|
358
|
+
self.assertEqual(resp.status_code, 200)
|
359
|
+
html_error = '<li>Enter a whole number.</li>'
|
360
|
+
self.assertInHTML(html_error, str(resp.content))
|
361
|
+
|
362
|
+
def test_search_view_missing_data(self):
|
363
|
+
initial = self.initial.copy()
|
364
|
+
del(initial[0]['value'])
|
365
|
+
data = get_form_data(initial)
|
366
|
+
url_params = urlencode(data)
|
367
|
+
base_url = reverse('searchkit_form')
|
368
|
+
url = f'{base_url}?{url_params}'
|
369
|
+
resp = self.client.get(url)
|
370
|
+
self.assertEqual(resp.status_code, 200)
|
371
|
+
html_error = '<li>This field is required.</li>'
|
372
|
+
self.assertInHTML(html_error, str(resp.content))
|
373
|
+
|
374
|
+
def test_search_view_with_range_operator(self):
|
375
|
+
data = get_form_data(self.initial_range)
|
376
|
+
url_params = urlencode(data)
|
377
|
+
base_url = reverse('searchkit_form')
|
378
|
+
url = f'{base_url}?{url_params}'
|
379
|
+
resp = self.client.get(url)
|
380
|
+
self.assertEqual(resp.status_code, 200)
|
381
|
+
html = '<input type="number" name="searchkit-example-modela-0-value_1" value="3" id="id_searchkit-example-modela-0-value_1">'
|
382
|
+
self.assertInHTML(html, str(resp.content))
|
383
|
+
|
384
|
+
def test_search_view_with_model(self):
|
385
|
+
data = get_form_data(self.initial)
|
386
|
+
data['searchkit_model'] = ContentType.objects.get_for_model(ModelA).pk
|
387
|
+
url_params = urlencode(data)
|
388
|
+
base_url = reverse('searchkit_form')
|
389
|
+
url = f'{base_url}?{url_params}'
|
390
|
+
resp = self.client.get(url)
|
391
|
+
self.assertEqual(resp.status_code, 200)
|
392
|
+
|
393
|
+
def test_search_view_with_invalid_model(self):
|
394
|
+
data = get_form_data(self.initial)
|
395
|
+
data['searchkit_model'] = 9999 # Non-existing content type.
|
396
|
+
url_params = urlencode(data)
|
397
|
+
base_url = reverse('searchkit_form')
|
398
|
+
url = f'{base_url}?{url_params}'
|
399
|
+
resp = self.client.get(url)
|
400
|
+
self.assertEqual(resp.status_code, 400)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from django.contrib import admin
|
2
|
+
|
3
|
+
|
4
|
+
def is_searchable_model(model):
|
5
|
+
"""
|
6
|
+
Check if the model is searchable by Searchkit.
|
7
|
+
"""
|
8
|
+
# We do not import SearchkitFilter to avoid circular imports. So we check
|
9
|
+
# the filter by its name.
|
10
|
+
return (
|
11
|
+
admin.site.is_registered(model)
|
12
|
+
and 'SearchkitFilter' in [getattr(f, '__name__', '') for f in admin.site._registry[model].list_filter]
|
13
|
+
)
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from django.utils.translation import gettext_lazy as _
|
2
|
+
from django.http import HttpResponse
|
3
|
+
from django.http import Http404, HttpResponseBadRequest
|
4
|
+
from django.apps import apps
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
6
|
+
from django.views.generic import View
|
7
|
+
from .forms import SearchkitModelForm
|
8
|
+
from .forms import searchkit_formset_factory
|
9
|
+
|
10
|
+
|
11
|
+
# FIXME: Check permissions and authentication.
|
12
|
+
class SearchkitAjaxView(View):
|
13
|
+
"""
|
14
|
+
Reload the formset via ajax.
|
15
|
+
"""
|
16
|
+
def get(self, request, **kwargs):
|
17
|
+
model_form = SearchkitModelForm(data=self.request.GET)
|
18
|
+
if model_form.is_valid():
|
19
|
+
model = model_form.cleaned_data['searchkit_model'].model_class()
|
20
|
+
formset = searchkit_formset_factory(model=model)(data=request.GET)
|
21
|
+
return HttpResponse(formset.render())
|
22
|
+
else:
|
23
|
+
return HttpResponseBadRequest(_('Invalid searchkit-model-form.'))
|
@@ -1,15 +1,18 @@
|
|
1
1
|
from django.contrib import admin
|
2
2
|
from django.http import HttpResponseRedirect
|
3
3
|
from django.urls import reverse
|
4
|
+
from django.utils.html import format_html
|
4
5
|
from .models import Search
|
5
6
|
from .forms import SearchForm
|
6
7
|
from .filters import SearchkitFilter
|
8
|
+
from .filters import SearchableModelFilter
|
7
9
|
|
8
10
|
|
9
11
|
@admin.register(Search)
|
10
12
|
class SearchkitSearchAdmin(admin.ModelAdmin):
|
11
13
|
form = SearchForm
|
12
|
-
list_display = ('name', 'contenttype', 'created_date')
|
14
|
+
list_display = ('name', 'contenttype', 'created_date', 'apply_search_view')
|
15
|
+
list_filter = (('contenttype', SearchableModelFilter),)
|
13
16
|
|
14
17
|
def get_url_for_applied_search(self, obj):
|
15
18
|
app_label = obj.contenttype.app_label
|
@@ -28,3 +31,14 @@ class SearchkitSearchAdmin(admin.ModelAdmin):
|
|
28
31
|
return HttpResponseRedirect(self.get_url_for_applied_search(obj))
|
29
32
|
else:
|
30
33
|
return super().response_change(request, obj, *args, **kwargs)
|
34
|
+
|
35
|
+
def apply_search_view(self, obj):
|
36
|
+
"""
|
37
|
+
Returns a link to apply the search.
|
38
|
+
"""
|
39
|
+
return format_html(
|
40
|
+
'<a href="{}" >Apply search "{}"</a>',
|
41
|
+
self.get_url_for_applied_search(obj),
|
42
|
+
obj.name
|
43
|
+
)
|
44
|
+
apply_search_view.short_description = 'Apply Search'
|
@@ -1,9 +1,10 @@
|
|
1
|
-
from django.contrib
|
1
|
+
from django.contrib import admin
|
2
2
|
from django.contrib.contenttypes.models import ContentType
|
3
3
|
from .models import Search
|
4
|
+
from .utils import is_searchable_model
|
4
5
|
|
5
6
|
|
6
|
-
class SearchkitFilter(SimpleListFilter):
|
7
|
+
class SearchkitFilter(admin.SimpleListFilter):
|
7
8
|
title = 'Searchkit Filter'
|
8
9
|
parameter_name = 'search'
|
9
10
|
template = 'searchkit/searchkit_filter.html'
|
@@ -26,3 +27,10 @@ class SearchkitFilter(SimpleListFilter):
|
|
26
27
|
if self.value():
|
27
28
|
search = Search.objects.get(id=int(self.value()))
|
28
29
|
return queryset.filter(**search.as_lookups())
|
30
|
+
|
31
|
+
|
32
|
+
class SearchableModelFilter(admin.filters.RelatedFieldListFilter):
|
33
|
+
def __init__(self, *args, **kwargs):
|
34
|
+
super().__init__(*args, **kwargs)
|
35
|
+
contenttypes = ContentType.objects.order_by('app_label', 'model')
|
36
|
+
self.lookup_choices = [(m.id, m) for m in contenttypes if is_searchable_model(m.model_class())]
|
@@ -1,4 +1,5 @@
|
|
1
1
|
from django import forms
|
2
|
+
from django.contrib.admin import widgets
|
2
3
|
from django.utils.translation import gettext_lazy as _
|
3
4
|
|
4
5
|
|
@@ -46,10 +47,10 @@ class IntegerRangeField(BaseRangeField):
|
|
46
47
|
class DateRangeField(BaseRangeField):
|
47
48
|
incomplete_message = _("Enter the first and the last date.")
|
48
49
|
field_type = forms.DateField
|
49
|
-
widget_type =
|
50
|
+
widget_type = widgets.AdminDateWidget
|
50
51
|
|
51
52
|
|
52
|
-
class DateTimeRangeField(
|
53
|
+
class DateTimeRangeField(DateRangeField):
|
53
54
|
incomplete_message = _("Enter the first and the last datetime.")
|
54
|
-
field_type = forms.
|
55
|
-
widget_type =
|
55
|
+
field_type = forms.SplitDateTimeField
|
56
|
+
widget_type = widgets.AdminSplitDateTime
|
@@ -1,19 +1,30 @@
|
|
1
1
|
from django import forms
|
2
|
+
from django.apps import apps
|
3
|
+
from django.contrib.admin import widgets
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
2
5
|
from django.utils.translation import gettext_lazy as _
|
3
6
|
from django.utils.functional import cached_property
|
4
7
|
from .utils import CssClassMixin, FIELD_PLAN, OPERATOR_DESCRIPTION
|
5
8
|
from .utils import SUPPORTED_FIELDS
|
6
9
|
from .utils import ModelTree
|
7
10
|
from .utils import MediaMixin
|
8
|
-
from .
|
11
|
+
from .fields import DateRangeField
|
12
|
+
from ..utils import is_searchable_model
|
9
13
|
|
10
14
|
|
11
15
|
class SearchkitModelForm(forms.Form):
|
12
16
|
"""
|
13
17
|
Form to select a content type.
|
14
18
|
"""
|
19
|
+
def __init__(self, *args, **kwargs):
|
20
|
+
super().__init__(*args, **kwargs)
|
21
|
+
models = [m for m in apps.get_models() if is_searchable_model(m)]
|
22
|
+
ids = [ContentType.objects.get_for_model(m).id for m in models]
|
23
|
+
queryset = self.fields['searchkit_model'].queryset.filter(pk__in=ids)
|
24
|
+
self.fields['searchkit_model'].queryset = queryset
|
25
|
+
|
15
26
|
searchkit_model = forms.ModelChoiceField(
|
16
|
-
queryset=
|
27
|
+
queryset=ContentType.objects.all().order_by('app_label', 'model'),
|
17
28
|
label=_('Model'),
|
18
29
|
empty_label=_('Select a Model'),
|
19
30
|
widget=forms.Select(attrs={
|
@@ -105,27 +116,19 @@ class BaseSearchkitForm(MediaMixin, CssClassMixin, forms.Form):
|
|
105
116
|
return choices
|
106
117
|
|
107
118
|
def _add_field_name_field(self):
|
108
|
-
initial = self.initial.get('field')
|
109
119
|
choices = self._get_model_field_choices()
|
110
|
-
field = forms.ChoiceField(label=_('Model field'), choices=choices
|
120
|
+
field = forms.ChoiceField(label=_('Model field'), choices=choices)
|
111
121
|
field.widget.attrs.update({"class": self.reload_on_change_css_class})
|
112
122
|
self.fields['field'] = field
|
113
123
|
|
114
124
|
def _add_operator_field(self):
|
115
|
-
initial = self.initial.get('operator')
|
116
125
|
choices = [(o, OPERATOR_DESCRIPTION[o]) for o in self.field_plan.keys()]
|
117
|
-
field = forms.ChoiceField(label=_('Operator'), choices=choices
|
126
|
+
field = forms.ChoiceField(label=_('Operator'), choices=choices)
|
118
127
|
field.widget.attrs.update({"class": self.reload_on_change_css_class})
|
119
128
|
self.fields['operator'] = field
|
120
129
|
|
121
130
|
def _add_value_field(self):
|
122
|
-
|
123
|
-
field_class = self.field_plan[self.operator][0]
|
124
|
-
if getattr(field_class, 'choices', None) and getattr(self.model_field, 'choices', None):
|
125
|
-
field = field_class(choices=self.model_field.choices, initial=initial)
|
126
|
-
else:
|
127
|
-
field = field_class()
|
128
|
-
self.fields['value'] = field
|
131
|
+
self.fields['value'] = self.field_plan[self.operator](self.model_field)
|
129
132
|
|
130
133
|
|
131
134
|
class BaseSearchkitFormSet(CssClassMixin, forms.BaseFormSet):
|
@@ -143,6 +146,26 @@ class BaseSearchkitFormSet(CssClassMixin, forms.BaseFormSet):
|
|
143
146
|
def get_default_prefix(self):
|
144
147
|
return "searchkit"
|
145
148
|
|
149
|
+
@cached_property
|
150
|
+
def uses_date_widget(self):
|
151
|
+
"""
|
152
|
+
Check if the form uses a date widget.
|
153
|
+
"""
|
154
|
+
for form in self.forms:
|
155
|
+
for field in form.fields.values():
|
156
|
+
if isinstance(field.widget, (widgets.AdminDateWidget, widgets.AdminSplitDateTime)):
|
157
|
+
return True
|
158
|
+
elif isinstance(field, DateRangeField):
|
159
|
+
return True
|
160
|
+
return False
|
161
|
+
|
162
|
+
@cached_property
|
163
|
+
def forms(self):
|
164
|
+
if self.model:
|
165
|
+
return super().forms
|
166
|
+
else:
|
167
|
+
return []
|
168
|
+
|
146
169
|
|
147
170
|
def searchkit_formset_factory(model, **kwargs):
|
148
171
|
form = type('SearchkitForm', (BaseSearchkitForm,), dict(model=model))
|