Source code for superdjango.ui.options.filters

"""
List filters for UI model lists.
"""
__author__ = "Shawn Davis <shawn@superdjango.com>"
__maintainer__ = "Shawn Davis <shawn@superdjango.com>"
__version__ = "0.5.1-d"

# Imports

import calendar
import datetime
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import os
from superdjango.shortcuts import title
from superdjango.ui.runtime.filters import Choice, Filter
from ..constants import FILTER, LOCATION, ORIENTATION

Q = models.Q

# Exports

__all__ = (
    "factory",
    "guess_factory",
    "AdHocFilter",
    "BaseFilter",
    "BooleanFilter",
    "ChoiceFilter",
    "DateFilter",
    "ForeignKeyFilter",
    "ManyToManyFilter",
    "NullBooleanFilter",
)

# Functions


[docs]def factory(field_instance, filter_class=None, keyword=None, label=None, location=LOCATION.TOP, orientation=None, template=None): """Create a filter for the given field. :param field_instance: The field instance to be checked. :type field_instance: BaseType[django.db.models.Field] :param filter_class: The filter class to use. If omitted a guess will be made. :param keyword: The GET keyword to use for identifying filter value. :type keyword: str :param label: The label for the filter control. Defaults to the verbose name of the field. :type label: str :param location: The location of the field. :type location: str :param orientation: The orientation of the filter. :type orientation: str :param template: The path to the filter template. :type template: str :rtype: BaseType[BaseFilter] | None """ if filter_class is None: filter_class = guess_factory(field_instance) if filter_class is None: return None return filter_class( field_instance=field_instance, keyword=keyword, label=label, location=location, orientation=orientation, template=template )
[docs]def guess_factory(field_instance): """Attempt to guess the appropriate filter class based on the field instance. :param field_instance: The field instance to be checked. :type field_instance: BaseType[django.db.models.Field] :rtype: class | None """ from django.contrib.contenttypes.fields import GenericForeignKey if isinstance(field_instance, (models.ManyToManyRel, models.ManyToOneRel, models.OneToOneRel)): return None if isinstance(field_instance, models.NullBooleanField): return NullBooleanFilter elif type(field_instance) == models.ManyToManyField: return ManyToManyFilter elif isinstance(field_instance, models.BooleanField): return BooleanFilter elif isinstance(field_instance, GenericForeignKey): return None elif field_instance.choices: return ChoiceFilter elif isinstance(field_instance, (models.DateField, models.DateTimeField)): return DateFilter elif isinstance(field_instance, models.ForeignKey): return ForeignKeyFilter else: return None
# Classes
[docs]class BaseFilter(object): """Base class for defining filters."""
[docs] def __init__(self, empty_choice=None, field_instance=None, field_name=None, keyword=None, label=None, location=LOCATION.TOP, orientation=None, template=None): """Initialize a filter. :param empty_choice: The label for an empty choice. :type empty_choice: str :param field_instance: The field instance to which the filter is applied. This is added dynamically when the UI is initialized. :param field_name: The name of the field being filtered. This is added dynamically when the UI is initialized. :type field_name: str :param keyword: The keyword used to identify the filter in GET. Added dynamically. :type keyword: str :param label: The label of the filter. :type label: str :param location: The desired location of the filter on the page. :type location: str :param orientation: The orientation of the filter. Influenced by the location. :type orientation: str :param template: The output template of the filter. :type template: str """ self.empty_choice = empty_choice self.field_instance = field_instance self.field_name = field_name self.keyword = keyword self.location = location self.template = template self._label = label if orientation is not None: self.orientation = orientation elif location in (LOCATION.LEFT, LOCATION.RIGHT): self.orientation = ORIENTATION.VERTICAL else: self.orientation = ORIENTATION.HORIZONTAL
def __repr__(self): name = "unknown" if self.field_instance: name = self.field_instance.name return "<%s %s>" % (self.__class__.__name__, name)
[docs] def get_choices(self, request, ui): """Get the filter choices. Must be implemented by child classes. :rtype: list[superdjango.ui.runtime.filter.Choice] """ raise NotImplementedError()
[docs] def get_current_value(self, request): """Get the value of the current choice. :rtype: list[str] | str | None """ value = request.GET.get(self.get_keyword(), None) if value is None or value in (0, "None"): return None if "," in value: return value.split(",") return value
[docs] def get_empty_choice(self): """Get the empty/no choice instance. :rtype: superdjango.ui.runtime.filter.Choice """ if self.empty_choice is not None: return Choice(self.empty_choice, None, is_active=True) # Horizontal choices have limited space. Assume the label of the choice is included as the empty choice. if self.is_horizontal: return Choice(self.label, None, is_active=True) return Choice(_("ALL"), None, is_active=True)
[docs] def get_keyword(self): """Get the keyword used for filtering. :rtype: str """ return self.keyword or "f_%s" % self.field_instance.name
[docs] def get_queryset(self, queryset, request, ui): """Get the filtered queryset. :param queryset: The existing queryset to be filtered. :type queryset: django.db.models.QuerySet :param request: The current HTTP request instance. :param ui: The current model UI instance. :type ui: ModelUI :rtype: django.db.models.QuerySet """ raise NotImplementedError()
# noinspection PyUnusedLocal
[docs] def get_template(self, request, ui): """Get the template to use for the filter. :param request: The current request instance. :param ui: The current ModelUI instance. :rtype: str The template is resolved in the following manner: 1. If a template was given upon instantiation, it is always used. 2. The location of the filter is used to set the default template. """ if self.template is not None: return self.template if self.location in (LOCATION.LEFT, LOCATION.RIGHT): template = "model_list_filter_" + FILTER.LISTGROUP else: template = "model_list_filter_" + FILTER.SELECT return os.path.join("superdjango", "ui", "includes", "%s.html" % template)
@property def is_horizontal(self): """Indicates the intended orientation for filter controls is horizontal. :rtype: bool """ return self.orientation == ORIENTATION.HORIZONTAL @property def is_vertical(self): """Indicates the intended orientation for filter controls is vertical. :rtype: bool """ return self.orientation == ORIENTATION.VERTICAL @property def label(self): """Get the label for the filter. :rtype: str """ if self._label is not None: return self._label if self.field_instance.verbose_name: return title(self.field_instance.verbose_name) return title(self.field_instance.name.replace("_", " "))
[docs] def load(self, request, ui): """Load the filter, performing any pre-rendering steps required to process the filter or its results. :param request: The current request instance. :type request: HttpRequest :param ui: The model UI instance. :type ui: superdjango.ui.interfaces.ModelUI :rtype: Filter :raise: ImproperlyConfigured """ # It's possible with custom development that the filter instance hasn't been fully prepared by runtime, so we # need to make sure that a field instance has been provided. if self.field_instance is None: raise ImproperlyConfigured("Filter requires a field_instance.") kwargs = { 'choices': self.get_choices(request, ui), 'keyword': self.get_keyword(), 'location': self.location, 'orientation': self.orientation, 'template': self.get_template(request, ui), } return Filter(self.label, self.field_instance.name, **kwargs)
# def prepare(self, request, ui): # """Allow the filter to prepare for queries. # :param request: The current request instance. # :type request: HttpRequest # # :param ui: The model UI instance. # :type ui: superdjango.ui.interfaces.ModelUI # """
[docs]class AdHocFilter(BaseFilter): """Attempts to allow *any* filed to be filtered. .. warning:: This filter is *not* provided by the factory and must be used manually and intentionally due to the potential performance ramifications. """
[docs] def get_choices(self, request, ui): """Acquire choices from distinct field values.""" current_value = self.get_current_value(request) a = list() if type(current_value) is list: a.append(Choice(_("Multiple Values"), None, is_active=True)) elif current_value is not None: a.append(Choice(_("Clear"), None)) else: a.append(self.get_empty_choice()) qs = ui.model.objects.values(self.field_name, flat=True).order_by(self.field_name) for r in qs: if str(r) == str(current_value): a.append(Choice(r, r, is_active=True)) else: a.append(Choice(r, r)) return a
[docs] def get_queryset(self, queryset, request, ui): """Provide adhoc filter results.""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = '%s__exact' % self.field_instance.name if type(current_value) is list: or_condition = Q() for i in current_value: criteria = { lookup: i, } or_condition.add(Q(**criteria), Q.OR) return queryset.filter(or_condition) criteria = { lookup: current_value, } return queryset.filter(**criteria)
[docs]class BooleanFilter(BaseFilter): """A filter for boolean fields."""
[docs] def get_choices(self, request, ui): """Handle boolean choices.""" current_value = self.get_current_value(request) if current_value is not None: choices = [ Choice(_("Clear"), None), Choice(_("%s: Yes" % self.label), 1), Choice(_("%s: No" % self.label), 0), ] else: choices = [ self.get_empty_choice(), Choice(_("Yes"), 1), Choice(_("No"), 0) ] if current_value == "1": choices[1].is_active = True elif current_value == "0": choices[2].is_active = True else: pass return choices
[docs] def get_queryset(self, queryset, request, ui): """Handle boolean filtering""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = self.field_instance.name if current_value == "1": value = True else: value = False criteria = { lookup: value, } return queryset.filter(**criteria)
[docs]class ChoiceFilter(BaseFilter): """A filter for fields with choices."""
[docs] def get_choices(self, request, ui): """Get the filter choices displayed to the user. :rtype: list[superdjango.ui.runtime.filter.Choice] """ current_value = self.get_current_value(request) a = list() if type(current_value) is list: a.append(Choice(_("Multiple Values"), None, is_active=True)) elif current_value is not None: a.append(Choice(_("Clear"), None)) else: a.append(self.get_empty_choice()) for value, label in self.field_instance.choices: if current_value is not None: label = "%s: %s" % (self.label, label) choice = Choice(label, value) if str(value) == str(current_value): choice.is_active = True if type(current_value) is list and str(value) in current_value: choice.is_multi = True a.append(choice) return a
[docs] def get_queryset(self, queryset, request, ui): """Handle choices filtering.""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = '%s__exact' % self.field_instance.name if type(current_value) is list: or_condition = Q() for i in current_value: criteria = { lookup: i, } or_condition.add(Q(**criteria), Q.OR) return queryset.filter(or_condition) criteria = { lookup: current_value, } return queryset.filter(**criteria)
[docs]class DateFilter(BaseFilter): """A filter for dates and datetimes."""
[docs] def __init__(self, choices=None, keyword_null=None, keyword_since=None, keyword_until=None, **kwargs): """Initialize the date filter. :param choices: Override the standard choices. :param keyword_null: The GET keyword to use for "no choice". :type keyword_null: str :param keyword_since: The GET keyword to use for the "since" filter. :type keyword_since: str :param keyword_until: The GET keyword to use for the "until" filter. :type keyword_until: str """ super().__init__(**kwargs) self.keyword_null = keyword_null self.keyword_since = keyword_since self.keyword_until = keyword_until self._choices = choices
[docs] def get_choices(self, request, ui): """Get the choices based on the filter options provided upon instantiation.""" current_value = self.get_current_value(request) a = [ Choice(self.label, None, is_active=current_value is None), Choice(_("Today"), "today", is_active=current_value == "today"), Choice(_("Tomorrow"), "tomorrow", is_active=current_value == "tomorrow"), Choice(_("Past 7 Days"), "past_7_days", is_active=current_value == "past_7_days"), Choice(_("Next 7 Days"), "next_7_days", is_active=current_value == "next_7_days"), Choice(_("This Month"), "this_month", is_active=current_value == "this_month"), Choice(_("Next Month"), "next_month", is_active=current_value == "next_month"), Choice(_("This Year"), "this_year", is_active=current_value == "this_year"), Choice(_("Next Year"), "next_year", is_active=current_value == "next_year"), ] if self.field_instance.null: a += [ Choice(_("No Date"), "null", is_active=current_value == "null"), Choice(_("Any Date"), "notnull", is_active=current_value == "notnull"), ] for choice in a: if current_value and choice.is_active: choice.label = "%s: %s" % (self.label, choice.label) # a = [ # Choice(_("ANY"), None), # Choice(_("Today"), { # self.keyword_since: str(today), # self.keyword_until: str(tomorrow), # }), # Choice(_("Past 7 Days"), { # self.keyword_since: str(past_7_days), # self.keyword_until: str(tomorrow), # }), # Choice(_("This Month"), { # self.keyword_since: str(today.replace(day=1)), # self.keyword_until: str(next_month), # }), # Choice(_("This Year"), { # self.keyword_since: str(today.replace(month=1, day=1)), # self.keyword_until: str(next_year), # }), # ] # # if self.field_instance.null: # self.keyword_null = '%s__isnull' % self.field_instance.name # a += [ # Choice(_("No Date"), { # self.field_instance.name + '__isnull': 'True' # }), # Choice(_("Has Date"), { # self.field_instance.name + '__isnull': 'False' # }), # ] return a
[docs] def get_criteria(self): """Advanced handling of date-related criteria.""" times = self.get_times() end_of_month = times['end_of_month'] last_day_of_next_month = times['last_day_of_next_month'] next_7_days = times['next_7_days'] next_month = times['next_month'] next_year = times['next_year'] past_7_days = times['past_7_days'] start_of_month = times['start_of_month'] today = times['today'] tomorrow = times['tomorrow'] criteria = { 'next_7_days': { self.keyword_since: today, self.keyword_until: next_7_days, }, 'next_month': { self.keyword_since: next_month, self.keyword_until: next_month.replace(day=last_day_of_next_month), }, 'next_year': { self.keyword_since: today.replace(month=1, day=1), self.keyword_until: next_year, }, 'notnull': { self.keyword_null: False, }, 'null': { self.keyword_null: True, }, 'past_7_days': { self.keyword_since: past_7_days, self.keyword_until: tomorrow, }, 'this_month': { self.keyword_since: start_of_month, self.keyword_until: end_of_month, }, 'this_year': { self.keyword_since: today.replace(month=1, day=1), self.keyword_until: today.replace(month=12, day=31), }, 'today': { self.keyword_since: today, self.keyword_until: tomorrow, }, 'tomorrow': { self.keyword_since: tomorrow, self.keyword_until: tomorrow + datetime.timedelta(days=1), }, } return criteria
[docs] def get_queryset(self, queryset, request, ui): """Get the queryset based on various date-related conditions.""" self._prepare() current_value = self.get_current_value(request) if current_value is None: return queryset _criteria = self.get_criteria() if type(current_value) is list: or_condition = Q() for i in current_value: try: criteria = _criteria[i] or_condition.add(Q(**criteria), Q.OR) except KeyError: raise ValueError("Unrecognized date filter value: %s" % i) return queryset.filter(or_condition) else: try: criteria = _criteria[current_value] return queryset.filter(**criteria) except KeyError: raise ValueError("Unrecognized date filter value: %s" % current_value)
[docs] def get_times(self): """Get the date and datetime values used for filtering. :rtype: dict """ # Adapted from Django's DateFieldListFilter. now = timezone.now() if timezone.is_aware(now): now = timezone.localtime(now) if isinstance(self.field_instance, models.DateTimeField): today = now.replace(hour=0, minute=0, second=0, microsecond=0) else: today = now.date() tomorrow = today + datetime.timedelta(days=1) last_day_of_month = calendar.monthrange(today.year, today.month)[1] if today.month == 12: next_month = today.replace(year=today.year + 1, month=1, day=1) else: next_month = today.replace(month=today.month + 1, day=1) last_day_of_next_month = calendar.monthrange(next_month.year, next_month.month)[1] if isinstance(self.field_instance, models.DateTimeField): end_of_month = today.replace(day=last_day_of_month, hour=23, minute=59, second=59) else: end_of_month = today.replace(day=last_day_of_month) next_year = today.replace(year=today.year + 1, month=1, day=1) return { 'end_of_month': end_of_month, 'last_day_of_month': last_day_of_month, 'last_day_of_next_month': last_day_of_next_month, 'next_7_days': today + datetime.timedelta(days=7), 'next_month': next_month, 'next_year': next_year, 'past_7_days': today - datetime.timedelta(days=7), 'start_of_month': today.replace(day=1), 'today': today, 'tomorrow': tomorrow, }
[docs] def load(self, request, ui): """Override to deal with special keywords.""" if self.field_instance is None: raise ImproperlyConfigured("Filter requires a field_instance.") kwargs = { 'choices': self.get_choices(request, ui), 'keyword': self.get_keyword(), 'location': self.location, 'orientation': self.orientation, 'template': self.get_template(request, ui), } return Filter(self.label, self.field_instance.name, **kwargs)
def _prepare(self): """Run before handling queries.""" if self.field_instance is None: raise ImproperlyConfigured("Filter requires a field_instance.") self.keyword_since = '%s__gte' % self.field_instance.name self.keyword_until = '%s__lte' % self.field_instance.name self.keyword_null = '%s__isnull' % self.field_instance.name
[docs]class ForeignKeyFilter(BaseFilter): """A filter for foreign keys."""
[docs] def get_choices(self, request, ui): """Get the choices from a queryset.""" choices = list() choices.append(self.get_empty_choice()) current_value = self.get_current_value(request) if type(current_value) is list: choices.append(Choice(_("Multiple Values"), None, is_active=True)) manager = getattr(ui.model, self.field_instance.name) qs = manager.get_queryset() criteria = self.get_criteria(request, ui) if criteria is not None: qs = qs.filter(**criteria) for row in qs: # TODO: The ForeignKeyFilter identifier should be the preferred identifier for the foreign model, but # currently defaults to the primary key. identifier = getattr(row, "pk") try: label = "%s: %s" % (self.label, row.get_choice_name()) except AttributeError: label = "%s: %s" % (self.label, str(row)) choice = Choice(label, identifier) if str(identifier) == str(current_value): choice.is_active = True if type(current_value) is list and str(identifier) in current_value: choice.is_multi = True choices.append(choice) return choices
# noinspection PyMethodMayBeStatic,PyUnusedLocal
[docs] def get_criteria(self, request, ui): """Provide extra criteria for ``get_choices()``. :rtype: dict | None """ return None
[docs] def get_lookup(self): """Get the lookup key used to identify the filtered records.""" return "%s__pk" % self.field_instance.name
[docs] def get_queryset(self, queryset, request, ui): """Get the queryset using various conditions.""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = self.get_lookup() if type(current_value) is list: or_condition = Q() for i in current_value: criteria = { lookup: i, } or_condition.add(Q(**criteria), Q.OR) return queryset.filter(or_condition) criteria = { lookup: current_value, } return queryset.filter(**criteria)
[docs]class ManyToManyFilter(BaseFilter): """A filter for many to many fields.""" # def __init__(self, **kwargs): # self.relationship = kwargs.pop('field_instance') # # kwargs['field_instance'] = self.relationship.field # # super().__init__(**kwargs)
[docs] def get_choices(self, request, ui): """Get the choices from a queryset.""" choices = list() choices.append(self.get_empty_choice()) current_value = self.get_current_value(request) if type(current_value) is list: choices.append(Choice(_("Multiple Values"), None, is_active=True)) # noinspection PyProtectedMember manager = self.field_instance.related_model._default_manager qs = manager.get_queryset() criteria = self.get_criteria(request, ui) if criteria is not None: qs = qs.filter(**criteria) for row in qs: # TODO: The ManyToManyFilter identifier should be the preferred identifier for the foreign model, but # currently defaults to the primary key. identifier = getattr(row, "pk") try: label = "%s: %s" % (self.label, row.get_choice_name()) except AttributeError: label = "%s: %s" % (self.label, str(row)) choice = Choice(label, identifier) if str(identifier) == str(current_value): choice.is_active = True if type(current_value) is list and str(identifier) in current_value: choice.is_multi = True choices.append(choice) return choices
# noinspection PyMethodMayBeStatic,PyUnusedLocal
[docs] def get_criteria(self, request, ui): """Provide extra criteria for ``get_choices()``. :rtype: dict | None """ return None
[docs] def get_lookup(self): """Get the lookup key used to identify the filtered records.""" return "%s__pk" % self.field_instance.name
[docs] def get_queryset(self, queryset, request, ui): """Get the queryset using various conditions.""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = self.get_lookup() if type(current_value) is list: or_condition = Q() for i in current_value: criteria = { lookup: i, } or_condition.add(Q(**criteria), Q.OR) return queryset.filter(or_condition) criteria = { lookup: current_value, } return queryset.filter(**criteria)
[docs]class NullBooleanFilter(BaseFilter): """A filter for null-boolean fields."""
[docs] def get_choices(self, request, ui): """Get choices specific to null-boolean values.""" current_value = self.get_current_value(request) if current_value is not None: choices = [ Choice(_("Clear"), None), Choice(_("%s: Yes" % self.label), 1), Choice(_("%s: No" % self.label), 0), Choice(_("%s: Unspecified" % self.label), -1) ] else: choices = [ self.get_empty_choice(), Choice(_("Yes"), 1), Choice(_("No"), 0), Choice(_("Unspecified"), -1) ] if current_value == "1": choices[1].is_active = True elif current_value == "0": choices[2].is_active = True elif current_value in ("-1", "none", "None", None): choices[3].is_active = True else: pass return choices
[docs] def get_queryset(self, queryset, request, ui): """Get the queryset specific to null-boolean values.""" current_value = self.get_current_value(request) if current_value is None: return queryset lookup = self.field_instance.name if current_value == "1": value = True elif current_value == "0": value = False else: value = None criteria = { lookup: value, } return queryset.filter(**criteria)