[IMP] orm: add support to fill temporal holes (read_group)

This commit add an extra parameter 'fill_temporal' to read_group which allows
the orm to add missing groups for date intervals. This is useful for charts.

Suppose that we are in a use case where data are grouped by a date fields
(typically months but it could be another interval) and displayed in a Bar Chart
or a Line Char.
Let's says a request has to group records by month for August, September
and ...December.  If we don't changed anything, we would get a Bar Chart
looking like this :
                                        ___
                              ___      |   |
                             |   |     |   |
                             |   | ___ |   |
                             |   ||   ||   |
                             |___||___||___|
                              Aug  Sep  D
December follows directly after September, it can be unintuitive for the
user, so we change that. We add some fake records for each missing months
between the earliest and the lastest date of the result
                                             ___
                         ___                |   |
                        |   |               |   |
                        |   | ___           |   |
                        |   ||   |          |   |
                        |___||___| ___  ___ |___|
                         Aug  Sep  Oct  Nov  Dec

This commit is part of task #1835644
This commit is contained in:
Francois Volral
2018-08-10 17:21:17 +02:00
parent 32e232149f
commit 4a40db2097
7 changed files with 838 additions and 7 deletions
+132 -1
View File
@@ -2,9 +2,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from dateutil.relativedelta import relativedelta
import pytz
import unittest
from odoo.tools import misc
from odoo.tools import misc, date_utils
from odoo.tests.common import TransactionCase, tagged
@@ -40,6 +42,135 @@ class TestCountingStream(unittest.TestCase):
self.assertEqual(s.index, 0)
@tagged('standard', 'at_install')
class TestDateRangeFunction(unittest.TestCase):
""" Test on date_range generator. """
def test_date_range_with_naive_datetimes(self):
""" Check date_range with naive datetimes. """
start = datetime.datetime(1985, 1, 1)
end = datetime.datetime(1986, 1, 1)
expected = [
datetime.datetime(1985, 1, 1, 0, 0),
datetime.datetime(1985, 2, 1, 0, 0),
datetime.datetime(1985, 3, 1, 0, 0),
datetime.datetime(1985, 4, 1, 0, 0),
datetime.datetime(1985, 5, 1, 0, 0),
datetime.datetime(1985, 6, 1, 0, 0),
datetime.datetime(1985, 7, 1, 0, 0),
datetime.datetime(1985, 8, 1, 0, 0),
datetime.datetime(1985, 9, 1, 0, 0),
datetime.datetime(1985, 10, 1, 0, 0),
datetime.datetime(1985, 11, 1, 0, 0),
datetime.datetime(1985, 12, 1, 0, 0),
datetime.datetime(1986, 1, 1, 0, 0)
]
dates = [date for date in date_utils.date_range(start, end)]
self.assertEqual(dates, expected)
def test_date_range_with_timezone_aware_datetimes_other_than_utc(self):
""" Check date_range with timezone-aware datetimes other than UTC."""
timezone = pytz.timezone('Europe/Brussels')
start = datetime.datetime(1985, 1, 1)
end = datetime.datetime(1986, 1, 1)
start = timezone.localize(start)
end = timezone.localize(end)
expected = [datetime.datetime(1985, 1, 1, 0, 0),
datetime.datetime(1985, 2, 1, 0, 0),
datetime.datetime(1985, 3, 1, 0, 0),
datetime.datetime(1985, 4, 1, 0, 0),
datetime.datetime(1985, 5, 1, 0, 0),
datetime.datetime(1985, 6, 1, 0, 0),
datetime.datetime(1985, 7, 1, 0, 0),
datetime.datetime(1985, 8, 1, 0, 0),
datetime.datetime(1985, 9, 1, 0, 0),
datetime.datetime(1985, 10, 1, 0, 0),
datetime.datetime(1985, 11, 1, 0, 0),
datetime.datetime(1985, 12, 1, 0, 0),
datetime.datetime(1986, 1, 1, 0, 0)]
expected = [timezone.localize(e) for e in expected]
dates = [date for date in date_utils.date_range(start, end)]
self.assertEqual(expected, dates)
def test_date_range_with_mismatching_zones(self):
""" Check date_range with mismatching zone should raise an exception."""
start_timezone = pytz.timezone('Europe/Brussels')
end_timezone = pytz.timezone('America/Recife')
start = datetime.datetime(1985, 1, 1)
end = datetime.datetime(1986, 1, 1)
start = start_timezone.localize(start)
end = end_timezone.localize(end)
with self.assertRaises(ValueError):
dates = [date for date in date_utils.date_range(start, end)]
def test_date_range_with_inconsistent_datetimes(self):
""" Check date_range with a timezone-aware datetime and a naive one."""
context_timezone = pytz.timezone('Europe/Brussels')
start = datetime.datetime(1985, 1, 1)
end = datetime.datetime(1986, 1, 1)
end = context_timezone.localize(end)
with self.assertRaises(ValueError):
dates = [date for date in date_utils.date_range(start, end)]
def test_date_range_with_hour(self):
""" Test date range with hour and naive datetime."""
start = datetime.datetime(2018, 3, 25)
end = datetime.datetime(2018, 3, 26)
step = relativedelta(hours=1)
expected = [
datetime.datetime(2018, 3, 25, 0, 0),
datetime.datetime(2018, 3, 25, 1, 0),
datetime.datetime(2018, 3, 25, 2, 0),
datetime.datetime(2018, 3, 25, 3, 0),
datetime.datetime(2018, 3, 25, 4, 0),
datetime.datetime(2018, 3, 25, 5, 0),
datetime.datetime(2018, 3, 25, 6, 0),
datetime.datetime(2018, 3, 25, 7, 0),
datetime.datetime(2018, 3, 25, 8, 0),
datetime.datetime(2018, 3, 25, 9, 0),
datetime.datetime(2018, 3, 25, 10, 0),
datetime.datetime(2018, 3, 25, 11, 0),
datetime.datetime(2018, 3, 25, 12, 0),
datetime.datetime(2018, 3, 25, 13, 0),
datetime.datetime(2018, 3, 25, 14, 0),
datetime.datetime(2018, 3, 25, 15, 0),
datetime.datetime(2018, 3, 25, 16, 0),
datetime.datetime(2018, 3, 25, 17, 0),
datetime.datetime(2018, 3, 25, 18, 0),
datetime.datetime(2018, 3, 25, 19, 0),
datetime.datetime(2018, 3, 25, 20, 0),
datetime.datetime(2018, 3, 25, 21, 0),
datetime.datetime(2018, 3, 25, 22, 0),
datetime.datetime(2018, 3, 25, 23, 0),
datetime.datetime(2018, 3, 26, 0, 0)
]
dates = [date for date in date_utils.date_range(start, end, step)]
self.assertEqual(dates, expected)
def test_date_range_with_identical_boundaries(self):
""" date_range with indentical boundaries. """
start = datetime.datetime(1985, 1, 1)
end = datetime.datetime(1985, 1, 1)
with self.assertRaises(ValueError):
dates = [date for date in date_utils.date_range(start, end)]
class TestFormatLangDate(TransactionCase):
def test_00_accepted_types(self):
date_datetime = datetime.datetime.strptime('2017-01-31 12:00:00', "%Y-%m-%d %H:%M:%S")
@@ -3,3 +3,4 @@ access_test_read_group_on_date,access_test_read_group_on_date,model_test_read_gr
access_test_read_group_aggregate_boolean,access_test_read_group_aggregate_boolean,model_test_read_group_aggregate_boolean,,1,0,0,0
access_test_read_group_aggregate,access_test_read_group_aggregate,model_test_read_group_aggregate,,1,0,0,0
access_test_read_group_on_selection,access_test_read_group_on_selection,model_test_read_group_on_selection,,1,0,0,0
access_test_read_group_fill_temporal,access_test_read_group_fill_temporal,model_test_read_group_fill_temporal,,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_test_read_group_aggregate_boolean access_test_read_group_aggregate_boolean model_test_read_group_aggregate_boolean 1 0 0 0
4 access_test_read_group_aggregate access_test_read_group_aggregate model_test_read_group_aggregate 1 0 0 0
5 access_test_read_group_on_selection access_test_read_group_on_selection model_test_read_group_on_selection 1 0 0 0
6 access_test_read_group_fill_temporal access_test_read_group_fill_temporal model_test_read_group_fill_temporal 1 0 0 0
+8
View File
@@ -37,3 +37,11 @@ class GroupOnSelection(models.Model):
def _expand_states(self, states, domain, order):
# return all possible states, in order
return [key for key, val in type(self).state.selection]
class FillTemporal(models.Model):
_name = 'test_read_group.fill_temporal'
date = fields.Date()
datetime = fields.Datetime()
value = fields.Integer()
@@ -2,3 +2,4 @@
from . import test_empty
from . import test_group_expand
from . import test_group_operator
from . import test_fill_temporal
@@ -0,0 +1,561 @@
# -*- coding: utf-8 -*-
"""Test for fill temporal."""
from odoo.tests import common
class TestFillTemporal(common.TransactionCase):
"""Test for fill temporal.
This feature is mainly used in graph view. For more informations, read the
documentation of models's '_read_group_fill_temporal' method.
"""
def setUp(self):
super(TestFillTemporal, self).setUp()
self.Model = self.env['test_read_group.fill_temporal']
def test_date_range_and_flag(self):
"""Simple date range test, the flag is also tested.
One of the most simple test. It must verify that dates 'holes' are filled
only when the fill_temporal flag is set.
"""
self.Model.create({'date': '1916-08-18', 'value': 2})
self.Model.create({'date': '1916-10-19', 'value': 3})
self.Model.create({'date': '1916-12-19', 'value': 5})
expected = [{
'__domain': ['&', ('date', '>=', '1916-08-01'), ('date', '<', '1916-09-01')],
'date': 'August 1916',
'date_count': 1,
'value': 2
}, {
'__domain': ['&', ('date', '>=', '1916-09-01'), ('date', '<', '1916-10-01')],
'date': 'September 1916',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1916-10-01'), ('date', '<', '1916-11-01')],
'date': 'October 1916',
'date_count': 1,
'value': 3
}, {
'__domain': ['&', ('date', '>=', '1916-11-01'), ('date', '<', '1916-12-01')],
'date': 'November 1916',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1916-12-01'), ('date', '<', '1917-01-01')],
'date': 'December 1916',
'date_count': 1,
'value': 5
}]
groups = self.Model.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, [group for group in expected if group['date_count']])
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
def test_date_range_with_context_timezone(self):
"""Test if date are date_trunced correctly by pgres.
This test was added in attempt to fix a bug appearing with babel that
we use to translate the dates. Typically after a daylight saving, A
whole year was displayed in a graph like this (APR missing and OCT
appearing twice) :
JAN FEB MAR MAY JUN JUL AUG SEP OCT OCT NOV
^^^ ^^^
"""
self.Model.create({'date': '1915-01-01', 'value': 3})
self.Model.create({'date': '1916-01-01', 'value': 5})
expected = [{
'__domain': ['&', ('date', '>=', '1915-01-01'), ('date', '<', '1915-02-01')],
'date': 'January 1915',
'date_count': 1,
'value': 3
}, {
'__domain': ['&', ('date', '>=', '1915-02-01'), ('date', '<', '1915-03-01')],
'date': 'February 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-03-01'), ('date', '<', '1915-04-01')],
'date': 'March 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-04-01'), ('date', '<', '1915-05-01')],
'date': 'April 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-05-01'), ('date', '<', '1915-06-01')],
'date': 'May 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-06-01'), ('date', '<', '1915-07-01')],
'date': 'June 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-07-01'), ('date', '<', '1915-08-01')],
'date': 'July 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-08-01'), ('date', '<', '1915-09-01')],
'date': 'August 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-09-01'), ('date', '<', '1915-10-01')],
'date': 'September 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-10-01'), ('date', '<', '1915-11-01')],
'date': 'October 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-11-01'), ('date', '<', '1915-12-01')],
'date': 'November 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1915-12-01'), ('date', '<', '1916-01-01')],
'date': 'December 1915',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1916-01-01'), ('date', '<', '1916-02-01')],
'date': 'January 1916',
'date_count': 1,
'value': 5
}]
# Time Zone UTC UTC DST
tzs = ["America/Anchorage", # −09:00 −08:00
"Europe/Brussels", # +01:00 +02:00
"Pacific/Kwajalein"] # +12:00 +12:00
for tz in tzs:
model_fill = self.Model.with_context(tz=tz, fill_temporal=True)
groups = model_fill.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
def test_only_with_only_null_date(self):
"""We should have the same result when fill_temporal is set or not."""
self.Model.create({'date': False, 'value': 13})
self.Model.create({'date': False, 'value': 11})
self.Model.create({'date': False, 'value': 17})
expected = [{'__domain': [('date', '=', False)],
'date_count': 3,
'value': 41,
'date': False}]
groups = self.Model.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
def test_date_range_and_null_date(self):
"""Test data with null and non-null dates."""
self.Model.create({'date': '1916-08-19', 'value': 4})
self.Model.create({'date': False, 'value': 13})
self.Model.create({'date': '1916-10-18', 'value': 5})
self.Model.create({'date': '1916-08-18', 'value': 3})
self.Model.create({'date': '1916-10-19', 'value': 4})
self.Model.create({'date': False, 'value': 11})
expected = [{
'__domain': ['&', ('date', '>=', '1916-08-01'), ('date', '<', '1916-09-01')],
'date': 'August 1916',
'date_count': 2,
'value': 7
}, {
'__domain': ['&', ('date', '>=', '1916-09-01'), ('date', '<', '1916-10-01')],
'date': 'September 1916',
'date_count': 0,
'value': 0
}, {
'__domain': ['&', ('date', '>=', '1916-10-01'), ('date', '<', '1916-11-01')],
'date': 'October 1916',
'date_count': 2,
'value': 9
}, {
'__domain': [('date', '=', False)],
'date': False,
'date_count': 2,
'value': 24
}]
groups = self.Model.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, [group for group in expected if group['date_count']])
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
def test_order_date_desc(self):
"""Test if changing Model._order has influence on the result."""
self.Model.create({'date': '1916-08-18', 'value': 3})
self.Model.create({'date': '1916-08-19', 'value': 4})
self.Model.create({'date': '1916-10-18', 'value': 5})
self.Model.create({'date': '1916-10-19', 'value': 4})
self.patch(type(self.Model), '_order', 'date desc')
expected = [{
'__domain': ['&', ('date', '>=', '1916-08-01'), ('date', '<', '1916-09-01')],
'date': 'August 1916',
'date_count': 2,
'value': 7
}, {
'__domain': ['&', ('date', '>=', '1916-09-01'), ('date', '<', '1916-10-01')],
'date': 'September 1916',
'date_count': 0,
'value': False
}, {
'__domain': ['&', ('date', '>=', '1916-10-01'), ('date', '<', '1916-11-01')],
'date': 'October 1916',
'date_count': 2,
'value': 9
}]
groups = self.Model.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, [group for group in expected if group['date_count']])
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['date', 'value'], groupby=['date'])
self.assertEqual(groups, expected)
def test_timestamp_without_timezone(self):
"""Test datetimes.
Date stored with an hour inside the Odoo model are processed as timestamp
without timezone by postgres.
"""
self.Model.create({'datetime': '1916-08-19 01:30:00', 'value': 7})
self.Model.create({'datetime': False, 'value': 13})
self.Model.create({'datetime': '1916-10-18 02:30:00', 'value': 5})
self.Model.create({'datetime': '1916-08-18 01:50:00', 'value': 3})
self.Model.create({'datetime': False, 'value': 11})
self.Model.create({'datetime': '1916-10-19 23:59:59', 'value': 2})
self.Model.create({'datetime': '1916-10-19', 'value': 19})
expected = [{
'__domain': ['&',
('datetime', '>=', '1916-08-01 00:00:00'),
('datetime', '<', '1916-09-01 00:00:00')],
'datetime': 'August 1916',
'datetime_count': 2,
'value': 10
}, {
'__domain': ['&',
('datetime', '>=', '1916-09-01 00:00:00'),
('datetime', '<', '1916-10-01 00:00:00')],
'datetime': 'September 1916',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-10-01 00:00:00'),
('datetime', '<', '1916-11-01 00:00:00')],
'datetime': 'October 1916',
'datetime_count': 3,
'value': 26
}, {
'__domain': [('datetime', '=', False)],
'datetime': False,
'datetime_count': 2,
'value': 24
}]
groups = self.Model.read_group([], fields=['datetime', 'value'], groupby=['datetime'])
self.assertEqual(groups, [group for group in expected if group['datetime_count']])
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['datetime', 'value'], groupby=['datetime'])
self.assertEqual(groups, expected)
def test_with_datetimes_and_groupby_per_hour(self):
"""Test with datetimes and groupby per hour.
Test if datetimes are filled correctly when grouping by hours instead of
months.
"""
self.Model.create({'datetime': '1916-01-01 01:30:00', 'value': 2})
self.Model.create({'datetime': '1916-01-01 01:50:00', 'value': 8})
self.Model.create({'datetime': '1916-01-01 02:30:00', 'value': 3})
self.Model.create({'datetime': '1916-01-01 13:50:00', 'value': 5})
self.Model.create({'datetime': '1916-01-01 23:50:00', 'value': 7})
expected = [{
'__domain': ['&',
('datetime', '>=', '1916-01-01 01:00:00'),
('datetime', '<', '1916-01-01 02:00:00')],
'datetime:hour': '01:00 01 Jan',
'datetime_count': 2,
'value': 10
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 02:00:00'),
('datetime', '<', '1916-01-01 03:00:00')],
'datetime:hour': '02:00 01 Jan',
'datetime_count': 1,
'value': 3
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 03:00:00'),
('datetime', '<', '1916-01-01 04:00:00')],
'datetime:hour': '03:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 04:00:00'),
('datetime', '<', '1916-01-01 05:00:00')],
'datetime:hour': '04:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 05:00:00'),
('datetime', '<', '1916-01-01 06:00:00')],
'datetime:hour': '05:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 06:00:00'),
('datetime', '<', '1916-01-01 07:00:00')],
'datetime:hour': '06:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 07:00:00'),
('datetime', '<', '1916-01-01 08:00:00')],
'datetime:hour': '07:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 08:00:00'),
('datetime', '<', '1916-01-01 09:00:00')],
'datetime:hour': '08:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 09:00:00'),
('datetime', '<', '1916-01-01 10:00:00')],
'datetime:hour': '09:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 10:00:00'),
('datetime', '<', '1916-01-01 11:00:00')],
'datetime:hour': '10:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 11:00:00'),
('datetime', '<', '1916-01-01 12:00:00')],
'datetime:hour': '11:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 12:00:00'),
('datetime', '<', '1916-01-01 13:00:00')],
'datetime:hour': '12:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 13:00:00'),
('datetime', '<', '1916-01-01 14:00:00')],
'datetime:hour': '01:00 01 Jan',
'datetime_count': 1,
'value': 5
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 14:00:00'),
('datetime', '<', '1916-01-01 15:00:00')],
'datetime:hour': '02:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 15:00:00'),
('datetime', '<', '1916-01-01 16:00:00')],
'datetime:hour': '03:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 16:00:00'),
('datetime', '<', '1916-01-01 17:00:00')],
'datetime:hour': '04:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 17:00:00'),
('datetime', '<', '1916-01-01 18:00:00')],
'datetime:hour': '05:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 18:00:00'),
('datetime', '<', '1916-01-01 19:00:00')],
'datetime:hour': '06:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 19:00:00'),
('datetime', '<', '1916-01-01 20:00:00')],
'datetime:hour': '07:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 20:00:00'),
('datetime', '<', '1916-01-01 21:00:00')],
'datetime:hour': '08:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 21:00:00'),
('datetime', '<', '1916-01-01 22:00:00')],
'datetime:hour': '09:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 22:00:00'),
('datetime', '<', '1916-01-01 23:00:00')],
'datetime:hour': '10:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 23:00:00'),
('datetime', '<', '1916-01-02 00:00:00')],
'datetime:hour': '11:00 01 Jan',
'datetime_count': 1,
'value': 7
}]
model_fill = self.Model.with_context(fill_temporal=True)
groups = model_fill.read_group([], fields=['datetime', 'value'], groupby=['datetime:hour'])
self.assertEqual(groups, expected)
def test_hour_with_timezones(self):
"""Test hour with timezones.
What we do here is similar to test_with_datetimes_and_groupby_per_hour
but with a timezone in the user context.
"""
self.Model.create({'datetime': '1915-12-31 22:30:00', 'value': 2})
self.Model.create({'datetime': '1916-01-01 03:30:00', 'value': 3})
expected = [{
'__domain': ['&',
('datetime', '>=', '1915-12-31 22:00:00'),
('datetime', '<', '1915-12-31 23:00:00')],
'datetime:hour': '04:00 01 Jan',
'datetime_count': 1,
'value': 2
}, {
'__domain': ['&',
('datetime', '>=', '1915-12-31 23:00:00'),
('datetime', '<', '1916-01-01 00:00:00')],
'datetime:hour': '05:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 00:00:00'),
('datetime', '<', '1916-01-01 01:00:00')],
'datetime:hour': '06:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 01:00:00'),
('datetime', '<', '1916-01-01 02:00:00')],
'datetime:hour': '07:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 02:00:00'),
('datetime', '<', '1916-01-01 03:00:00')],
'datetime:hour': '08:00 01 Jan',
'datetime_count': 0,
'value': False
}, {
'__domain': ['&',
('datetime', '>=', '1916-01-01 03:00:00'),
('datetime', '<', '1916-01-01 04:00:00')],
'datetime:hour': '09:00 01 Jan',
'datetime_count': 1,
'value': 3
}]
model_fill = self.Model.with_context(tz='Asia/Hovd', fill_temporal=True)
groups = model_fill.read_group([], fields=['datetime', 'value'],
groupby=['datetime:hour'])
self.assertEqual(groups, expected)
def test_egde_fx_tz(self):
"""We test if different edge effect by using a different timezone from the user context
Suppose a user resident near Hovd, a city in Mongolia. he sells a product
at exacltly 4:00 AM on 1st January 2018. Using its context, that datetime
is previously converted to UTC time by the ORM so as being stored properly
inside the datebase. We are in winter time so 'Asia/Hovd' is UTC+7 :
'2018-01-01 04:00:00' --> '2017-12-31 21:00:00'
If that same user groups by datetime, we must ensure that the last
displayed date is in January and not in December.
"""
self.Model.create({'datetime': '2017-12-31 21:00:00', 'value': 42})
expected = [{
'__domain': ['&',
('datetime', '>=', '2017-12-31 17:00:00'),
('datetime', '<', '2018-01-31 17:00:00')],
'datetime': 'January 2018',
'datetime_count': 1,
'value': 42
}]
model_fill = self.Model.with_context(tz='Asia/Hovd', fill_temporal=True)
groups = model_fill.read_group([], fields=['datetime', 'value'], groupby=['datetime'])
self.assertEqual(groups, expected)
+94 -4
View File
@@ -58,6 +58,7 @@ from .tools.func import frame_codeinfo
from .tools.misc import CountingStream, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
from .tools.safe_eval import safe_eval
from .tools.translate import _
from .tools import date_utils
_logger = logging.getLogger(__name__)
_schema = logging.getLogger(__name__ + '.schema')
@@ -1755,6 +1756,89 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
return list(result.values())
@api.model
def _read_group_fill_temporal(self, data, groupby, aggregated_fields, annotated_groupbys,
interval=dateutil.relativedelta.relativedelta(months=1)):
"""Helper method for filling date/datetime 'holes' in a result set.
We are in a use case where data are grouped by a date field (typically
months but it could be any other interval) and displayed in a chart.
Assume we group records by month, and we only have data for August,
September and December. By default, plotting the result gives something
like:
___
___ | |
| | | |
| | ___ | |
| || || |
|___||___||___|
Aug Sep Dec
The problem is that December data follows immediately September data,
which is misleading for the user. Adding explicit zeroes for missing data
gives something like:
___
___ | |
| | | |
| | ___ | |
| || | | |
|___||___| ___ ___ |___|
Aug Sep Oct Nov Dec
:param list data: the data containing groups
:param list groupby: name of the first group by
:param aggregated_fields list: list of aggregated fields in the query
:param relativedelta interval: interval between to temporal groups
expressed as a relativedelta month by default
:rtype: list
:return: list
"""
first_a_gby = annotated_groupbys[0]
if not data:
return
if first_a_gby['type'] not in ('date', 'datetime'):
return data
interval = first_a_gby['interval']
groupby_name = groupby[0]
# existing non null datetimes
existing = [d[groupby_name] for d in data if d[groupby_name]]
if len(existing) < 2:
return data
# assumption: existing data is sorted by field 'groupby_name'
first, last = existing[0], existing[-1]
expected = list(date_utils.date_range(first, last, interval))
if len(existing) < len(expected):
empty_data = dict.fromkeys(aggregated_fields, False)
empty_data['id'] = False
empty_data[groupby_name.split(':')[0] + '_count'] = 0
data = collections.deque(data)
new_data = []
# Note: the list 'expected' contains the the dates that should be
# represented inside data when it is returned. Notice that 'expected'
# is sorted like data, so what we do is empty data progressivly by
# popping its elements from first to last. To do this properly, we
# need to compare dates together.
for dt in expected:
if not data[0][groupby_name] or data[0][groupby_name] == dt:
new_data.append(data.popleft())
else:
new_data.append(dict(empty_data, **{groupby_name: dt}))
assert not data
return new_data
return data
@api.model
def _read_group_prepare(self, orderby, aggregated_fields, annotated_groupbys, query):
"""
@@ -1845,7 +1929,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
}
if tz_convert:
qualified_field = "timezone('%s', timezone('UTC',%s))" % (self._context.get('tz', 'UTC'), qualified_field)
qualified_field = "date_trunc('%s', %s)" % (gb_function or 'month', qualified_field)
qualified_field = "date_trunc('%s', %s::timestamp)" % (gb_function or 'month', qualified_field)
if field_type == 'boolean':
qualified_field = "coalesce(%s,false)" % qualified_field
return {
@@ -1855,7 +1939,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
'display_format': display_formats[gb_function or 'month'] if temporal else None,
'interval': time_intervals[gb_function or 'month'] if temporal else None,
'tz_convert': tz_convert,
'qualified_field': qualified_field
'qualified_field': qualified_field,
}
@api.model
@@ -1984,7 +2068,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
groupby = [groupby] if isinstance(groupby, pycompat.string_types) else list(OrderedSet(groupby))
dt = [
f for f in groupby
if self._fields[f.split(':')[0]].type in ('date', 'datetime')
if self._fields[f.split(':')[0]].type in ('date', 'datetime') # e.g. 'date:month'
]
# iterate on all results and replace the "full" date/datetime value
@@ -2100,8 +2184,14 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
self._read_group_resolve_many2one_fields(fetched_data, annotated_groupbys)
data = ({k: self._read_group_prepare_data(k,v, groupby_dict) for k,v in r.items()} for r in fetched_data)
data = [{k: self._read_group_prepare_data(k, v, groupby_dict) for k, v in r.items()} for r in fetched_data]
if self.env.context.get('fill_temporal'):
data = self._read_group_fill_temporal(data, groupby, aggregated_fields,
annotated_groupbys)
result = [self._read_group_format_result(d, annotated_groupbys, groupby, domain) for d in data]
if lazy:
# Right now, read_group only fill results in lazy mode (by default).
# If you need to have the empty groups in 'eager' mode, then the
+41 -2
View File
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
import math
import calendar
from datetime import date, datetime, time
import pytz
from dateutil.relativedelta import relativedelta
from . import ustr
@@ -178,3 +177,43 @@ def json_default(obj):
return fields.Datetime.to_string(obj)
return fields.Date.to_string(obj)
return ustr(obj)
def date_range(start, end, step=relativedelta(months=1)):
"""Date range generator with a step interval.
:param start datetime: begining date of the range.
:param end datetime: ending date of the range.
:param step relativedelta: interval of the range.
:return: a range of datetime from start to end.
:rtype: Iterator[datetime]
"""
are_naive = start.tzinfo is None and end.tzinfo is None
are_utc = start.tzinfo == pytz.utc and end.tzinfo == pytz.utc
# Cases with miscellenous timezone are more complexe because of DST.
are_others = start.tzinfo and end.tzinfo and not are_utc
if are_others:
if start.tzinfo.zone != end.tzinfo.zone:
raise ValueError("Timezones of start argument and end argument seem inconsistent")
if not are_naive and not are_utc and not are_others:
raise ValueError("Timezones of start argument and end argument mismatch")
if start > end:
raise ValueError("start > end, start date must be before end")
if start == start + step:
raise ValueError("Looks like step is null")
if start.tzinfo:
localize = start.tzinfo.localize
else:
localize = lambda dt: dt
dt = start.replace(tzinfo=None)
end = end.replace(tzinfo=None)
while dt <= end:
yield localize(dt)
dt = dt + step