Browse Source

Fixed #28650 -- Added TruncWeek database function.

Sigurd Ljødal 7 years ago
parent
commit
a455e732a0

+ 10 - 0
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:

+ 4 - 0
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':

+ 6 - 0
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':

+ 2 - 2
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',

+ 7 - 1
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'
 

+ 4 - 4
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:

+ 10 - 0
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

+ 18 - 6
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
 <default-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::

+ 4 - 0
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
 ~~~~~~~~~~~~~~~~~~~~~~
 

+ 8 - 1
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):

+ 9 - 0
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)",

+ 34 - 2
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')