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.
Files changed (65) 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/searchkit/__version__.py +1 -1
  33. build/lib/searchkit/admin.py +4 -4
  34. build/lib/searchkit/filters.py +7 -11
  35. build/lib/searchkit/forms/__init__.py +5 -3
  36. build/lib/searchkit/forms/search.py +37 -17
  37. build/lib/searchkit/forms/searchkit.py +60 -95
  38. build/lib/searchkit/forms/utils.py +44 -15
  39. build/lib/searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  40. build/lib/searchkit/models.py +8 -4
  41. build/lib/searchkit/templatetags/searchkit.py +0 -27
  42. build/lib/searchkit/tests.py +201 -49
  43. build/lib/searchkit/urls.py +1 -2
  44. build/lib/searchkit/views.py +11 -18
  45. {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/METADATA +9 -24
  46. django_searchkit-1.1.dist-info/RECORD +99 -0
  47. example/example/admin.py +1 -1
  48. searchkit/__version__.py +1 -1
  49. searchkit/admin.py +4 -4
  50. searchkit/filters.py +7 -11
  51. searchkit/forms/__init__.py +5 -3
  52. searchkit/forms/search.py +37 -17
  53. searchkit/forms/searchkit.py +60 -95
  54. searchkit/forms/utils.py +44 -15
  55. searchkit/migrations/0002_rename_searchkitsearch_search.py +18 -0
  56. searchkit/models.py +8 -4
  57. searchkit/templatetags/searchkit.py +0 -27
  58. searchkit/tests.py +201 -49
  59. searchkit/urls.py +1 -2
  60. searchkit/views.py +11 -18
  61. django_searchkit-1.0.dist-info/RECORD +0 -66
  62. {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/WHEEL +0 -0
  63. {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/licenses/LICENCE +0 -0
  64. {django_searchkit-1.0.dist-info → django_searchkit-1.1.dist-info}/top_level.txt +0 -0
  65. {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 django.contrib.contenttypes.models import ContentType
6
- from .utils import CSS_CLASSES, FIELD_PLAN, OPERATOR_DESCRIPTION
7
- from .utils import SUPPORTED_FIELDS, SUPPORTED_RELATIONS
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
- # FIXME: Make this a setting
11
- RELATION_DEPTH = 3
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 SearchkitForm(CSS_CLASSES, forms.Form):
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
- def __init__(self, model, *args, **kwargs):
39
+ model = None # Set by the formset factory.
40
+
41
+ def __init__(self, *args, **kwargs):
28
42
  super().__init__(*args, **kwargs)
29
- self.model = model
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
- 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=[]):
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
- 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])
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(self.model)
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": CSS_CLASSES.reload_on_change_css_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": CSS_CLASSES.reload_on_change_css_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 ContentTypeForm(CSS_CLASSES, forms.Form):
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
- default_prefix = 'searchkit'
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
- if self.model:
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(cls):
171
- return cls.default_prefix
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
- SearchkitFormSet = forms.formset_factory(
187
- form=SearchkitForm,
188
- formset=BaseSearchkitFormset,
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 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
+ ]
@@ -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
  """