Skip to content

Commit

Permalink
added django admin autocomplete / json dropdown filters
Browse files Browse the repository at this point in the history
  • Loading branch information
wolph committed Jan 15, 2021
1 parent faf98da commit e944cf0
Show file tree
Hide file tree
Showing 9 changed files with 350 additions and 13 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ omit =
*/nose/*
*/django/*
*/tests/*
django_utils/admin/filters.py

[paths]
source =
Expand Down
55 changes: 43 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ Introduction

Travis status:

.. image:: https://travis-ci.org/WoLpH/django-utils.png?branch=master
.. image:: https://travis-ci.org/WoLpH/django-utils.svg?branch=master
:target: https://travis-ci.org/WoLpH/django-utils

Coverage:

.. image:: https://coveralls.io/repos/WoLpH/django-utils/badge.png?branch=master
.. image:: https://coveralls.io/repos/WoLpH/django-utils/badge.svg?branch=master
:target: https://coveralls.io/r/WoLpH/django-utils?branch=master

Django Utils is a collection of small Django helper functions, utilities and
Expand All @@ -18,11 +18,14 @@ keep extending it.

Examples are:

- Admin Select (Dropdown) filters
- Admin Select2 (Autocomplete dropdown) filters
- Admin JSON sub-field filters
- Enum based choicefields
- Models with automatic `__str__`, `__unicode__` and `__repr__` functions
- Models with automatic ``__str__``, ``__unicode__`` and ``__repr__`` functions
based on names and/or slugs using simple mixins.
- Models with automatic `updated_at` and `created_at` fields
- Models with automatic slugs based on the `name` property.
- Models with automatic ``updated_at`` and ``created_at`` fields
- Models with automatic slugs based on the ``name`` property.
- Iterating through querysets in predefined chunks to prevent out of memory
errors

Expand All @@ -35,14 +38,42 @@ Install

To install:

1. Run `pip install django-utils2` or execute `python setup.py install` in the source directory
2. Add `django_utils` to your `INSTALLED_APPS`
1. Run ``pip install django-utils2`` or execute ``python setup.py install`` in the source directory
2. Add ``django_utils`` to your ``INSTALLED_APPS``

If you want to run the tests, run `py.test` (requirements in `tests/requirements.txt`)
If you want to run the tests, run ``py.test`` (requirements in ``tests/requirements.txt``)

Admin Select / Dropdown / Autocomplete (JSON) Filters
-----------------------------------------------------

Usage
-----
All of the standard admin list filters are available through ``django_utils
.admin.filters`` as:

- The original filter (e.g. ``SimpleListFilter``)
- A basic select/dropdown filter: ``SimpleListFilterDropdown``
- A select2 based autocompleting dropdown filter: ``SimpleListFilterSelect2``

On PostgreSQL you can additionally filter on JSON fields as well given paths:

.. code-block:: python
class SomeModelAdmin(admin.ModelAdmin):
list_filter = (
JSONFieldFilterSelect2.create('some_json_field__some__sub_path'),
)
That will filter a JSON field named ``some_json_field`` and look for values
like this:

.. code-block:: json
{"some": {"sub_path": "some value"}}
By default the results for the JSON filters are cached for 10 minutes but can
be changed through the ``create`` parameters.

Choices usage
-------------

To enable easy to use choices which are more convenient than the Django 3.0 choices system you can use this:

Expand All @@ -51,7 +82,7 @@ To enable easy to use choices which are more convenient than the Django 3.0 choi
from django_utils import choices
# For manually specifying the value (automatically detects `str`, `int` and `float`):
# For manually specifying the value (automatically detects ``str``, ``int`` and ``float``):
class Human(models.Model):
class Gender(choices.Choices):
MALE = 'm'
Expand All @@ -61,7 +92,7 @@ To enable easy to use choices which are more convenient than the Django 3.0 choi
gender = models.CharField(max_length=1, choices=Gender)
# To define the values as `male` implicitly:
# To define the values as ``male`` implicitly:
class Human(models.Model):
class Gender(choices.Choices):
MALE = choices.Choice()
Expand Down
Empty file added django_utils/admin/__init__.py
Empty file.
190 changes: 190 additions & 0 deletions django_utils/admin/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import typing
from abc import ABC
from datetime import timedelta

from django import http
from django.contrib import admin
from django.contrib.admin import widgets
from django.contrib.admin.filters import AllValuesFieldListFilter
from django.contrib.admin.filters import BooleanFieldListFilter
from django.contrib.admin.filters import ChoicesFieldListFilter
from django.contrib.admin.filters import DateFieldListFilter
from django.contrib.admin.filters import FieldListFilter
from django.contrib.admin.filters import ListFilter
from django.contrib.admin.filters import RelatedFieldListFilter
from django.contrib.admin.filters import RelatedOnlyFieldListFilter
from django.contrib.admin.filters import SimpleListFilter
from django.core.cache import cache
from django.db import models

__all__ = (
'AllValuesFieldListFilter',
'BooleanFieldListFilter',
'ChoicesFieldListFilter',
'DateFieldListFilter',
'FieldListFilter',
'ListFilter',
'RelatedFieldListFilter',
'RelatedOnlyFieldListFilter',
'SimpleListFilter',
)

from django.utils import text

CACHE_TIMEOUT = timedelta(minutes=10)


class DropdownMixin:
template = 'django_utils/admin/dropdown_filter.html'


class Select2Mixin:
template = 'django_utils/admin/select2_filter.html'

def select_html_id(self):
return text.slugify(self.title)

# Quick hack to re-use the admin select2 files
Media = widgets.AutocompleteMixin(None, None).media


class FilterBase(admin.SimpleListFilter):
timeout: timedelta = None

def get_lookups_cache_timeout(self):
timeout = self.timeout or CACHE_TIMEOUT
if timeout:
return timeout.total_seconds()

def get_lookups_cache_key(self, request: http.HttpRequest):
return request.get_full_path() + self.title

def set_lookups_cache(self, request: http.HttpRequest, lookups):
timeout = self.get_lookups_cache_timeout()
if timeout:
cache.set(self.get_lookups_cache_key(request), lookups, timeout)

def get_lookups_cache(self, request: http.HttpRequest):
return cache.get(self.get_lookups_cache_key(request))

def formatter(self, value):
'''Formatter to convert the value in human readable output'''
return str(value).title()


class JSONFieldFilter(FilterBase):
field_path = None

@staticmethod
def cast(value):
return value

@property
def attribute_path(self):
return self.field_path.split('__', 1)[1]

def lookups(self, request, model_admin):
'''The list of value/label pairs for the filter bar with caching'''
assert self.field_path, '`field_path` is required'

cache = self.get_lookups_cache(request)
if cache:
return cache

values = model_admin.model.objects.values_list(
self.field_path, flat=True,
).order_by(self.field_path).distinct()

lookups = [(value, self.formatter(value)) for value in values]
self.set_lookups_cache(request, lookups)

return lookups

def queryset(self, request: http.HttpRequest, queryset: models.QuerySet) \
-> models.QuerySet:
value = self.value()
if value:
return queryset.filter(**{self.field_path: self.cast(value)})
else:
return queryset

@classmethod
def create(cls,
field_path: str,
title: str = None,
parameter_name: str = None,
template: str = None,
formatter: typing.Callable[[typing.Any], str] = None,
cast: typing.Callable[[str], typing.Any] = None,
timeout: timedelta = None) -> typing.Type['JSONFieldFilter']:

class Filter(cls):
pass

assert '__' in field_path, 'Paths require both the field and parameter'
Filter.field_path = field_path
Filter.title = title or (' '.join(field_path.split('_'))).title()
Filter.parameter_name = parameter_name or field_path
Filter.timeout = timeout

if formatter:
Filter.formatter = formatter

if template:
Filter.template = template

if cast:
Filter.cast = cast

return Filter


class JSONFieldFilterSelect2(Select2Mixin, JSONFieldFilter):
pass


class SimpleListFilterSelect2(Select2Mixin, SimpleListFilter, ABC):
pass


class AllValuesFieldListFilterSelect2(Select2Mixin, AllValuesFieldListFilter):
pass


class ChoicesFieldListFilterSelect2(Select2Mixin, ChoicesFieldListFilter):
pass


class RelatedFieldListFilterSelect2(Select2Mixin, RelatedFieldListFilter):
pass


class RelatedOnlyFieldListFilterSelect2(Select2Mixin,
RelatedOnlyFieldListFilter):
pass


class JSONFieldFilterDropdown(DropdownMixin, JSONFieldFilter):
pass


class SimpleListFilterDropdown(DropdownMixin, SimpleListFilter, ABC):
pass


class AllValuesFieldListFilterDropdown(DropdownMixin,
AllValuesFieldListFilter):
pass


class ChoicesFieldListFilterDropdown(DropdownMixin, ChoicesFieldListFilter):
pass


class RelatedFieldListFilterDropdown(DropdownMixin, RelatedFieldListFilter):
pass


class RelatedOnlyFieldListFilterDropdown(DropdownMixin,
RelatedOnlyFieldListFilter):
pass
34 changes: 34 additions & 0 deletions django_utils/templates/django_utils/admin/dropdown_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% comment %}
This template was partially copied from the Django Admin List Filter Dropdown
package. It was copied to allow for easy re-use in the other filters within
this library.
Source: https://github.com/mrts/django-admin-list-filter-dropdown
{% endcomment %}
{% load i18n %}
{% block pre %}
{% endblock %}
<h3>{% blocktrans with title as filter_title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul class="admin-filter-{{ title|slugify }}">
{% if choices|length > 3 %}
<li>
<select
class="form-control"
style="width: 95%;margin-left: 2%;"
{% if spec.select_html_id %}id="{{ spec.select_html_id }}"{% endif %}
onchange="window.location = window.location.pathname + this.options[this.selectedIndex].value">
{% for choice in choices %}
<option{% if choice.selected %} selected="selected"{% endif %}
value="{{ choice.query_string|iriencode }}">{{ choice.display }}</option>
{% endfor %}
</select>
</li>
{% else %}
{% for choice in choices %}
<li{% if choice.selected %} class="selected"{% endif %}>
<a href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
{% endfor %}

{% endif %}
{% block post %}
{% endblock %}
</ul>
7 changes: 7 additions & 0 deletions django_utils/templates/django_utils/admin/select2_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends 'django_utils/admin/dropdown_filter.html' %}

{% block post %}
<script type="text/javascript">
django.jQuery(document).ready($ => $('#{{ spec.select_html_id }}').select2());
</script>
{% endblock %}
21 changes: 21 additions & 0 deletions docs/django_utils.admin.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
django\_utils.admin package
===========================

Submodules
----------

django\_utils.admin.filters module
----------------------------------

.. automodule:: django_utils.admin.filters
:members:
:undoc-members:
:show-inheritance:

Module contents
---------------

.. automodule:: django_utils.admin
:members:
:undoc-members:
:show-inheritance:
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def run_tests(self):
license='BSD',
packages=setuptools.find_packages(exclude=['tests']),
install_requires=[
'python-utils>=2.0.1',
'python-utils>=2.5.0',
'anyjson>=0.3.0'
],
extras_require={
Expand Down

0 comments on commit e944cf0

Please sign in to comment.