diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 794cc15c6b2e6..63081ba113c2e 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -113,6 +113,14 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): """ raise NotImplementedError('subclasses of BaseDatabaseOperations may require a datetime_trunk_sql() method') + def time_trunc_sql(self, lookup_type, field_name): + """ + Given a lookup_type of 'hour', 'minute' or 'second', returns the SQL + that truncates the given time field field_name to a time object with + only the given specificity. + """ + raise NotImplementedError('subclasses of BaseDatabaseOperations may require a time_trunc_sql() method') + def time_extract_sql(self, lookup_type, field_name): """ Given a lookup_type of 'hour', 'minute' or 'second', returns the SQL diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index b9b8cd908977e..5ced46f9708a5 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -70,6 +70,18 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): sql = "CAST(DATE_FORMAT(%s, '%s') AS DATETIME)" % (field_name, format_str) return sql, params + def time_trunc_sql(self, lookup_type, field_name): + fields = { + 'hour': '%%H:00:00', + 'minute': '%%H:%%i:00', + 'second': '%%H:%%i:%%s', + } # Use double percents to escape. + if lookup_type in fields: + format_str = fields[lookup_type] + return "CAST(DATE_FORMAT(%s, '%s') AS TIME)" % (field_name, format_str) + else: + return "TIME(%s)" % (field_name) + def date_interval_sql(self, timedelta): return "INTERVAL '%d 0:0:%d:%d' DAY_MICROSECOND" % ( timedelta.days, timedelta.seconds, timedelta.microseconds), [] diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 4a5f52479b7e9..0a6a239956879 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -148,6 +148,18 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): sql = "CAST(%s AS DATE)" % field_name # Cast to DATE removes sub-second precision. return sql, [] + def time_trunc_sql(self, lookup_type, field_name): + # The implementation is similar to `datetime_trunc_sql` as both + # `DateTimeField` and `TimeField` are stored as TIMESTAMP where + # the date part of the later is ignored. + if lookup_type == 'hour': + sql = "TRUNC(%s, 'HH24')" % field_name + elif lookup_type == 'minute': + sql = "TRUNC(%s, 'MI')" % field_name + elif lookup_type == 'second': + sql = "CAST(%s AS DATE)" % field_name # Cast to DATE removes sub-second precision. + return sql + def get_db_converters(self, expression): converters = super(DatabaseOperations, self).get_db_converters(expression) internal_type = expression.output_field.get_internal_type() diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py index 9b646150018c2..2130571a05f64 100644 --- a/django/db/backends/postgresql/operations.py +++ b/django/db/backends/postgresql/operations.py @@ -56,6 +56,9 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): sql = "DATE_TRUNC('%s', %s)" % (lookup_type, field_name) return sql, params + def time_trunc_sql(self, lookup_type, field_name): + return "DATE_TRUNC('%s', %s)::time" % (lookup_type, field_name) + def deferrable_sql(self): return " DEFERRABLE INITIALLY DEFERRED" diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 4f52cc36371d5..70d511f108d9d 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -213,6 +213,7 @@ def get_new_connection(self, conn_params): conn.create_function("django_datetime_extract", 3, _sqlite_datetime_extract) conn.create_function("django_datetime_trunc", 3, _sqlite_datetime_trunc) conn.create_function("django_time_extract", 2, _sqlite_time_extract) + conn.create_function("django_time_trunc", 2, _sqlite_time_trunc) conn.create_function("django_time_diff", 2, _sqlite_time_diff) conn.create_function("django_timestamp_diff", 2, _sqlite_timestamp_diff) conn.create_function("regexp", 2, _sqlite_regexp) @@ -370,6 +371,19 @@ def _sqlite_date_trunc(lookup_type, dt): return "%i-%02i-%02i" % (dt.year, dt.month, dt.day) +def _sqlite_time_trunc(lookup_type, dt): + try: + dt = backend_utils.typecast_time(dt) + except (ValueError, TypeError): + return None + if lookup_type == 'hour': + return "%02i:00:00" % dt.hour + elif lookup_type == 'minute': + return "%02i:%02i:00" % (dt.hour, dt.minute) + elif lookup_type == 'second': + return "%02i:%02i:%02i" % (dt.hour, dt.minute, dt.second) + + def _sqlite_datetime_parse(dt, tzname): if dt is None: return None diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index 1daa38fe50bac..4b7fc091db53d 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/django/db/backends/sqlite3/operations.py @@ -70,6 +70,13 @@ def date_trunc_sql(self, lookup_type, field_name): # cause a collision with a field name). return "django_date_trunc('%s', %s)" % (lookup_type.lower(), field_name) + def time_trunc_sql(self, lookup_type, field_name): + # sqlite doesn't support DATE_TRUNC, so we fake it with a user-defined + # function django_date_trunc that's registered in connect(). Note that + # single quotes are used because this is a string (and could otherwise + # cause a collision with a field name). + return "django_time_trunc('%s', %s)" % (lookup_type.lower(), field_name) + def _require_pytz(self): if settings.USE_TZ and pytz is None: raise ImproperlyConfigured("This query requires pytz, but it isn't installed.") diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 2980460709f1b..85a398a50b31c 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -151,26 +151,38 @@ def as_sql(self, compiler, connection): elif isinstance(self.output_field, DateField): sql = connection.ops.date_trunc_sql(self.kind, inner_sql) params = [] + elif isinstance(self.output_field, TimeField): + sql = connection.ops.time_trunc_sql(self.kind, inner_sql) + params = [] else: - raise ValueError('Trunc only valid on DateField or DateTimeField.') + raise ValueError('Trunc only valid on DateField, TimeField, or DateTimeField.') return sql, inner_params + params def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False): copy = super(TruncBase, self).resolve_expression(query, allow_joins, reuse, summarize, for_save) field = copy.lhs.output_field # DateTimeField is a subclass of DateField so this works for both. - assert isinstance(field, DateField), ( - "%r isn't a DateField or DateTimeField." % field.name + assert isinstance(field, (DateField, TimeField)), ( + "%r isn't a DateField, TimeField, or DateTimeField." % field.name ) # If self.output_field was None, then accessing the field will trigger # the resolver to assign it to self.lhs.output_field. - if not isinstance(copy.output_field, (DateField, DateTimeField)): - raise ValueError('output_field must be either DateField or DateTimeField') - # Passing dates to functions expecting datetimes is most likely a - # mistake. + if not isinstance(copy.output_field, (DateField, DateTimeField, TimeField)): + raise ValueError('output_field must be either DateField, TimeField, or DateTimeField') + # Passing dates or times to functions expecting datetimes is most + # likely a mistake. + output_field = copy.output_field + explicit_output_field = field.__class__ != copy.output_field.__class__ if type(field) == DateField and ( - isinstance(copy.output_field, DateTimeField) or copy.kind in ('hour', 'minute', 'second')): - raise ValueError("Cannot truncate DateField '%s' to DateTimeField. " % field.name) + isinstance(output_field, DateTimeField) or copy.kind in ('hour', 'minute', 'second', 'time')): + raise ValueError("Cannot truncate DateField '%s' to %s. " % ( + 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')): + raise ValueError("Cannot truncate TimeField '%s' to %s. " % ( + field.name, output_field.__class__.__name__ if explicit_output_field else 'DateTimeField' + )) return copy def convert_value(self, value, expression, connection, context): @@ -184,8 +196,10 @@ def convert_value(self, value, expression, connection, context): value = value.replace(tzinfo=None) value = timezone.make_aware(value, self.tzinfo) elif isinstance(value, datetime): - # self.output_field is definitely a DateField here. - value = value.date() + if isinstance(self.output_field, DateField): + value = value.date() + elif isinstance(self.output_field, TimeField): + value = value.time() return value @@ -209,6 +223,7 @@ class TruncDay(TruncBase): class TruncDate(TruncBase): + kind = 'date' lookup_name = 'date' @cached_property @@ -227,25 +242,13 @@ def as_sql(self, compiler, connection): class TruncHour(TruncBase): kind = 'hour' - @cached_property - def output_field(self): - return DateTimeField() - class TruncMinute(TruncBase): kind = 'minute' - @cached_property - def output_field(self): - return DateTimeField() - class TruncSecond(TruncBase): kind = 'second' - @cached_property - def output_field(self): - return DateTimeField() - DateTimeField.register_lookup(TruncDate) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index d6c48f966ce69..20ceadde91c34 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -288,8 +288,10 @@ We'll be using the following model in examples of each function:: class Experiment(models.Model): start_datetime = models.DateTimeField() start_date = models.DateField(null=True, blank=True) + start_time = models.TimeField(null=True, blank=True) end_datetime = models.DateTimeField(null=True, blank=True) end_date = models.DateField(null=True, blank=True) + end_time = models.TimeField(null=True, blank=True) ``Extract`` ----------- @@ -500,13 +502,14 @@ but not the exact second, then ``Trunc`` (and its subclasses) can be useful to filter or aggregate your data. For example, you can use ``Trunc`` to calculate the number of sales per day. -``Trunc`` takes a single ``expression``, representing a ``DateField`` or -``DateTimeField``, a ``kind`` representing a date part, and an ``output_field`` -that's either ``DateTimeField()`` or ``DateField()``. It returns a datetime or -date, depending on ``output_field``, with fields up to ``kind`` set to their -minimum value. If ``output_field`` is omitted, it will default to the -``output_field`` of ``expression``. A ``tzinfo`` subclass, usually provided by -``pytz``, can be passed to truncate a value in a specific timezone. +``Trunc`` takes a single ``expression``, representing a ``DateField``, +``TimeField``, or ``DateTimeField``, a ``kind`` representing a date or time +part, and an ``output_field`` that's either ``DateTimeField()``, +``TimeField()``, or ``DateField()``. It returns a datetime, date, or time +depending on ``output_field``, with fields up to ``kind`` set to their minimum +value. If ``output_field`` is omitted, it will default to the ``output_field`` +of ``expression``. A ``tzinfo`` subclass, usually provided by ``pytz``, can be +passed to truncate a value in a specific timezone. Given the datetime ``2015-06-15 14:30:50.000321+00:00``, the built-in ``kind``\s return: @@ -616,6 +619,61 @@ that deal with date-parts can be used with ``DateField``:: 2016-01-01 00:00:00+11:00 1 2014-06-01 00:00:00+10:00 1 +``TimeField`` truncation +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.11 + +.. class:: TruncHour(expression, output_field=None, tzinfo=None, **extra) + + .. attribute:: kind = 'hour' + +.. class:: TruncMinute(expression, output_field=None, tzinfo=None, **extra) + + .. attribute:: kind = 'minute' + +.. class:: TruncSecond(expression, output_field=None, tzinfo=None, **extra) + + .. attribute:: kind = 'second' + +These are logically equivalent to ``Trunc('time_field', kind)``. They truncate +all parts of the time up to ``kind`` which allows grouping or filtering times +with less precision. ``expression`` can have an ``output_field`` of either +``TimeField`` or ``DateTimeField``. + +Since ``TimeField``\s don't have a date component, only ``Trunc`` subclasses +that deal with time-parts can be used with ``TimeField``:: + + >>> from datetime import datetime + >>> from django.db.models import Count, TimeField + >>> from django.db.models.functions import TruncHour + >>> from django.utils import timezone + >>> start1 = datetime(2014, 6, 15, 14, 30, 50, 321, tzinfo=timezone.utc) + >>> start2 = datetime(2014, 6, 15, 14, 40, 2, 123, tzinfo=timezone.utc) + >>> start3 = datetime(2015, 12, 31, 17, 5, 27, 999, tzinfo=timezone.utc) + >>> Experiment.objects.create(start_datetime=start1, start_time=start1.time()) + >>> Experiment.objects.create(start_datetime=start2, start_time=start2.time()) + >>> Experiment.objects.create(start_datetime=start3, start_time=start3.time()) + >>> experiments_per_hour = Experiment.objects.annotate( + ... hour=TruncHour('start_datetime', output_field=TimeField()), + ... ).values('hour').annotate(experiments=Count('id')) + >>> for exp in experiments_per_hour: + ... print(exp['hour'], exp['experiments']) + ... + 14:00:00 2 + 17:00:00 1 + + >>> import pytz + >>> melb = pytz.timezone('Australia/Melbourne') + >>> experiments_per_hour = Experiment.objects.annotate( + ... hour=TruncHour('start_datetime', tzinfo=melb), + ... ).values('hour').annotate(experiments=Count('id')) + >>> for exp in experiments_per_hour: + ... print(exp['hour'], exp['experiments']) + ... + 2014-06-16 00:00:00+10:00 2 + 2016-01-01 04:00:00+11:00 1 + ``DateTimeField`` truncation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 2359eec910de8..392ded70b827a 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -198,6 +198,9 @@ Models * :class:`~django.db.models.ImageField` now has a default :data:`~django.core.validators.validate_image_file_extension` validator. +* Added support for time truncation to + :class:`~django.db.models.functions.datetime.Trunc` functions. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ @@ -263,7 +266,12 @@ Backwards incompatible changes in 1.11 Database backend API -------------------- -* ... +* The ``DatabaseOperations.time_trunc_sql()`` method is added to support + ``TimeField`` truncation. It accepts a ``lookup_type`` and ``field_name`` + arguments and returns the appropriate SQL to truncate the given time field + ``field_name`` to a time object with only the given specificity. The + ``lookup_type`` argument can be either ``'hour'``, ``'minute'``, or + ``'second'``. Dropped support for PostgreSQL 9.2 and PostGIS 2.0 -------------------------------------------------- diff --git a/tests/db_functions/test_datetime.py b/tests/db_functions/test_datetime.py index 011db8bb885e2..e727ea5b7d725 100644 --- a/tests/db_functions/test_datetime.py +++ b/tests/db_functions/test_datetime.py @@ -5,7 +5,7 @@ from django.conf import settings from django.db import connection -from django.db.models import DateField, DateTimeField, IntegerField +from django.db.models import DateField, DateTimeField, IntegerField, TimeField from django.db.models.functions import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, @@ -353,18 +353,25 @@ def test_trunc_func(self): self.create_model(start_datetime, end_datetime) self.create_model(end_datetime, start_datetime) - with self.assertRaisesMessage(ValueError, 'output_field must be either DateField or DateTimeField'): + msg = 'output_field must be either DateField, TimeField, or DateTimeField' + with self.assertRaisesMessage(ValueError, msg): list(DTModel.objects.annotate(truncated=Trunc('start_datetime', 'year', output_field=IntegerField()))) - with self.assertRaisesMessage(AssertionError, "'name' isn't a DateField or DateTimeField."): + with self.assertRaisesMessage(AssertionError, "'name' isn't a DateField, TimeField, or DateTimeField."): list(DTModel.objects.annotate(truncated=Trunc('name', 'year', output_field=DateTimeField()))) with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to DateTimeField"): list(DTModel.objects.annotate(truncated=Trunc('start_date', 'second'))) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=Trunc('start_time', 'month'))) + with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to DateTimeField"): list(DTModel.objects.annotate(truncated=Trunc('start_date', 'month', output_field=DateTimeField()))) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=Trunc('start_time', 'second', output_field=DateTimeField()))) + def test_datetime_kind(kind): self.assertQuerysetEqual( DTModel.objects.annotate( @@ -389,9 +396,24 @@ def test_date_kind(kind): lambda m: (m.start_datetime, m.truncated) ) + def test_time_kind(kind): + self.assertQuerysetEqual( + DTModel.objects.annotate( + truncated=Trunc('start_time', kind, output_field=TimeField()) + ).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.time(), kind)), + (end_datetime, truncate_to(end_datetime.time(), kind)) + ], + lambda m: (m.start_datetime, m.truncated) + ) + test_date_kind('year') 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('month') test_datetime_kind('day') @@ -428,6 +450,12 @@ def test_trunc_year_func(self): ) self.assertEqual(DTModel.objects.filter(start_datetime=TruncYear('start_datetime')).count(), 1) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncYear('start_time'))) + + 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_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') @@ -454,6 +482,12 @@ def test_trunc_month_func(self): ) self.assertEqual(DTModel.objects.filter(start_datetime=TruncMonth('start_datetime')).count(), 1) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncMonth('start_time'))) + + 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_date_func(self): start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)) @@ -472,6 +506,12 @@ def test_trunc_date_func(self): ) self.assertEqual(DTModel.objects.filter(start_datetime__date=TruncDate('start_datetime')).count(), 2) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateField"): + list(DTModel.objects.annotate(truncated=TruncDate('start_time'))) + + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateField"): + list(DTModel.objects.annotate(truncated=TruncDate('start_time', output_field=TimeField()))) + def test_trunc_day_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)), 'day') @@ -490,6 +530,12 @@ def test_trunc_day_func(self): ) self.assertEqual(DTModel.objects.filter(start_datetime=TruncDay('start_datetime')).count(), 1) + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncDay('start_time'))) + + with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): + list(DTModel.objects.annotate(truncated=TruncDay('start_time', output_field=TimeField()))) + def test_trunc_hour_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)), 'hour') @@ -506,6 +552,14 @@ def test_trunc_hour_func(self): ], lambda m: (m.start_datetime, m.extracted) ) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=TruncHour('start_time')).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.time(), 'hour')), + (end_datetime, truncate_to(end_datetime.time(), 'hour')), + ], + lambda m: (m.start_datetime, m.extracted) + ) self.assertEqual(DTModel.objects.filter(start_datetime=TruncHour('start_datetime')).count(), 1) with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to DateTimeField"): @@ -530,6 +584,14 @@ def test_trunc_minute_func(self): ], lambda m: (m.start_datetime, m.extracted) ) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=TruncMinute('start_time')).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.time(), 'minute')), + (end_datetime, truncate_to(end_datetime.time(), 'minute')), + ], + lambda m: (m.start_datetime, m.extracted) + ) self.assertEqual(DTModel.objects.filter(start_datetime=TruncMinute('start_datetime')).count(), 1) with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to DateTimeField"): @@ -554,6 +616,14 @@ def test_trunc_second_func(self): ], lambda m: (m.start_datetime, m.extracted) ) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=TruncSecond('start_time')).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.time(), 'second')), + (end_datetime, truncate_to(end_datetime.time(), 'second')) + ], + lambda m: (m.start_datetime, m.extracted) + ) result = 1 if connection.features.supports_microsecond_precision else 2 self.assertEqual(DTModel.objects.filter(start_datetime=TruncSecond('start_datetime')).count(), result) @@ -680,9 +750,24 @@ def test_date_kind(kind, tzinfo=melb): lambda m: (m.start_datetime, m.truncated) ) + def test_time_kind(kind, tzinfo=melb): + self.assertQuerysetEqual( + DTModel.objects.annotate( + truncated=Trunc('start_time', kind, output_field=TimeField(), tzinfo=melb) + ).order_by('start_datetime'), + [ + (start_datetime, truncate_to(start_datetime.time(), kind)), + (end_datetime, truncate_to(end_datetime.time(), kind)) + ], + lambda m: (m.start_datetime, m.truncated) + ) + test_date_kind('year') 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('month') test_datetime_kind('day') diff --git a/tests/queries/tests.py b/tests/queries/tests.py index 48b57376f9f0e..e40ab1fa58364 100644 --- a/tests/queries/tests.py +++ b/tests/queries/tests.py @@ -1312,7 +1312,7 @@ def test_ticket7107(self): def test_ticket8683(self): # An error should be raised when QuerySet.datetimes() is passed the # wrong type of field. - with self.assertRaisesMessage(AssertionError, "'name' isn't a DateField or DateTimeField."): + with self.assertRaisesMessage(AssertionError, "'name' isn't a DateField, TimeField, or DateTimeField."): Item.objects.datetimes('name', 'month') def test_ticket22023(self):