diff --git a/django/views/generic/dates.py b/django/views/generic/dates.py index 24680a376b3..32238bb8e95 100644 --- a/django/views/generic/dates.py +++ b/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 @@ 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} diff --git a/docs/ref/class-based-views.txt b/docs/ref/class-based-views.txt index aa3f8f77737..5d308694e57 100644 --- a/docs/ref/class-based-views.txt +++ b/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 ` 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, diff --git a/tests/regressiontests/generic_views/dates.py b/tests/regressiontests/generic_views/dates.py index 9de817acc2c..f3984c6c0e4 100644 --- a/tests/regressiontests/generic_views/dates.py +++ b/tests/regressiontests/generic_views/dates.py @@ -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) diff --git a/tests/regressiontests/generic_views/models.py b/tests/regressiontests/generic_views/models.py index 5977258f5f4..2355769d582 100644 --- a/tests/regressiontests/generic_views/models.py +++ b/tests/regressiontests/generic_views/models.py @@ -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() diff --git a/tests/regressiontests/generic_views/urls.py b/tests/regressiontests/generic_views/urls.py index 090ec73c4a7..9c47ab8d822 100644 --- a/tests/regressiontests/generic_views/urls.py +++ b/tests/regressiontests/generic_views/urls.py @@ -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\d{4})/$', + views.BookSigningYearArchive.as_view()), # MonthArchiveView (r'^dates/books/(?P\d{4})/(?P[a-z]{3})/$', @@ -170,6 +174,8 @@ urlpatterns = patterns('', views.BookMonthArchive.as_view(paginate_by=30)), (r'^dates/books/(?P\d{4})/no_month/$', views.BookMonthArchive.as_view()), + (r'^dates/booksignings/(?P\d{4})/(?P[a-z]{3})/$', + views.BookSigningMonthArchive.as_view()), # WeekArchiveView (r'^dates/books/(?P\d{4})/week/(?P\d{1,2})/$', @@ -184,6 +190,8 @@ urlpatterns = patterns('', views.BookWeekArchive.as_view()), (r'^dates/books/(?P\d{4})/week/(?P\d{1,2})/monday/$', views.BookWeekArchive.as_view(week_format='%W')), + (r'^dates/booksignings/(?P\d{4})/week/(?P\d{1,2})/$', + views.BookSigningWeekArchive.as_view()), # DayArchiveView (r'^dates/books/(?P\d{4})/(?P[a-z]{3})/(?P\d{1,2})/$', @@ -198,12 +206,16 @@ urlpatterns = patterns('', views.BookDayArchive.as_view(paginate_by=True)), (r'^dates/books/(?P\d{4})/(?P[a-z]{3})/no_day/$', views.BookDayArchive.as_view()), + (r'^dates/booksignings/(?P\d{4})/(?P[a-z]{3})/(?P\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\d{4})/(?P[a-z]{3})/(?P\d{1,2})/(?P\d+)/$', @@ -221,6 +233,9 @@ urlpatterns = patterns('', (r'^dates/books/get_object_custom_queryset/(?P\d{4})/(?P[a-z]{3})/(?P\d{1,2})/(?P\d+)/$', views.BookDetailGetObjectCustomQueryset.as_view()), + (r'^dates/booksignings/(?P\d{4})/(?P[a-z]{3})/(?P\d{1,2})/(?P\d+)/$', + views.BookSigningDetail.as_view()), + # Useful for testing redirects (r'^accounts/login/$', 'django.contrib.auth.views.login') ) diff --git a/tests/regressiontests/generic_views/views.py b/tests/regressiontests/generic_views/views.py index 1e70ba4b464..d11d810f3d3 100644 --- a/tests/regressiontests/generic_views/views.py +++ b/tests/regressiontests/generic_views/views.py @@ -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'