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.
- build/lib/build/lib/example/example/__init__.py +0 -0
- build/lib/build/lib/example/example/admin.py +16 -0
- build/lib/build/lib/example/example/asgi.py +16 -0
- build/lib/build/lib/example/example/management/__init__.py +0 -0
- build/lib/build/lib/example/example/management/commands/__init__.py +0 -0
- build/lib/build/lib/example/example/management/commands/createtestdata.py +62 -0
- build/lib/build/lib/example/example/migrations/0001_initial.py +48 -0
- build/lib/build/lib/example/example/migrations/__init__.py +0 -0
- build/lib/build/lib/example/example/models.py +38 -0
- build/lib/build/lib/example/example/settings.py +125 -0
- build/lib/build/lib/example/example/urls.py +23 -0
- build/lib/build/lib/example/example/wsgi.py +16 -0
- build/lib/build/lib/searchkit/__init__.py +0 -0
- build/lib/build/lib/searchkit/__version__.py +16 -0
- build/lib/build/lib/searchkit/admin.py +30 -0
- build/lib/build/lib/searchkit/apps.py +6 -0
- build/lib/build/lib/searchkit/filters.py +27 -0
- build/lib/build/lib/searchkit/forms/__init__.py +5 -0
- build/lib/build/lib/searchkit/forms/fields.py +55 -0
- build/lib/build/lib/searchkit/forms/search.py +62 -0
- build/lib/build/lib/searchkit/forms/searchkit.py +154 -0
- build/lib/build/lib/searchkit/forms/utils.py +178 -0
- build/lib/build/lib/searchkit/migrations/0001_initial.py +30 -0
- build/lib/build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
- build/lib/build/lib/searchkit/migrations/__init__.py +0 -0
- build/lib/build/lib/searchkit/models.py +27 -0
- build/lib/build/lib/searchkit/templatetags/__init__.py +0 -0
- build/lib/build/lib/searchkit/templatetags/searchkit.py +20 -0
- build/lib/build/lib/searchkit/tests.py +402 -0
- build/lib/build/lib/searchkit/urls.py +7 -0
- build/lib/build/lib/searchkit/views.py +23 -0
- 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 +27 -0
- build/lib/searchkit/forms/__init__.py +5 -0
- build/lib/searchkit/forms/fields.py +55 -0
- build/lib/searchkit/forms/search.py +62 -0
- build/lib/searchkit/forms/searchkit.py +154 -0
- build/lib/searchkit/forms/utils.py +178 -0
- build/lib/searchkit/migrations/0001_initial.py +30 -0
- build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
- build/lib/searchkit/migrations/__init__.py +0 -0
- build/lib/searchkit/models.py +27 -0
- build/lib/searchkit/templatetags/__init__.py +0 -0
- build/lib/searchkit/templatetags/searchkit.py +20 -0
- build/lib/searchkit/tests.py +402 -0
- build/lib/searchkit/urls.py +7 -0
- build/lib/searchkit/views.py +23 -0
- {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/METADATA +34 -13
- django_searchkit-1.1.dist-info/RECORD +99 -0
- {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/WHEEL +1 -1
- {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/top_level.txt +1 -0
- example/example/admin.py +1 -1
- searchkit/__version__.py +1 -1
- searchkit/admin.py +4 -4
- searchkit/filters.py +7 -11
- searchkit/forms/__init__.py +5 -2
- searchkit/forms/search.py +36 -19
- searchkit/forms/searchkit.py +61 -99
- searchkit/forms/utils.py +44 -15
- searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
- searchkit/models.py +8 -4
- searchkit/templatetags/searchkit.py +0 -27
- searchkit/tests.py +283 -78
- searchkit/urls.py +1 -2
- searchkit/views.py +11 -18
- django_searchkit-0.1.dist-info/RECORD +0 -36
- {django_searchkit-0.1.dist-info → django_searchkit-1.1.dist-info}/licenses/LICENCE +0 -0
- {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
|
5
|
-
from
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
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
|
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
|
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 .
|
5
|
-
from .
|
6
|
-
from .
|
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='
|
12
|
-
operator='
|
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
|
-
|
19
|
-
value_1=123,
|
32
|
+
value=[1, 123],
|
20
33
|
),
|
21
34
|
dict(
|
22
35
|
field='float',
|
23
|
-
operator='
|
36
|
+
operator='gt',
|
24
37
|
value='0.3',
|
25
38
|
),
|
26
39
|
dict(
|
27
40
|
field='decimal',
|
28
|
-
operator='
|
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
|
92
|
+
# Check field choices for the model.
|
63
93
|
form_model_field = form.fields['field']
|
64
94
|
self.assertTrue(form_model_field.choices)
|
65
|
-
|
66
|
-
|
67
|
-
|
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(
|
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
|
-
|
83
|
-
|
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
|
-
|
88
|
-
|
89
|
-
|
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'{
|
130
|
+
f'{add_prefix(0)}-field': 'foobar',
|
95
131
|
}
|
96
|
-
form = SearchkitForm(
|
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.
|
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'{
|
144
|
+
f'{add_prefix(0)}-field': 'integer',
|
110
145
|
}
|
111
|
-
form = SearchkitForm(
|
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'{
|
121
|
-
f'{
|
154
|
+
f'{add_prefix(0)}-field': 'integer',
|
155
|
+
f'{add_prefix(0)}-operator': 'foobar',
|
122
156
|
}
|
123
|
-
form = SearchkitForm(
|
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.
|
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'{
|
137
|
-
f'{
|
169
|
+
f'{add_prefix(0)}-field': 'integer',
|
170
|
+
f'{add_prefix(0)}-operator': 'exact',
|
138
171
|
}
|
139
|
-
form = SearchkitForm(
|
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'{
|
149
|
-
f'{
|
150
|
-
f'{
|
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(
|
184
|
+
form = SearchkitForm(data, prefix=add_prefix(0))
|
153
185
|
self.check_form(form)
|
154
186
|
|
155
|
-
# Form should be valid
|
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'{
|
166
|
-
f'{
|
167
|
-
f'{
|
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(
|
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.
|
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(
|
217
|
+
formset = SearchkitFormSet(FORM_DATA)
|
188
218
|
self.assertTrue(formset.is_valid())
|
189
219
|
|
190
|
-
|
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'{
|
196
|
-
formset = SearchkitFormSet(
|
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/
|
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
|
]
|