From c7f6ffbdcf9ca8df905aebf73336ef9905771f7c Mon Sep 17 00:00:00 2001 From: Mads Jensen Date: Thu, 8 Jun 2017 21:15:29 +0200 Subject: [PATCH] Fixed #28103 -- Added quarter extract, truncation, and lookup. Thanks Mariusz Felisiak, Tim Graham, and Adam Johnson for review. --- django/db/backends/mysql/operations.py | 10 +++ django/db/backends/oracle/operations.py | 6 ++ django/db/backends/sqlite3/base.py | 11 +++ django/db/models/functions/__init__.py | 12 +-- django/db/models/functions/datetime.py | 11 ++- docs/ref/models/database-functions.txt | 33 ++++++-- docs/ref/models/querysets.txt | 22 +++++ docs/releases/2.0.txt | 9 ++ tests/db_functions/test_datetime.py | 105 +++++++++++++++++++++++- 9 files changed, 202 insertions(+), 17 deletions(-) diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index c1d0451a54..584eb56e70 100644 --- a/django/db/backends/mysql/operations.py +++ b/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: diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 0ee0fbf0fe..17c3d93a5c 100644 --- a/django/db/backends/oracle/operations.py +++ b/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': diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 5892de92f8..faa8b16f1f 100644 --- a/django/db/backends/sqlite3/base.py +++ b/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': diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index b8bb89b171..f2e59f38ff 100644 --- a/django/db/models/functions/__init__.py +++ b/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', ] diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index debf65db3a..24f55e8f5b 100644 --- a/django/db/models/functions/datetime.py +++ b/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' diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 17ac25226a..9bb36ccafd 100644 --- a/docs/ref/models/database-functions.txt +++ b/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 diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 48c547f514..b8868f3a72 100644 --- a/docs/ref/models/querysets.txt +++ b/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 `. +.. 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 `. + .. fieldlookup:: time ``time`` diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index 2c9df4e3ce..de5559a7eb 100644 --- a/docs/releases/2.0.txt +++ b/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 ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/db_functions/test_datetime.py b/tests/db_functions/test_datetime.py index f517b2f699..a64eac75c9 100644 --- a/tests/db_functions/test_datetime.py +++ b/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')