commit 94e57d4e8c1ac3686eb4df81a67024dcdf9b5b5f Author: Thomas Leichtfuß Date: Fri Sep 4 18:40:00 2020 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1f783a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +.python-version +.coverage +.coveralls.yml +db.sqlite3* +/django_admin_more_filters.egg-info/ +/build/ +/dist/ +/.tox/ +/.vscode/ diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..b5a0733 --- /dev/null +++ b/LICENCE @@ -0,0 +1,27 @@ +Copyright (c) 2020, Thomas Leichtfuß. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of the author nor the names of contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c937cb2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.rst +recursive-include more_filters/templates * diff --git a/more_filters/__init__.py b/more_filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/more_filters/apps.py b/more_filters/apps.py new file mode 100644 index 0000000..cb9beae --- /dev/null +++ b/more_filters/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DjangoAdminSelectfilterConfig(AppConfig): + name = 'django_admin_more_filters' diff --git a/more_filters/filters.py b/more_filters/filters.py new file mode 100644 index 0000000..a474a83 --- /dev/null +++ b/more_filters/filters.py @@ -0,0 +1,423 @@ +# -*- coding: utf-8 -*- +from django.contrib.admin.utils import prepare_lookup_value +from django.contrib import admin +from django.db.models import Q +from django.utils.encoding import force_text +from django.utils.translation import gettext_lazy as _ +from django.contrib.admin.utils import reverse_field_path +from django.contrib.admin.utils import get_model_from_relation +from django.core.exceptions import ValidationError +from django.contrib.admin.options import IncorrectLookupParameters +from django.contrib.admin.filters import AllValuesFieldListFilter +from django.contrib.admin.filters import ChoicesFieldListFilter +from django.contrib.admin.filters import RelatedFieldListFilter +from django.contrib.admin.filters import RelatedOnlyFieldListFilter + + +class SelectFilter(admin.SimpleListFilter): + title = _('Selection') + parameter_name = 'selected' + parameter_inverse = 'inverse' + template = 'selectfilter.html' + + def __init__(self, request, params, model, model_admin): + super(SelectFilter, self).__init__(request, params, model, model_admin) + self.inverse = eval(params.pop(self.parameter_inverse, 'False')) + + def has_output(self): + return True + + def lookups(self, request, model_admin): + return () + + def queryset(self, request, queryset): + if not self.value(): return + if self.inverse: + return queryset.exclude(id__in=self.value().split(',')) + else: + return queryset.filter(id__in=self.value().split(',')) + + def choices(self, changelist): + exclude = [self.parameter_name, self.parameter_inverse] + yield { + 'selected': self.value() is None, + 'query_string': changelist.get_query_string({}, exclude), + 'display': _('All'), + } + yield { + 'selected': bool(self.value()), + 'query_string': changelist.get_query_string({}, exclude), + 'display': _('Select'), + 'id': 'selectfilter', + } + if self.value() and self.inverse: + yield { + 'selected': False, + 'query_string': changelist.get_query_string({}, []), + 'display': _('* Remove'), + 'id': 'selectfilter_add' + } + exclude = [self.parameter_inverse] + yield { + 'selected': False, + 'query_string': changelist.get_query_string({}, exclude), + 'display': _('* Undo inversion'), + } + elif self.value() and not self.inverse: + yield { + 'selected': False, + 'query_string': changelist.get_query_string({}, []), + 'display': _('* Remove'), + 'id': 'selectfilter_remove' + } + include = {self.parameter_inverse: True} + yield { + 'selected': False, + 'query_string': changelist.get_query_string(include, []), + 'display': _('* Invert'), + } + + +class MultiSelectMixin(object): + def queryset(self, request, queryset): + params = Q() + for lookup_arg, value in self.used_parameters.items(): + params |= Q(**{lookup_arg:value}) + try: + return queryset.filter(params) + except (ValueError, ValidationError) as e: + # Fields may raise a ValueError or ValidationError when converting + # the parameters to the correct type. + raise IncorrectLookupParameters(e) + + def querystring_for_choices(self, val, changelist): + lookup_vals = self.lookup_vals[:] + if val in self.lookup_vals: + lookup_vals.remove(val) + else: + lookup_vals.append(val) + if lookup_vals: + query_string = changelist.get_query_string({ + self.lookup_kwarg: ','.join(lookup_vals), + }, []) + else: + query_string = changelist.get_query_string({}, + [self.lookup_kwarg]) + return query_string + + def querystring_for_isnull(self, changelist): + if self.lookup_val_isnull: + query_string = changelist.get_query_string({}, + [self.lookup_kwarg_isnull]) + else: + query_string = changelist.get_query_string({ + self.lookup_kwarg_isnull: 'True', + }, []) + return query_string + + def has_output(self): + return len(self.lookup_choices) > 1 + + +class MultiSelectFilter(MultiSelectMixin, admin.AllValuesFieldListFilter): + def __init__(self, field, request, params, model, model_admin, field_path): + self.lookup_kwarg = '%s__in' % field_path + self.lookup_kwarg_isnull = '%s__isnull' % field_path + lookup_vals = request.GET.get(self.lookup_kwarg) + self.lookup_vals = lookup_vals.split(',') if lookup_vals else list() + self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull) + self.empty_value_display = model_admin.get_empty_value_display() + parent_model, reverse_path = reverse_field_path(model, field_path) + # Obey parent ModelAdmin queryset when deciding which options to show + if model == parent_model: + queryset = model_admin.get_queryset(request) + else: + queryset = parent_model._default_manager.all() + self.lookup_choices = (queryset + .distinct() + .order_by(field.name) + .values_list(field.name, flat=True)) + super(admin.AllValuesFieldListFilter, self).__init__(field, request, params, model, model_admin, field_path) + self.used_parameters = self.prepare_used_parameters(self.used_parameters) + + def prepare_querystring_value(self, value): + # mask all commas or these values will be used + # in a comma-seperated-list as get-parameter + return str(value).replace(',', '%~') + + def prepare_used_parameters(self, used_parameters): + # remove comma-mask from list-values for __in-lookups + for key, value in used_parameters.items(): + if not key.endswith('__in'): continue + used_parameters[key] = [v.replace('%~', ',') for v in value] + return used_parameters + + def choices(self, changelist): + yield { + 'selected': not self.lookup_vals and self.lookup_val_isnull is None, + 'query_string': changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull]), + 'display': _('All'), + } + include_none = False + for val in self.lookup_choices: + if val is None: + include_none = True + continue + val = str(val) + qval = self.prepare_querystring_value(val) + yield { + 'selected': qval in self.lookup_vals, + 'query_string': self.querystring_for_choices(qval, changelist), + 'display': val, + } + if include_none: + yield { + 'selected': bool(self.lookup_val_isnull), + 'query_string': self.querystring_for_isnull(changelist), + 'display': self.empty_value_display, + } + + +class MultiSelectRelatedFilter(MultiSelectMixin, admin.RelatedFieldListFilter): + def __init__(self, field, request, params, model, model_admin, field_path): + other_model = get_model_from_relation(field) + self.lookup_kwarg = '%s__%s__in' % (field_path, field.target_field.name) + self.lookup_kwarg_isnull = '%s__isnull' % field_path + lookup_vals = request.GET.get(self.lookup_kwarg) + self.lookup_vals = lookup_vals.split(',') if lookup_vals else list() + self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull) + super(admin.RelatedFieldListFilter, self).__init__(field, request, params, model, model_admin, field_path) + self.lookup_choices = self.field_choices(field, request, model_admin) + if hasattr(field, 'verbose_name'): + self.lookup_title = field.verbose_name + else: + self.lookup_title = other_model._meta.verbose_name + self.title = self.lookup_title + self.empty_value_display = model_admin.get_empty_value_display() + + def choices(self, changelist): + yield { + 'selected': not self.lookup_vals and not self.lookup_val_isnull, + 'query_string': changelist.get_query_string( + {}, + [self.lookup_kwarg, self.lookup_kwarg_isnull] + ), + 'display': _('All'), + } + for pk_val, val in self.lookup_choices: + pk_val = str(pk_val) + yield { + 'selected': pk_val in self.lookup_vals, + 'query_string': self.querystring_for_choices(pk_val, changelist), + 'display': val, + } + if self.include_empty_choice: + yield { + 'selected': bool(self.lookup_val_isnull), + 'query_string': self.querystring_for_isnull(changelist), + 'display': self.empty_value_display, + } + + +class MultiSelectDropdownFilter(MultiSelectFilter): + template = 'dropdownmultiselectfilter.html' + + def choices(self, changelist): + query_string = changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull]) + yield { + 'selected': not self.lookup_vals and self.lookup_val_isnull is None, + 'query_string': query_string, + 'display': _('All'), + } + include_none = False + for val in self.lookup_choices: + if val is None: + include_none = True + continue + + val = str(val) + qval = self.prepare_querystring_value(val) + yield { + 'selected': qval in self.lookup_vals, + 'query_string': query_string, + 'display': val, + 'value': val, + 'key': self.lookup_kwarg, + } + if include_none: + yield { + 'selected': bool(self.lookup_val_isnull), + 'query_string': query_string, + 'display': self.empty_value_display, + 'value': 'True', + 'key': self.lookup_kwarg_isnull, + } + + +class MultiSelectRelatedDropdownFilter(MultiSelectRelatedFilter): + template = 'dropdownmultiselectfilter.html' + + def choices(self, changelist): + query_string = changelist.get_query_string({}, [self.lookup_kwarg, self.lookup_kwarg_isnull]) + yield { + 'selected': not self.lookup_vals and not self.lookup_val_isnull, + 'query_string': query_string, + 'display': _('All'), + } + for pk_val, val in self.lookup_choices: + pk_val = str(pk_val) + yield { + 'selected': pk_val in self.lookup_vals, + 'query_string': query_string, + 'display': val, + 'value': pk_val, + 'key': self.lookup_kwarg, + } + if self.include_empty_choice: + yield { + 'selected': bool(self.lookup_val_isnull), + 'query_string': query_string, + 'display': self.empty_value_display, + 'value': 'True', + 'key': self.lookup_kwarg_isnull, + } + + +class DropdownFilter(AllValuesFieldListFilter): + template = 'dropdownfilter.html' + + +class ChoicesDropdownFilter(ChoicesFieldListFilter): + template = 'dropdownfilter.html' + + +class RelatedDropdownFilter(RelatedFieldListFilter): + template = 'dropdownfilter.html' + + +class RelatedOnlyDropdownFilter(RelatedOnlyFieldListFilter): + template = 'dropdownfilter.html' + + +class PlusMinusFilter(admin.AllValuesFieldListFilter): + """ + PlusMinusFilter + """ + + template = 'plusminusfilter.html' + + def queryset(self, request, queryset): + if self.lookup_val is None: return queryset + sign = self.lookup_val[0] + value = self.lookup_val[1:] + if sign == '-': + return queryset.exclude(**{self.field_path:value}) + elif sign == '+': + return queryset.filter(**{self.field_path:value}) + + def choices(self, changelist): + yield { + 'selected': self.lookup_val is None, + 'query_string': changelist.get_query_string({}, [self.lookup_kwarg]), + 'display': _('All'), + } + for lookup in self.lookup_choices: + plus_lookup = '+' + lookup + minus_lookup = '-' + lookup + yield { + 'display': lookup, + 'selected': self.lookup_val and self.lookup_val[1:] == force_text(lookup), + 'plus': { + 'selected': self.lookup_val == force_text(plus_lookup), + 'query_string': changelist.get_query_string({self.lookup_kwarg: plus_lookup}, []), + }, + 'minus': { + 'selected': self.lookup_val == force_text(minus_lookup), + 'query_string': changelist.get_query_string({self.lookup_kwarg: minus_lookup}, []), + } + } + + +class AnnotationListFilter(admin.ListFilter): + """ + Baseclass for annotation-list-filters. + + This is more or less a rewrite of admin.FieldListFilter. But we must not + subclass it to not confuse django's filter-setup-routine. + """ + attribute_name = None + nullable_attribute = None + + @classmethod + def init(cls, attribute_name, nullable=True): + """ + Since filters are listed as classes in ModelAdmin.list_filter we are + not able to initialize the filter within the ModelAdmin. + We use this classmethod to setup a filter-class for a specific annotated + attribute:: + + MyModelAdmin(admin.ModelAdmin): + list_filter = [ + MyAnnotationListFilter.init('my_attribute'), + ] + """ + attrs = dict(attribute_name=attribute_name, nullable=nullable) + cls = type('cls.__name__' + attribute_name, (cls,), attrs) + return cls + + def __init__(self, attribute_name, request, params, model, model_admin): + self.title = attribute_name + super().__init__(request, params, model, model_admin) + for p in self.expected_parameters(): + if p in params: + value = params.pop(p) + self.used_parameters[p] = prepare_lookup_value(p, value) + + def has_output(self): + return True + + def queryset(self, request, queryset): + try: + return queryset.filter(**self.used_parameters) + except (ValueError, ValidationError) as e: + # Fields may raise a ValueError or ValidationError when converting + # the parameters to the correct type. + raise IncorrectLookupParameters(e) + + +class BooleanAnnotationListFilter(AnnotationListFilter): + """ + Filter for annotated boolean-attributes. + + This is more or less the same than admin.BooleanFieldListFilter but for + annotated attributes. + """ + def __init__(self, request, params, model, model_admin): + self.lookup_kwarg = '%s__exact' % self.attribute_name + self.lookup_kwarg2 = '%s__isnull' % self.attribute_name + self.lookup_val = params.get(self.lookup_kwarg) + self.lookup_val2 = params.get(self.lookup_kwarg2) + super().__init__(self.attribute_name, request, params, model, model_admin) + if (self.used_parameters and self.lookup_kwarg in self.used_parameters and + self.used_parameters[self.lookup_kwarg] in ('1', '0')): + self.used_parameters[self.lookup_kwarg] = bool(int(self.used_parameters[self.lookup_kwarg])) + + def expected_parameters(self): + return [self.lookup_kwarg, self.lookup_kwarg2] + + def choices(self, changelist): + for lookup, title in ( + (None, _('All')), + ('1', _('Yes')), + ('0', _('No'))): + yield { + 'selected': self.lookup_val == lookup and not self.lookup_val2, + 'query_string': changelist.get_query_string({self.lookup_kwarg: lookup}, [self.lookup_kwarg2]), + 'display': title, + } + if self.nullable_attribute: + yield { + 'selected': self.lookup_val2 == 'True', + 'query_string': changelist.get_query_string({self.lookup_kwarg2: 'True'}, [self.lookup_kwarg]), + 'display': _('Unknown'), + } diff --git a/more_filters/templates/dropdownfilter.html b/more_filters/templates/dropdownfilter.html new file mode 100644 index 0000000..cd5a3ea --- /dev/null +++ b/more_filters/templates/dropdownfilter.html @@ -0,0 +1,26 @@ +{% load i18n admin_urls %} +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+ + + + diff --git a/more_filters/templates/dropdownmultiselectfilter.html b/more_filters/templates/dropdownmultiselectfilter.html new file mode 100644 index 0000000..6e4bca8 --- /dev/null +++ b/more_filters/templates/dropdownmultiselectfilter.html @@ -0,0 +1,55 @@ +{% load i18n admin_urls %} +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+ + + + diff --git a/more_filters/templates/selectfilter.html b/more_filters/templates/selectfilter.html new file mode 100644 index 0000000..235f47d --- /dev/null +++ b/more_filters/templates/selectfilter.html @@ -0,0 +1,36 @@ +{% load i18n %} +

{% blocktrans with filter_title=title %}{{ filter_title }} {% endblocktrans %}

+ +