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.
This commit is contained in:
Aymeric Augustin 2012-04-30 20:41:38 +02:00
parent 596cb9c7e2
commit 78ba9670af
6 changed files with 239 additions and 51 deletions

View File

@ -1,8 +1,10 @@
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,15 +314,23 @@ 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():
object_list = qs.order_by('-'+date_field)
object_list = qs.order_by('-' + date_field)
else:
# We need this to be a queryset since parent classes introspect it
# to find information about the model.
@ -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}

View File

@ -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,

View File

@ -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)

View File

@ -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()

View File

@ -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')
)

View File

@ -7,7 +7,7 @@ from django.utils.decorators import method_decorator
from django.views import generic
from .forms import AuthorForm
from .models import Artist, Author, Book, Page
from .models import Artist, Author, Book, Page, BookSigning
class CustomTemplateView(generic.TemplateView):
@ -198,3 +198,31 @@ class CustomContextView(generic.detail.SingleObjectMixin, generic.View):
def get_context_object_name(self, obj):
return "test_name"
class BookSigningConfig(object):
model = BookSigning
date_field = 'event_date'
# use the same templates as for books
def get_template_names(self):
return ['generic_views/book%s.html' % self.template_name_suffix]
class BookSigningArchive(BookSigningConfig, generic.ArchiveIndexView):
pass
class BookSigningYearArchive(BookSigningConfig, generic.YearArchiveView):
pass
class BookSigningMonthArchive(BookSigningConfig, generic.MonthArchiveView):
pass
class BookSigningWeekArchive(BookSigningConfig, generic.WeekArchiveView):
pass
class BookSigningDayArchive(BookSigningConfig, generic.DayArchiveView):
pass
class BookSigningTodayArchive(BookSigningConfig, generic.TodayArchiveView):
pass
class BookSigningDetail(BookSigningConfig, generic.DateDetailView):
context_object_name = 'book'