├── .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 | [![Run tests for django-more-admin-filters](https://github.com/thomst/django-more-admin-filters/actions/workflows/ci.yml/badge.svg)](https://github.com/thomst/django-more-admin-filters/actions/workflows/ci.yml) 4 | [![coveralls badge](https://coveralls.io/repos/github/thomst/django-more-admin-filters/badge.svg?branch=master)](https://coveralls.io/github/thomst/django-more-admin-filters?branch=master) 5 | [![python: 3.7, 3.8, 3.9, 3.10, 3.11](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)](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 | [![django: 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0, 5.1](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)](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 | 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 | 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 | --------------------------------------------------------------------------------