django-searchkit 1.0__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/searchkit/__version__.py +1 -1
- build/lib/searchkit/admin.py +4 -4
- build/lib/searchkit/filters.py +7 -11
- build/lib/searchkit/forms/__init__.py +5 -3
- build/lib/searchkit/forms/search.py +37 -17
- build/lib/searchkit/forms/searchkit.py +60 -95
- build/lib/searchkit/forms/utils.py +44 -15
- build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
- build/lib/searchkit/models.py +8 -4
- build/lib/searchkit/templatetags/searchkit.py +0 -27
- build/lib/searchkit/tests.py +201 -49
- build/lib/searchkit/urls.py +1 -2
- build/lib/searchkit/views.py +11 -18
- {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/METADATA +9 -24
- django_searchkit-1.1.dist-info/RECORD +99 -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 -3
- searchkit/forms/search.py +37 -17
- searchkit/forms/searchkit.py +60 -95
- 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 +201 -49
- searchkit/urls.py +1 -2
- searchkit/views.py +11 -18
- django_searchkit-1.0.dist-info/RECORD +0 -66
- {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/WHEEL +0 -0
- {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/licenses/LICENCE +0 -0
- {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/top_level.txt +0 -0
- {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/zip-safe +0 -0
@@ -1,17 +1,29 @@
|
|
1
|
-
from collections import OrderedDict
|
2
1
|
from django import forms
|
3
2
|
from django.utils.translation import gettext_lazy as _
|
4
3
|
from django.utils.functional import cached_property
|
5
|
-
from
|
6
|
-
from .utils import
|
7
|
-
from .utils import
|
4
|
+
from .utils import CssClassMixin, FIELD_PLAN, OPERATOR_DESCRIPTION
|
5
|
+
from .utils import SUPPORTED_FIELDS
|
6
|
+
from .utils import ModelTree
|
7
|
+
from .utils import MediaMixin
|
8
|
+
from .utils import get_searchable_models
|
8
9
|
|
9
10
|
|
10
|
-
|
11
|
-
|
11
|
+
class SearchkitModelForm(forms.Form):
|
12
|
+
"""
|
13
|
+
Form to select a content type.
|
14
|
+
"""
|
15
|
+
searchkit_model = forms.ModelChoiceField(
|
16
|
+
queryset=get_searchable_models(),
|
17
|
+
label=_('Model'),
|
18
|
+
empty_label=_('Select a Model'),
|
19
|
+
widget=forms.Select(attrs={
|
20
|
+
"class": CssClassMixin.reload_on_change_css_class,
|
21
|
+
"data-total-forms": 1,
|
22
|
+
}),
|
23
|
+
)
|
12
24
|
|
13
25
|
|
14
|
-
class
|
26
|
+
class BaseSearchkitForm(MediaMixin, CssClassMixin, forms.Form):
|
15
27
|
"""
|
16
28
|
Searchkit form representing a model field lookup based on the field name,
|
17
29
|
the operator and one or two values.
|
@@ -24,9 +36,11 @@ class SearchkitForm(CSS_CLASSES, forms.Form):
|
|
24
36
|
|
25
37
|
See the FIELD_PLAN variable for the logic of building the form.
|
26
38
|
"""
|
27
|
-
|
39
|
+
model = None # Set by the formset factory.
|
40
|
+
|
41
|
+
def __init__(self, *args, **kwargs):
|
28
42
|
super().__init__(*args, **kwargs)
|
29
|
-
self.
|
43
|
+
self.model_tree = ModelTree(self.model)
|
30
44
|
self.model_field = None
|
31
45
|
self.field_plan = None
|
32
46
|
self.operator = None
|
@@ -64,40 +78,44 @@ class SearchkitForm(CSS_CLASSES, forms.Form):
|
|
64
78
|
|
65
79
|
def _get_model_field(self, lookup):
|
66
80
|
path = lookup.split('__')
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
return
|
73
|
-
|
74
|
-
def _get_model_field_choices(self
|
81
|
+
field_name = path[-1]
|
82
|
+
if path[:-1]:
|
83
|
+
model = self.model_tree.get('__'.join(path[:-1])).model
|
84
|
+
else:
|
85
|
+
model = self.model
|
86
|
+
return model._meta.get_field(field_name)
|
87
|
+
|
88
|
+
def _get_model_field_choices(self):
|
75
89
|
choices = list()
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
90
|
+
label_path = list()
|
91
|
+
for node in self.model_tree.iterate():
|
92
|
+
label_path.append
|
93
|
+
for model_field in node.model._meta.fields:
|
94
|
+
if not any(isinstance(model_field, f) for f in SUPPORTED_FIELDS):
|
95
|
+
continue
|
96
|
+
if node.is_root:
|
97
|
+
lookup = model_field.name
|
98
|
+
label = f'`{model_field.verbose_name}`'
|
99
|
+
else:
|
100
|
+
lookup = f'{node.field_path}__{model_field.name}'
|
101
|
+
get_field_name = lambda f: getattr(f, 'verbose_name', f.name)
|
102
|
+
label_path = [f'`{get_field_name(n.field)}` => <{n.model._meta.verbose_name}>' for n in node.path[1:]]
|
103
|
+
label = ".".join(label_path + [f'`{model_field.verbose_name}`'])
|
80
104
|
choices.append((lookup, label))
|
81
|
-
if len(fields) < RELATION_DEPTH:
|
82
|
-
for model_field in model._meta.fields:
|
83
|
-
if any(isinstance(model_field, f) for f in SUPPORTED_RELATIONS):
|
84
|
-
related_model = model_field.remote_field.model
|
85
|
-
fields = [*fields, model_field]
|
86
|
-
choices += self._get_model_field_choices(related_model, fields)
|
87
105
|
return choices
|
88
106
|
|
89
107
|
def _add_field_name_field(self):
|
90
108
|
initial = self.initial.get('field')
|
91
|
-
choices = self._get_model_field_choices(
|
109
|
+
choices = self._get_model_field_choices()
|
92
110
|
field = forms.ChoiceField(label=_('Model field'), choices=choices, initial=initial)
|
93
|
-
field.widget.attrs.update({"class":
|
111
|
+
field.widget.attrs.update({"class": self.reload_on_change_css_class})
|
94
112
|
self.fields['field'] = field
|
95
113
|
|
96
114
|
def _add_operator_field(self):
|
97
115
|
initial = self.initial.get('operator')
|
98
116
|
choices = [(o, OPERATOR_DESCRIPTION[o]) for o in self.field_plan.keys()]
|
99
117
|
field = forms.ChoiceField(label=_('Operator'), choices=choices, initial=initial)
|
100
|
-
field.widget.attrs.update({"class":
|
118
|
+
field.widget.attrs.update({"class": self.reload_on_change_css_class})
|
101
119
|
self.fields['operator'] = field
|
102
120
|
|
103
121
|
def _add_value_field(self):
|
@@ -110,80 +128,27 @@ class SearchkitForm(CSS_CLASSES, forms.Form):
|
|
110
128
|
self.fields['value'] = field
|
111
129
|
|
112
130
|
|
113
|
-
class
|
114
|
-
"""
|
115
|
-
Form to select a content type.
|
116
|
-
"""
|
117
|
-
contenttype = forms.ModelChoiceField(
|
118
|
-
queryset=ContentType.objects.all(), # FIXME: Limit choices to models that can be filtered.
|
119
|
-
label=_('Model'),
|
120
|
-
empty_label=_('Select a Model'),
|
121
|
-
widget=forms.Select(attrs={"class": CSS_CLASSES.reload_on_change_css_class}),
|
122
|
-
)
|
123
|
-
|
124
|
-
class Media:
|
125
|
-
js = [
|
126
|
-
'admin/js/vendor/jquery/jquery.min.js',
|
127
|
-
'admin/js/jquery.init.js',
|
128
|
-
"searchkit/searchkit.js"
|
129
|
-
]
|
130
|
-
|
131
|
-
|
132
|
-
class BaseSearchkitFormset(CSS_CLASSES, forms.BaseFormSet):
|
131
|
+
class BaseSearchkitFormSet(CssClassMixin, forms.BaseFormSet):
|
133
132
|
"""
|
134
133
|
Formset holding all searchkit forms.
|
135
134
|
"""
|
136
135
|
template_name = "searchkit/searchkit.html"
|
137
136
|
template_name_div = "searchkit/searchkit.html"
|
138
|
-
|
139
|
-
form = SearchkitForm
|
140
|
-
contenttype_form_class = ContentTypeForm
|
141
|
-
|
142
|
-
def __init__(self, *args, **kwargs):
|
143
|
-
self.model = kwargs.pop('model', None)
|
144
|
-
super().__init__(*args, **kwargs)
|
145
|
-
self.contenttype_form = self.get_conttenttype_form(kwargs)
|
146
|
-
if not self.model and self.contenttype_form.is_valid():
|
147
|
-
self.model = self.contenttype_form.cleaned_data.get('contenttype').model_class()
|
148
|
-
if self.initial:
|
149
|
-
self.extra = 0
|
150
|
-
|
151
|
-
def get_conttenttype_form(self, kwargs):
|
152
|
-
ct_kwargs = dict()
|
153
|
-
ct_kwargs['data'] = self.data or None
|
154
|
-
ct_kwargs['prefix'] = self.prefix
|
155
|
-
if self.model:
|
156
|
-
contenttype = ContentType.objects.get_for_model(self.model)
|
157
|
-
ct_kwargs['initial'] = dict(contenttype=contenttype)
|
158
|
-
return self.contenttype_form_class(**ct_kwargs)
|
159
|
-
|
160
|
-
def get_form_kwargs(self, index):
|
161
|
-
kwargs = self.form_kwargs.copy()
|
162
|
-
kwargs['model'] = self.model
|
163
|
-
return kwargs
|
137
|
+
model = None # Set by the formset factory.
|
164
138
|
|
165
139
|
def add_prefix(self, index):
|
166
|
-
|
167
|
-
return "%s-%s-%s" % (self.prefix, self.model._meta.model_name, index)
|
140
|
+
return "%s-%s-%s-%s" % (self.prefix, self.model._meta.app_label, self.model._meta.model_name, index)
|
168
141
|
|
169
142
|
@classmethod
|
170
|
-
def get_default_prefix(
|
171
|
-
return
|
172
|
-
|
173
|
-
@cached_property
|
174
|
-
def forms(self):
|
175
|
-
# We won't render any forms if we got no model.
|
176
|
-
return super().forms if self.model else []
|
177
|
-
|
178
|
-
@property
|
179
|
-
def media(self):
|
180
|
-
return self.contenttype_form.media
|
181
|
-
|
182
|
-
def is_valid(self):
|
183
|
-
return self.contenttype_form.is_valid() and self.forms and super().is_valid()
|
143
|
+
def get_default_prefix(self):
|
144
|
+
return "searchkit"
|
184
145
|
|
185
146
|
|
186
|
-
|
187
|
-
|
188
|
-
|
147
|
+
def searchkit_formset_factory(model, **kwargs):
|
148
|
+
form = type('SearchkitForm', (BaseSearchkitForm,), dict(model=model))
|
149
|
+
formset = type('SearchkitFormSet', (BaseSearchkitFormSet,), dict(model=model))
|
150
|
+
return forms.formset_factory(
|
151
|
+
form=form,
|
152
|
+
formset=formset,
|
153
|
+
**kwargs
|
189
154
|
)
|
@@ -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
|
+
]
|
build/lib/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
|
"""
|