django-searchkit 0.1__py3-none-any.whl → 1.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.
Files changed (83) hide show
  1. build/lib/build/lib/example/example/__init__.py +0 -0
  2. build/lib/build/lib/example/example/admin.py +16 -0
  3. build/lib/build/lib/example/example/asgi.py +16 -0
  4. build/lib/build/lib/example/example/management/__init__.py +0 -0
  5. build/lib/build/lib/example/example/management/commands/__init__.py +0 -0
  6. build/lib/build/lib/example/example/management/commands/createtestdata.py +62 -0
  7. build/lib/build/lib/example/example/migrations/0001_initial.py +48 -0
  8. build/lib/build/lib/example/example/migrations/__init__.py +0 -0
  9. build/lib/build/lib/example/example/models.py +38 -0
  10. build/lib/build/lib/example/example/settings.py +125 -0
  11. build/lib/build/lib/example/example/urls.py +23 -0
  12. build/lib/build/lib/example/example/wsgi.py +16 -0
  13. build/lib/build/lib/searchkit/__init__.py +0 -0
  14. build/lib/build/lib/searchkit/__version__.py +16 -0
  15. build/lib/build/lib/searchkit/admin.py +30 -0
  16. build/lib/build/lib/searchkit/apps.py +6 -0
  17. build/lib/build/lib/searchkit/filters.py +27 -0
  18. build/lib/build/lib/searchkit/forms/__init__.py +5 -0
  19. build/lib/build/lib/searchkit/forms/fields.py +55 -0
  20. build/lib/build/lib/searchkit/forms/search.py +62 -0
  21. build/lib/build/lib/searchkit/forms/searchkit.py +154 -0
  22. build/lib/build/lib/searchkit/forms/utils.py +178 -0
  23. build/lib/build/lib/searchkit/migrations/0001_initial.py +30 -0
  24. build/lib/build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  25. build/lib/build/lib/searchkit/migrations/__init__.py +0 -0
  26. build/lib/build/lib/searchkit/models.py +27 -0
  27. build/lib/build/lib/searchkit/templatetags/__init__.py +0 -0
  28. build/lib/build/lib/searchkit/templatetags/searchkit.py +20 -0
  29. build/lib/build/lib/searchkit/tests.py +402 -0
  30. build/lib/build/lib/searchkit/urls.py +7 -0
  31. build/lib/build/lib/searchkit/views.py +23 -0
  32. build/lib/example/example/__init__.py +0 -0
  33. build/lib/example/example/admin.py +16 -0
  34. build/lib/example/example/asgi.py +16 -0
  35. build/lib/example/example/management/__init__.py +0 -0
  36. build/lib/example/example/management/commands/__init__.py +0 -0
  37. build/lib/example/example/management/commands/createtestdata.py +62 -0
  38. build/lib/example/example/migrations/0001_initial.py +48 -0
  39. build/lib/example/example/migrations/__init__.py +0 -0
  40. build/lib/example/example/models.py +38 -0
  41. build/lib/example/example/settings.py +125 -0
  42. build/lib/example/example/urls.py +23 -0
  43. build/lib/example/example/wsgi.py +16 -0
  44. build/lib/searchkit/__init__.py +0 -0
  45. build/lib/searchkit/__version__.py +16 -0
  46. build/lib/searchkit/admin.py +30 -0
  47. build/lib/searchkit/apps.py +6 -0
  48. build/lib/searchkit/filters.py +27 -0
  49. build/lib/searchkit/forms/__init__.py +5 -0
  50. build/lib/searchkit/forms/fields.py +55 -0
  51. build/lib/searchkit/forms/search.py +62 -0
  52. build/lib/searchkit/forms/searchkit.py +154 -0
  53. build/lib/searchkit/forms/utils.py +178 -0
  54. build/lib/searchkit/migrations/0001_initial.py +30 -0
  55. build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  56. build/lib/searchkit/migrations/__init__.py +0 -0
  57. build/lib/searchkit/models.py +27 -0
  58. build/lib/searchkit/templatetags/__init__.py +0 -0
  59. build/lib/searchkit/templatetags/searchkit.py +20 -0
  60. build/lib/searchkit/tests.py +402 -0
  61. build/lib/searchkit/urls.py +7 -0
  62. build/lib/searchkit/views.py +23 -0
  63. {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/METADATA +34 -13
  64. django_searchkit-1.1.dist-info/RECORD +99 -0
  65. {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/WHEEL +1 -1
  66. {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/top_level.txt +1 -0
  67. example/example/admin.py +1 -1
  68. searchkit/__version__.py +1 -1
  69. searchkit/admin.py +4 -4
  70. searchkit/filters.py +7 -11
  71. searchkit/forms/__init__.py +5 -2
  72. searchkit/forms/search.py +36 -19
  73. searchkit/forms/searchkit.py +61 -99
  74. searchkit/forms/utils.py +44 -15
  75. searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  76. searchkit/models.py +8 -4
  77. searchkit/templatetags/searchkit.py +0 -27
  78. searchkit/tests.py +283 -78
  79. searchkit/urls.py +1 -2
  80. searchkit/views.py +11 -18
  81. django_searchkit-0.1.dist-info/RECORD +0 -36
  82. {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/licenses/LICENCE +0 -0
  83. {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/zip-safe +0 -0
searchkit/forms/utils.py CHANGED
@@ -1,8 +1,23 @@
1
+ from modeltree import ModelTree as BaseModelTree
2
+ from collections import OrderedDict
3
+ from django.apps import apps
4
+ from django.contrib import admin
5
+ from django.contrib.contenttypes.models import ContentType
1
6
  from django.db import models
7
+ from django.db.utils import OperationalError
2
8
  from django import forms
3
9
  from django.utils.translation import gettext_lazy as _
4
- from collections import OrderedDict
5
- from searchkit.forms import fields as searchkit_fields
10
+ from ..filters import SearchkitFilter
11
+ from . import fields as searchkit_fields
12
+
13
+
14
+ class ModelTree(BaseModelTree):
15
+ MAX_DEPTH = 3
16
+ FOLLOW_ACROSS_APPS = True
17
+ RELATION_TYPES = [
18
+ 'one_to_one',
19
+ 'many_to_one',
20
+ ]
6
21
 
7
22
 
8
23
  OPERATOR_DESCRIPTION = {
@@ -29,10 +44,6 @@ SUPPORTED_FIELDS = [
29
44
  models.DateField,
30
45
  models.DateTimeField,
31
46
  ]
32
- SUPPORTED_RELATIONS = [
33
- models.ForeignKey,
34
- models.OneToOneField,
35
- ]
36
47
 
37
48
 
38
49
  FIELD_PLAN = OrderedDict((
@@ -132,18 +143,36 @@ FIELD_PLAN = OrderedDict((
132
143
  ))
133
144
 
134
145
 
135
- class CSS_CLASSES:
146
+ class CssClassMixin:
136
147
  reload_on_change_css_class = "searchkit-reload-on-change"
137
148
  reload_on_click_css_class = "searchkit-reload-on-click"
138
149
 
139
150
 
140
- def get_filter_rules(formset):
151
+ class MediaMixin:
152
+ class Media:
153
+ js = [
154
+ 'admin/js/vendor/jquery/jquery.min.js',
155
+ 'admin/js/jquery.init.js',
156
+ "searchkit/searchkit.js"
157
+ ]
158
+
159
+
160
+ def is_searchable_model(model):
161
+ """
162
+ Check if the model is searchable by Searchkit.
163
+ """
164
+ return admin.site.is_registered(model) and SearchkitFilter in admin.site._registry[model].list_filter
165
+
166
+
167
+ def get_searchable_models():
141
168
  """
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
169
+ Return a queryset of searchable models.
145
170
  """
146
- lookups = OrderedDict()
147
- for data in formset.cleaned_data:
148
- lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
149
- return lookups
171
+ # Before mirating the database we get an OperationalError when trying to
172
+ # access ContentType database table.
173
+ try:
174
+ models = [m for m in apps.get_models() if is_searchable_model(m)]
175
+ ids = [ContentType.objects.get_for_model(m).id for m in models]
176
+ return ContentType.objects.filter(pk__in=ids).order_by('app_label', 'model')
177
+ except OperationalError:
178
+ return ContentType.objects.all()
@@ -0,0 +1,18 @@
1
+ # Generated by Django 5.1.3 on 2025-06-07 05:46
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('contenttypes', '0002_remove_content_type_name'),
10
+ ('searchkit', '0001_initial'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.RenameModel(
15
+ old_name='SearchkitSearch',
16
+ new_name='Search',
17
+ ),
18
+ ]
searchkit/models.py CHANGED
@@ -5,9 +5,7 @@ from django.contrib.contenttypes.models import ContentType
5
5
  from django.utils.translation import gettext_lazy as _
6
6
 
7
7
 
8
- # Create your models here.
9
- # TODO: Use pickled cleaned data with a char field.
10
- class SearchkitSearch(models.Model):
8
+ class Search(models.Model):
11
9
  name = models.CharField(_('Search name'), max_length=255)
12
10
  contenttype = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_('Model'))
13
11
  data = PickledObjectField(_('Serialized filter rule data'))
@@ -16,8 +14,14 @@ class SearchkitSearch(models.Model):
16
14
  class Meta:
17
15
  unique_together = ('name', 'contenttype')
18
16
 
19
- def get_filter_rules(self):
17
+ def as_lookups(self):
20
18
  lookups = OrderedDict()
21
19
  for data in self.data:
22
20
  lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
23
21
  return lookups
22
+
23
+ def as_queryset(self):
24
+ """
25
+ Returns a filtered queryset for the model.
26
+ """
27
+ return self.contenttype.model_class().objects.filter(**self.as_lookups())
@@ -1,36 +1,9 @@
1
1
  from django.template import Library
2
- from django.urls import reverse
3
2
  from django.contrib.admin.helpers import Fieldset
4
- from ..forms.utils import CSS_CLASSES
5
3
 
6
4
 
7
5
  register = Library()
8
6
 
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
7
  @register.inclusion_tag("admin/includes/fieldset.html")
35
8
  def as_fieldset(form, heading_level=2, prefix='', id_prefix=0, id_suffix='', **fieldset_kwargs):
36
9
  """
searchkit/tests.py CHANGED
@@ -1,31 +1,44 @@
1
+ from urllib.parse import urlencode
1
2
  from django.test import TestCase
2
- from django import forms
3
+ from django.contrib.contenttypes.models import ContentType
4
+ from django.contrib.auth.models import User
5
+ from django.urls import reverse
3
6
  from example.models import ModelA
4
- from .searchkit import FIELD_PLAN
5
- from .searchkit import SearchkitForm
6
- from .searchkit import SearchkitFormSet
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
7
21
 
8
22
 
9
23
  INITIAL_DATA = [
10
24
  dict(
11
- field='chars',
12
- operator='exact',
25
+ field='model_b__chars',
26
+ operator='contains',
13
27
  value='anytext',
14
28
  ),
15
29
  dict(
16
30
  field='integer',
17
31
  operator='range',
18
- value_0=1,
19
- value_1=123,
32
+ value=[1, 123],
20
33
  ),
21
34
  dict(
22
35
  field='float',
23
- operator='exact',
36
+ operator='gt',
24
37
  value='0.3',
25
38
  ),
26
39
  dict(
27
40
  field='decimal',
28
- operator='exact',
41
+ operator='lte',
29
42
  value='1.23',
30
43
  ),
31
44
  dict(
@@ -40,18 +53,35 @@ INITIAL_DATA = [
40
53
  )
41
54
  ]
42
55
 
56
+ add_prefix = lambda i: SearchkitFormSet().add_prefix(i)
57
+ contenttype = ContentType.objects.get_for_model(ModelA)
43
58
  DEFAULT_PREFIX = SearchkitFormSet.get_default_prefix()
44
- FORM_DATA = {
45
- f'{DEFAULT_PREFIX}-TOTAL_FORMS': '6',
46
- f'{DEFAULT_PREFIX}-INITIAL_FORMS': '1'
47
- }
48
- for i, data in enumerate(INITIAL_DATA):
49
- for key, value in data.items():
50
- FORM_DATA.update({f'{DEFAULT_PREFIX}-{i}-{key}': value})
51
-
52
-
53
- class SearchkitFormTestCase(TestCase):
54
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
+ """
55
85
  def check_form(self, form):
56
86
  # Three fields should be generated on instantiation.
57
87
  self.assertIn('field', form.fields)
@@ -59,12 +89,19 @@ class SearchkitFormTestCase(TestCase):
59
89
  self.assertIn('value', form.fields)
60
90
  self.assertEqual(len(form.fields), 3)
61
91
 
62
- # Check choices of the model_field.
92
+ # Check field choices for the model.
63
93
  form_model_field = form.fields['field']
64
94
  self.assertTrue(form_model_field.choices)
65
- self.assertEqual(len(form_model_field.choices), len(ModelA._meta.fields))
66
- for model_field in ModelA._meta.fields:
67
- self.assertIn(model_field.name, [c[0] for c in 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)
68
105
 
69
106
  # Check the field_plan choosen based on the model_field.
70
107
  field_plan = next(iter([p for t, p in FIELD_PLAN.items() if t(form.model_field)]))
@@ -73,27 +110,26 @@ class SearchkitFormTestCase(TestCase):
73
110
  # Check choices of the operator field based on the field_plan.
74
111
  operator_field = form.fields['operator']
75
112
  self.assertTrue(operator_field.choices)
76
- self.assertEqual(len(form_model_field.choices), len(form.field_plan))
113
+ self.assertEqual(len(operator_field.choices), len(form.field_plan))
77
114
  for operator in form.field_plan.keys():
78
115
  self.assertIn(operator, [c[0] for c in operator_field.choices])
79
116
 
80
117
 
118
+ class SearchkitFormTestCase(CheckFormMixin, TestCase):
119
+
81
120
  def test_blank_searchkitform(self):
82
- for index in range(3):
83
- prefix = SearchkitFormSet(ModelA).add_prefix(index)
84
- form = SearchkitForm(ModelA, prefix=prefix)
85
- self.check_form(form)
121
+ form = SearchkitForm(prefix=add_prefix(0))
122
+ self.check_form(form)
86
123
 
87
- # Form should not be bound or valid.
88
- self.assertFalse(form.is_bound)
89
- self.assertFalse(form.is_valid())
124
+ # Form should not be bound or valid.
125
+ self.assertFalse(form.is_bound)
126
+ self.assertFalse(form.is_valid())
90
127
 
91
128
  def test_searchkitform_with_invalid_model_field_data(self):
92
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
93
129
  data = {
94
- f'{prefix}-field': 'foobar',
130
+ f'{add_prefix(0)}-field': 'foobar',
95
131
  }
96
- form = SearchkitForm(ModelA, data, prefix=prefix)
132
+ form = SearchkitForm(data, prefix=add_prefix(0))
97
133
  self.check_form(form)
98
134
 
99
135
  # Form should be invalid.
@@ -101,26 +137,24 @@ class SearchkitFormTestCase(TestCase):
101
137
 
102
138
  # Check error message in html.
103
139
  errors = ['Select a valid choice. foobar is not one of the available choices.']
104
- self.assertFormError(form, 'field', errors)
140
+ self.assertIn(errors, form.errors.values())
105
141
 
106
142
  def test_searchkitform_with_valid_model_field_data(self):
107
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
108
143
  data = {
109
- f'{prefix}-field': 'integer',
144
+ f'{add_prefix(0)}-field': 'integer',
110
145
  }
111
- form = SearchkitForm(ModelA, data, prefix=prefix)
146
+ form = SearchkitForm(data, prefix=add_prefix(0))
112
147
  self.check_form(form)
113
148
 
114
- # Form should be invalid.
149
+ # Form should be invalid since no value data is provieded.
115
150
  self.assertFalse(form.is_valid())
116
151
 
117
152
  def test_searchkitform_with_invalid_operator_data(self):
118
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
119
153
  data = {
120
- f'{prefix}-field': 'integer',
121
- f'{prefix}-operator': 'foobar',
154
+ f'{add_prefix(0)}-field': 'integer',
155
+ f'{add_prefix(0)}-operator': 'foobar',
122
156
  }
123
- form = SearchkitForm(ModelA, data, prefix=prefix)
157
+ form = SearchkitForm(data, prefix=add_prefix(0))
124
158
  self.check_form(form)
125
159
 
126
160
  # Form should be invalid.
@@ -128,45 +162,38 @@ class SearchkitFormTestCase(TestCase):
128
162
 
129
163
  # Check error message in html.
130
164
  errors = ['Select a valid choice. foobar is not one of the available choices.']
131
- self.assertFormError(form, 'operator', errors)
165
+ self.assertIn(errors, form.errors.values())
132
166
 
133
167
  def test_searchkitform_with_valid_operator_data(self):
134
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
135
168
  data = {
136
- f'{prefix}-field': 'integer',
137
- f'{prefix}-operator': 'exact',
169
+ f'{add_prefix(0)}-field': 'integer',
170
+ f'{add_prefix(0)}-operator': 'exact',
138
171
  }
139
- form = SearchkitForm(ModelA, data, prefix=prefix)
172
+ form = SearchkitForm(data, prefix=add_prefix(0))
140
173
  self.check_form(form)
141
174
 
142
- # Form should be invalid.
175
+ # Form should be invalid since no value data is provieded.
143
176
  self.assertFalse(form.is_valid())
144
177
 
145
178
  def test_searchkitform_with_valid_data(self):
146
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
147
179
  data = {
148
- f'{prefix}-field': 'integer',
149
- f'{prefix}-operator': 'exact',
150
- f'{prefix}-value': '123',
180
+ f'{add_prefix(0)}-field': 'integer',
181
+ f'{add_prefix(0)}-operator': 'exact',
182
+ f'{add_prefix(0)}-value': '123',
151
183
  }
152
- form = SearchkitForm(ModelA, data, prefix=prefix)
184
+ form = SearchkitForm(data, prefix=add_prefix(0))
153
185
  self.check_form(form)
154
186
 
155
- # Form should be valid, bound and complete
187
+ # Form should be valid.
156
188
  self.assertTrue(form.is_valid())
157
189
 
158
- # Get filter rule and check if a lookup does not raises any error.
159
- rule = form.get_filter_rule()
160
- self.assertFalse(ModelA.objects.filter(**dict((rule,))))
161
-
162
190
  def test_searchkitform_with_invalid_data(self):
163
- prefix = SearchkitFormSet(ModelA).add_prefix(0)
164
191
  data = {
165
- f'{prefix}-field': 'integer',
166
- f'{prefix}-operator': 'exact',
167
- f'{prefix}-value': 'foobar',
192
+ f'{add_prefix(0)}-field': 'integer',
193
+ f'{add_prefix(0)}-operator': 'exact',
194
+ f'{add_prefix(0)}-value': 'foobar',
168
195
  }
169
- form = SearchkitForm(ModelA, data, prefix=prefix)
196
+ form = SearchkitForm(data, prefix=add_prefix(0))
170
197
  self.check_form(form)
171
198
 
172
199
  # Form should be invalid.
@@ -174,24 +201,202 @@ class SearchkitFormTestCase(TestCase):
174
201
 
175
202
  # Check error message in html.
176
203
  errors = ['Enter a whole number.']
177
- self.assertFormError(form, 'value', errors)
178
-
179
- # get_filter_rule should raise an error.
180
- with self.assertRaises(forms.ValidationError):
181
- form.get_filter_rule()
204
+ self.assertIn(errors, form.errors.values())
182
205
 
183
206
 
184
- class SearchkitFormSetTestCase(TestCase):
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())
185
215
 
186
216
  def test_searchkit_formset_with_valid_data(self):
187
- formset = SearchkitFormSet(ModelA, FORM_DATA)
217
+ formset = SearchkitFormSet(FORM_DATA)
188
218
  self.assertTrue(formset.is_valid())
189
219
 
190
- # Just check if the filter rules are applicable. Result should be empty.
191
- self.assertFalse(ModelA.objects.filter(**formset.get_filter_rules()))
192
-
193
- def test_searchkit_formset_with_incomplete_data(self):
220
+ def test_searchkit_formset_with_invalid_data(self):
194
221
  data = FORM_DATA.copy()
195
- del data[f'{DEFAULT_PREFIX}-0-value']
196
- formset = SearchkitFormSet(ModelA, data)
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)
197
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
+ queryset = form.instance.as_queryset()
267
+ self.assertTrue(queryset.model == ModelA)
268
+
269
+
270
+ class SearchkitModelFormTestCase(TestCase):
271
+ def test_searchkit_model_form_choices(self):
272
+ form = SearchkitModelForm()
273
+ labels = [c[1] for c in form.fields['searchkit_model'].choices]
274
+ self.assertEqual(len(labels), 3)
275
+ self.assertEqual('select a model', labels[0].lower())
276
+ self.assertEqual('example | model a', labels[1].lower())
277
+ self.assertEqual('example | model b', labels[2].lower())
278
+
279
+
280
+ class AdminBackendTest(TestCase):
281
+ @classmethod
282
+ def setUpTestData(cls):
283
+ CreateTestData().handle()
284
+
285
+ def setUp(self):
286
+ admin = User.objects.get(username='admin')
287
+ self.client.force_login(admin)
288
+
289
+ def test_search_form(self):
290
+ url = reverse('admin:searchkit_search_add')
291
+ resp = self.client.get(url)
292
+ self.assertEqual(resp.status_code, 200)
293
+ select = b'<select name="searchkit_model" class="searchkit-reload-on-change" data-total-forms="1" required id="id_searchkit_model">'
294
+ for snippet in select.split(b' '):
295
+ self.assertIn(snippet, resp.content)
296
+
297
+ def test_search_form_with_initial(self):
298
+ url = reverse('admin:searchkit_search_add') + '?searchkit_model=1'
299
+ resp = self.client.get(url)
300
+ self.assertEqual(resp.status_code, 200)
301
+ select = '<select name="searchkit_model" class="searchkit-reload-on-change" data-total-forms="1" required id="id_searchkit_model">'
302
+ for snippet in select.split(' '):
303
+ self.assertIn(snippet, str(resp.content))
304
+ self.assertIn('<option value="1" selected>', str(resp.content))
305
+ self.assertIn('name="searchkit-example-modela-0-field"', str(resp.content))
306
+
307
+ def test_add_search(self):
308
+ # Create a search object via the admin backend.
309
+ url = reverse('admin:searchkit_search_add')
310
+ data = FORM_DATA.copy()
311
+ data['_save_and_apply'] = True
312
+ resp = self.client.post(url, data, follow=True)
313
+ self.assertEqual(resp.status_code, 200)
314
+ self.assertEqual(len(Search.objects.all()), 1)
315
+
316
+ # Change it via backend.
317
+ url = reverse('admin:searchkit_search_change', args=(1,))
318
+ data['name'] = 'Changed name'
319
+ data['searchkit-example-modela-0-field'] = 'boolean'
320
+ data['searchkit-example-modela-0-operator'] = 'exact'
321
+ data['searchkit-example-modela-0-value'] = 'true'
322
+ resp = self.client.post(url, data, follow=True)
323
+ self.assertEqual(resp.status_code, 200)
324
+ self.assertEqual(Search.objects.get(pk=1).name, data['name'])
325
+
326
+ # Will the search be listed in the admin filter?
327
+ url = reverse('admin:example_modela_changelist')
328
+ resp = self.client.get(url)
329
+ self.assertEqual(resp.status_code, 200)
330
+ self.assertIn('href="?search=1"', str(resp.content))
331
+ self.assertIn(data['name'], str(resp.content))
332
+
333
+
334
+ class SearchViewTest(TestCase):
335
+
336
+ def setUp(self):
337
+ self.initial = [
338
+ dict(
339
+ field='integer',
340
+ operator='exact',
341
+ value=1,
342
+ )
343
+ ]
344
+ self.initial_range = [
345
+ dict(
346
+ field='integer',
347
+ operator='range',
348
+ value=[1,3],
349
+ )
350
+ ]
351
+
352
+ def test_search_view_invalid_data(self):
353
+ initial = self.initial.copy()
354
+ initial[0]['value'] = 'no integer'
355
+ data = get_form_data(initial)
356
+ url_params = urlencode(data)
357
+ base_url = reverse('searchkit_form')
358
+ url = f'{base_url}?{url_params}'
359
+ resp = self.client.get(url)
360
+ self.assertEqual(resp.status_code, 200)
361
+ html_error = '<li>Enter a whole number.</li>'
362
+ self.assertInHTML(html_error, str(resp.content))
363
+
364
+ def test_search_view_missing_data(self):
365
+ initial = self.initial.copy()
366
+ del(initial[0]['value'])
367
+ data = get_form_data(initial)
368
+ url_params = urlencode(data)
369
+ base_url = reverse('searchkit_form')
370
+ url = f'{base_url}?{url_params}'
371
+ resp = self.client.get(url)
372
+ self.assertEqual(resp.status_code, 200)
373
+ html_error = '<li>This field is required.</li>'
374
+ self.assertInHTML(html_error, str(resp.content))
375
+
376
+ def test_search_view_with_range_operator(self):
377
+ data = get_form_data(self.initial_range)
378
+ url_params = urlencode(data)
379
+ base_url = reverse('searchkit_form')
380
+ url = f'{base_url}?{url_params}'
381
+ resp = self.client.get(url)
382
+ self.assertEqual(resp.status_code, 200)
383
+ html = '<input type="number" name="searchkit-example-modela-0-value_1" value="3" id="id_searchkit-example-modela-0-value_1">'
384
+ self.assertInHTML(html, str(resp.content))
385
+
386
+ def test_search_view_with_model(self):
387
+ data = get_form_data(self.initial)
388
+ data['searchkit_model'] = ContentType.objects.get_for_model(ModelA).pk
389
+ url_params = urlencode(data)
390
+ base_url = reverse('searchkit_form')
391
+ url = f'{base_url}?{url_params}'
392
+ resp = self.client.get(url)
393
+ self.assertEqual(resp.status_code, 200)
394
+
395
+ def test_search_view_with_invalid_model(self):
396
+ data = get_form_data(self.initial)
397
+ data['searchkit_model'] = 9999 # Non-existing content type.
398
+ url_params = urlencode(data)
399
+ base_url = reverse('searchkit_form')
400
+ url = f'{base_url}?{url_params}'
401
+ resp = self.client.get(url)
402
+ self.assertEqual(resp.status_code, 400)
searchkit/urls.py CHANGED
@@ -3,6 +3,5 @@ from .views import SearchkitAjaxView
3
3
 
4
4
 
5
5
  urlpatterns = [
6
- path("searchkit/form/", SearchkitAjaxView.as_view(), name="searchkit_form"),
7
- path("searchkit/form/<slug:app_label>/<slug:model_name>/", SearchkitAjaxView.as_view(), name="searchkit_form_model"),
6
+ path("searchkit/", SearchkitAjaxView.as_view(), name="searchkit_form"),
8
7
  ]