"""
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)