├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── LICENSE
├── MANIFEST.in
├── README.md
├── more_admin_filters
├── __init__.py
├── __version__.py
├── apps.py
├── filters.py
└── templates
│ └── more_admin_filters
│ ├── dropdownfilter.html
│ └── multiselectdropdownfilter.html
├── setup.py
├── tests
├── manage.py
└── testapp
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── createtestdata.py
│ ├── migrations
│ ├── 0001_initial.py
│ ├── 0002_modela_multiselect_utf8.py
│ └── __init__.py
│ ├── models.py
│ ├── settings.py
│ ├── tests
│ ├── __init__.py
│ ├── test_filters.py
│ └── test_live_filters.py
│ ├── urls.py
│ └── wsgi.py
└── tox.ini
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-22.04
12 | strategy:
13 | max-parallel: 4
14 | matrix:
15 | include:
16 | - python-version: "3.7"
17 | django-version: Django==2.2
18 |
19 | - python-version: "3.7"
20 | django-version: Django==3.0
21 |
22 | - python-version: "3.8"
23 | django-version: Django==3.1
24 |
25 | - python-version: "3.8"
26 | django-version: Django==3.2
27 |
28 | - python-version: "3.9"
29 | django-version: Django==4.0
30 |
31 | - python-version: "3.9"
32 | django-version: Django==4.1
33 |
34 | - python-version: "3.10"
35 | django-version: Django==4.2
36 |
37 | - python-version: "3.10"
38 | django-version: Django==5.0
39 |
40 | - python-version: "3.11"
41 | django-version: Django==5.1
42 |
43 | steps:
44 | - name: Checkout repository
45 | uses: actions/checkout@v3
46 |
47 | - name: Set up Python ${{ matrix.python-version }}
48 | uses: actions/setup-python@v4
49 | with:
50 | python-version: ${{ matrix.python-version }}
51 |
52 | - name: Install Dependencies
53 | run: |
54 | python -m pip install --upgrade pip
55 | pip install coverage wheel "selenium<4.3.0"
56 | pip install ${{ matrix.django-version }}
57 | pip install --editable ./
58 |
59 | - name: Run Tests
60 | run: coverage run --source=more_admin_filters tests/manage.py test testapp
61 |
62 | - name: Create coverage lcov file
63 | run: coverage lcov -o coverage.lcov
64 |
65 | - name: Coveralls Parallel
66 | uses: coverallsapp/github-action@master
67 | with:
68 | github-token: ${{ secrets.github_token }}
69 | flag-name: run-${{ matrix.python-version }}-${{ matrix.django-version }}
70 | path-to-lcov: coverage.lcov
71 | parallel: true
72 |
73 | finish:
74 | needs: test
75 | runs-on: ubuntu-latest
76 | steps:
77 | - name: Coveralls Finished
78 | uses: coverallsapp/github-action@master
79 | with:
80 | github-token: ${{ secrets.github_token }}
81 | parallel-finished: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .python-version
3 | .coverage
4 | .coveralls.yml
5 | geckodriver.log
6 | db.sqlite3*
7 | /django_more_admin_filters.egg-info/
8 | /build/
9 | /dist/
10 | /.tox/
11 | /.vscode/
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020, Thomas Leichtfuß.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of the author nor the names of contributors
15 | may be used to endorse or promote products derived from this software
16 | without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.md
3 | recursive-include more_admin_filters/templates *
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to django-more-admin-filters
2 |
3 | [](https://github.com/thomst/django-more-admin-filters/actions/workflows/ci.yml)
4 | [](https://coveralls.io/github/thomst/django-more-admin-filters?branch=master)
5 | [](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue)
6 | [](https://img.shields.io/badge/django-2.2%20%7C%203.0%20%7C%203.1%20%7C%203.2%20%7C%204.0%20%7C%204.1%20%7C%204.2%20%7C%205.0%20%7C%205.1-orange)
7 |
8 | > **Note**
9 | > If you are looking for a generic way of building and applying complex filters with a dynamic form right in the django admin backend, please checkout [django-searchkit](https://github.com/thomst/django-searchkit).
10 |
11 | ## Description
12 |
13 | **Django-more-admin-filters** is a collection of Django admin filters with a focus on filters using dropdown widgets, multiple choice filters and filters working with annotated attributes.
14 |
15 | ## Installation
16 |
17 | Install from pypi.org:
18 |
19 | ```bash
20 | pip install django-more-admin-filters
21 | ```
22 |
23 | Add `more_admin_filters` to your installed apps:
24 |
25 | ```python
26 | INSTALLED_APPS = [
27 | 'more_admin_filters',
28 | ...
29 | ]
30 | ```
31 |
32 | Use the filter classes with your `ModelAdmin`:
33 |
34 | ```python
35 | from more_admin_filters import MultiSelectDropdownFilter
36 |
37 | class MyModelAdmin(admin.ModelAdmin):
38 | ...
39 | list_filter = [
40 | ('myfield', MultiSelectDropdownFilter),
41 | ...
42 | ]
43 | ```
44 |
45 | Since the `ModelAdmin` routine to initialize the list filters doesn't work with annotated attributes, the usage for an annotation filter is a little bit special. The filter class needs to be equipped with the attribute's name:
46 |
47 | ```python
48 | MyModelAdmin(admin.ModelAdmin):
49 | list_filter = [
50 | BooleanAnnotationFilter.init('my_annotated_attribute'),
51 | ...
52 | ]
53 | ```
54 |
55 | ## Filter classes
56 |
57 | - **DropdownFilter**
58 | Dropdown filter for all kinds of fields.
59 | - **ChoicesDropdownFilter**
60 | Dropdown filter for fields using choices.
61 | - **RelatedDropdownFilter**
62 | Dropdown filter for relation fields.
63 | - **RelatedOnlyDropdownFilter**
64 | Dropdown filter for relation fields using `limit_choices_to`.
65 | - **MultiSelectFilter**
66 | Multi select filter for all kinds of fields.
67 | - **MultiSelectRelatedFilter**
68 | Multi select filter for relation fields.
69 | - **MultiSelectRelatedOnlyFilter**
70 | Multi select filter for related fields with choices limited to the objects involved in that relation.
71 | - **MultiSelectDropdownFilter**
72 | Multi select dropdown filter for all kinds of fields.
73 | - **MultiSelectRelatedDropdownFilter**
74 | Multi select dropdown filter for relation fields.
75 | - **MultiSelectRelatedOnlyDropdownFilter**
76 | Multi select dropdown filter for relation fields with choices limited to the objects involved in that relation.
77 | - **BooleanAnnotationFilter**
78 | Filter for annotated boolean attributes.
79 |
80 | > **Note**
81 | > More kinds of annotation filters will be added in future versions.
82 |
--------------------------------------------------------------------------------
/more_admin_filters/__init__.py:
--------------------------------------------------------------------------------
1 | from .filters import (
2 | MultiSelectFilter,
3 | MultiSelectRelatedFilter,
4 | MultiSelectRelatedOnlyFilter,
5 | MultiSelectDropdownFilter,
6 | MultiSelectRelatedDropdownFilter,
7 | DropdownFilter,
8 | ChoicesDropdownFilter,
9 | RelatedDropdownFilter,
10 | BooleanAnnotationFilter,
11 | )
12 |
--------------------------------------------------------------------------------
/more_admin_filters/__version__.py:
--------------------------------------------------------------------------------
1 | """
2 | This project uses the Semantic Versioning scheme in conjunction with PEP 0440:
3 |
4 |
5 |
6 | Major versions introduce significant changes to the API, and backwards
7 | compatibility is not guaranteed. Minor versions are for new features and other
8 | backwards-compatible changes to the API. Patch versions are for bug fixes and
9 | internal code changes that do not affect the API. Development versions are
10 | incomplete states of a release .
11 |
12 | Version 0.x should be considered a development version with an unstable API,
13 | and backwards compatibility is not guaranteed for minor versions.
14 | """
15 |
16 | __version__ = "1.13"
17 |
--------------------------------------------------------------------------------
/more_admin_filters/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class DjangoAdminSelectfilterConfig(AppConfig):
5 | name = 'more_admin_filters'
6 |
--------------------------------------------------------------------------------
/more_admin_filters/filters.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import urllib.parse
3 | from django.contrib.admin.utils import prepare_lookup_value
4 | from django.contrib import admin
5 | from django.db.models import Q
6 | from django.utils.translation import gettext_lazy as _
7 | from django.contrib.admin.utils import reverse_field_path
8 | from django.contrib.admin.utils import get_model_from_relation
9 | from django.core.exceptions import ValidationError
10 | from django.contrib.admin.options import IncorrectLookupParameters
11 | from django.contrib.admin.filters import AllValuesFieldListFilter
12 | from django.contrib.admin.filters import ChoicesFieldListFilter
13 | from django.contrib.admin.filters import RelatedFieldListFilter
14 | from django.contrib.admin.filters import RelatedOnlyFieldListFilter
15 |
16 |
17 | def flatten_used_parameters(used_parameters: dict, keep_list: bool = True):
18 | # FieldListFilter.__init__ calls prepare_lookup_value,
19 | # which returns a list if lookup_kwarg ends with "__in"
20 | for k, v in used_parameters.items():
21 | if type(v) == bool:
22 | continue
23 | elif len(v) == 1 and (isinstance(v[0], list) or not keep_list):
24 | used_parameters[k] = v[0]
25 | elif k.endswith("__isnull") and len(v) == 1 and isinstance(v, list) and isinstance(v[0], bool):
26 | used_parameters[k] = v[0]
27 |
28 |
29 | # Copied this from django5 to be backward compatible.
30 | def get_last_value_from_parameters(parameters, key):
31 | value = parameters.get(key)
32 | return value[-1] if isinstance(value, list) else value
33 |
34 |
35 | # Generic filter using a dropdown widget instead of a list.
36 | class DropdownFilter(AllValuesFieldListFilter):
37 | """
38 | Dropdown filter for all kind of fields.
39 | """
40 | template = 'more_admin_filters/dropdownfilter.html'
41 |
42 |
43 | class ChoicesDropdownFilter(ChoicesFieldListFilter):
44 | """
45 | Dropdown filter for fields using choices.
46 | """
47 | template = 'more_admin_filters/dropdownfilter.html'
48 |
49 |
50 | class RelatedDropdownFilter(RelatedFieldListFilter):
51 | """
52 | Dropdown filter for relation fields.
53 | """
54 | template = 'more_admin_filters/dropdownfilter.html'
55 |
56 |
57 | class RelatedOnlyDropdownFilter(RelatedOnlyFieldListFilter):
58 | """
59 | Dropdown filter for relation fields using limit_choices_to.
60 | """
61 | template = 'more_admin_filters/dropdownfilter.html'
62 |
63 |
64 | class MultiSelectMixin(object):
65 | def queryset(self, request, queryset):
66 | params = Q()
67 | for lookup_arg, value in self.used_parameters.items():
68 | params |= Q(**{lookup_arg:value})
69 | try:
70 | return queryset.filter(params)
71 | except (ValueError, ValidationError) as e:
72 | # Fields may raise a ValueError or ValidationError when converting
73 | # the parameters to the correct type.
74 | raise IncorrectLookupParameters(e)
75 |
76 | def querystring_for_choices(self, val, changelist):
77 | lookup_vals = self.lookup_vals[:]
78 | if val in self.lookup_vals:
79 | lookup_vals.remove(val)
80 | else:
81 | lookup_vals.append(val)
82 | if lookup_vals:
83 | query_string = changelist.get_query_string({
84 | self.lookup_kwarg: ','.join(lookup_vals),
85 | }, [])
86 | else:
87 | query_string = changelist.get_query_string({},
88 | [self.lookup_kwarg])
89 | return query_string
90 |
91 | def querystring_for_isnull(self, changelist):
92 | if self.lookup_val_isnull:
93 | query_string = changelist.get_query_string({},
94 | [self.lookup_kwarg_isnull])
95 | else:
96 | query_string = changelist.get_query_string({
97 | self.lookup_kwarg_isnull: 'True',
98 | }, [])
99 | return query_string
100 |
101 | def has_output(self):
102 | return len(self.lookup_choices) > 1
103 |
104 | def get_facet_counts(self, pk_attname, filtered_qs):
105 | if not self.lookup_kwarg.endswith("__in"):
106 | raise NotImplementedError("Facets are only supported for default lookup_kwarg values, ending with '__in' "
107 | "(got '%s')" % self.lookup_kwarg)
108 |
109 | orig_lookup_kwarg = self.lookup_kwarg
110 | self.lookup_kwarg = self.lookup_kwarg.removesuffix("in") + "exact"
111 | counts = super().get_facet_counts(pk_attname, filtered_qs)
112 | self.lookup_kwarg = orig_lookup_kwarg
113 | return counts
114 |
115 |
116 | class MultiSelectFilter(MultiSelectMixin, admin.AllValuesFieldListFilter):
117 | """
118 | Multi select filter for all kind of fields.
119 | """
120 | def __init__(self, field, request, params, model, model_admin, field_path):
121 | self.lookup_kwarg = '%s__in' % field_path
122 | self.lookup_kwarg_isnull = '%s__isnull' % field_path
123 | lookup_vals = request.GET.get(self.lookup_kwarg)
124 | self.lookup_vals = lookup_vals.split(',') if lookup_vals else list()
125 | self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull)
126 | self.empty_value_display = model_admin.get_empty_value_display()
127 | parent_model, reverse_path = reverse_field_path(model, field_path)
128 | # Obey parent ModelAdmin queryset when deciding which options to show
129 | if model == parent_model:
130 | queryset = model_admin.get_queryset(request)
131 | else:
132 | queryset = parent_model._default_manager.all()
133 | self.lookup_choices = (queryset
134 | .distinct()
135 | .order_by(field.name)
136 | .values_list(field.name, flat=True))
137 | super(admin.AllValuesFieldListFilter, self).__init__(field, request, params, model, model_admin, field_path)
138 | flatten_used_parameters(self.used_parameters)
139 | self.used_parameters = self.prepare_used_parameters(self.used_parameters)
140 |
141 | def prepare_querystring_value(self, value):
142 | # mask all commas or these values will be used
143 | # in a comma-seperated-list as get-parameter
144 | return str(value).replace(',', '%~')
145 |
146 | def prepare_used_parameters(self, used_parameters):
147 | # remove comma-mask from list-values for __in-lookups
148 | for key, value in used_parameters.items():
149 | if not key.endswith('__in'): continue
150 | used_parameters[key] = [v.replace('%~', ',') for v in value]
151 | return used_parameters
152 |
153 | def choices(self, changelist):
154 | add_facets = getattr(changelist, "add_facets", False)
155 | facet_counts = self.get_facet_queryset(changelist) if add_facets else None
156 | yield {
157 | 'selected': not self.lookup_vals and self.lookup_val_isnull is None,
158 | 'query_string': changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull]),
159 | 'display': _('All'),
160 | }
161 | include_none = False
162 | count = None
163 | empty_title = self.empty_value_display
164 | for i, val in enumerate(self.lookup_choices):
165 | if add_facets:
166 | count = facet_counts[f"{i}__c"]
167 | if val is None:
168 | include_none = True
169 | empty_title = f"{empty_title} ({count})" if add_facets else empty_title
170 | continue
171 | val = str(val)
172 | qval = self.prepare_querystring_value(val)
173 | yield {
174 | 'selected': qval in self.lookup_vals,
175 | 'query_string': self.querystring_for_choices(qval, changelist),
176 | "display": f"{val} ({count})" if add_facets else val,
177 | }
178 | if include_none:
179 | yield {
180 | 'selected': bool(self.lookup_val_isnull),
181 | 'query_string': self.querystring_for_isnull(changelist),
182 | 'display': empty_title,
183 | }
184 |
185 |
186 | class MultiSelectRelatedFilter(MultiSelectMixin, admin.RelatedFieldListFilter):
187 | """
188 | Multi select filter for relation fields.
189 | """
190 | def __init__(self, field, request, params, model, model_admin, field_path):
191 | other_model = get_model_from_relation(field)
192 | self.lookup_kwarg = '%s__%s__in' % (field_path, field.target_field.name)
193 | self.lookup_kwarg_isnull = '%s__isnull' % field_path
194 | lookup_vals = request.GET.get(self.lookup_kwarg)
195 | self.lookup_vals = lookup_vals.split(',') if lookup_vals else list()
196 | self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull)
197 | super(admin.RelatedFieldListFilter, self).__init__(field, request, params, model, model_admin, field_path)
198 | flatten_used_parameters(self.used_parameters)
199 | self.lookup_choices = self.field_choices(field, request, model_admin)
200 | if hasattr(field, 'verbose_name'):
201 | self.lookup_title = field.verbose_name
202 | else:
203 | self.lookup_title = other_model._meta.verbose_name
204 | self.title = self.lookup_title
205 | self.empty_value_display = model_admin.get_empty_value_display()
206 |
207 | def choices(self, changelist):
208 | add_facets = getattr(changelist, "add_facets", False)
209 | facet_counts = self.get_facet_queryset(changelist) if add_facets else None
210 | yield {
211 | 'selected': not self.lookup_vals and not self.lookup_val_isnull,
212 | 'query_string': changelist.get_query_string(
213 | {},
214 | [self.lookup_kwarg, self.lookup_kwarg_isnull]
215 | ),
216 | 'display': _('All'),
217 | }
218 | for pk_val, val in self.lookup_choices:
219 | if add_facets:
220 | count = facet_counts[f"{pk_val}__c"]
221 | val = f"{val} ({count})"
222 | pk_val = str(pk_val)
223 | yield {
224 | 'selected': pk_val in self.lookup_vals,
225 | 'query_string': self.querystring_for_choices(pk_val, changelist),
226 | 'display': val,
227 | }
228 | if self.include_empty_choice:
229 | empty_title = self.empty_value_display
230 | if add_facets:
231 | count = facet_counts["__c"]
232 | empty_title = f"{empty_title} ({count})"
233 | yield {
234 | 'selected': bool(self.lookup_val_isnull),
235 | 'query_string': self.querystring_for_isnull(changelist),
236 | 'display': empty_title,
237 | }
238 |
239 |
240 | class MultiSelectRelatedOnlyFilter(MultiSelectRelatedFilter):
241 | def field_choices(self, field, request, model_admin):
242 | pk_qs = (
243 | model_admin.get_queryset(request)
244 | .distinct()
245 | .values_list("%s__pk" % self.field_path, flat=True)
246 | )
247 | ordering = self.field_admin_ordering(field, request, model_admin)
248 | return field.get_choices(
249 | include_blank=False, limit_choices_to={"pk__in": pk_qs}, ordering=ordering
250 | )
251 |
252 |
253 | class MultiSelectDropdownFilter(MultiSelectFilter):
254 | """
255 | Multi select dropdown filter for all kind of fields.
256 | """
257 | template = 'more_admin_filters/multiselectdropdownfilter.html'
258 |
259 | def choices(self, changelist):
260 | add_facets = getattr(changelist, "add_facets", False)
261 | facet_counts = self.get_facet_queryset(changelist) if add_facets else None
262 | query_string = changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull])
263 | yield {
264 | 'selected': not self.lookup_vals and self.lookup_val_isnull is None,
265 | 'query_string': query_string,
266 | 'display': _('All'),
267 | }
268 | include_none = False
269 | count = None
270 | empty_title = self.empty_value_display
271 | for i, val in enumerate(self.lookup_choices):
272 | if add_facets:
273 | count = facet_counts[f"{i}__c"]
274 | if val is None:
275 | include_none = True
276 | empty_title = f"{empty_title} ({count})" if add_facets else empty_title
277 | continue
278 |
279 | val = str(val)
280 | qval = self.prepare_querystring_value(val)
281 | yield {
282 | 'selected': qval in self.lookup_vals,
283 | 'query_string': query_string,
284 | "display": f"{val} ({count})" if add_facets else val,
285 | 'value': urllib.parse.quote_plus(val.replace(',', '%~')),
286 | 'key': self.lookup_kwarg,
287 | }
288 | if include_none:
289 | yield {
290 | 'selected': bool(self.lookup_val_isnull),
291 | 'query_string': query_string,
292 | "display": empty_title,
293 | 'value': 'True',
294 | 'key': self.lookup_kwarg_isnull,
295 | }
296 |
297 |
298 | class MultiSelectRelatedDropdownFilter(MultiSelectRelatedFilter):
299 | """
300 | Multi select dropdown filter for relation fields.
301 | """
302 | template = 'more_admin_filters/multiselectdropdownfilter.html'
303 |
304 | def choices(self, changelist):
305 | add_facets = getattr(changelist, "add_facets", False)
306 | facet_counts = self.get_facet_queryset(changelist) if add_facets else None
307 | query_string = changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull])
308 | yield {
309 | 'selected': not self.lookup_vals and not self.lookup_val_isnull,
310 | 'query_string': query_string,
311 | 'display': _('All'),
312 | }
313 | for pk_val, val in self.lookup_choices:
314 | if add_facets:
315 | count = facet_counts[f"{pk_val}__c"]
316 | val = f"{val} ({count})"
317 | pk_val = str(pk_val)
318 | yield {
319 | 'selected': pk_val in self.lookup_vals,
320 | 'query_string': query_string,
321 | 'display': val,
322 | 'value': pk_val,
323 | 'key': self.lookup_kwarg,
324 | }
325 | if self.include_empty_choice:
326 | empty_title = self.empty_value_display
327 | if add_facets:
328 | count = facet_counts["__c"]
329 | empty_title = f"{empty_title} ({count})"
330 | yield {
331 | 'selected': bool(self.lookup_val_isnull),
332 | 'query_string': query_string,
333 | 'display': empty_title,
334 | 'value': 'True',
335 | 'key': self.lookup_kwarg_isnull,
336 | }
337 |
338 |
339 | class MultiSelectRelatedOnlyDropdownFilter(MultiSelectRelatedDropdownFilter, MultiSelectRelatedOnlyFilter):
340 | pass
341 |
342 |
343 | # Filter for annotated attributes.
344 | # NOTE: The code is more or less the same than admin.FieldListFilter but
345 | # we must not subclass it. Otherwise django's filter setup routine wants a real
346 | # model field.
347 | class BaseAnnotationFilter(admin.ListFilter):
348 | """
349 | Baseclass for annotation-list-filters.
350 | """
351 | attribute_name = None
352 | nullable_attribute = None
353 |
354 | @classmethod
355 | def init(cls, attribute_name, nullable=True):
356 | """
357 | Since filters are listed as classes in ModelAdmin.list_filter we are
358 | not able to initialize the filter within the ModelAdmin.
359 | We use this classmethod to setup a filter-class for a specific annotated
360 | attribute::
361 |
362 | MyModelAdmin(admin.ModelAdmin):
363 | list_filter = [
364 | MyAnnotationListFilter.init('my_annotated_attribute'),
365 | ]
366 | """
367 | attrs = dict(attribute_name=attribute_name, nullable=nullable)
368 | cls = type('cls.__name__' + attribute_name, (cls,), attrs)
369 | return cls
370 |
371 | def __init__(self, request, params, model, model_admin):
372 | self.title = self.attribute_name
373 | super().__init__(request, params, model, model_admin)
374 | for p in self.expected_parameters():
375 | if p in params:
376 | value = params.pop(p)
377 | self.used_parameters[p] = prepare_lookup_value(p, value)
378 |
379 | def has_output(self):
380 | return True
381 |
382 | def queryset(self, request, queryset):
383 | try:
384 | return queryset.filter(**self.used_parameters)
385 | except (ValueError, ValidationError) as e:
386 | # Fields may raise a ValueError or ValidationError when converting
387 | # the parameters to the correct type.
388 | raise IncorrectLookupParameters(e)
389 |
390 |
391 | # NOTE: The code is more or less the same than admin.BooleanFieldListFilter but
392 | # we must not subclass it. Otherwise django's filter setup routine wants a real
393 | # model field.
394 | class BooleanAnnotationFilter(BaseAnnotationFilter):
395 | """
396 | Filter for annotated boolean-attributes.
397 | """
398 | def __init__(self, request, params, model, model_admin):
399 | self.lookup_kwarg = '%s__exact' % self.attribute_name
400 | self.lookup_kwarg2 = '%s__isnull' % self.attribute_name
401 | self.lookup_val = get_last_value_from_parameters(params, self.lookup_kwarg)
402 | self.lookup_val2 = get_last_value_from_parameters(params, self.lookup_kwarg2)
403 | super().__init__(request, params, model, model_admin)
404 | flatten_used_parameters(self.used_parameters, False)
405 | if (self.used_parameters and self.lookup_kwarg in self.used_parameters and
406 | self.used_parameters[self.lookup_kwarg] in ('1', '0')):
407 | self.used_parameters[self.lookup_kwarg] = bool(int(self.used_parameters[self.lookup_kwarg]))
408 |
409 | def expected_parameters(self):
410 | return [self.lookup_kwarg, self.lookup_kwarg2]
411 |
412 | def choices(self, changelist):
413 | for lookup, title in (
414 | (None, _('All')),
415 | ('1', _('Yes')),
416 | ('0', _('No'))):
417 | yield {
418 | 'selected': self.lookup_val == lookup and not self.lookup_val2,
419 | 'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}, [self.lookup_kwarg2]),
420 | 'display': title,
421 | }
422 | if self.nullable_attribute:
423 | yield {
424 | 'selected': self.lookup_val2 == 'True',
425 | 'query_string': changelist.get_query_string({self.lookup_kwarg2: 'True'}, [self.lookup_kwarg]),
426 | 'display': _('Unknown'),
427 | }
428 |
--------------------------------------------------------------------------------
/more_admin_filters/templates/more_admin_filters/dropdownfilter.html:
--------------------------------------------------------------------------------
1 | {% load i18n admin_urls %}
2 | {% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
3 |
4 |
5 | {% if choices|slice:"4:" %}
6 |
12 | {% else %}
13 | {% for choice in choices %}
14 | -
15 | {{ choice.display }}
16 | {% endfor %}
17 | {% endif %}
18 |
19 |
20 | {% if choices|slice:"4:" %}
21 |
28 | {% endif %}
29 |
--------------------------------------------------------------------------------
/more_admin_filters/templates/more_admin_filters/multiselectdropdownfilter.html:
--------------------------------------------------------------------------------
1 | {% load i18n admin_urls %}
2 | {% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
3 |
4 |
5 | {% for choice in choices|slice:":1" %}
6 | -
7 | {{ choice.display }}
8 |
9 | {% endfor %}
10 | -
11 |
21 |
22 | -
23 | filter
25 |
26 |
27 |
28 |
56 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | from setuptools import setup
5 | from setuptools import find_namespace_packages
6 | from pathlib import Path
7 |
8 |
9 | def version():
10 | """Get the local package version."""
11 | namespace = {}
12 | path = Path("more_admin_filters", "__version__.py")
13 | exec(path.read_text(), namespace)
14 | return namespace["__version__"]
15 |
16 |
17 | def read(filename):
18 | path = os.path.join(os.path.dirname(__file__), filename)
19 | with open(path, encoding="utf-8") as file:
20 | return file.read()
21 |
22 |
23 | version = version()
24 | if "dev" in version:
25 | dev_status = "Development Status :: 3 - Alpha"
26 | elif "beta" in version:
27 | dev_status = "Development Status :: 4 - Beta"
28 | else:
29 | dev_status = "Development Status :: 5 - Production/Stable"
30 |
31 |
32 | setup(
33 | name="django-more-admin-filters",
34 | version=version,
35 | description="Additional filters for django-admin.",
36 | long_description=read("README.md"),
37 | long_description_content_type='text/markdown',
38 | author="Thomas Leichtfuß",
39 | author_email="thomas.leichtfuss@posteo.de",
40 | url="https://github.com/thomst/django-more-admin-filters",
41 | license="BSD License",
42 | platforms=["OS Independent"],
43 | packages=find_namespace_packages(exclude=["tests"]),
44 | include_package_data=True,
45 | install_requires=[
46 | "Django>=2.2,<6.0",
47 | ],
48 | classifiers=[
49 | dev_status,
50 | "Framework :: Django",
51 | "Framework :: Django :: 2.2",
52 | "Framework :: Django :: 3.0",
53 | "Framework :: Django :: 3.1",
54 | "Framework :: Django :: 3.2",
55 | "Framework :: Django :: 4.0",
56 | "Framework :: Django :: 4.1",
57 | "Framework :: Django :: 4.2",
58 | "Framework :: Django :: 5.0",
59 | "Framework :: Django :: 5.1",
60 | "Environment :: Web Environment",
61 | "Intended Audience :: Developers",
62 | "License :: OSI Approved :: BSD License",
63 | "Operating System :: OS Independent",
64 | "Programming Language :: Python",
65 | "Programming Language :: Python :: 3",
66 | "Programming Language :: Python :: 3.7",
67 | "Programming Language :: Python :: 3.8",
68 | "Programming Language :: Python :: 3.9",
69 | "Programming Language :: Python :: 3.10",
70 | "Programming Language :: Python :: 3.11",
71 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
72 | "Topic :: Software Development",
73 | "Topic :: Software Development :: Libraries :: Application Frameworks",
74 | ],
75 | zip_safe=True,
76 | )
77 |
--------------------------------------------------------------------------------
/tests/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 | sys.path.insert(1, os.path.abspath('..'))
7 |
8 |
9 | def main():
10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings')
11 | try:
12 | from django.core.management import execute_from_command_line
13 | except ImportError as exc:
14 | raise ImportError(
15 | "Couldn't import Django. Are you sure it's installed and "
16 | "available on your PYTHONPATH environment variable? Did you "
17 | "forget to activate a virtual environment?"
18 | ) from exc
19 | execute_from_command_line(sys.argv)
20 |
21 |
22 | if __name__ == '__main__':
23 | main()
24 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomst/django-more-admin-filters/271fb87ad3f3a0494883242a868b96ccc20c07d4/tests/testapp/__init__.py
--------------------------------------------------------------------------------
/tests/testapp/admin.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from django.contrib import admin
4 | from more_admin_filters import (
5 | MultiSelectFilter, MultiSelectRelatedFilter, MultiSelectDropdownFilter,
6 | MultiSelectRelatedDropdownFilter, DropdownFilter, ChoicesDropdownFilter,
7 | RelatedDropdownFilter, BooleanAnnotationFilter
8 | )
9 | from more_admin_filters import apps
10 | from .models import ModelA
11 | from .models import ModelB
12 |
13 |
14 | @admin.register(ModelA)
15 | class ModelAAdmin(admin.ModelAdmin):
16 | search_fields = ('dropdown_lte3',)
17 | list_display = (
18 | 'dropdown_lte3',
19 | 'dropdown_gt3',
20 | 'multiselect',
21 | 'multiselect_utf8',
22 | 'multiselect_dropdown',
23 | 'choices_dropdown',
24 | 'related_dropdown',
25 | 'multiselect_related',
26 | 'multiselect_related_dropdown',
27 | 'annotation_view',
28 | 'boolean_annotation_view',
29 | )
30 |
31 | list_filter = (
32 | ('dropdown_lte3', DropdownFilter),
33 | ('dropdown_gt3', DropdownFilter),
34 | ('multiselect', MultiSelectFilter),
35 | ('multiselect_dropdown', MultiSelectDropdownFilter),
36 | ('choices_dropdown', ChoicesDropdownFilter),
37 | ('related_dropdown', RelatedDropdownFilter),
38 | ('multiselect_related', MultiSelectRelatedFilter),
39 | ('multiselect_related_dropdown', MultiSelectRelatedDropdownFilter),
40 | BooleanAnnotationFilter.init('boolean_annotation'),
41 | ('multiselect_utf8', MultiSelectDropdownFilter),
42 | )
43 |
44 | def annotation_view(self, obj):
45 | return obj.annotation
46 |
47 | def boolean_annotation_view(self, obj):
48 | return obj.boolean_annotation
--------------------------------------------------------------------------------
/tests/testapp/apps.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class TestappConfig(AppConfig):
7 | name = 'testapp'
8 |
--------------------------------------------------------------------------------
/tests/testapp/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomst/django-more-admin-filters/271fb87ad3f3a0494883242a868b96ccc20c07d4/tests/testapp/management/__init__.py
--------------------------------------------------------------------------------
/tests/testapp/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomst/django-more-admin-filters/271fb87ad3f3a0494883242a868b96ccc20c07d4/tests/testapp/management/commands/__init__.py
--------------------------------------------------------------------------------
/tests/testapp/management/commands/createtestdata.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from django.contrib.auth.models import User
3 | from django.db.utils import IntegrityError
4 |
5 | from ...models import ModelA, ModelB, ModelC
6 |
7 |
8 | UTF8_STRESS_TEST_STRING = 'STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑'
9 |
10 |
11 | def create_test_data():
12 | try:
13 | User.objects.create_superuser(
14 | 'admin',
15 | 'admin@testapp.org',
16 | 'adminpassword')
17 | except IntegrityError:
18 | pass
19 |
20 | # clear existing data
21 | ModelA.objects.all().delete()
22 | ModelB.objects.all().delete()
23 |
24 | # TODO: create null values as well
25 | c_models = list()
26 | for i in range(36):
27 |
28 | if i > 0:
29 | model_a = ModelA()
30 | model_b = ModelB()
31 | model_c = ModelC()
32 |
33 | model_b.id = i
34 | model_b.save()
35 | model_c.id = i
36 | model_c.save()
37 | c_models.append(model_c)
38 |
39 | model_a.dropdown_lte3 = None if i % 3 == 0 else i % 3
40 | model_a.dropdown_gt3 = i % 4
41 | model_a.choices_dropdown = i % 9 +1
42 | model_a.multiselect = i % 5
43 | model_a.multiselect_utf8 = f'{i % 5} + {UTF8_STRESS_TEST_STRING}'
44 | model_a.multiselect_dropdown = i % 6
45 | model_a.related_dropdown = model_b
46 | model_a.multiselect_related = model_b
47 | model_a.multiselect_related_dropdown = model_b
48 | model_a.save()
49 | model_a.c_models.set(c_models)
50 | else:
51 | model_a = ModelA()
52 | model_a.save()
53 |
54 |
55 |
56 | class Command(BaseCommand):
57 | help = 'Create test data.'
58 |
59 | def handle(self, *args, **options):
60 | create_test_data()
61 | # if options['create_test_data']:
62 |
--------------------------------------------------------------------------------
/tests/testapp/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.16 on 2020-09-05 21:10
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = [
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='ModelB',
17 | fields=[
18 | ('id', models.AutoField(primary_key=True, serialize=False)),
19 | ],
20 | ),
21 | migrations.CreateModel(
22 | name='ModelC',
23 | fields=[
24 | ('id', models.AutoField(primary_key=True, serialize=False)),
25 | ],
26 | ),
27 | migrations.CreateModel(
28 | name='ModelA',
29 | fields=[
30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31 | ('dropdown_lte3', models.IntegerField(blank=True, null=True)),
32 | ('dropdown_gt3', models.IntegerField(blank=True, null=True)),
33 | ('multiselect', models.IntegerField(blank=True, null=True)),
34 | ('multiselect_dropdown', models.IntegerField(blank=True, null=True)),
35 | ('choices_dropdown', models.CharField(blank=True, choices=[('1', 'one'), ('2', 'two'), ('3', 'three'), ('4', 'four'), ('5', 'five'), ('6', 'six'), ('7', 'seven'), ('8', 'eight'), ('9', 'nine')], max_length=255, null=True)),
36 | ('c_models', models.ManyToManyField(to='testapp.ModelC')),
37 | ('multiselect_related', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='multiselect_related_reverse', to='testapp.ModelB')),
38 | ('multiselect_related_dropdown', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='multiselect_related_dropdown_reverse', to='testapp.ModelB')),
39 | ('related_dropdown', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_dropdown_reverse', to='testapp.ModelB')),
40 | ],
41 | ),
42 | ]
43 |
--------------------------------------------------------------------------------
/tests/testapp/migrations/0002_modela_multiselect_utf8.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.6 on 2025-03-12 14:42
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('testapp', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='modela',
15 | name='multiselect_utf8',
16 | field=models.CharField(blank=True, max_length=255, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/tests/testapp/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomst/django-more-admin-filters/271fb87ad3f3a0494883242a868b96ccc20c07d4/tests/testapp/migrations/__init__.py
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from django.db import models
4 | from django.db.models import Count, Case, When, Value
5 |
6 |
7 | class ModelAManager(models.Manager):
8 | def get_queryset(self):
9 | qs = super().get_queryset()
10 | qs = qs.annotate(annotation=Count('c_models'))
11 | qs = qs.annotate(boolean_annotation=Case(
12 | When(annotation__gt=12, then=Value(True)),
13 | default=Value(False),
14 | output_field=models.BooleanField()
15 | ))
16 | return qs
17 |
18 |
19 | class ModelA(models.Model):
20 | objects = ModelAManager()
21 | CHOICES = (
22 | ('1', 'one'),
23 | ('2', 'two'),
24 | ('3', 'three'),
25 | ('4', 'four'),
26 | ('5', 'five'),
27 | ('6', 'six'),
28 | ('7', 'seven'),
29 | ('8', 'eight'),
30 | ('9', 'nine'),
31 | )
32 | dropdown_lte3 = models.IntegerField(blank=True, null=True)
33 | dropdown_gt3 = models.IntegerField(blank=True, null=True)
34 | multiselect = models.IntegerField(blank=True, null=True)
35 | multiselect_utf8 = models.CharField(max_length=255, blank=True, null=True)
36 | multiselect_dropdown = models.IntegerField(blank=True, null=True)
37 | choices_dropdown = models.CharField(max_length=255, blank=True, null=True, choices=CHOICES)
38 | related_dropdown = models.ForeignKey(
39 | 'ModelB',
40 | blank=True,
41 | null=True,
42 | on_delete=models.CASCADE,
43 | related_name='related_dropdown_reverse')
44 | multiselect_related = models.ForeignKey(
45 | 'ModelB',
46 | blank=True,
47 | null=True,
48 | on_delete=models.CASCADE,
49 | related_name='multiselect_related_reverse')
50 | multiselect_related_dropdown = models.ForeignKey(
51 | 'ModelB',
52 | blank=True,
53 | null=True,
54 | on_delete=models.CASCADE,
55 | related_name='multiselect_related_dropdown_reverse')
56 | c_models = models.ManyToManyField('ModelC')
57 |
58 |
59 | class ModelB(models.Model):
60 | id = models.AutoField(primary_key=True)
61 |
62 | def __str__(self):
63 | return 'ModelB {}'.format(self.id)
64 |
65 |
66 | class ModelC(models.Model):
67 | id = models.AutoField(primary_key=True)
68 |
--------------------------------------------------------------------------------
/tests/testapp/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for testapp project.
3 |
4 | Generated by 'django-admin startproject' using Django 2.2.10.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/2.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/2.2/ref/settings/
11 | """
12 |
13 | import os
14 | import sys
15 |
16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18 |
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = '*xje--vy(__r5_*7t&z^im09#v4#auk*1!t@7!^duf=e$vuzy4'
25 |
26 | # SECURITY WARNING: don't run with debug turned on in production!
27 | DEBUG = True
28 |
29 | ALLOWED_HOSTS = []
30 |
31 |
32 | # Application definition
33 |
34 | INSTALLED_APPS = [
35 | 'testapp',
36 | 'more_admin_filters',
37 | 'django.contrib.admin',
38 | 'django.contrib.auth',
39 | 'django.contrib.contenttypes',
40 | 'django.contrib.sessions',
41 | 'django.contrib.messages',
42 | 'django.contrib.staticfiles',
43 | ]
44 |
45 | MIDDLEWARE = [
46 | 'django.middleware.security.SecurityMiddleware',
47 | 'django.contrib.sessions.middleware.SessionMiddleware',
48 | 'django.middleware.common.CommonMiddleware',
49 | 'django.middleware.csrf.CsrfViewMiddleware',
50 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
51 | 'django.contrib.messages.middleware.MessageMiddleware',
52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
53 | ]
54 |
55 | ROOT_URLCONF = 'testapp.urls'
56 |
57 | TEMPLATES = [
58 | {
59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
60 | 'DIRS': [],
61 | 'APP_DIRS': True,
62 | 'OPTIONS': {
63 | 'context_processors': [
64 | 'django.template.context_processors.debug',
65 | 'django.template.context_processors.request',
66 | 'django.contrib.auth.context_processors.auth',
67 | 'django.contrib.messages.context_processors.messages',
68 | ],
69 | },
70 | },
71 | ]
72 |
73 | WSGI_APPLICATION = 'testapp.wsgi.application'
74 |
75 |
76 | # Database
77 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases
78 |
79 | if 'test' in sys.argv:
80 | DB_NAME = ':memory:'
81 | else:
82 | DB_NAME = os.path.join(BASE_DIR, 'db.sqlite3')
83 |
84 | DATABASES = {
85 | 'default': {
86 | 'ENGINE': 'django.db.backends.sqlite3',
87 | 'NAME': DB_NAME,
88 | }
89 | }
90 |
91 |
92 | # Password validation
93 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
94 |
95 | AUTH_PASSWORD_VALIDATORS = [
96 | {
97 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
98 | },
99 | {
100 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
101 | },
102 | {
103 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
104 | },
105 | {
106 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
107 | },
108 | ]
109 |
110 |
111 | # Internationalization
112 | # https://docs.djangoproject.com/en/2.2/topics/i18n/
113 |
114 | LANGUAGE_CODE = 'en-us'
115 |
116 | TIME_ZONE = 'UTC'
117 |
118 | USE_I18N = True
119 |
120 | USE_L10N = True
121 |
122 | USE_TZ = True
123 |
124 |
125 | # Static files (CSS, JavaScript, Images)
126 | # https://docs.djangoproject.com/en/2.2/howto/static-files/
127 |
128 | STATIC_URL = '/static/'
129 |
130 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
131 |
--------------------------------------------------------------------------------
/tests/testapp/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thomst/django-more-admin-filters/271fb87ad3f3a0494883242a868b96ccc20c07d4/tests/testapp/tests/__init__.py
--------------------------------------------------------------------------------
/tests/testapp/tests/test_filters.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | from django.test import TestCase
4 | from django.contrib.auth.models import User
5 | from django.urls import reverse
6 |
7 | from ..management.commands.createtestdata import create_test_data
8 |
9 |
10 | class FilterTest(TestCase):
11 | @classmethod
12 | def setUpTestData(cls):
13 | create_test_data()
14 |
15 | def setUp(self):
16 | self.admin = User.objects.get(username='admin')
17 | self.client.force_login(self.admin)
18 | self.url = reverse('admin:testapp_modela_changelist')
19 |
20 | def test_01_dropdown(self):
21 | resp = self.client.get(self.url)
22 | self.assertEqual(resp.status_code, 200)
23 |
24 | # the dropdown widget should have been loaded for dropdown_gt3
25 | self.assertIn('dropdown-gt3_filter_select', resp.content.decode('utf8'))
26 | # but not for dropdown_lte3
27 | self.assertNotIn('dropdown-lte3_filter_select', resp.content.decode('utf8'))
28 |
29 | # check other dropdown widgets
30 | self.assertIn('multiselect-dropdown_select', resp.content.decode('utf8'))
31 | self.assertIn('choices-dropdown_filter_select', resp.content.decode('utf8'))
32 | self.assertIn('related-dropdown_filter_select', resp.content.decode('utf8'))
33 | self.assertIn('multiselect-related-dropdown_select', resp.content.decode('utf8'))
34 |
35 | def test_02_filtering(self):
36 | queries = (
37 | ('', 36),
38 | ('dropdown_gt3=1&dropdown_lte3__isnull=True', 3),
39 | ('dropdown_gt3=1&multiselect_dropdown__in=3', 3),
40 | ('dropdown_gt3=1&multiselect_dropdown__in=3,4,5', 6),
41 | ('choices_dropdown__exact=3&multiselect_dropdown__in=0,1,2', 2),
42 | ('multiselect_dropdown__in=0,1,2&related_dropdown__id__exact=6', 1),
43 | ('related_dropdown__id__exact=6&multiselect_dropdown__in=3,4,5', 0),
44 | ('multiselect_dropdown__in=3,4,5&multiselect_related__id__in=35,34,33,32', 3),
45 | ('multiselect_dropdown__in=3,4,5&multiselect_related_dropdown__id__in=29,30,31,32,33,34,35', 4),
46 | ('boolean_annotation__exact=1&multiselect__in=0,2,4', 14),
47 | )
48 | for query, count in queries:
49 | resp = self.client.get(self.url + '?' + query)
50 | self.assertEqual(resp.status_code, 200)
51 | self.assertIn('{} selected'.format(count), resp.content.decode('utf8'))
52 |
53 | def test_03_multiselect_isnull_issue(self):
54 | queries = [
55 | 'multiselect__isnull=Truee',
56 | 'multiselect_dropdown__isnull=True',
57 | 'multiselect_related__isnull=Truee',
58 | ]
59 | for query in queries:
60 | resp = self.client.get(self.url + '?' + query)
61 | self.assertEqual(resp.status_code, 200)
62 |
--------------------------------------------------------------------------------
/tests/testapp/tests/test_live_filters.py:
--------------------------------------------------------------------------------
1 |
2 | import time
3 | import urllib
4 |
5 | from django.contrib.auth.models import User
6 | from django.urls import reverse
7 | from django.contrib.staticfiles.testing import StaticLiveServerTestCase
8 | from selenium.webdriver.common.by import By
9 | from selenium.webdriver.firefox.webdriver import WebDriver
10 | from selenium.webdriver.firefox.options import Options
11 | from selenium.webdriver.support.ui import Select
12 |
13 | from ..management.commands.createtestdata import create_test_data
14 | from ..management.commands.createtestdata import UTF8_STRESS_TEST_STRING
15 |
16 |
17 | class FilterPage:
18 | WAIT_FOR_RELOAD = 1
19 | URL_PATH = reverse('admin:testapp_modela_changelist')
20 |
21 | # ul-indexes
22 | MULTISELECT_UL = 3
23 | MULTISELECT_RELATED_UL = 7
24 |
25 | def __init__(self, selenium, base_url):
26 | self.base_url = base_url
27 | self.url = base_url + self.URL_PATH
28 | self.selenium = selenium
29 | self.current_url = self.selenium.current_url
30 |
31 | def login(self, client):
32 | # login to selenium - using a cookie from the django test client
33 | admin = User.objects.get(username='admin')
34 | client.force_login(admin)
35 | cookie = client.cookies['sessionid']
36 | #selenium will set cookie domain based on current page domain
37 | self.selenium.get(self.base_url + '/admin/')
38 | self.selenium.add_cookie({'name': 'sessionid', 'value': cookie.value, 'secure': False, 'path': '/'})
39 | #need to update page for logged in user
40 | self.selenium.refresh()
41 |
42 | def get(self, url_query=str()):
43 | return self.selenium.get(self.url + '?' + url_query)
44 |
45 | def wait_for_reload(self):
46 | now = time.time()
47 | while self.current_url == self.selenium.current_url:
48 | self.selenium.refresh()
49 | if time.time() - now < self.WAIT_FOR_RELOAD:
50 | msg = "Could not reload live server page. Waited {} sec."
51 | raise RuntimeError(msg.format(self.WAIT_FOR_RELOAD))
52 | else:
53 | self.current_url = self.selenium.current_url
54 | return True
55 |
56 | @property
57 | def item_count(self):
58 | return len(self.selenium.find_elements(By.XPATH, '//*[@id="result_list"]/tbody/tr'))
59 |
60 | @property
61 | def url_query(self):
62 | return self.selenium.current_url.split('?')[-1].replace('%2C', ',')
63 |
64 | def get_selected_li_count(self, ul):
65 | return len(ul.find_elements(By.CSS_SELECTOR, 'li.selected'))
66 |
67 | def use_dropdown_filter(self, select_id, option):
68 | select = Select(self.selenium.find_element(By.ID, select_id))
69 | select.select_by_visible_text(option)
70 | self.wait_for_reload()
71 | return Select(self.selenium.find_element(By.ID, select_id))
72 |
73 | def use_multiselect_filter(self, ul_num, title):
74 | uls_css = '#changelist-filter ul'
75 | a_xpath = f'li/a[text() = "{title}"]'
76 | ul = self.selenium.find_elements(By.CSS_SELECTOR, uls_css)[ul_num-1]
77 | ul.find_element(By.XPATH, a_xpath).click()
78 | self.wait_for_reload()
79 | return self.selenium.find_elements(By.CSS_SELECTOR, uls_css)[ul_num-1]
80 |
81 | def use_multiselect_dropdown_filter(self, field, options):
82 | select = Select(self.selenium.find_element(By.ID, field + '_select'))
83 | for value in options:
84 | select.select_by_value(value)
85 | self.selenium.find_element(By.ID, field + '_submit').click()
86 | self.wait_for_reload()
87 | return Select(self.selenium.find_element(By.ID, field + '_select'))
88 |
89 |
90 | class LiveFilterTest(StaticLiveServerTestCase):
91 | @classmethod
92 | def setUpClass(cls):
93 | super().setUpClass()
94 | options = Options()
95 | options.headless = True
96 | cls.selenium = WebDriver(options=options)
97 | cls.url_path = reverse('admin:testapp_modela_changelist')
98 |
99 | @classmethod
100 | def tearDownClass(cls):
101 | cls.selenium.quit()
102 | super().tearDownClass()
103 |
104 | def setUp(self):
105 | create_test_data()
106 | self.page = FilterPage(self.selenium, self.live_server_url)
107 | self.page.login(self.client)
108 |
109 | def check_dropdown_filter(self, select_id, query_key, query_value, option, count):
110 | select = self.page.use_dropdown_filter(select_id, option)
111 | self.assertEqual(self.page.item_count, count)
112 | self.assertEqual(select.first_selected_option.text, option)
113 | if option == 'All':
114 | self.assertNotIn(query_key, self.page.url_query)
115 | else:
116 | self.assertIn(query_key + query_value, self.page.url_query)
117 |
118 | def test_01_dropdown_filter(self):
119 | self.page.get()
120 |
121 | # check simple dropdown filter
122 | select_id, query_key = 'dropdown-gt3_filter_select', 'dropdown_gt3='
123 | self.check_dropdown_filter(select_id, query_key, '2', '2', 9)
124 | self.check_dropdown_filter(select_id, query_key, '', 'All', 36)
125 |
126 | # Check choices dropdown filter:
127 | select_id, query_key = 'choices-dropdown_filter_select', 'choices_dropdown__exact='
128 | self.check_dropdown_filter(select_id, query_key, '3', 'three', 4)
129 | self.check_dropdown_filter(select_id, query_key, '', 'All', 36)
130 |
131 | # Check related dropdown filter:
132 | select_id, query_key = 'related-dropdown_filter_select', 'related_dropdown__id__exact='
133 | self.check_dropdown_filter(select_id, query_key, '9', 'ModelB 9', 1)
134 | self.check_dropdown_filter(select_id, query_key, '', 'All', 36)
135 |
136 | def check_multiselect_filter(self, ul_num, query_key, query_value, option, count, selected):
137 | ul = self.page.use_multiselect_filter(ul_num, option)
138 | self.assertEqual(self.page.item_count, count)
139 | self.assertEqual(self.page.get_selected_li_count(ul), selected)
140 | if option == 'All':
141 | self.assertNotIn(query_key, self.page.url_query)
142 | else:
143 | self.assertIn(query_key + query_value, self.page.url_query)
144 |
145 | def test_02_multiselect_filter(self):
146 | # start with an already filtered changelist
147 | self.page.get('dropdown_gt3=2')
148 |
149 | # check simple multiselect filter
150 | ul_num, query_key = self.page.MULTISELECT_UL, 'multiselect__in='
151 | self.check_multiselect_filter(ul_num, query_key, '4', '4', 2, 1)
152 | self.check_multiselect_filter(ul_num, query_key, '4,3', '3', 3, 2)
153 | self.check_multiselect_filter(ul_num, query_key, '4,3,2', '2', 5, 3)
154 | self.check_multiselect_filter(ul_num, query_key, '', 'All', 9, 1)
155 |
156 | # check the multiselect related filter
157 | ul_num, query_key = self.page.MULTISELECT_RELATED_UL, 'multiselect_related__id__in='
158 | self.check_multiselect_filter(ul_num, query_key, '34', 'ModelB 34', 1, 1)
159 | self.check_multiselect_filter(ul_num, query_key, '34,30', 'ModelB 30', 2, 2)
160 | self.check_multiselect_filter(ul_num, query_key, '34,30,26', 'ModelB 26', 3, 3)
161 | self.check_multiselect_filter(ul_num, query_key, '30,26', 'ModelB 34', 2, 2)
162 | self.check_multiselect_filter(ul_num, query_key, '', 'All', 9, 1)
163 |
164 | def check_multiselect_dropdown_filter(self, field, options, query_key, count):
165 | select = self.page.use_multiselect_dropdown_filter(field, options)
166 | self.assertEqual(len(select.all_selected_options), len(options))
167 | self.assertEqual(self.page.item_count, count)
168 | self.assertIn(query_key + ','.join(options), self.page.url_query)
169 | select.deselect_all()
170 |
171 | def test_03_multiselect_dropdown_filter(self):
172 | self.page.get()
173 |
174 | # check multiselect-dropdown
175 | field, query_key = 'multiselect-dropdown', 'multiselect_dropdown__in='
176 | options = [str(i) for i in range(2, 5)]
177 | self.check_multiselect_dropdown_filter(field, options, query_key, 18)
178 |
179 | # check multiselect-related-dropdown
180 | # (multiselect-dropdown filter is still effectual)
181 | field, query_key = 'multiselect-related-dropdown', 'multiselect_related_dropdown__id__in='
182 | options = [str(i) for i in range(1, 9)]
183 | self.check_multiselect_dropdown_filter(field, options, query_key, 4)
184 |
185 | def test_03_multiselect_dropdown_utf8_filter(self):
186 | self.page.get()
187 |
188 | # check multiselect dropdown filter with utf8
189 | field, query_key = 'multiselect-utf8', 'multiselect_utf8__in='
190 | v = lambda i: urllib.parse.quote_plus(f'{i} + {UTF8_STRESS_TEST_STRING}'.replace(',', '%~'))
191 | options = [v(i) for i in [0, 3]]
192 | self.check_multiselect_dropdown_filter(field, options, query_key, 14)
193 |
--------------------------------------------------------------------------------
/tests/testapp/urls.py:
--------------------------------------------------------------------------------
1 | """testapp URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from django.contrib import admin
17 | from django.urls import re_path
18 |
19 | urlpatterns = [
20 | re_path(r'^admin/', admin.site.urls),
21 | ]
22 |
--------------------------------------------------------------------------------
/tests/testapp/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for testapp project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # tox (https://tox.readthedocs.io/) is a tool for running tests
2 | # in multiple virtualenvs. This configuration file will run the
3 | # test suite on all supported python versions. To use it, "pip install tox"
4 | # and then run "tox" from this directory.
5 |
6 | [tox]
7 | envlist =
8 | {py37,py38}-django22
9 | {py37,py38}-django30
10 | {py37,py38}-django31
11 | {py38,py39}-django32
12 | {py39,py310}-django40
13 | {py39,py310}-django41
14 | {py39,py310}-django42
15 | {py310,py311}-django50
16 | {py310,py311}-django51
17 |
18 | skip_missing_interpreters = true
19 |
20 | [testenv]
21 | deps =
22 | django22: Django>=2.2,<3.0
23 | django30: Django>=3.0,<3.1
24 | django31: Django>=3.1,<3.2
25 | django32: Django>=3.2,<4.0
26 | django40: Django>=4.0,<4.1
27 | django41: Django>=4.1,<4.2
28 | django42: Django>=4.2,<5.0
29 | django50: Django>=5.0,<5.1
30 | django51: Django>=5.1,<5.2
31 | selenium<4.3.0
32 |
33 | commands = {envpython} tests/manage.py test testapp {posargs}
34 | setenv = PYTHONPATH = .:{toxworkdir}
35 |
--------------------------------------------------------------------------------