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.
Files changed (75) hide show
  1. build/lib/build/lib/build/lib/build/lib/example/example/__init__.py +0 -0
  2. build/lib/build/lib/build/lib/build/lib/example/example/admin.py +16 -0
  3. build/lib/build/lib/build/lib/build/lib/example/example/asgi.py +16 -0
  4. build/lib/build/lib/build/lib/build/lib/example/example/management/__init__.py +0 -0
  5. build/lib/build/lib/build/lib/build/lib/example/example/management/commands/__init__.py +0 -0
  6. build/lib/build/lib/build/lib/build/lib/example/example/management/commands/createtestdata.py +62 -0
  7. build/lib/build/lib/build/lib/build/lib/example/example/migrations/0001_initial.py +48 -0
  8. build/lib/build/lib/build/lib/build/lib/example/example/migrations/__init__.py +0 -0
  9. build/lib/build/lib/build/lib/build/lib/example/example/models.py +38 -0
  10. build/lib/build/lib/build/lib/build/lib/example/example/settings.py +125 -0
  11. build/lib/build/lib/build/lib/build/lib/example/example/urls.py +23 -0
  12. build/lib/build/lib/build/lib/build/lib/example/example/wsgi.py +16 -0
  13. build/lib/build/lib/build/lib/build/lib/searchkit/__init__.py +0 -0
  14. build/lib/build/lib/build/lib/build/lib/searchkit/__version__.py +16 -0
  15. build/lib/build/lib/build/lib/build/lib/searchkit/admin.py +44 -0
  16. build/lib/build/lib/build/lib/build/lib/searchkit/apps.py +6 -0
  17. build/lib/build/lib/build/lib/build/lib/searchkit/filters.py +36 -0
  18. build/lib/build/lib/build/lib/build/lib/searchkit/forms/__init__.py +5 -0
  19. build/lib/build/lib/build/lib/build/lib/searchkit/forms/fields.py +56 -0
  20. build/lib/build/lib/build/lib/build/lib/searchkit/forms/search.py +61 -0
  21. build/lib/build/lib/build/lib/build/lib/searchkit/forms/searchkit.py +177 -0
  22. build/lib/build/lib/build/lib/build/lib/searchkit/forms/utils.py +154 -0
  23. build/lib/build/lib/build/lib/build/lib/searchkit/migrations/0001_initial.py +30 -0
  24. build/lib/build/lib/build/lib/build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  25. build/lib/build/lib/build/lib/build/lib/searchkit/migrations/__init__.py +0 -0
  26. build/lib/build/lib/build/lib/build/lib/searchkit/models.py +21 -0
  27. build/lib/build/lib/build/lib/build/lib/searchkit/templatetags/__init__.py +0 -0
  28. build/lib/build/lib/build/lib/build/lib/searchkit/templatetags/searchkit.py +20 -0
  29. build/lib/build/lib/build/lib/build/lib/searchkit/tests.py +400 -0
  30. build/lib/build/lib/build/lib/build/lib/searchkit/urls.py +7 -0
  31. build/lib/build/lib/build/lib/build/lib/searchkit/utils.py +13 -0
  32. build/lib/build/lib/build/lib/build/lib/searchkit/views.py +23 -0
  33. build/lib/build/lib/build/lib/searchkit/__version__.py +1 -1
  34. build/lib/build/lib/build/lib/searchkit/admin.py +15 -1
  35. build/lib/build/lib/build/lib/searchkit/filters.py +10 -2
  36. build/lib/build/lib/build/lib/searchkit/forms/fields.py +5 -4
  37. build/lib/build/lib/build/lib/searchkit/forms/search.py +0 -1
  38. build/lib/build/lib/build/lib/searchkit/forms/searchkit.py +36 -13
  39. build/lib/build/lib/build/lib/searchkit/forms/utils.py +50 -74
  40. build/lib/build/lib/build/lib/searchkit/tests.py +1 -1
  41. build/lib/build/lib/build/lib/searchkit/utils.py +13 -0
  42. build/lib/build/lib/example/example/admin.py +1 -1
  43. build/lib/build/lib/searchkit/__version__.py +1 -1
  44. build/lib/build/lib/searchkit/admin.py +15 -1
  45. build/lib/build/lib/searchkit/filters.py +10 -2
  46. build/lib/build/lib/searchkit/forms/fields.py +5 -4
  47. build/lib/build/lib/searchkit/forms/search.py +0 -1
  48. build/lib/build/lib/searchkit/forms/searchkit.py +36 -13
  49. build/lib/build/lib/searchkit/forms/utils.py +50 -74
  50. build/lib/build/lib/searchkit/tests.py +1 -1
  51. build/lib/build/lib/searchkit/utils.py +13 -0
  52. build/lib/searchkit/__version__.py +1 -1
  53. build/lib/searchkit/admin.py +15 -1
  54. build/lib/searchkit/filters.py +10 -2
  55. build/lib/searchkit/forms/fields.py +5 -4
  56. build/lib/searchkit/forms/search.py +0 -1
  57. build/lib/searchkit/forms/searchkit.py +36 -13
  58. build/lib/searchkit/forms/utils.py +50 -74
  59. build/lib/searchkit/tests.py +1 -1
  60. build/lib/searchkit/utils.py +13 -0
  61. {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/METADATA +1 -1
  62. {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/RECORD +75 -39
  63. searchkit/__version__.py +1 -1
  64. searchkit/admin.py +15 -1
  65. searchkit/filters.py +10 -2
  66. searchkit/forms/fields.py +5 -4
  67. searchkit/forms/search.py +0 -1
  68. searchkit/forms/searchkit.py +36 -13
  69. searchkit/forms/utils.py +50 -74
  70. searchkit/tests.py +1 -1
  71. searchkit/utils.py +13 -0
  72. {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/WHEEL +0 -0
  73. {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/licenses/LICENCE +0 -0
  74. {django_searchkit-1.2.dist-info → django_searchkit-1.3.dist-info}/top_level.txt +0 -0
  75. {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,7 @@
1
+ from django.urls import path
2
+ from .views import SearchkitAjaxView
3
+
4
+
5
+ urlpatterns = [
6
+ path("searchkit/", SearchkitAjaxView.as_view(), name="searchkit_form"),
7
+ ]
@@ -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.'))
@@ -13,4 +13,4 @@ Version 0.x should be considered a development version with an unstable API,
13
13
  and backwards compatibility is not guaranteed for minor versions.
14
14
  """
15
15
 
16
- __version__ = "1.2"
16
+ __version__ = "1.3"
@@ -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.admin import SimpleListFilter
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 = forms.DateInput
50
+ widget_type = widgets.AdminDateWidget
50
51
 
51
52
 
52
- class DateTimeRangeField(BaseRangeField):
53
+ class DateTimeRangeField(DateRangeField):
53
54
  incomplete_message = _("Enter the first and the last datetime.")
54
- field_type = forms.DateTimeField
55
- widget_type = forms.DateTimeInput
55
+ field_type = forms.SplitDateTimeField
56
+ widget_type = widgets.AdminSplitDateTime
@@ -1,6 +1,5 @@
1
1
  from django import forms
2
2
  from django.utils.functional import cached_property
3
- from django.contrib.contenttypes.models import ContentType
4
3
  from ..models import Search
5
4
  from .searchkit import SearchkitModelForm
6
5
  from .searchkit import searchkit_formset_factory
@@ -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 .utils import get_searchable_models
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=get_searchable_models(),
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, initial=initial)
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, initial=initial)
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
- initial = self.initial.get('value')
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))