diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 367a794d5970d..bf7e34968a4a9 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -367,8 +367,7 @@ def link(filters): 'choices': [{'title': capfirst(formats.date_format(day, 'MONTH_DAY_FORMAT'))}] } elif year_lookup and month_lookup: - days = cl.queryset.filter(**{year_field: year_lookup, month_field: month_lookup}) - days = getattr(days, 'dates')(field_name, 'day') + days = getattr(cl.queryset, 'dates')(field_name, 'day') return { 'show': True, 'back': { @@ -381,8 +380,7 @@ def link(filters): } for day in days] } elif year_lookup: - months = cl.queryset.filter(**{year_field: year_lookup}) - months = getattr(months, 'dates')(field_name, 'month') + months = getattr(cl.queryset, 'dates')(field_name, 'month') return { 'show': True, 'back': { diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index 2d13593b30950..7d8cba0484442 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -1,5 +1,7 @@ +import datetime from collections import OrderedDict +from django.conf import settings from django.contrib.admin import FieldListFilter from django.contrib.admin.exceptions import ( DisallowedModelAdminLookup, DisallowedModelAdminToField, @@ -17,6 +19,7 @@ from django.db import models from django.urls import reverse from django.utils.http import urlencode +from django.utils.timezone import make_aware from django.utils.translation import gettext # Changelist settings @@ -135,6 +138,32 @@ def get_filters(self, request): if spec and spec.has_output(): filter_specs.append(spec) + if self.date_hierarchy: + year = lookup_params.pop('{}__year'.format(self.date_hierarchy), None) + if year is not None: + month = lookup_params.pop('{}__month'.format(self.date_hierarchy), None) + day = lookup_params.pop('{}__day'.format(self.date_hierarchy), None) + + from_date = datetime.datetime( + int(year), + int(month if month is not None else 1), + int(day if day is not None else 1), + ) + if settings.USE_TZ: + from_date = make_aware(from_date) + + if day: + to_date = from_date + datetime.timedelta(days=1) + + elif month: + to_date = (from_date + datetime.timedelta(days=32)).replace(day=1) + + else: + to_date = from_date.replace(year=from_date.year + 1) + + lookup_params['{}__gte'.format(self.date_hierarchy)] = from_date + lookup_params['{}__lt'.format(self.date_hierarchy)] = to_date + # At this point, all the parameters used by the various ListFilters # have been removed from lookup_params, which now only contains other # parameters passed via the query string. We now loop through the diff --git a/tests/admin_filters/tests.py b/tests/admin_filters/tests.py index 4a11c1a96bd8d..9a00df37e6e43 100644 --- a/tests/admin_filters/tests.py +++ b/tests/admin_filters/tests.py @@ -7,10 +7,12 @@ RelatedOnlyFieldListFilter, SimpleListFilter, site, ) from django.contrib.admin.options import IncorrectLookupParameters +from django.contrib.admin.templatetags.admin_list import date_hierarchy from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase, override_settings +from django.utils import timezone from .models import Book, Bookmark, Department, Employee, TaggedItem @@ -244,6 +246,10 @@ class BookmarkAdminGenericRelation(ModelAdmin): list_filter = ['tags__tag'] +class BookAdminWithDateHierarchy(ModelAdmin): + date_hierarchy = 'date_registered' + + class ListFiltersTests(TestCase): def setUp(self): @@ -1099,3 +1105,95 @@ def test_list_filter_queryset_filtered_by_default(self): changelist = modeladmin.get_changelist_instance(request) changelist.get_results(request) self.assertEqual(changelist.full_result_count, 4) + + def test_should_filter_by_date_hierarchy(self): + modeladmin = BookAdminWithDateHierarchy(Book, site) + + def _test_date_hierarchy(query, expected_from_date, expected_to_date): + request = self.request_factory.get('/', query) + changelist = modeladmin.get_changelist_instance(request) + _, _, lookup_params, _ = changelist.get_filters(request) + self.assertEqual(lookup_params['date_registered__gte'], expected_from_date) + self.assertEqual(lookup_params['date_registered__lt'], expected_to_date) + + _test_date_hierarchy( + {'date_registered__year': 2017}, + datetime.datetime(2017, 1, 1, 0, 0, 0), + datetime.datetime(2018, 1, 1, 0, 0, 0), + ) + + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 2}, + datetime.datetime(2017, 2, 1, 0, 0, 0), + datetime.datetime(2017, 3, 1, 0, 0, 0), + ) + + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 12}, + datetime.datetime(2017, 12, 1, 0, 0, 0), + datetime.datetime(2018, 1, 1, 0, 0, 0), + ) + + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 12, 'date_registered__day': 15}, + datetime.datetime(2017, 12, 15, 0, 0, 0), + datetime.datetime(2017, 12, 16, 0, 0, 0), + ) + + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 12, 'date_registered__day': 31}, + datetime.datetime(2017, 12, 31, 0, 0, 0), + datetime.datetime(2018, 1, 1, 0, 0, 0), + ) + + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 2, 'date_registered__day': 28}, + datetime.datetime(2017, 2, 28, 0, 0, 0), + datetime.datetime(2017, 3, 1, 0, 0, 0), + ) + + with override_settings(USE_TZ=True, TIME_ZONE='Asia/Jerusalem'): + tz = timezone.get_default_timezone() + _test_date_hierarchy( + {'date_registered__year': 2017, 'date_registered__month': 2, 'date_registered__day': 28}, + tz.localize(datetime.datetime(2017, 2, 28, 0, 0, 0)), + tz.localize(datetime.datetime(2017, 3, 1, 0, 0, 0)), + ) + + def test_should_show_date_hierarchy_choices(self): + modeladmin = BookAdminWithDateHierarchy(Book, site) + + Book.objects.all().delete() + for date_registered in ( + datetime.date(2017, 10, 1), + datetime.date(2017, 12, 15), + datetime.date(2017, 12, 31), + datetime.date(2018, 2, 1), + ): + Book.objects.create(title='title', date_registered=date_registered) + + def _test_date_hierarch_choices(query, expected_choices): + request = self.request_factory.get('/', query) + changelist = modeladmin.get_changelist_instance(request) + spec = date_hierarchy(changelist) + choices = [choice['link'] for choice in spec['choices']] + self.assertEqual(choices, expected_choices) + + _test_date_hierarch_choices({}, [ + '?date_registered__year=2017', + '?date_registered__year=2018', + ]) + + _test_date_hierarch_choices({'date_registered__year': 2016}, []) + + _test_date_hierarch_choices({'date_registered__year': 2017}, [ + '?date_registered__month=10&date_registered__year=2017', + '?date_registered__month=12&date_registered__year=2017', + ]) + + _test_date_hierarch_choices({'date_registered__year': 2017, 'date_registered__month': 9}, []) + + _test_date_hierarch_choices({'date_registered__year': 2017, 'date_registered__month': 12}, [ + '?date_registered__day=15&date_registered__month=12&date_registered__year=2017', + '?date_registered__day=31&date_registered__month=12&date_registered__year=2017', + ])