Browse Source

Fixed #28103 -- Added quarter extract, truncation, and lookup.

Thanks Mariusz Felisiak, Tim Graham, and Adam Johnson for review.
Mads Jensen 7 years ago
parent
commit
c7f6ffbdcf

+ 10 - 0
django/db/backends/mysql/operations.py

@@ -39,6 +39,10 @@ class DatabaseOperations(BaseDatabaseOperations):
         if lookup_type in fields:
             format_str = fields[lookup_type]
             return "CAST(DATE_FORMAT(%s, '%s') AS DATE)" % (field_name, format_str)
+        elif lookup_type == 'quarter':
+            return "MAKEDATE(YEAR(%s), 1) + INTERVAL QUARTER(%s) QUARTER - INTERVAL 1 QUARTER" % (
+                field_name, field_name
+            )
         else:
             return "DATE(%s)" % (field_name)
 
@@ -64,6 +68,12 @@ class DatabaseOperations(BaseDatabaseOperations):
         fields = ['year', 'month', 'day', 'hour', 'minute', 'second']
         format = ('%%Y-', '%%m', '-%%d', ' %%H:', '%%i', ':%%s')  # Use double percents to escape.
         format_def = ('0000-', '01', '-01', ' 00:', '00', ':00')
+        if lookup_type == 'quarter':
+            return (
+                "CAST(DATE_FORMAT(MAKEDATE(YEAR({field_name}), 1) + "
+                "INTERVAL QUARTER({field_name}) QUARTER - " +
+                "INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)"
+            ).format(field_name=field_name)
         try:
             i = fields.index(lookup_type) + 1
         except ValueError:

+ 6 - 0
django/db/backends/oracle/operations.py

@@ -67,6 +67,8 @@ END;
         elif lookup_type == 'week':
             # IW = ISO week number
             return "TO_CHAR(%s, 'IW')" % field_name
+        elif lookup_type == 'quarter':
+            return "TO_CHAR(%s, 'Q')" % field_name
         else:
             # https://docs.oracle.com/database/121/SQLRF/functions067.htm#SQLRF00639
             return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)
@@ -81,6 +83,8 @@ END;
         # https://docs.oracle.com/database/121/SQLRF/functions271.htm#SQLRF52058
         if lookup_type in ('year', 'month'):
             return "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
+        elif lookup_type == 'quarter':
+            return "TRUNC(%s, 'Q')" % field_name
         else:
             return "TRUNC(%s)" % field_name
 
@@ -117,6 +121,8 @@ END;
         # https://docs.oracle.com/database/121/SQLRF/functions271.htm#SQLRF52058
         if lookup_type in ('year', 'month'):
             sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
+        elif lookup_type == 'quarter':
+            sql = "TRUNC(%s, 'Q')" % field_name
         elif lookup_type == 'day':
             sql = "TRUNC(%s)" % field_name
         elif lookup_type == 'hour':

+ 11 - 0
django/db/backends/sqlite3/base.py

@@ -2,6 +2,7 @@
 SQLite3 backend for the sqlite3 module in the standard library.
 """
 import decimal
+import math
 import re
 import warnings
 from sqlite3 import dbapi2 as Database
@@ -309,6 +310,8 @@ def _sqlite_date_extract(lookup_type, dt):
         return (dt.isoweekday() % 7) + 1
     elif lookup_type == 'week':
         return dt.isocalendar()[1]
+    elif lookup_type == 'quarter':
+        return math.ceil(dt.month / 3)
     else:
         return getattr(dt, lookup_type)
 
@@ -320,6 +323,9 @@ def _sqlite_date_trunc(lookup_type, dt):
         return None
     if lookup_type == 'year':
         return "%i-01-01" % dt.year
+    elif lookup_type == 'quarter':
+        month_in_quarter = dt.month - (dt.month - 1) % 3
+        return '%i-%02i-01' % (dt.year, month_in_quarter)
     elif lookup_type == 'month':
         return "%i-%02i-01" % (dt.year, dt.month)
     elif lookup_type == 'day':
@@ -373,6 +379,8 @@ def _sqlite_datetime_extract(lookup_type, dt, tzname):
         return (dt.isoweekday() % 7) + 1
     elif lookup_type == 'week':
         return dt.isocalendar()[1]
+    elif lookup_type == 'quarter':
+        return math.ceil(dt.month / 3)
     else:
         return getattr(dt, lookup_type)
 
@@ -383,6 +391,9 @@ def _sqlite_datetime_trunc(lookup_type, dt, tzname):
         return None
     if lookup_type == 'year':
         return "%i-01-01 00:00:00" % dt.year
+    elif lookup_type == 'quarter':
+        month_in_quarter = dt.month - (dt.month - 1) % 3
+        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 == 'day':

+ 6 - 6
django/db/models/functions/__init__.py

@@ -4,9 +4,9 @@ from .base import (
 )
 from .datetime import (
     Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
-    ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate,
-    TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime,
-    TruncYear,
+    ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear,
+    Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth,
+    TruncQuarter, TruncSecond, TruncTime, TruncYear,
 )
 
 __all__ = [
@@ -15,7 +15,7 @@ __all__ = [
     'Lower', 'Now', 'StrIndex', 'Substr', 'Upper',
     # datetime
     'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
-    'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay', 'ExtractYear',
-    'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth',
-    'TruncSecond', 'TruncTime', 'TruncYear',
+    'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay',
+    'ExtractYear', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute',
+    'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime', 'TruncYear',
 ]

+ 10 - 1
django/db/models/functions/datetime.py

@@ -101,6 +101,10 @@ class ExtractWeekDay(Extract):
     lookup_name = 'week_day'
 
 
+class ExtractQuarter(Extract):
+    lookup_name = 'quarter'
+
+
 class ExtractHour(Extract):
     lookup_name = 'hour'
 
@@ -118,6 +122,7 @@ DateField.register_lookup(ExtractMonth)
 DateField.register_lookup(ExtractDay)
 DateField.register_lookup(ExtractWeekDay)
 DateField.register_lookup(ExtractWeek)
+DateField.register_lookup(ExtractQuarter)
 
 TimeField.register_lookup(ExtractHour)
 TimeField.register_lookup(ExtractMinute)
@@ -179,7 +184,7 @@ class TruncBase(TimezoneMixin, Transform):
                 field.name, output_field.__class__.__name__ if explicit_output_field else 'DateTimeField'
             ))
         elif isinstance(field, TimeField) and (
-                isinstance(output_field, DateTimeField) or copy.kind in ('year', 'month', 'day', 'date')):
+                isinstance(output_field, DateTimeField) or copy.kind in ('year', 'quarter', 'month', 'day', 'date')):
             raise ValueError("Cannot truncate TimeField '%s' to %s. " % (
                 field.name, output_field.__class__.__name__ if explicit_output_field else 'DateTimeField'
             ))
@@ -214,6 +219,10 @@ class TruncYear(TruncBase):
     kind = 'year'
 
 
+class TruncQuarter(TruncBase):
+    kind = 'quarter'
+
+
 class TruncMonth(TruncBase):
     kind = 'month'
 

+ 26 - 7
docs/ref/models/database-functions.txt

@@ -342,6 +342,7 @@ Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in
 ``lookup_name``\s return:
 
 * "year": 2015
+* "quarter": 2
 * "month": 6
 * "day": 15
 * "week": 25
@@ -428,6 +429,12 @@ Usage example::
 
     .. attribute:: lookup_name = 'week'
 
+.. class:: ExtractQuarter(expression, tzinfo=None, **extra)
+
+    .. versionadded:: 2.0
+
+    .. attribute:: lookup_name = 'quarter'
+
 These are logically equivalent to ``Extract('date_field', lookup_name)``. Each
 class is also a ``Transform`` registered on ``DateField`` and ``DateTimeField``
 as ``__(lookup_name)``, e.g. ``__year``.
@@ -438,7 +445,8 @@ that deal with date-parts can be used with ``DateField``::
     >>> from datetime import datetime
     >>> from django.utils import timezone
     >>> from django.db.models.functions import (
-    ...     ExtractDay, ExtractMonth, ExtractWeek, ExtractWeekDay, ExtractYear,
+    ...     ExtractDay, ExtractMonth, ExtractQuarter, ExtractWeek,
+    ...     ExtractWeekDay, ExtractYear,
     ... )
     >>> start_2015 = datetime(2015, 6, 15, 23, 30, 1, tzinfo=timezone.utc)
     >>> end_2015 = datetime(2015, 6, 16, 13, 11, 27, tzinfo=timezone.utc)
@@ -447,14 +455,15 @@ that deal with date-parts can be used with ``DateField``::
     ...    end_datetime=end_2015, end_date=end_2015.date())
     >>> Experiment.objects.annotate(
     ...     year=ExtractYear('start_date'),
+    ...     quarter=ExtractQuarter('start_date'),
     ...     month=ExtractMonth('start_date'),
     ...     week=ExtractWeek('start_date'),
     ...     day=ExtractDay('start_date'),
     ...     weekday=ExtractWeekDay('start_date'),
-    ... ).values('year', 'month', 'week', 'day', 'weekday').get(
+    ... ).values('year', 'quarter', 'month', 'week', 'day', 'weekday').get(
     ...     end_date__year=ExtractYear('start_date'),
     ... )
-    {'year': 2015, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2}
+    {'year': 2015, 'quarter': 2, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2}
 
 ``DateTimeField`` extracts
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -483,8 +492,9 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
     >>> from datetime import datetime
     >>> from django.utils import timezone
     >>> from django.db.models.functions import (
-    ...     ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractSecond,
-    ...     ExtractWeek, ExtractWeekDay, ExtractYear,
+    ...     ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
+    ...     ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
+    ...     ExtractYear,
     ... )
     >>> start_2015 = datetime(2015, 6, 15, 23, 30, 1, tzinfo=timezone.utc)
     >>> end_2015 = datetime(2015, 6, 16, 13, 11, 27, tzinfo=timezone.utc)
@@ -493,6 +503,7 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
     ...    end_datetime=end_2015, end_date=end_2015.date())
     >>> Experiment.objects.annotate(
     ...     year=ExtractYear('start_datetime'),
+    ...     quarter=ExtractQuarter('start_datetime'),
     ...     month=ExtractMonth('start_datetime'),
     ...     week=ExtractWeek('start_datetime'),
     ...     day=ExtractDay('start_datetime'),
@@ -503,8 +514,8 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
     ... ).values(
     ...     'year', 'month', 'week', 'day', 'weekday', 'hour', 'minute', 'second',
     ... ).get(end_datetime__year=ExtractYear('start_datetime'))
-    {'year': 2015, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2, 'hour': 23,
-     'minute': 30, 'second': 1}
+    {'year': 2015, 'quarter': 2, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2,
+     'hour': 23, 'minute': 30, 'second': 1}
 
 When :setting:`USE_TZ` is ``True`` then datetimes are stored in the database
 in UTC. If a different timezone is active in Django, the datetime is converted
@@ -564,6 +575,7 @@ Given the datetime ``2015-06-15 14:30:50.000321+00:00``, the built-in ``kind``\s
 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
 * "day": 2015-06-15 00:00:00+00:00
 * "hour": 2015-06-15 14:00:00+00:00
@@ -576,6 +588,7 @@ The timezone offset for Melbourne in the example date above is +10:00. The
 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
 * "day": 2015-06-16 00:00:00+10:00
 * "hour": 2015-06-16 00:00:00+10:00
@@ -629,6 +642,12 @@ Usage example::
 
     .. attribute:: kind = 'month'
 
+.. class:: TruncQuarter(expression, output_field=None, tzinfo=None, **extra)
+
+    .. versionadded:: 2.0
+
+    .. attribute:: kind = 'quarter'
+
 These are logically equivalent to ``Trunc('date_field', kind)``. They truncate
 all parts of the date up to ``kind`` which allows grouping or filtering dates
 with less precision. ``expression`` can have an ``output_field`` of either

+ 22 - 0
docs/ref/models/querysets.txt

@@ -2830,6 +2830,28 @@ When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
 current time zone before filtering. This requires :ref:`time zone definitions
 in the database <database-time-zone-definitions>`.
 
+.. fieldlookup:: quarter
+
+``quarter``
+~~~~~~~~~~~
+
+.. versionadded:: 2.0
+
+For date and datetime fields, a 'quarter of the year' match. Allows chaining
+additional field lookups. Takes an integer value between 1 and 4 representing
+the quarter of the year.
+
+Example to retrieve entries in the second quarter (April 1 to June 30)::
+
+    Entry.objects.filter(pub_date__quarter=2)
+
+(No equivalent SQL code fragment is included for this lookup because
+implementation of the relevant query varies among different database engines.)
+
+When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
+current time zone before filtering. This requires :ref:`time zone definitions
+in the database <database-time-zone-definitions>`.
+
 .. fieldlookup:: time
 
 ``time``

+ 9 - 0
docs/releases/2.0.txt

@@ -227,6 +227,15 @@ Models
   from the database. For databases that don't support server-side cursors, it
   controls the number of results Django fetches from the database adapter.
 
+* Added the :class:`~django.db.models.functions.datetime.ExtractQuarter`
+  function to extract the quarter from :class:`~django.db.models.DateField` and
+  :class:`~django.db.models.DateTimeField`, and exposed it through the
+  :lookup:`quarter` lookup.
+
+* Added the :class:`~django.db.models.functions.datetime.TruncQuarter`
+  function to truncate :class:`~django.db.models.DateField` and
+  :class:`~django.db.models.DateTimeField` to the first day of a quarter.
+
 Requests and Responses
 ~~~~~~~~~~~~~~~~~~~~~~
 

+ 102 - 3
tests/db_functions/test_datetime.py

@@ -7,9 +7,9 @@ from django.db import connection
 from django.db.models import DateField, DateTimeField, IntegerField, TimeField
 from django.db.models.functions import (
     Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
-    ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate,
-    TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime,
-    TruncYear,
+    ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear,
+    Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth,
+    TruncQuarter, TruncSecond, TruncTime, TruncYear,
 )
 from django.test import TestCase, override_settings
 from django.utils import timezone
@@ -41,6 +41,11 @@ def truncate_to(value, kind, tzinfo=None):
             if isinstance(value, datetime):
                 return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
             return value.replace(day=1)
+        if kind == 'quarter':
+            month_in_quarter = value.month - (value.month - 1) % 3
+            if isinstance(value, datetime):
+                return value.replace(month=month_in_quarter, day=1, hour=0, minute=0, second=0, microsecond=0)
+            return value.replace(month=month_in_quarter, day=1)
         # otherwise, truncate to year
         if isinstance(value, datetime):
             return value.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
@@ -155,6 +160,11 @@ class DateFunctionTests(TestCase):
             [(start_datetime, start_datetime.year), (end_datetime, end_datetime.year)],
             lambda m: (m.start_datetime, m.extracted)
         )
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(extracted=Extract('start_datetime', 'quarter')).order_by('start_datetime'),
+            [(start_datetime, 2), (end_datetime, 2)],
+            lambda m: (m.start_datetime, m.extracted)
+        )
         self.assertQuerysetEqual(
             DTModel.objects.annotate(extracted=Extract('start_datetime', 'month')).order_by('start_datetime'),
             [(start_datetime, start_datetime.month), (end_datetime, end_datetime.month)],
@@ -279,6 +289,47 @@ class DateFunctionTests(TestCase):
         # both dates are from the same week.
         self.assertEqual(DTModel.objects.filter(start_datetime__week=ExtractWeek('start_datetime')).count(), 2)
 
+    def test_extract_quarter_func(self):
+        start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321))
+        end_datetime = microsecond_support(datetime(2016, 8, 15, 14, 10, 50, 123))
+        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=ExtractQuarter('start_datetime')).order_by('start_datetime'),
+            [(start_datetime, 2), (end_datetime, 3)],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(extracted=ExtractQuarter('start_date')).order_by('start_datetime'),
+            [(start_datetime, 2), (end_datetime, 3)],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+        self.assertEqual(DTModel.objects.filter(start_datetime__quarter=ExtractQuarter('start_datetime')).count(), 2)
+
+    def test_extract_quarter_func_boundaries(self):
+        end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123))
+        if settings.USE_TZ:
+            end_datetime = timezone.make_aware(end_datetime, is_dst=False)
+
+        last_quarter_2014 = microsecond_support(datetime(2014, 12, 31, 13, 0))
+        first_quarter_2015 = microsecond_support(datetime(2015, 1, 1, 13, 0))
+        if settings.USE_TZ:
+            last_quarter_2014 = timezone.make_aware(last_quarter_2014, is_dst=False)
+            first_quarter_2015 = timezone.make_aware(first_quarter_2015, is_dst=False)
+        dates = [last_quarter_2014, first_quarter_2015]
+        self.create_model(last_quarter_2014, end_datetime)
+        self.create_model(first_quarter_2015, end_datetime)
+        qs = DTModel.objects.filter(start_datetime__in=dates).annotate(
+            extracted=ExtractQuarter('start_datetime'),
+        ).order_by('start_datetime')
+        self.assertQuerysetEqual(qs, [
+            (last_quarter_2014, 4),
+            (first_quarter_2015, 1),
+        ], lambda m: (m.start_datetime, m.extracted))
+
     def test_extract_week_func_boundaries(self):
         end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123))
         if settings.USE_TZ:
@@ -456,12 +507,14 @@ class DateFunctionTests(TestCase):
             )
 
         test_date_kind('year')
+        test_date_kind('quarter')
         test_date_kind('month')
         test_date_kind('day')
         test_time_kind('hour')
         test_time_kind('minute')
         test_time_kind('second')
         test_datetime_kind('year')
+        test_datetime_kind('quarter')
         test_datetime_kind('month')
         test_datetime_kind('day')
         test_datetime_kind('hour')
@@ -503,6 +556,47 @@ class DateFunctionTests(TestCase):
         with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
             list(DTModel.objects.annotate(truncated=TruncYear('start_time', output_field=TimeField())))
 
+    def test_trunc_quarter_func(self):
+        start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321))
+        end_datetime = truncate_to(microsecond_support(datetime(2016, 10, 15, 14, 10, 50, 123)), 'quarter')
+        last_quarter_2015 = truncate_to(microsecond_support(datetime(2015, 12, 31, 14, 10, 50, 123)), 'quarter')
+        first_quarter_2016 = truncate_to(microsecond_support(datetime(2016, 1, 1, 14, 10, 50, 123)), 'quarter')
+        if settings.USE_TZ:
+            start_datetime = timezone.make_aware(start_datetime, is_dst=False)
+            end_datetime = timezone.make_aware(end_datetime, is_dst=False)
+            last_quarter_2015 = timezone.make_aware(last_quarter_2015, is_dst=False)
+            first_quarter_2016 = timezone.make_aware(first_quarter_2016, is_dst=False)
+        self.create_model(start_datetime=start_datetime, end_datetime=end_datetime)
+        self.create_model(start_datetime=end_datetime, end_datetime=start_datetime)
+        self.create_model(start_datetime=last_quarter_2015, end_datetime=end_datetime)
+        self.create_model(start_datetime=first_quarter_2016, end_datetime=end_datetime)
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(extracted=TruncQuarter('start_date')).order_by('start_datetime'),
+            [
+                (start_datetime, truncate_to(start_datetime.date(), 'quarter')),
+                (last_quarter_2015, truncate_to(last_quarter_2015.date(), 'quarter')),
+                (first_quarter_2016, truncate_to(first_quarter_2016.date(), 'quarter')),
+                (end_datetime, truncate_to(end_datetime.date(), 'quarter')),
+            ],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(extracted=TruncQuarter('start_datetime')).order_by('start_datetime'),
+            [
+                (start_datetime, truncate_to(start_datetime, 'quarter')),
+                (last_quarter_2015, truncate_to(last_quarter_2015, 'quarter')),
+                (first_quarter_2016, truncate_to(first_quarter_2016, 'quarter')),
+                (end_datetime, truncate_to(end_datetime, 'quarter')),
+            ],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+
+        with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
+            list(DTModel.objects.annotate(truncated=TruncQuarter('start_time')))
+
+        with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
+            list(DTModel.objects.annotate(truncated=TruncQuarter('start_time', output_field=TimeField())))
+
     def test_trunc_month_func(self):
         start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321))
         end_datetime = truncate_to(microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)), 'month')
@@ -723,6 +817,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
             week=Extract('start_datetime', 'week', tzinfo=melb),
             weekday=ExtractWeekDay('start_datetime'),
             weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb),
+            quarter=ExtractQuarter('start_datetime', tzinfo=melb),
             hour=ExtractHour('start_datetime'),
             hour_melb=ExtractHour('start_datetime', tzinfo=melb),
         ).order_by('start_datetime')
@@ -733,6 +828,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
         self.assertEqual(utc_model.week, 25)
         self.assertEqual(utc_model.weekday, 2)
         self.assertEqual(utc_model.weekday_melb, 3)
+        self.assertEqual(utc_model.quarter, 2)
         self.assertEqual(utc_model.hour, 23)
         self.assertEqual(utc_model.hour_melb, 9)
 
@@ -743,6 +839,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
         self.assertEqual(melb_model.day_melb, 16)
         self.assertEqual(melb_model.week, 25)
         self.assertEqual(melb_model.weekday, 3)
+        self.assertEqual(melb_model.quarter, 2)
         self.assertEqual(melb_model.weekday_melb, 3)
         self.assertEqual(melb_model.hour, 9)
         self.assertEqual(melb_model.hour_melb, 9)
@@ -836,12 +933,14 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
             )
 
         test_date_kind('year')
+        test_date_kind('quarter')
         test_date_kind('month')
         test_date_kind('day')
         test_time_kind('hour')
         test_time_kind('minute')
         test_time_kind('second')
         test_datetime_kind('year')
+        test_datetime_kind('quarter')
         test_datetime_kind('month')
         test_datetime_kind('day')
         test_datetime_kind('hour')