Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
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.
  • Loading branch information...
commit 78ba9670af373f5261f95d6560bfd08425adcaad 1 parent 596cb9c
@aaugustin aaugustin authored
View
130 django/views/generic/dates.py
@@ -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 @@ def get_allow_future(self):
"""
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 @@ def get(self, request, *args, **kwargs):
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 @@ def get_dated_queryset(self, **lookup):
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 @@ def get_date_list(self, queryset, date_type):
return date_list
+
class BaseArchiveIndexView(BaseDateListView):
"""
Base class for archives of date-based items.
@@ -265,15 +314,23 @@ def get_dated_items(self):
"""
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 @@ def get_dated_items(self):
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 @@ def get_dated_items(self):
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 @@ def _get_dated_items(self, date):
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 @@ def get_object(self, queryset=None):
# 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
6 docs/ref/class-based-views.txt
@@ -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
102 tests/regressiontests/generic_views/dates.py
@@ -4,8 +4,18 @@
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 @@ def test_paginated_archive_view_does_not_load_entire_table(self):
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 @@ def test_year_view_invalid_pattern(self):
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 @@ def test_previous_month_without_content(self):
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 @@ def test_week_start_Monday(self):
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 @@ def test_today_view(self):
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 @@ def test_get_object_custom_queryset(self):
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
3  tests/regressiontests/generic_views/models.py
@@ -42,3 +42,6 @@ def __unicode__(self):
class Page(models.Model):
content = models.TextField()
template = models.CharField(max_length=300)
+
+class BookSigning(models.Model):
+ event_date = models.DateTimeField()
View
19 tests/regressiontests/generic_views/urls.py
@@ -108,6 +108,8 @@
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 @@
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 @@
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 @@
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 @@
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 @@
(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
30 tests/regressiontests/generic_views/views.py
@@ -7,7 +7,7 @@
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 @@ def get_context_data(self, **kwargs):
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'
Please sign in to comment.
Something went wrong with that request. Please try again.