diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index ad4c13652e..2961d6449b 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -53,6 +53,10 @@ class DatabaseOperations(BaseDatabaseOperations): return "MAKEDATE(YEAR(%s), 1) + INTERVAL QUARTER(%s) QUARTER - INTERVAL 1 QUARTER" % ( field_name, field_name ) + elif lookup_type == 'week': + return "DATE_SUB(%s, INTERVAL WEEKDAY(%s) DAY)" % ( + field_name, field_name + ) else: return "DATE(%s)" % (field_name) @@ -84,6 +88,12 @@ class DatabaseOperations(BaseDatabaseOperations): "INTERVAL QUARTER({field_name}) QUARTER - " + "INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)" ).format(field_name=field_name) + if lookup_type == 'week': + return ( + "CAST(DATE_FORMAT(DATE_SUB({field_name}, " + "INTERVAL WEEKDAY({field_name}) DAY), " + "'%%Y-%%m-%%d 00:00:00') AS DATETIME)" + ).format(field_name=field_name) try: i = fields.index(lookup_type) + 1 except ValueError: diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index bea03e355e..748f606368 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -78,6 +78,8 @@ END; return "TRUNC(%s, '%s')" % (field_name, lookup_type.upper()) elif lookup_type == 'quarter': return "TRUNC(%s, 'Q')" % field_name + elif lookup_type == 'week': + return "TRUNC(%s, 'IW')" % field_name else: return "TRUNC(%s)" % field_name @@ -116,6 +118,8 @@ END; sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper()) elif lookup_type == 'quarter': sql = "TRUNC(%s, 'Q')" % field_name + elif lookup_type == 'week': + sql = "TRUNC(%s, 'IW')" % field_name elif lookup_type == 'day': sql = "TRUNC(%s)" % field_name elif lookup_type == 'hour': diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 857b050361..0971c500dd 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -335,6 +335,9 @@ def _sqlite_date_trunc(lookup_type, dt): return '%i-%02i-01' % (dt.year, month_in_quarter) elif lookup_type == 'month': return "%i-%02i-01" % (dt.year, dt.month) + elif lookup_type == 'week': + dt = dt - datetime.timedelta(days=dt.weekday()) + return "%i-%02i-%02i" % (dt.year, dt.month, dt.day) elif lookup_type == 'day': return "%i-%02i-%02i" % (dt.year, dt.month, dt.day) @@ -403,6 +406,9 @@ def _sqlite_datetime_trunc(lookup_type, dt, tzname): return '%i-%02i-01 00:00:00' % (dt.year, month_in_quarter) elif lookup_type == 'month': return "%i-%02i-01 00:00:00" % (dt.year, dt.month) + elif lookup_type == 'week': + dt = dt - datetime.timedelta(days=dt.weekday()) + return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day) elif lookup_type == 'day': return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day) elif lookup_type == 'hour': diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index 0680200c7a..0815628dd0 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -3,7 +3,7 @@ from .datetime import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth, - TruncQuarter, TruncSecond, TruncTime, TruncYear, + TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear, ) from .text import ( Concat, ConcatPair, Length, Lower, Replace, StrIndex, Substr, Upper, @@ -21,7 +21,7 @@ __all__ = [ 'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay', 'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime', - 'TruncYear', + 'TruncWeek', 'TruncYear', # text 'Concat', 'ConcatPair', 'Length', 'Lower', 'Replace', 'StrIndex', 'Substr', 'Upper', diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 44bcb731e6..74b88b77ca 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -199,7 +199,8 @@ class TruncBase(TimezoneMixin, Transform): field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField' )) elif isinstance(field, TimeField) and ( - isinstance(output_field, DateTimeField) or copy.kind in ('year', 'quarter', 'month', 'day', 'date')): + isinstance(output_field, DateTimeField) or + copy.kind in ('year', 'quarter', 'month', 'week', 'day', 'date')): raise ValueError("Cannot truncate TimeField '%s' to %s. " % ( field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField' )) @@ -242,6 +243,11 @@ class TruncMonth(TruncBase): kind = 'month' +class TruncWeek(TruncBase): + """Truncate to midnight on the Monday of the week.""" + kind = 'week' + + class TruncDay(TruncBase): kind = 'day' diff --git a/django/db/models/query.py b/django/db/models/query.py index e17f8da1a8..44bc51d0e0 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -777,8 +777,8 @@ class QuerySet: Return a list of date objects representing all available dates for the given field_name, scoped to 'kind'. """ - assert kind in ("year", "month", "day"), \ - "'kind' must be one of 'year', 'month' or 'day'." + assert kind in ('year', 'month', 'week', 'day'), \ + "'kind' must be one of 'year', 'month', 'week', or 'day'." assert order in ('ASC', 'DESC'), \ "'order' must be either 'ASC' or 'DESC'." return self.annotate( @@ -793,8 +793,8 @@ class QuerySet: Return a list of datetime objects representing all available datetimes for the given field_name, scoped to 'kind'. """ - assert kind in ("year", "month", "day", "hour", "minute", "second"), \ - "'kind' must be one of 'year', 'month', 'day', 'hour', 'minute' or 'second'." + assert kind in ('year', 'month', 'week', 'day', 'hour', 'minute', 'second'), \ + "'kind' must be one of 'year', 'month', 'week', 'day', 'hour', 'minute', or 'second'." assert order in ('ASC', 'DESC'), \ "'order' must be either 'ASC' or 'DESC'." if settings.USE_TZ: diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 97634d3584..68943e8c8e 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -439,6 +439,7 @@ return: * "year": 2015-01-01 00:00:00+00:00 * "quarter": 2015-04-01 00:00:00+00:00 * "month": 2015-06-01 00:00:00+00:00 +* "week": 2015-06-15 00:00:00+00:00 * "day": 2015-06-15 00:00:00+00:00 * "hour": 2015-06-15 14:00:00+00:00 * "minute": 2015-06-15 14:30:00+00:00 @@ -452,6 +453,7 @@ values returned when this timezone is active will be: * "year": 2015-01-01 00:00:00+11:00 * "quarter": 2015-04-01 00:00:00+10:00 * "month": 2015-06-01 00:00:00+10:00 +* "week": 2015-06-16 00:00:00+10:00 * "day": 2015-06-16 00:00:00+10:00 * "hour": 2015-06-16 00:00:00+10:00 * "minute": 2015-06-16 00:30:00+10:00 @@ -504,6 +506,14 @@ Usage example:: .. attribute:: kind = 'month' +.. class:: TruncWeek(expression, output_field=None, tzinfo=None, **extra) + + .. versionadded:: 2.1 + + Truncates to midnight on the Monday of the week. + + .. attribute:: kind = 'week' + .. class:: TruncQuarter(expression, output_field=None, tzinfo=None, **extra) .. versionadded:: 2.0 diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 5064a42c9e..10251efc72 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -694,13 +694,15 @@ objects representing all available dates of a particular kind within the contents of the ``QuerySet``. ``field`` should be the name of a ``DateField`` of your model. -``kind`` should be either ``"year"``, ``"month"`` or ``"day"``. Each -``datetime.date`` object in the result list is "truncated" to the given -``type``. +``kind`` should be either ``"year"``, ``"month"``, ``"week"``, or ``"day"``. +Each :class:`datetime.date` object in the result list is "truncated" to the +given ``type``. * ``"year"`` returns a list of all distinct year values for the field. * ``"month"`` returns a list of all distinct year/month values for the field. +* ``"week"`` returns a list of all distinct year/week values for the field. All + dates will be a Monday. * ``"day"`` returns a list of all distinct year/month/day values for the field. @@ -713,6 +715,8 @@ Examples:: [datetime.date(2005, 1, 1)] >>> Entry.objects.dates('pub_date', 'month') [datetime.date(2005, 2, 1), datetime.date(2005, 3, 1)] + >>> Entry.objects.dates('pub_date', 'week') + [datetime.date(2005, 2, 14), datetime.date(2005, 3, 14)] >>> Entry.objects.dates('pub_date', 'day') [datetime.date(2005, 2, 20), datetime.date(2005, 3, 20)] >>> Entry.objects.dates('pub_date', 'day', order='DESC') @@ -720,6 +724,10 @@ Examples:: >>> Entry.objects.filter(headline__contains='Lennon').dates('pub_date', 'day') [datetime.date(2005, 3, 20)] +.. versionchanged:: 2.1 + + "week" support was added. + ``datetimes()`` ~~~~~~~~~~~~~~~ @@ -731,9 +739,9 @@ contents of the ``QuerySet``. ``field_name`` should be the name of a ``DateTimeField`` of your model. -``kind`` should be either ``"year"``, ``"month"``, ``"day"``, ``"hour"``, -``"minute"`` or ``"second"``. Each ``datetime.datetime`` object in the result -list is "truncated" to the given ``type``. +``kind`` should be either ``"year"``, ``"month"``, ``"week"``, ``"day"``, +``"hour"``, ``"minute"``, or ``"second"``. Each :class:`datetime.datetime` +object in the result list is "truncated" to the given ``type``. ``order``, which defaults to ``'ASC'``, should be either ``'ASC'`` or ``'DESC'``. This specifies how to order the results. @@ -745,6 +753,10 @@ object. If it's ``None``, Django uses the :ref:`current time zone `. It has no effect when :setting:`USE_TZ` is ``False``. +.. versionchanged:: 2.1 + + "week" support was added. + .. _database-time-zone-definitions: .. note:: diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 24ab0faa50..323da06fd6 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -173,6 +173,10 @@ Models * The new :class:`~django.db.models.functions.Replace` database function replaces strings in an expression. +* The new :class:`~django.db.models.functions.TruncWeek` function truncates + :class:`~django.db.models.DateField` and + :class:`~django.db.models.DateTimeField` to the Monday of a week. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/dates/tests.py b/tests/dates/tests.py index 9cb77fe9e2..ebdf05812e 100644 --- a/tests/dates/tests.py +++ b/tests/dates/tests.py @@ -55,6 +55,12 @@ class DatesTests(TestCase): datetime.date(2010, 7, 1), ], ) + self.assertSequenceEqual( + Comment.objects.dates("article__pub_date", "week"), [ + datetime.date(2005, 7, 25), + datetime.date(2010, 7, 26), + ], + ) self.assertSequenceEqual( Comment.objects.dates("article__pub_date", "day"), [ datetime.date(2005, 7, 28), @@ -93,7 +99,8 @@ class DatesTests(TestCase): ) def test_dates_fails_when_given_invalid_kind_argument(self): - with self.assertRaisesMessage(AssertionError, "'kind' must be one of 'year', 'month' or 'day'."): + msg = "'kind' must be one of 'year', 'month', 'week', or 'day'." + with self.assertRaisesMessage(AssertionError, msg): Article.objects.dates("pub_date", "bad_kind") def test_dates_fails_when_given_invalid_order_argument(self): diff --git a/tests/datetimes/tests.py b/tests/datetimes/tests.py index 50a83f0371..a9f1199f06 100644 --- a/tests/datetimes/tests.py +++ b/tests/datetimes/tests.py @@ -53,6 +53,12 @@ class DateTimesTests(TestCase): datetime.datetime(2010, 7, 1), ], ) + self.assertSequenceEqual( + Comment.objects.datetimes("article__pub_date", "week"), [ + datetime.datetime(2005, 7, 25), + datetime.datetime(2010, 7, 26), + ], + ) self.assertSequenceEqual( Comment.objects.datetimes("article__pub_date", "day"), [ datetime.datetime(2005, 7, 28), @@ -98,6 +104,9 @@ class DateTimesTests(TestCase): self.assertQuerysetEqual( Article.objects.datetimes('pub_date', 'month'), ["datetime.datetime(2005, 7, 1, 0, 0)"]) + self.assertQuerysetEqual( + Article.objects.datetimes('pub_date', 'week'), + ["datetime.datetime(2005, 7, 25, 0, 0)"]) self.assertQuerysetEqual( Article.objects.datetimes('pub_date', 'day'), ["datetime.datetime(2005, 7, 28, 0, 0)", diff --git a/tests/db_functions/test_datetime.py b/tests/db_functions/test_datetime.py index e2f786bf6b..dc4c911ab9 100644 --- a/tests/db_functions/test_datetime.py +++ b/tests/db_functions/test_datetime.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import pytz @@ -8,7 +8,7 @@ from django.db.models.functions import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth, - TruncQuarter, TruncSecond, TruncTime, TruncYear, + TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear, ) from django.test import ( TestCase, override_settings, skipIfDBFeature, skipUnlessDBFeature, @@ -34,6 +34,10 @@ def truncate_to(value, kind, tzinfo=None): if isinstance(value, datetime): return value.replace(hour=0, minute=0, second=0, microsecond=0) return value + if kind == 'week': + if isinstance(value, datetime): + return (value - timedelta(days=value.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + return value - timedelta(days=value.weekday()) if kind == 'month': if isinstance(value, datetime): return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0) @@ -536,6 +540,7 @@ class DateFunctionTests(TestCase): test_date_kind('year') test_date_kind('quarter') test_date_kind('month') + test_date_kind('week') test_date_kind('day') test_time_kind('hour') test_time_kind('minute') @@ -543,6 +548,7 @@ class DateFunctionTests(TestCase): test_datetime_kind('year') test_datetime_kind('quarter') test_datetime_kind('month') + test_datetime_kind('week') test_datetime_kind('day') test_datetime_kind('hour') test_datetime_kind('minute') @@ -656,6 +662,30 @@ class DateFunctionTests(TestCase): with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): list(DTModel.objects.annotate(truncated=TruncMonth('start_time', output_field=TimeField()))) + def test_trunc_week_func(self): + start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321) + end_datetime = truncate_to(datetime(2016, 6, 15, 14, 10, 50, 123), 'week') + if settings.USE_TZ: + start_datetime = timezone.make_aware(start_datetime, is_dst=False) + end_datetime = timezone.make_aware(end_datetime, is_dst=False) + self.create_model(start_datetime, end_datetime) + self.create_model(end_datetime, start_datetime) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=TruncWeek('start_datetime')).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime, 'week')), + (end_datetime, truncate_to(end_datetime, 'week')), + ], + lambda m: (m.start_datetime, m.extracted) + ) + self.assertEqual(DTModel.objects.filter(start_datetime=TruncWeek('start_datetime')).count(), 1) + + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncWeek('start_time'))) + + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncWeek('start_time', output_field=TimeField()))) + def test_trunc_date_func(self): start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321) end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123) @@ -960,6 +990,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): test_date_kind('year') test_date_kind('quarter') test_date_kind('month') + test_date_kind('week') test_date_kind('day') test_time_kind('hour') test_time_kind('minute') @@ -967,6 +998,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): test_datetime_kind('year') test_datetime_kind('quarter') test_datetime_kind('month') + test_datetime_kind('week') test_datetime_kind('day') test_datetime_kind('hour') test_datetime_kind('minute')