# -*- coding: utf-8 -*-
# This file is part of Shoop.
#
# Copyright (c) 2012-2016, Shoop Ltd. All rights reserved.
#
# This source code is licensed under the AGPLv3 license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import unicode_literals
import json
import six
from django.core.paginator import EmptyPage, Paginator
from django.db.models import Manager, Q, QuerySet
from django.http.response import JsonResponse
from django.template.defaultfilters import yesno
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import get_language
from shoop.admin.utils.urls import get_model_url, NoModelUrl
from shoop.utils.dates import try_parse_date
from shoop.utils.objects import compact
from shoop.utils.serialization import ExtendedJSONEncoder
[docs]def maybe_callable(thing, context=None):
"""
If `thing` is callable, return it.
If `thing` names a callable attribute of `context`, return it.
"""
if callable(thing):
return thing
if isinstance(thing, six.string_types):
thing = getattr(context, thing, None)
if callable(thing):
return thing
return None
[docs]def maybe_call(thing, context, args=None, kwargs=None):
"""
If `thing` is callable, call it with args and kwargs and return the value.
If `thing` names a callable attribute of `context`, call it with args and kwargs and return the value.
Otherwise return `thing`.
"""
func = maybe_callable(context=context, thing=thing)
if func:
thing = func(*(args or ()), **(kwargs or {}))
return thing
[docs]class Filter(object):
type = None
[docs] def to_json(self, context):
return None
[docs] def filter_queryset(self, queryset, column, value):
return queryset # pragma: no cover
[docs]class ChoicesFilter(Filter):
type = "choices"
def __init__(self, choices=None, filter_field=None, default=None):
self.filter_field = filter_field
self.choices = choices
self.default = default
def _flatten_choices(self, context):
if not self.choices:
return None
choices = maybe_call(self.choices, context=context)
if isinstance(choices, QuerySet):
choices = [(c.pk, c) for c in choices]
return [("_all", "")] + [
(force_text(value, strings_only=True), force_text(display))
for (value, display)
in choices
]
[docs] def to_json(self, context):
return {
"choices": self._flatten_choices(context),
"defaultChoice": self.default
}
[docs] def filter_queryset(self, queryset, column, value):
if value == "_all":
return queryset
return queryset.filter(**{(self.filter_field or column.id): value})
[docs]class RangeFilter(Filter):
type = "range"
def __init__(self, min=None, max=None, step=None, field_type=None, filter_field=None):
"""
:param filter_field: Filter field (Django query expression). If None, column ID is used.
:type filter_field: str|None
:param min: Minimum value.
:param max: Maximum value.
:param step: Step value. See the HTML5 documentation for semantics.
:param field_type: Field type string. See the HTML5 documentation for semantics.
:type field_type: str|None
"""
self.filter_field = filter_field
self.min = min
self.max = max
self.step = step
self.field_type = field_type
[docs] def to_json(self, context):
return {
"range": compact({
"min": maybe_call(self.min, context=context),
"max": maybe_call(self.max, context=context),
"step": maybe_call(self.step, context=context),
"type": self.field_type,
})
}
[docs] def filter_queryset(self, queryset, column, value):
if value:
min = value.get("min")
max = value.get("max")
q = {}
filter_field = (self.filter_field or column.id)
if min is not None:
q["%s__gte" % filter_field] = min
if max is not None:
q["%s__lte" % filter_field] = max
if q:
queryset = queryset.filter(**q)
return queryset
[docs]class DateRangeFilter(RangeFilter):
def __init__(self, *args, **kwargs):
super(DateRangeFilter, self).__init__(*args, **kwargs)
if not self.field_type:
self.field_type = "date"
[docs] def filter_queryset(self, queryset, column, value):
if value:
value = {
"min": try_parse_date(value.get("min")),
"max": try_parse_date(value.get("max")),
}
return super(DateRangeFilter, self).filter_queryset(queryset, column, value)
[docs]class TextFilter(Filter):
type = "text"
def __init__(self, field_type=None, placeholder=None, operator="icontains", filter_field=None):
"""
:param filter_field: Filter field (Django query expression). If None, column ID is used.
:type filter_field: str|None
:param field_type: Field type string. See the HTML5 documentation for semantics.
:type field_type: str|None
:param placeholder: Field placeholder string.
:type placeholder: str|None
:param operator: Django operator for the queryset.
:type operator: str
"""
self.filter_field = filter_field
self.field_type = field_type
self.placeholder = placeholder
self.operator = operator
[docs] def to_json(self, context):
return {
"text": compact({
"type": self.field_type,
"placeholder": force_text(self.placeholder) if self.placeholder else None,
})
}
[docs] def filter_queryset(self, queryset, column, value):
if value:
value = force_text(value).strip()
if value:
return queryset.filter(**{"%s__%s" % ((self.filter_field or column.id), self.operator): value})
return queryset
[docs]class MultiFieldTextFilter(TextFilter):
def __init__(self, filter_fields, **kwargs):
"""
:param filter_field: List of Filter fields (Django query expression).
:type filter_field: list<str>
:param kwargs: Kwargs for `TextFilter`.
"""
super(MultiFieldTextFilter, self).__init__(**kwargs)
self.filter_fields = tuple(filter_fields)
[docs] def filter_queryset(self, queryset, column, value):
if value:
q = Q()
for filter_field in self.filter_fields:
q |= Q(**{"%s__%s" % (filter_field, self.operator): value})
return queryset.filter(q)
return queryset
true_or_false_filter = ChoicesFilter([
(False, _("no")),
(True, _("yes"))
])
[docs]class Column(object):
def __init__(self, id, title, **kwargs):
self.id = id
self.title = title
self.sort_field = kwargs.pop("sort_field", id)
self.display = kwargs.pop("display", id)
self.class_name = kwargs.pop("class_name", None)
self.filter_config = kwargs.pop("filter_config", None)
self.sortable = bool(kwargs.pop("sortable", True))
self.linked = bool(kwargs.pop("linked", True))
self.raw = bool(kwargs.pop("raw", False))
if kwargs and type(self) is Column: # If we're not derived, validate that client code doesn't fail
raise NameError("Unexpected kwarg(s): %s" % kwargs.keys())
[docs] def to_json(self, context=None):
out = {
"id": force_text(self.id),
"title": force_text(self.title),
"className": force_text(self.class_name) if self.class_name else None,
"filter": self.filter_config.to_json(context=context) if self.filter_config else None,
"sortable": bool(self.sortable),
"linked": bool(self.linked),
"raw": bool(self.raw),
}
return dict((key, value) for (key, value) in six.iteritems(out) if value is not None)
[docs] def sort_queryset(self, queryset, desc=False):
order_by = ("-" if desc else "") + self.sort_field
if "translations__" in self.sort_field:
queryset = queryset.translated(get_language())
return queryset.order_by(order_by)
[docs] def filter_queryset(self, queryset, value):
if self.filter_config:
queryset = self.filter_config.filter_queryset(queryset, self, value)
return queryset
[docs] def get_display_value(self, context, object):
display_callable = maybe_callable(self.display, context=context)
if display_callable:
return display_callable(object)
value = object
for bit in self.display.split("__"):
value = getattr(value, bit, None)
if isinstance(value, bool):
value = yesno(value)
if isinstance(value, Manager):
value = ", ".join("%s" % x for x in value.all())
if not value:
value = ""
return force_text(value)
[docs]class Picotable(object):
def __init__(self, request, columns, queryset, context):
self.request = request
self.columns = columns
self.queryset = queryset
self.context = context
self.columns_by_id = dict((c.id, c) for c in self.columns)
self.get_object_url = maybe_callable("get_object_url", context=self.context)
self.get_object_abstract = maybe_callable("get_object_abstract", context=self.context)
self.default_filters = self._get_default_filters()
def _get_default_filter(self, column):
filter_config = getattr(column, "filter_config")
if(filter_config and hasattr(filter_config, "default") and filter_config.default is not None):
field = filter_config.filter_field or column.id
return (field, filter_config.default)
else:
return None
def _get_default_filters(self):
filters = {}
for column in self.columns:
default_filter = self._get_default_filter(column)
if default_filter:
filters[default_filter[0]] = default_filter[1]
return filters
[docs] def process_queryset(self, query):
queryset = self.queryset
filters = (query.get("filters") or self._get_default_filters())
for column, value in six.iteritems(filters):
column = self.columns_by_id.get(column)
if column:
queryset = column.filter_queryset(queryset, value)
sort = query.get("sort")
if sort:
desc = (sort[0] == "-")
column = self.columns_by_id.get(sort[1:])
if not (column and column.sortable):
raise ValueError("Can't sort by column %r" % sort[1:])
queryset = column.sort_queryset(queryset, desc=desc)
return queryset
[docs] def get_data(self, query):
paginator = Paginator(self.process_queryset(query), query["perPage"])
try:
page = paginator.page(int(query["page"]))
except EmptyPage:
page = paginator.page(paginator.num_pages)
out = {
"columns": [c.to_json(context=self.context) for c in self.columns],
"pagination": {
"perPage": paginator.per_page,
"nPages": paginator.num_pages,
"nItems": paginator.count,
"pageNum": page.number,
},
"items": [self.process_item(item) for item in page],
"itemInfo": _("Showing %(per_page)s of %(n_items)s %(verbose_name_plural)s") % {
"per_page": min(paginator.per_page, paginator.count),
"n_items": paginator.count,
"verbose_name_plural": self.get_verbose_name_plural(),
}
}
return out
[docs] def process_item(self, object):
object_url = self.get_object_url(object) if callable(self.get_object_url) else None
out = {
"_id": object.id,
"_url": object_url,
"_linked_in_mobile": True if object_url else False
}
for column in self.columns:
out[column.id] = column.get_display_value(context=self.context, object=object)
out["_abstract"] = (self.get_object_abstract(object, item=out) if callable(self.get_object_abstract) else None)
return out
[docs] def get_verbose_name_plural(self):
try:
return self.queryset.model._meta.verbose_name_plural
except AttributeError:
return _("objects")
[docs]class PicotableViewMixin(object):
columns = []
picotable_class = Picotable
template_name = "shoop/admin/base_picotable.jinja"
[docs] def process_picotable(self, query_json):
pico = self.picotable_class(
request=self.request,
columns=self.columns,
queryset=self.get_queryset(),
context=self
)
return JsonResponse(pico.get_data(json.loads(query_json)), encoder=ExtendedJSONEncoder)
[docs] def get(self, request, *args, **kwargs):
query = request.GET.get("jq")
if query:
return self.process_picotable(query)
return super(PicotableViewMixin, self).get(request, *args, **kwargs)
[docs] def get_object_url(self, instance):
try:
return get_model_url(instance)
except NoModelUrl:
pass
return None
[docs] def get_object_abstract(self, instance, item):
"""
Get the object abstract lines (used for mobile layouts) for this object.
Supported keys in abstract line dicts are:
* text (required)
* title
* class (CSS class name -- `header` for instance)
* raw (boolean; whether or not the `text` is raw HTML)
:param instance: The instance
:param item: The item dict so far. Useful for reusing precalculated values.
:return: Iterable of dicts to pass through to the picotable javascript
:rtype: Iterable[dict]
"""
return None
[docs] def get_filter(self):
filter_string = self.request.GET.get("filter")
return json.loads(filter_string) if filter_string else {}