Commit 78ba9670 authored by Aymeric Augustin's avatar Aymeric Augustin
Browse files

Fixed #18217 -- Time zone support in generic views

Introduced a distinct implementation depending on the type of the
date field (DateField or DateTimeField), and applied appropriate
conversions is the latter case, when time zone support is enabled.
parent 596cb9c7
Loading
Loading
Loading
Loading
+83 −47
Original line number Diff line number Diff line
import datetime
from django.conf import settings
from django.db import models
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from django.utils.encoding import force_unicode
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django.utils import timezone
from django.views.generic.base import View
@@ -164,6 +166,51 @@ class DateMixin(object):
        """
        return self.allow_future

    # Note: the following three methods only work in subclasses that also
    # inherit SingleObjectMixin or MultipleObjectMixin.

    @cached_property
    def uses_datetime_field(self):
        """
        Return `True` if the date field is a `DateTimeField` and `False`
        if it's a `DateField`.
        """
        model = self.get_queryset().model if self.model is None else self.model
        field = model._meta.get_field(self.get_date_field())
        return isinstance(field, models.DateTimeField)

    def _make_date_lookup_arg(self, value):
        """
        Convert a date into a datetime when the date field is a DateTimeField.

        When time zone support is enabled, `date` is assumed to be in the
        current time zone, so that displayed items are consistent with the URL.
        """
        if self.uses_datetime_field:
            value = datetime.datetime.combine(value, datetime.time.min)
            if settings.USE_TZ:
                value = timezone.make_aware(value, timezone.get_current_timezone())
        return value

    def _make_single_date_lookup(self, date):
        """
        Get the lookup kwargs for filtering on a single date.

        If the date field is a DateTimeField, we can't just filter on
        date_field=date because that doesn't take the time into account.
        """
        date_field = self.get_date_field()
        if self.uses_datetime_field:
            since = self._make_date_lookup_arg(date)
            until = self._make_date_lookup_arg(date + datetime.timedelta(days=1))
            return {
                '%s__gte' % date_field: since,
                '%s__lt' % date_field: until,
            }
        else:
            # Skip self._make_date_lookup_arg, it's a no-op in this branch.
            return {date_field: date}


class BaseDateListView(MultipleObjectMixin, DateMixin, View):
    """
@@ -180,7 +227,7 @@ class BaseDateListView(MultipleObjectMixin, DateMixin, View):

    def get_dated_items(self):
        """
        Obtain the list of dates and itesm
        Obtain the list of dates and items.
        """
        raise NotImplementedError('A DateView must provide an implementation of get_dated_items()')

@@ -196,7 +243,8 @@ class BaseDateListView(MultipleObjectMixin, DateMixin, View):
        paginate_by = self.get_paginate_by(qs)

        if not allow_future:
            qs = qs.filter(**{'%s__lte' % date_field: timezone.now()})
            now = timezone.now() if self.uses_datetime_field else datetime.date.today()
            qs = qs.filter(**{'%s__lte' % date_field: now})

        if not allow_empty:
            # When pagination is enabled, it's better to do a cheap query
@@ -225,6 +273,7 @@ class BaseDateListView(MultipleObjectMixin, DateMixin, View):

        return date_list


class BaseArchiveIndexView(BaseDateListView):
    """
    Base class for archives of date-based items.
@@ -265,11 +314,19 @@ class BaseYearArchiveView(YearMixin, BaseDateListView):
        """
        Return (date_list, items, extra_context) for this request.
        """
        # Yes, no error checking: the URLpattern ought to validate this; it's
        # an error if it doesn't.
        year = self.get_year()

        date_field = self.get_date_field()
        qs = self.get_dated_queryset(**{date_field+'__year': year})
        date = _date_from_string(year, self.get_year_format())

        since = self._make_date_lookup_arg(date)
        until = self._make_date_lookup_arg(datetime.date(date.year + 1, 1, 1))
        lookup_kwargs = {
            '%s__gte' % date_field: since,
            '%s__lt' % date_field: until,
        }

        qs = self.get_dated_queryset(**lookup_kwargs)
        date_list = self.get_date_list(qs, 'month')

        if self.get_make_object_list():
@@ -312,14 +369,14 @@ class BaseMonthArchiveView(YearMixin, MonthMixin, BaseDateListView):
                                 month, self.get_month_format())

        # Construct a date-range lookup.
        first_day = date.replace(day=1)
        if first_day.month == 12:
            last_day = first_day.replace(year=first_day.year + 1, month=1)
        since = self._make_date_lookup_arg(date)
        if date.month == 12:
            until = self._make_date_lookup_arg(datetime.date(date.year + 1, 1, 1))
        else:
            last_day = first_day.replace(month=first_day.month + 1)
            until = self._make_date_lookup_arg(datetime.date(date.year, date.month + 1, 1))
        lookup_kwargs = {
            '%s__gte' % date_field: first_day,
            '%s__lt' % date_field: last_day,
            '%s__gte' % date_field: since,
            '%s__lt' % date_field: until,
        }

        qs = self.get_dated_queryset(**lookup_kwargs)
@@ -362,11 +419,11 @@ class BaseWeekArchiveView(YearMixin, WeekMixin, BaseDateListView):
                                 week, week_format)

        # Construct a date-range lookup.
        first_day = date
        last_day = date + datetime.timedelta(days=7)
        since = self._make_date_lookup_arg(date)
        until = self._make_date_lookup_arg(date + datetime.timedelta(days=7))
        lookup_kwargs = {
            '%s__gte' % date_field: first_day,
            '%s__lt' % date_field: last_day,
            '%s__gte' % date_field: since,
            '%s__lt' % date_field: until,
        }

        qs = self.get_dated_queryset(**lookup_kwargs)
@@ -404,11 +461,7 @@ class BaseDayArchiveView(YearMixin, MonthMixin, DayMixin, BaseDateListView):
        Do the actual heavy lifting of getting the dated items; this accepts a
        date object so that TodayArchiveView can be trivial.
        """
        date_field = self.get_date_field()

        field = self.get_queryset().model._meta.get_field(date_field)
        lookup_kwargs = _date_lookup_for_field(field, date)

        lookup_kwargs = self._make_single_date_lookup(date)
        qs = self.get_dated_queryset(**lookup_kwargs)

        return (None, qs, {
@@ -474,10 +527,8 @@ class BaseDateDetailView(YearMixin, MonthMixin, DayMixin, DateMixin, BaseDetailV
        # Filter down a queryset from self.queryset using the date from the
        # URL. This'll get passed as the queryset to DetailView.get_object,
        # which'll handle the 404
        date_field = self.get_date_field()
        field = qs.model._meta.get_field(date_field)
        lookup = _date_lookup_for_field(field, date)
        qs = qs.filter(**lookup)
        lookup_kwargs = self._make_single_date_lookup(date)
        qs = qs.filter(**lookup_kwargs)

        return super(BaseDetailView, self).get_object(queryset=qs)

@@ -490,10 +541,10 @@ class DateDetailView(SingleObjectTemplateResponseMixin, BaseDateDetailView):
    template_name_suffix = '_detail'


def _date_from_string(year, year_format, month, month_format, day='', day_format='', delim='__'):
def _date_from_string(year, year_format, month='', month_format='', day='', day_format='', delim='__'):
    """
    Helper: get a datetime.date object given a format string and a year,
    month, and possibly day; raise a 404 for an invalid date.
    month, and day (only year is mandatory). Raise a 404 for an invalid date.
    """
    format = delim.join((year_format, month_format, day_format))
    datestr = delim.join((year, month, day))
@@ -548,10 +599,10 @@ def _get_next_prev_month(generic_view, naive_result, is_previous, use_first_day)
        # Construct a lookup and an ordering depending on whether we're doing
        # a previous date or a next date lookup.
        if is_previous:
            lookup = {'%s__lte' % date_field: naive_result}
            lookup = {'%s__lte' % date_field: generic_view._make_date_lookup_arg(naive_result)}
            ordering = '-%s' % date_field
        else:
            lookup = {'%s__gte' % date_field: naive_result}
            lookup = {'%s__gte' % date_field: generic_view._make_date_lookup_arg(naive_result)}
            ordering = date_field

        qs = generic_view.get_queryset().filter(**lookup).order_by(ordering)
@@ -564,7 +615,9 @@ def _get_next_prev_month(generic_view, naive_result, is_previous, use_first_day)
            result = None

    # Convert datetimes to a dates
    if hasattr(result, 'date'):
    if result and generic_view.uses_datetime_field:
        if settings.USE_TZ:
            result = timezone.localtime(result)
        result = result.date()

    # For month views, we always want to have a date that's the first of the
@@ -577,20 +630,3 @@ def _get_next_prev_month(generic_view, naive_result, is_previous, use_first_day)
        return result
    else:
        return None


def _date_lookup_for_field(field, date):
    """
    Get the lookup kwargs for looking up a date against a given Field. If the
    date field is a DateTimeField, we can't just do filter(df=date) because
    that doesn't take the time into account. So we need to make a range lookup
    in those cases.
    """
    if isinstance(field, models.DateTimeField):
        date_range = (
            datetime.datetime.combine(date, datetime.time.min),
            datetime.datetime.combine(date, datetime.time.max)
        )
        return {'%s__range' % field.name: date_range}
    else:
        return {field.name: date}
+6 −0
Original line number Diff line number Diff line
@@ -748,6 +748,12 @@ DateMixin
        ``QuerySet``'s model that the date-based archive should use to
        determine the objects on the page.

        When :doc:`time zone support </topics/i18n/timezones>` is enabled and
        ``date_field`` is a ``DateTimeField``, dates are assumed to be in the
        current time zone. As a consequence, if you have implemented per-user
        time zone selection, users living in different time zones may view a
        different set of objects at the same URL.

    .. attribute:: allow_future

        A boolean specifying whether to include "future" objects on this page,
+101 −1
Original line number Diff line number Diff line
@@ -4,8 +4,18 @@ import datetime

from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone

from .models import Book, BookSigning


import warnings
warnings.filterwarnings(
        'error', r"DateTimeField received a naive datetime",
        RuntimeWarning, r'django\.db\.models\.fields')


from .models import Book


class ArchiveIndexViewTests(TestCase):
@@ -88,6 +98,18 @@ class ArchiveIndexViewTests(TestCase):
        with self.assertNumQueries(3):
            self.client.get('/dates/books/paginated/')

    def test_datetime_archive_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        res = self.client.get('/dates/booksignings/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_archive_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/')
        self.assertEqual(res.status_code, 200)


class YearArchiveViewTests(TestCase):
    fixtures = ['generic-views-test-data.json']
    urls = 'regressiontests.generic_views.urls'
@@ -141,6 +163,18 @@ class YearArchiveViewTests(TestCase):
        res = self.client.get('/dates/books/no_year/')
        self.assertEqual(res.status_code, 404)

    def test_datetime_year_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        res = self.client.get('/dates/booksignings/2008/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_year_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/')
        self.assertEqual(res.status_code, 200)


class MonthArchiveViewTests(TestCase):
    fixtures = ['generic-views-test-data.json']
    urls = 'regressiontests.generic_views.urls'
@@ -245,6 +279,21 @@ class MonthArchiveViewTests(TestCase):
        self.assertEqual(res.status_code, 200)
        self.assertEqual(res.context['previous_month'], datetime.date(2010,9,1))

    def test_datetime_month_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 2, 1, 12, 0))
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        BookSigning.objects.create(event_date=datetime.datetime(2008, 6, 3, 12, 0))
        res = self.client.get('/dates/booksignings/2008/apr/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_month_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 2, 1, 12, 0, tzinfo=timezone.utc))
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        BookSigning.objects.create(event_date=datetime.datetime(2008, 6, 3, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/')
        self.assertEqual(res.status_code, 200)


class WeekArchiveViewTests(TestCase):
    fixtures = ['generic-views-test-data.json']
@@ -300,6 +349,18 @@ class WeekArchiveViewTests(TestCase):
        self.assertEqual(res.status_code, 200)
        self.assertEqual(res.context['week'], datetime.date(2008, 9, 29))

    def test_datetime_week_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        res = self.client.get('/dates/booksignings/2008/week/13/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_week_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/week/13/')
        self.assertEqual(res.status_code, 200)


class DayArchiveViewTests(TestCase):
    fixtures = ['generic-views-test-data.json']
    urls = 'regressiontests.generic_views.urls'
@@ -388,6 +449,26 @@ class DayArchiveViewTests(TestCase):
        self.assertEqual(res.status_code, 200)
        self.assertEqual(res.context['day'], datetime.date.today())

    def test_datetime_day_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        res = self.client.get('/dates/booksignings/2008/apr/2/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_day_view(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/')
        self.assertEqual(res.status_code, 200)
        # 2008-04-02T00:00:00+03:00 (beginning of day) > 2008-04-01T22:00:00+00:00 (book signing event date)
        BookSigning.objects.filter(pk=1).update(event_date=datetime.datetime(2008, 4, 1, 22, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/')
        self.assertEqual(res.status_code, 200)
        # 2008-04-03T00:00:00+03:00 (end of day) > 2008-04-02T22:00:00+00:00 (book signing event date)
        BookSigning.objects.filter(pk=1).update(event_date=datetime.datetime(2008, 4, 2, 22, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/')
        self.assertEqual(res.status_code, 404)


class DateDetailViewTests(TestCase):
    fixtures = ['generic-views-test-data.json']
    urls = 'regressiontests.generic_views.urls'
@@ -441,3 +522,22 @@ class DateDetailViewTests(TestCase):
        res = self.client.get(
            '/dates/books/get_object_custom_queryset/2008/oct/01/1/')
        self.assertEqual(res.status_code, 404)

    def test_datetime_date_detail(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0))
        res = self.client.get('/dates/booksignings/2008/apr/2/1/')
        self.assertEqual(res.status_code, 200)

    @override_settings(USE_TZ=True, TIME_ZONE='Africa/Nairobi')
    def test_aware_datetime_date_detail(self):
        BookSigning.objects.create(event_date=datetime.datetime(2008, 4, 2, 12, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/1/')
        self.assertEqual(res.status_code, 200)
        # 2008-04-02T00:00:00+03:00 (beginning of day) > 2008-04-01T22:00:00+00:00 (book signing event date)
        BookSigning.objects.filter(pk=1).update(event_date=datetime.datetime(2008, 4, 1, 22, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/1/')
        self.assertEqual(res.status_code, 200)
        # 2008-04-03T00:00:00+03:00 (end of day) > 2008-04-02T22:00:00+00:00 (book signing event date)
        BookSigning.objects.filter(pk=1).update(event_date=datetime.datetime(2008, 4, 2, 22, 0, tzinfo=timezone.utc))
        res = self.client.get('/dates/booksignings/2008/apr/2/1/')
        self.assertEqual(res.status_code, 404)
+3 −0
Original line number Diff line number Diff line
@@ -42,3 +42,6 @@ class Book(models.Model):
class Page(models.Model):
    content = models.TextField()
    template = models.CharField(max_length=300)

class BookSigning(models.Model):
    event_date = models.DateTimeField()
+17 −2
Original line number Diff line number Diff line
@@ -108,6 +108,8 @@ urlpatterns = patterns('',
        views.BookArchive.as_view(queryset=None)),
    (r'^dates/books/paginated/$',
        views.BookArchive.as_view(paginate_by=10)),
    (r'^dates/booksignings/$',
        views.BookSigningArchive.as_view()),

    # ListView
    (r'^list/dict/$',
@@ -156,6 +158,8 @@ urlpatterns = patterns('',
        views.BookYearArchive.as_view(make_object_list=True, paginate_by=30)),
    (r'^dates/books/no_year/$',
        views.BookYearArchive.as_view()),
    (r'^dates/booksignings/(?P<year>\d{4})/$',
        views.BookSigningYearArchive.as_view()),

    # MonthArchiveView
    (r'^dates/books/(?P<year>\d{4})/(?P<month>[a-z]{3})/$',
@@ -170,6 +174,8 @@ urlpatterns = patterns('',
        views.BookMonthArchive.as_view(paginate_by=30)),
    (r'^dates/books/(?P<year>\d{4})/no_month/$',
        views.BookMonthArchive.as_view()),
    (r'^dates/booksignings/(?P<year>\d{4})/(?P<month>[a-z]{3})/$',
        views.BookSigningMonthArchive.as_view()),

    # WeekArchiveView
    (r'^dates/books/(?P<year>\d{4})/week/(?P<week>\d{1,2})/$',
@@ -184,6 +190,8 @@ urlpatterns = patterns('',
        views.BookWeekArchive.as_view()),
    (r'^dates/books/(?P<year>\d{4})/week/(?P<week>\d{1,2})/monday/$',
        views.BookWeekArchive.as_view(week_format='%W')),
    (r'^dates/booksignings/(?P<year>\d{4})/week/(?P<week>\d{1,2})/$',
        views.BookSigningWeekArchive.as_view()),

    # DayArchiveView
    (r'^dates/books/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\d{1,2})/$',
@@ -198,12 +206,16 @@ urlpatterns = patterns('',
        views.BookDayArchive.as_view(paginate_by=True)),
    (r'^dates/books/(?P<year>\d{4})/(?P<month>[a-z]{3})/no_day/$',
        views.BookDayArchive.as_view()),
    (r'^dates/booksignings/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\d{1,2})/$',
        views.BookSigningDayArchive.as_view()),

    # TodayArchiveView
    (r'dates/books/today/$',
    (r'^dates/books/today/$',
        views.BookTodayArchive.as_view()),
    (r'dates/books/today/allow_empty/$',
    (r'^dates/books/today/allow_empty/$',
        views.BookTodayArchive.as_view(allow_empty=True)),
    (r'^dates/booksignings/today/$',
        views.BookSigningTodayArchive.as_view()),

    # DateDetailView
    (r'^dates/books/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\d{1,2})/(?P<pk>\d+)/$',
@@ -221,6 +233,9 @@ urlpatterns = patterns('',
    (r'^dates/books/get_object_custom_queryset/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\d{1,2})/(?P<pk>\d+)/$',
        views.BookDetailGetObjectCustomQueryset.as_view()),

    (r'^dates/booksignings/(?P<year>\d{4})/(?P<month>[a-z]{3})/(?P<day>\d{1,2})/(?P<pk>\d+)/$',
        views.BookSigningDetail.as_view()),

    # Useful for testing redirects
    (r'^accounts/login/$',  'django.contrib.auth.views.login')
)
Loading