diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..489dd16 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,38 @@ +sudo: false +language: python + +python: + - 2.7 + - 3.2 + - 3.3 + - 3.4 + - 3.5 + + +install: + - pip install Django${DJANGO_VERSION} + +env: + matrix: + - DJANGO_VERSION=">=1.6,<1.7" + - DJANGO_VERSION=">=1.7,<1.8" + - DJANGO_VERSION=">=1.8,<1.9" + - DJANGO_VERSION=">=1.9,<1.10" + +matrix: + exclude: + - python: 3.5 + env: DJANGO_VERSION=">=1.6,<1.7" + - python: 3.5 + env: DJANGO_VERSION=">=1.7,<1.8" + - python: 3.3 + env: DJANGO_VERSION=">=1.9,<1.10" + - python: 3.2 + env: DJANGO_VERSION=">=1.9,<1.10" + +branches: + only: + - master + +script: + - python runtests.py diff --git a/CHANGES b/CHANGES new file mode 100755 index 0000000..a035477 --- /dev/null +++ b/CHANGES @@ -0,0 +1,2 @@ +0.1.0 (2016-06-24) + - Initial release diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..dbb5726 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Dmitriy Sokolov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..1785173 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE CHANGES README.rst +recursive-include rangefilter/static *.js *.css *.png *.eot *.svg *.ttf *.woff +recursive-include rangefilter/templates *.html +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/README.rst b/README.rst new file mode 100755 index 0000000..b2072a7 --- /dev/null +++ b/README.rst @@ -0,0 +1,58 @@ +.. image:: https://travis-ci.org/silentsokolov/django-admin-rangefilter.png?branch=master + :target: https://travis-ci.org/silentsokolov/django-admin-rangefilter + + +django-admin-rangefilter +======================== + +django-admin-rangefilter app, add the filter by a custom date range on the admin UI. + +.. image:: https://raw.githubusercontent.com/silentsokolov/django-admin-rangefilter/master/docs/images/screenshot.png + + +Requirements +------------ + +* Python 2.7+ or Python 3.2+ +* Django 1.7+ + + +Installation +------------ + +Use your favorite Python package manager to install the app from PyPI, e.g. + +Example: + +``pip install django-admin-rangefilter`` + + +Add ``rangefilter`` to ``INSTALLED_APPS``: + +Example: + +.. code:: python + + INSTALLED_APPS = ( + ... + 'rangefilter', + ... + ) + + +Example usage +------------- + +In admin +~~~~~~~~ + +.. code:: python + + from django.contrib import admin + from rangefilter.filtres import DateRangeFilter + + @admin.register(Post) + class PostAdmin(admin.ModelAdmin): + list_filter = ( + ('created_at', DateRangeFilter), ('updated_at', DateRangeFilter), + ) diff --git a/docs/images/screenshot.png b/docs/images/screenshot.png new file mode 100644 index 0000000..b0acd63 Binary files /dev/null and b/docs/images/screenshot.png differ diff --git a/rangefilter/__init__.py b/rangefilter/__init__.py new file mode 100644 index 0000000..11c8fc2 --- /dev/null +++ b/rangefilter/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + + +__author__ = 'Dmitriy Sokolov' +__version__ = '0.1.0' + + +default_app_config = 'rangefilter.apps.RangeFilterConfig' diff --git a/rangefilter/apps.py b/rangefilter/apps.py new file mode 100644 index 0000000..0d31e32 --- /dev/null +++ b/rangefilter/apps.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class RangeFilterConfig(AppConfig): + name = 'rangefilter' + verbose_name = _('Range Filter') diff --git a/rangefilter/filter.py b/rangefilter/filter.py new file mode 100644 index 0000000..e53e1c8 --- /dev/null +++ b/rangefilter/filter.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import datetime +import django + +from collections import OrderedDict + +from django import forms +from django.conf import settings +from django.contrib import admin +from django.template.defaultfilters import slugify +from django.utils.translation import ugettext as _ + +try: + import pytz +except ImportError: + pytz = None + +try: + from suit.widgets import SuitDateWidget as AdminDateWidget +except ImportError: + from django.contrib.admin.widgets import AdminDateWidget + + +def make_dt_aware(dt): + if pytz is not None and settings.USE_TZ: + timezone = pytz.timezone(settings.TIME_ZONE) + if dt.tzinfo is not None: + dt = timezone.normalize(dt) + else: + dt = timezone.localize(dt) + return dt + + +class DateRangeFilter(admin.filters.FieldListFilter): + def __init__(self, field, request, params, model, model_admin, field_path): + self.lookup_kwarg_gte = '{}__gte'.format(field_path) + self.lookup_kwarg_lte = '{}__lte'.format(field_path) + + super(DateRangeFilter, self).__init__(field, request, params, model, model_admin, field_path) + + self.form = self.get_form(request) + + def choices(self, cl): + yield { + 'system_name': slugify(self.title), + 'query_string': cl.get_query_string( + {}, remove=[self.lookup_kwarg_gte, self.lookup_kwarg_lte] + ) + } + + def expected_parameters(self): + return [self.lookup_kwarg_gte, self.lookup_kwarg_lte] + + def queryset(self, request, queryset): + if self.form.is_valid(): + filter_params = dict(filter(lambda f: f[1] is not None, self.form.cleaned_data.items())) + + if filter_params: + _filter = { + '{0}__range'.format(self.field_path): ( + make_dt_aware(datetime.datetime.combine( + filter_params[self.lookup_kwarg_gte], datetime.time.min + )), + make_dt_aware(datetime.datetime.combine( + filter_params[self.lookup_kwarg_lte], datetime.time.max + )) + ) + } + + return queryset.filter(**_filter) + return queryset + + def get_template(self): + if django.VERSION >= (1, 9): + return 'admin/daterange_filter19.html' + return 'admin/daterange_filter.html' + + template = property(get_template) + + def get_form(self, request): + form_class = self._get_form_class() + return form_class(self.used_parameters) + + def _get_form_class(self): + fields = self._get_form_fields() + + form_class = type( + str('DateRangeForm'), + (forms.BaseForm,), + {'base_fields': fields} + ) + form_class.media = self._get_media() + + return form_class + + def _get_form_fields(self): + return OrderedDict(( + (self.lookup_kwarg_gte, forms.DateField( + label='', + widget=AdminDateWidget(attrs={'placeholder': _('From date')}), + localize=True, + required=False + )), + (self.lookup_kwarg_lte, forms.DateField( + label='', + widget=AdminDateWidget(attrs={'placeholder': _('To date')}), + localize=True, + required=False + )), + )) + + @staticmethod + def _get_media(): + js = [ + 'calendar.js', + 'admin/DateTimeShortcuts.js', + ] + css = [ + 'widgets.css', + ] + return forms.Media( + js=['admin/js/%s' % url for url in js], + css={'all': ['admin/css/%s' % path for path in css]} + ) diff --git a/rangefilter/models.py b/rangefilter/models.py new file mode 100644 index 0000000..4a574b3 --- /dev/null +++ b/rangefilter/models.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals diff --git a/rangefilter/templates/rangefilter/date_filter.html b/rangefilter/templates/rangefilter/date_filter.html new file mode 100644 index 0000000..9652834 --- /dev/null +++ b/rangefilter/templates/rangefilter/date_filter.html @@ -0,0 +1,70 @@ +{% load i18n admin_static %} +

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

+ + +
+
+ {{ spec.form.media }} + {{ spec.form.as_p }} + {% for choice in choices %} + + {% endfor %} +
+ + +
+
+
diff --git a/rangefilter/templates/rangefilter/date_filter_1_8.html b/rangefilter/templates/rangefilter/date_filter_1_8.html new file mode 100644 index 0000000..8b9ab2c --- /dev/null +++ b/rangefilter/templates/rangefilter/date_filter_1_8.html @@ -0,0 +1,62 @@ +{% load i18n admin_static %} +

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

+ + +
+
+ {{ spec.form.media }} + {{ spec.form }} + {% for choice in choices %} + + {% endfor %} +
+ + +
+
+
diff --git a/rangefilter/tests.py b/rangefilter/tests.py new file mode 100644 index 0000000..a5a0ba6 --- /dev/null +++ b/rangefilter/tests.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import datetime + +from django.utils import timezone +from django.test import RequestFactory, TestCase +from django.test.utils import override_settings +from django.db import models +from django.contrib.admin import ModelAdmin, site +from django.contrib.admin.views.main import ChangeList +from django.utils.encoding import force_text + +from .filter import make_dt_aware, DateRangeFilter + + +class MyModel(models.Model): + created_at = models.DateTimeField() + + class Meta: + ordering = ('created_at',) + + +class MyModelAdmin(ModelAdmin): + list_filter = (('created_at', DateRangeFilter),) + ordering = ('-id',) + + +def select_by(dictlist): + return [x for x in dictlist][0] + + +class DateFuncTestCase(TestCase): + def test_make_dt_aware_without_pytz(self): + with override_settings(USE_TZ=False): + now = datetime.datetime.now() + date = make_dt_aware(now) + + self.assertEqual(date.tzinfo, None) + self.assertTrue(timezone.is_naive(date)) + + def test_make_dt_aware_with_pytz(self): + local_tz = timezone.get_current_timezone() + now = datetime.datetime.now() + date = make_dt_aware(now) + + self.assertEqual(date.tzinfo.zone, local_tz.zone) + self.assertTrue(timezone.is_aware(date)) + + now = timezone.now() + date = make_dt_aware(now) + + self.assertEqual(date.tzinfo.zone, local_tz.zone) + self.assertTrue(timezone.is_aware(date)) + + +class DateRangeFilterTestCase(TestCase): + def setUp(self): + self.today = datetime.date.today() + self.tomorrow = self.today + datetime.timedelta(days=1) + self.one_week_ago = self.today - datetime.timedelta(days=7) + + self.django_book = MyModel.objects.create(created_at=self.today) + self.djangonaut_book = MyModel.objects.create(created_at=self.one_week_ago) + + def get_changelist(self, request, model, modeladmin): + return ChangeList( + request, model, modeladmin.list_display, + modeladmin.list_display_links, modeladmin.list_filter, + modeladmin.date_hierarchy, modeladmin.search_fields, + modeladmin.list_select_related, modeladmin.list_per_page, + modeladmin.list_max_show_all, modeladmin.list_editable, modeladmin, + ) + + def test_datefilter(self): + self.request_factory = RequestFactory() + modeladmin = MyModelAdmin(MyModel, site) + + request = self.request_factory.get('/') + changelist = self.get_changelist(request, MyModel, modeladmin) + + queryset = changelist.get_queryset(request) + + self.assertEqual(list(queryset), [self.djangonaut_book, self.django_book]) + filterspec = changelist.get_filters(request)[0][0] + self.assertEqual(force_text(filterspec.title), 'created at') + + def test_datefilter_filtered(self): + self.request_factory = RequestFactory() + modeladmin = MyModelAdmin(MyModel, site) + + request = self.request_factory.get('/', {'created_at__gte': self.today, + 'created_at__lte': self.tomorrow}) + changelist = self.get_changelist(request, MyModel, modeladmin) + + queryset = changelist.get_queryset(request) + + self.assertEqual(list(queryset), [self.django_book]) + filterspec = changelist.get_filters(request)[0][0] + self.assertEqual(force_text(filterspec.title), 'created at') + + choice = select_by(filterspec.choices(changelist)) + self.assertEqual(choice['query_string'], '?') + self.assertEqual(choice['system_name'], 'created-at') diff --git a/runtests.py b/runtests.py new file mode 100755 index 0000000..001df88 --- /dev/null +++ b/runtests.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +from __future__ import unicode_literals + +import django + +from django.conf import settings, global_settings +from django.core.management import call_command + + +settings.configure( + MIDDLEWARE_CLASSES=global_settings.MIDDLEWARE_CLASSES, + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sites', + 'django.contrib.admin', + 'django.contrib.sessions', + 'rangefilter', + ), + DATABASES={ + 'default': {'ENGINE': 'django.db.backends.sqlite3'} + }, + TEST_RUNNER='django.test.runner.DiscoverRunner', + USE_TZ=True +) + +from django.test.utils import setup_test_environment +setup_test_environment() + +if django.VERSION > (1, 7): + django.setup() + +if __name__ == '__main__': + call_command('test', 'rangefilter') diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..986be03 --- /dev/null +++ b/setup.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +from setuptools import setup + + +def get_packages(package): + return [dirpath for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, '__init__.py'))] + + +def get_package_data(package): + walk = [(dirpath.replace(package + os.sep, '', 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if not os.path.exists(os.path.join(dirpath, '__init__.py'))] + + filepaths = [] + for base, filenames in walk: + filepaths.extend([os.path.join(base, filename) + for filename in filenames]) + return {package: filepaths} + + +setup( + name='django-admin-rangefilter', + version='0.1.0', + url='https://github.com/silentsokolov/django-admin-rangefilter', + license='MIT', + author='Dmitriy Sokolov', + author_email='silentsokolov@gmail.com', + description='django-admin-rangefilter app, add the filter by a custom date range on the admin UI.', + zip_safe=False, + include_package_data=True, + platforms='any', + packages=get_packages('rangefilter'), + package_data=get_package_data('rangefilter'), + install_requires=[], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Topic :: Utilities', + ], +)