django-searchkit 0.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.
@@ -0,0 +1,55 @@
1
+ from django import forms
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class RangeWidget(forms.MultiWidget):
6
+ def decompress(self, value):
7
+ """The value should be already a list."""
8
+ if value:
9
+ return value
10
+ else:
11
+ return [None, None]
12
+
13
+
14
+ class BaseRangeField(forms.MultiValueField):
15
+ incomplete_message = None
16
+ field_type = None
17
+ widget_type = None
18
+ field_kwargs = dict()
19
+
20
+ def __init__(self, **kwargs):
21
+ error_messages = dict(incomplete=self.incomplete_message)
22
+ widget = RangeWidget(widgets=[self.widget_type, self.widget_type])
23
+ fields = (
24
+ self.field_type(label=_('From'), **self.field_kwargs),
25
+ self.field_type(label=_('To'), **self.field_kwargs),
26
+ )
27
+ super().__init__(
28
+ fields=fields,
29
+ widget=widget,
30
+ error_messages=error_messages,
31
+ require_all_fields=True,
32
+ **kwargs
33
+ )
34
+
35
+ def compress(self, data_list):
36
+ """We want the data list as data list."""
37
+ return data_list
38
+
39
+
40
+ class IntegerRangeField(BaseRangeField):
41
+ incomplete_message = _("Enter the first and the last number.")
42
+ field_type = forms.IntegerField
43
+ widget_type = forms.NumberInput
44
+
45
+
46
+ class DateRangeField(BaseRangeField):
47
+ incomplete_message = _("Enter the first and the last date.")
48
+ field_type = forms.DateField
49
+ widget_type = forms.DateInput
50
+
51
+
52
+ class DateTimeRangeField(BaseRangeField):
53
+ incomplete_message = _("Enter the first and the last datetime.")
54
+ field_type = forms.DateTimeField
55
+ widget_type = forms.DateTimeInput
@@ -0,0 +1,45 @@
1
+ from django import forms
2
+ from django.utils.functional import cached_property
3
+ from ..models import SearchkitSearch
4
+ from .searchkit import SearchkitFormSet
5
+
6
+
7
+ class SearchkitSearchForm(forms.ModelForm):
8
+ """
9
+ Represents a SearchkitSearch model. Using a SearchkitFormSet for the data
10
+ json field.
11
+ """
12
+ class Meta:
13
+ model = SearchkitSearch
14
+ fields = ['name']
15
+
16
+ def __init__(self, *args, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+
19
+ @property
20
+ def media(self):
21
+ # TODO: Check if child classes inherit those media files.
22
+ return self.formset.media
23
+
24
+ @cached_property
25
+ def formset(self):
26
+ """
27
+ A searchkit formset for the model.
28
+ """
29
+ kwargs = dict()
30
+ kwargs['data'] = self.data or None
31
+ kwargs['prefix'] = self.prefix
32
+ if self.instance.pk:
33
+ kwargs['model'] = self.instance.contenttype.model_class()
34
+ kwargs['initial'] = self.instance.data
35
+ return SearchkitFormSet(**kwargs)
36
+
37
+ def is_valid(self):
38
+ return self.formset.is_valid() and super().is_valid()
39
+
40
+ def clean(self):
41
+ if self.formset.contenttype_form.is_valid():
42
+ self.instance.contenttype = self.formset.contenttype_form.cleaned_data['contenttype']
43
+ if self.formset.is_valid():
44
+ self.instance.data = self.formset.cleaned_data
45
+ return super().clean()
@@ -0,0 +1,192 @@
1
+ from collections import OrderedDict
2
+ from django import forms
3
+ from django.utils.translation import gettext_lazy as _
4
+ from django.utils.functional import cached_property
5
+ from django.contrib.contenttypes.models import ContentType
6
+ from .utils import CSS_CLASSES, FIELD_PLAN, OPERATOR_DESCRIPTION
7
+ from .utils import SUPPORTED_FIELDS, SUPPORTED_RELATIONS
8
+
9
+
10
+ # FIXME: Make this a setting
11
+ RELATION_DEPTH = 3
12
+
13
+
14
+ class SearchkitForm(CSS_CLASSES, forms.Form):
15
+ """
16
+ Searchkit form representing a model field lookup based on the field name,
17
+ the operator and one or two values.
18
+
19
+ The unbound form is composed of an index field (the count of the searchkit
20
+ form) and a choice field offering the names of the model fields.
21
+
22
+ The bound form is dynamically extended by the operator field or the operator and
23
+ the value field depending on the provided data
24
+
25
+ See the FIELD_PLAN variable for the logic of building the form.
26
+ """
27
+ def __init__(self, model, *args, **kwargs):
28
+ super().__init__(*args, **kwargs)
29
+ self.model = model
30
+ self.model_field = None
31
+ self.field_plan = None
32
+ self.operator = None
33
+ self._add_field_name_field()
34
+ lookup = self._preload_clean_data('field')
35
+ self.model_field = self._get_model_field(lookup)
36
+ self.field_plan = next(iter([p for t, p in FIELD_PLAN.items() if t(self.model_field)]))
37
+ self._add_operator_field()
38
+ self.operator = self._preload_clean_data('operator')
39
+ self._add_value_field()
40
+
41
+ @cached_property
42
+ def unprefixed_data(self):
43
+ data = dict()
44
+ for key, value in self.data.items():
45
+ if key.startswith(self.prefix):
46
+ data[key[len(self.prefix) + 1:]] = value
47
+ return data
48
+
49
+ def _preload_clean_data(self, field_name):
50
+ # Try the initial value first since it is already cleaned.
51
+ if self.initial and field_name in self.initial:
52
+ return self.initial[field_name]
53
+ # Otherwise look up the data dict.
54
+ elif field_name in self.unprefixed_data:
55
+ try:
56
+ # Do we have a valid value?
57
+ return self.fields[field_name].clean(self.unprefixed_data[field_name])
58
+ except forms.ValidationError:
59
+ pass
60
+ else:
61
+ # At last simply return the first option which will be the selected
62
+ # one.
63
+ return self.fields[field_name].choices[0][0]
64
+
65
+ def _get_model_field(self, lookup):
66
+ path = lookup.split('__')
67
+ model = self.model
68
+ for index, field_name in enumerate(path, 1):
69
+ field = model._meta.get_field(field_name)
70
+ if index < len(path):
71
+ model = field.remote_field.model
72
+ return field
73
+
74
+ def _get_model_field_choices(self, model, fields=[]):
75
+ choices = list()
76
+ for model_field in model._meta.fields:
77
+ if any(isinstance(model_field, f) for f in SUPPORTED_FIELDS):
78
+ lookup = '__'.join([f.name for f in [*fields, model_field]])
79
+ label = ' -> '.join(f.verbose_name for f in [*fields, model_field])
80
+ 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
+ return choices
88
+
89
+ def _add_field_name_field(self):
90
+ initial = self.initial.get('field')
91
+ choices = self._get_model_field_choices(self.model)
92
+ field = forms.ChoiceField(label=_('Model field'), choices=choices, initial=initial)
93
+ field.widget.attrs.update({"class": CSS_CLASSES.reload_on_change_css_class})
94
+ self.fields['field'] = field
95
+
96
+ def _add_operator_field(self):
97
+ initial = self.initial.get('operator')
98
+ choices = [(o, OPERATOR_DESCRIPTION[o]) for o in self.field_plan.keys()]
99
+ field = forms.ChoiceField(label=_('Operator'), choices=choices, initial=initial)
100
+ field.widget.attrs.update({"class": CSS_CLASSES.reload_on_change_css_class})
101
+ self.fields['operator'] = field
102
+
103
+ def _add_value_field(self):
104
+ initial = self.initial.get('value')
105
+ field_class = self.field_plan[self.operator][0]
106
+ if getattr(field_class, 'choices', None) and getattr(self.model_field, 'choices', None):
107
+ field = field_class(choices=self.model_field.choices, initial=initial)
108
+ else:
109
+ field = field_class()
110
+ self.fields['value'] = field
111
+
112
+
113
+ class ContentTypeForm(CSS_CLASSES, forms.Form):
114
+ """
115
+ Form to select a content type.
116
+ """
117
+ contenttype = forms.ModelChoiceField(
118
+ queryset=ContentType.objects.all(),
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):
133
+ """
134
+ Formset holding all searchkit forms.
135
+ """
136
+ template_name = "searchkit/searchkit.html"
137
+ template_name_div = "searchkit/searchkit.html"
138
+ default_prefix = 'searchkit'
139
+ form = SearchkitForm
140
+ contenttype_form_class = ContentTypeForm
141
+
142
+ def __init__(self, *args, **kwargs):
143
+ self.contenttype_form = self.get_conttenttype_form(kwargs)
144
+ self.model = self.get_model(kwargs)
145
+ super().__init__(*args, **kwargs)
146
+ if self.initial:
147
+ self.extra = 0
148
+
149
+ def get_conttenttype_form(self, kwargs):
150
+ ct_kwargs = dict()
151
+ ct_kwargs['data'] = kwargs.get('data')
152
+ ct_kwargs['prefix'] = kwargs.get('prefix')
153
+ if model := kwargs.pop('model', None):
154
+ ct_kwargs['initial'] = dict(contenttype=ContentType.objects.get_for_model(model))
155
+ return self.contenttype_form_class(**ct_kwargs)
156
+
157
+ def get_model(self, kwargs):
158
+ if self.contenttype_form.initial:
159
+ return self.contenttype_form.initial['contenttype'].model_class()
160
+ elif self.contenttype_form.is_valid():
161
+ return self.contenttype_form.cleaned_data['contenttype'].model_class()
162
+
163
+ def get_form_kwargs(self, index):
164
+ kwargs = self.form_kwargs.copy()
165
+ kwargs['model'] = self.model
166
+ return kwargs
167
+
168
+ def add_prefix(self, index):
169
+ if self.model:
170
+ return "%s-%s-%s" % (self.prefix, self.model._meta.model_name, index)
171
+
172
+ @classmethod
173
+ def get_default_prefix(cls):
174
+ return cls.default_prefix
175
+
176
+ @cached_property
177
+ def forms(self):
178
+ # We won't render any forms if we got no model.
179
+ return super().forms if self.model else []
180
+
181
+ @property
182
+ def media(self):
183
+ return self.contenttype_form.media
184
+
185
+ def is_valid(self):
186
+ return self.contenttype_form.is_valid() and self.forms and super().is_valid()
187
+
188
+
189
+ SearchkitFormSet = forms.formset_factory(
190
+ form=SearchkitForm,
191
+ formset=BaseSearchkitFormset,
192
+ )
@@ -0,0 +1,149 @@
1
+ from django.db import models
2
+ from django import forms
3
+ from django.utils.translation import gettext_lazy as _
4
+ from collections import OrderedDict
5
+ from searchkit.forms import fields as searchkit_fields
6
+
7
+
8
+ OPERATOR_DESCRIPTION = {
9
+ 'exact': _('is exact'),
10
+ 'contains': _('contains'),
11
+ 'startswith': _('starts with'),
12
+ 'endswith': _('ends with'),
13
+ 'regex': _('matches regular expression'),
14
+ 'gt': _('is greater than'),
15
+ 'gte': _('is greater than or equal'),
16
+ 'lt': _('is lower than'),
17
+ 'lte': _('is lower than or equal'),
18
+ 'range': _('is between'),
19
+ 'in': _('is one of'),
20
+ }
21
+
22
+
23
+ SUPPORTED_FIELDS = [
24
+ models.BooleanField,
25
+ models.CharField,
26
+ models.IntegerField,
27
+ models.FloatField,
28
+ models.DecimalField,
29
+ models.DateField,
30
+ models.DateTimeField,
31
+ ]
32
+ SUPPORTED_RELATIONS = [
33
+ models.ForeignKey,
34
+ models.OneToOneField,
35
+ ]
36
+
37
+
38
+ FIELD_PLAN = OrderedDict((
39
+ (
40
+ lambda f: isinstance(f, models.BooleanField),
41
+ {
42
+ 'exact': (forms.NullBooleanField,),
43
+ }
44
+ ),
45
+ (
46
+ lambda f: isinstance(f, models.CharField) and f.choices,
47
+ {
48
+ 'exact': (forms.ChoiceField,),
49
+ 'contains': (forms.CharField,),
50
+ 'startswith': (forms.CharField,),
51
+ 'endswith': (forms.CharField,),
52
+ 'regex': (forms.CharField,),
53
+ 'in': (forms.MultipleChoiceField,),
54
+ }
55
+ ),
56
+ (
57
+ lambda f: isinstance(f, models.CharField),
58
+ {
59
+ 'exact': (forms.CharField,),
60
+ 'contains': (forms.CharField,),
61
+ 'startswith': (forms.CharField,),
62
+ 'endswith': (forms.CharField,),
63
+ 'regex': (forms.CharField,),
64
+ }
65
+ ),
66
+ (
67
+ lambda f: isinstance(f, models.IntegerField) and f.choices,
68
+ {
69
+ 'exact': (forms.ChoiceField,),
70
+ 'contains': (forms.IntegerField,),
71
+ 'startswith': (forms.IntegerField,),
72
+ 'endswith': (forms.IntegerField,),
73
+ 'regex': (forms.IntegerField,),
74
+ 'in': (forms.MultipleChoiceField,),
75
+ }
76
+ ),
77
+ (
78
+ lambda f: isinstance(f, models.IntegerField),
79
+ {
80
+ 'exact': (forms.IntegerField,),
81
+ 'gt': (forms.IntegerField,),
82
+ 'gte': (forms.IntegerField,),
83
+ 'lt': (forms.IntegerField,),
84
+ 'lte': (forms.IntegerField,),
85
+ 'range': (searchkit_fields.IntegerRangeField,),
86
+ }
87
+ ),
88
+ (
89
+ lambda f: isinstance(f, models.FloatField),
90
+ {
91
+ 'exact': (forms.FloatField,),
92
+ 'gt': (forms.FloatField,),
93
+ 'gte': (forms.FloatField,),
94
+ 'lt': (forms.FloatField,),
95
+ 'lte': (forms.FloatField,),
96
+ 'range': (searchkit_fields.IntegerRangeField,),
97
+ }
98
+ ),
99
+ (
100
+ lambda f: isinstance(f, models.DecimalField),
101
+ {
102
+ 'exact': (forms.DecimalField,),
103
+ 'gt': (forms.DecimalField,),
104
+ 'gte': (forms.DecimalField,),
105
+ 'lt': (forms.DecimalField,),
106
+ 'lte': (forms.DecimalField,),
107
+ 'range': (searchkit_fields.IntegerRangeField,),
108
+ }
109
+ ),
110
+ (
111
+ lambda f: isinstance(f, models.DateTimeField),
112
+ {
113
+ 'exact': (forms.DateTimeField,),
114
+ 'gt': (forms.DateTimeField,),
115
+ 'gte': (forms.DateTimeField,),
116
+ 'lt': (forms.DateTimeField,),
117
+ 'lte': (forms.DateTimeField,),
118
+ 'range': (searchkit_fields.DateTimeRangeField,),
119
+ }
120
+ ),
121
+ (
122
+ lambda f: isinstance(f, models.DateField),
123
+ {
124
+ 'exact': (forms.DateField,),
125
+ 'gt': (forms.DateField,),
126
+ 'gte': (forms.DateField,),
127
+ 'lt': (forms.DateField,),
128
+ 'lte': (forms.DateField,),
129
+ 'range': (searchkit_fields.DateRangeField,),
130
+ }
131
+ ),
132
+ ))
133
+
134
+
135
+ class CSS_CLASSES:
136
+ reload_on_change_css_class = "searchkit-reload-on-change"
137
+ reload_on_click_css_class = "searchkit-reload-on-click"
138
+
139
+
140
+ def get_filter_rules(formset):
141
+ """
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
145
+ """
146
+ lookups = OrderedDict()
147
+ for data in formset.cleaned_data:
148
+ lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
149
+ return lookups
@@ -0,0 +1,30 @@
1
+ # Generated by Django 5.1.3 on 2025-05-22 19:04
2
+
3
+ import django.db.models.deletion
4
+ import picklefield.fields
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('contenttypes', '0002_remove_content_type_name'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='SearchkitSearch',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('name', models.CharField(max_length=255, verbose_name='Search name')),
22
+ ('data', picklefield.fields.PickledObjectField(editable=False, verbose_name='Serialized filter rule data')),
23
+ ('created_date', models.DateTimeField(auto_now_add=True)),
24
+ ('contenttype', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Model')),
25
+ ],
26
+ options={
27
+ 'unique_together': {('name', 'contenttype')},
28
+ },
29
+ ),
30
+ ]
File without changes
searchkit/models.py ADDED
@@ -0,0 +1,23 @@
1
+ from collections import OrderedDict
2
+ from picklefield.fields import PickledObjectField
3
+ from django.db import models
4
+ from django.contrib.contenttypes.models import ContentType
5
+ from django.utils.translation import gettext_lazy as _
6
+
7
+
8
+ # Create your models here.
9
+ # TODO: Use pickled cleaned data with a char field.
10
+ class SearchkitSearch(models.Model):
11
+ name = models.CharField(_('Search name'), max_length=255)
12
+ contenttype = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_('Model'))
13
+ data = PickledObjectField(_('Serialized filter rule data'))
14
+ created_date = models.DateTimeField(auto_now_add=True)
15
+
16
+ class Meta:
17
+ unique_together = ('name', 'contenttype')
18
+
19
+ def get_filter_rules(self):
20
+ lookups = OrderedDict()
21
+ for data in self.data:
22
+ lookups[f'{data["field"]}__{data["operator"]}'] = data['value']
23
+ return lookups
File without changes
@@ -0,0 +1,47 @@
1
+ from django.template import Library
2
+ from django.urls import reverse
3
+ from django.contrib.admin.helpers import Fieldset
4
+ from ..forms.utils import CSS_CLASSES
5
+
6
+
7
+ register = Library()
8
+
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
+ @register.inclusion_tag("admin/includes/fieldset.html")
35
+ def as_fieldset(form, heading_level=2, prefix='', id_prefix=0, id_suffix='', **fieldset_kwargs):
36
+ """
37
+ Create and render a fieldset for form.
38
+ """
39
+ fieldset = Fieldset(form, fields=form.fields, **fieldset_kwargs)
40
+ context = dict(
41
+ fieldset=fieldset,
42
+ heading_level=heading_level,
43
+ prefix=prefix,
44
+ id_prefix=id_prefix,
45
+ id_suffix=id_suffix,
46
+ )
47
+ return context