Files
odoo_source/addons/project/tests/test_burndown_chart.py
T
Laurent Stukkens (LTU)andYannick Tivisse ac7cd0e874 [IMP] project: improve burndown chart performance
Purpose of this commit
======================

The Burndown Chart report was very slow on big databases as it was not
possible for Postgresql to optimize the query as it was based on a view
that was using several generate series.

This commit aims to improve the performance by injecting the constraints
at a lower level than it was in the past, lowering the amount of data
processed in the higher level of the query.

/!\ Important note
------------------

Overwriting the `read_group_raw` is really not a good practice and should
be avoided in most case. If you fall on this implementation by grepping
the source code, please be advised that this is not the right way of doing
things.

Implementation details
----------------------

- The report is now run by generating the `SQL` that is executed by the
  `read_group_raw`. This allows inserting `SQL` constraints at a lower
  level and simnifically improves performance. As there is no other way
  to do it, the code is unfortunately a modified copy of the actual
  `read_group_raw`.
- The pivot view has been removed as it had no meaning and was creating
  confusing data.
- The `Group By` menu has been limited to `stage_id` and `date` as bringing
  more data trough the different `GROUP BY` statements up to the higher level
  is costly. Further more, additional `Group By` did not bring added value
  as the Chart was less readable.
- The JS code has been adapted in order to force a group by both `stage_id`
  and `date` so that the date displayed is always making sense.
- The sort ascending and descending options have been removed as creating
  confusing data.
- The compare with previous period has also been removed as the chart only
  really make sense when seen chronologically.
- A lot of tests have been added in order to ensure that changes that would
  be harmful for the report will trigger test fails.

task-2845729

Co-authored-by: Yannick Tivisse <yti@odoo.com>
2022-07-20 15:57:32 +02:00

336 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
from datetime import datetime
from odoo import Command
from odoo.osv.expression import AND, OR
from odoo.tests.common import tagged, HttpCase
from .test_project_base import TestProjectCommon
class TestBurndownChartCommon(TestProjectCommon):
@classmethod
def set_create_date(cls, table, res_id, create_date):
cls.env.cr.execute("UPDATE {} SET create_date=%s WHERE id=%s".format(table), (create_date, res_id))
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.current_year = datetime.now().year
create_date = datetime(cls.current_year - 1, 1, 1)
kanban_state_vals = {
"legend_blocked": 'Blocked',
"legend_done": 'Ready',
"legend_normal": 'In Progress'
}
Stage = cls.env['project.task.type']
cls.todo_stage = Stage.create({
'sequence': 1,
'name': 'TODO',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.todo_stage.id, create_date)
cls.in_progress_stage = Stage.create({
'sequence': 10,
'name': 'In Progress',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.in_progress_stage.id, create_date)
cls.testing_stage = Stage.create({
'sequence': 20,
'name': 'Testing',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.testing_stage.id, create_date)
cls.done_stage = Stage.create({
'sequence': 30,
'name': 'Done',
**kanban_state_vals,
})
cls.set_create_date('project_task_type', cls.done_stage.id, create_date)
cls.stages = cls.todo_stage + cls.in_progress_stage + cls.testing_stage + cls.done_stage
cls.project = cls.env['project.project'].create({
'name': 'Burndown Chart Test',
'privacy_visibility': 'employees',
'alias_name': 'project+burndown_chart',
'type_ids': [Command.link(stage_id) for stage_id in cls.stages.ids],
})
cls.set_create_date('project_project', cls.project.id, create_date)
cls.project.invalidate_model()
cls.milestone = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create({
'name': 'Test Milestone',
'project_id': cls.project_pigs.id,
})
cls.task_a = cls.env['project.task'].create({
'name': 'Task A',
'priority': 0,
'project_id': cls.project.id,
'stage_id': cls.todo_stage.id,
})
cls.set_create_date('project_task', cls.task_a.id, create_date)
cls.task_b = cls.task_a.copy({
'name': 'Task B',
'user_ids': [Command.set([cls.user_projectuser.id, cls.user_projectmanager.id])],
})
cls.set_create_date('project_task', cls.task_b.id, create_date)
cls.task_c = cls.task_a.copy({
'name': 'Task C',
'partner_id': cls.partner_1.id,
'user_ids': [Command.link(cls.user_projectuser.id)],
})
cls.set_create_date('project_task', cls.task_c.id, create_date)
cls.task_d = cls.task_a.copy({
'name': 'Task D',
'milestone_id': cls.milestone.id,
'user_ids': [Command.link(cls.user_projectmanager.id)],
})
cls.set_create_date('project_task', cls.task_d.id, create_date)
cls.task_e = cls.task_a.copy({
'name': 'Task E',
'partner_id': cls.partner_1.id,
})
cls.set_create_date('project_task', cls.task_e.id, create_date)
# Create a new task to check if a task without changing its stage is taken into account
task_f = cls.env['project.task'].create({
'name': 'Task F',
'priority': 0,
'project_id': cls.project.id,
'milestone_id': cls.milestone.id,
'stage_id': cls.todo_stage.id,
})
cls.set_create_date('project_task', task_f.id, datetime(cls.current_year - 1, 12, 20))
cls.project_2 = cls.env['project.project'].create({
'name': 'Burndown Chart Test 2 mySearchTag',
'privacy_visibility': 'employees',
'alias_name': 'project+burndown_chart+2',
'type_ids': [Command.link(stage_id) for stage_id in cls.stages.ids],
})
cls.set_create_date('project_project', cls.project_2.id, create_date)
cls.project.invalidate_model()
cls.task_g = cls.env['project.task'].create({
'name': 'Task G',
'priority': 0,
'project_id': cls.project_2.id,
'stage_id': cls.todo_stage.id,
'user_ids': [Command.link(cls.user_projectuser.id)],
})
cls.set_create_date('project_task', cls.task_g.id, create_date)
cls.task_h = cls.task_g.copy({
'name': 'Task H',
'user_ids': [Command.link(cls.user_projectmanager.id)],
})
cls.set_create_date('project_task', cls.task_h.id, create_date)
# Precommit to have the records in db and allow to rollback at the end of test
cls.env.cr.flush()
with freeze_time('%s-02-10' % (cls.current_year - 1)):
(cls.task_a + cls.task_b).write({'stage_id': cls.in_progress_stage.id})
cls.env.cr.flush()
with freeze_time('%s-02-20' % (cls.current_year - 1)):
cls.task_c.write({'stage_id': cls.in_progress_stage.id})
cls.env.cr.flush()
with freeze_time('%s-03-15' % (cls.current_year - 1)):
(cls.task_d + cls.task_e).write({'stage_id': cls.in_progress_stage.id})
cls.env.cr.flush()
with freeze_time('%s-04-10' % (cls.current_year - 1)):
(cls.task_a + cls.task_b).write({'stage_id': cls.testing_stage.id})
cls.env.cr.flush()
with freeze_time('%s-05-12' % (cls.current_year - 1)):
cls.task_c.write({'stage_id': cls.testing_stage.id})
cls.env.cr.flush()
with freeze_time('%s-06-25' % (cls.current_year - 1)):
cls.task_d.write({'stage_id': cls.testing_stage.id})
cls.env.cr.flush()
with freeze_time('%s-07-25' % (cls.current_year - 1)):
cls.task_e.write({'stage_id': cls.testing_stage.id})
cls.env.cr.flush()
with freeze_time('%s-08-01' % (cls.current_year - 1)):
cls.task_a.write({'stage_id': cls.done_stage.id})
cls.env.cr.flush()
with freeze_time('%s-09-10' % (cls.current_year - 1)):
cls.task_b.write({'stage_id': cls.done_stage.id})
cls.env.cr.flush()
with freeze_time('%s-10-05' % (cls.current_year - 1)):
cls.task_c.write({'stage_id': cls.done_stage.id})
cls.env.cr.flush()
with freeze_time('%s-11-25' % (cls.current_year - 1)):
cls.task_d.write({'stage_id': cls.done_stage.id})
cls.env.cr.flush()
with freeze_time('%s-12-12' % (cls.current_year - 1)):
cls.task_e.write({'stage_id': cls.done_stage.id})
cls.env.cr.flush()
class TestBurndownChart(TestBurndownChartCommon):
def map_read_group_result(self, read_group_result):
return {(res['date:month'], res['stage_id'][0]): res['__count'] for res in read_group_result if res['stage_id'][1]}
def check_read_group_results(self, domain, expected_results_dict):
stages_dict = {stage.id: stage.name for stage in self.stages}
read_group_result = self.env['project.task.burndown.chart.report'].read_group(
domain, ['date', 'stage_id'], ['date:month', 'stage_id'], lazy=False)
read_group_result_dict = self.map_read_group_result(read_group_result)
for (month, stage_id), __count in read_group_result_dict.items():
expected_count = expected_results_dict.get((month, stage_id), 100000)
self.assertEqual(
__count,
expected_count,
'In %s, the number of tasks should be equal to %s in %s stage.' % (month, expected_count, stages_dict.get(stage_id, 'Unknown'))
)
def test_burndown_chart(self):
burndown_chart_domain = [('display_project_id', '!=', False)]
project_domain = [('project_id', '=', self.project.id)]
# Check that we get the expected results for the complete data of `self.project`.
project_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 5,
('January %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('January %s' % (self.current_year - 1), self.testing_stage.id): 0,
('January %s' % (self.current_year - 1), self.done_stage.id): 0,
('February %s' % (self.current_year - 1), self.todo_stage.id): 2,
('February %s' % (self.current_year - 1), self.in_progress_stage.id): 3,
('February %s' % (self.current_year - 1), self.testing_stage.id): 0,
('February %s' % (self.current_year - 1), self.done_stage.id): 0,
('March %s' % (self.current_year - 1), self.todo_stage.id): 0,
('March %s' % (self.current_year - 1), self.in_progress_stage.id): 5,
('March %s' % (self.current_year - 1), self.testing_stage.id): 0,
('March %s' % (self.current_year - 1), self.done_stage.id): 0,
('April %s' % (self.current_year - 1), self.todo_stage.id): 0,
('April %s' % (self.current_year - 1), self.in_progress_stage.id): 3,
('April %s' % (self.current_year - 1), self.testing_stage.id): 2,
('April %s' % (self.current_year - 1), self.done_stage.id): 0,
('May %s' % (self.current_year - 1), self.todo_stage.id): 0,
('May %s' % (self.current_year - 1), self.in_progress_stage.id): 2,
('May %s' % (self.current_year - 1), self.testing_stage.id): 3,
('May %s' % (self.current_year - 1), self.done_stage.id): 0,
('June %s' % (self.current_year - 1), self.todo_stage.id): 0,
('June %s' % (self.current_year - 1), self.in_progress_stage.id): 1,
('June %s' % (self.current_year - 1), self.testing_stage.id): 4,
('June %s' % (self.current_year - 1), self.done_stage.id): 0,
('July %s' % (self.current_year - 1), self.todo_stage.id): 0,
('July %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('July %s' % (self.current_year - 1), self.testing_stage.id): 5,
('July %s' % (self.current_year - 1), self.done_stage.id): 0,
('August %s' % (self.current_year - 1), self.todo_stage.id): 0,
('August %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('August %s' % (self.current_year - 1), self.testing_stage.id): 4,
('August %s' % (self.current_year - 1), self.done_stage.id): 1,
('September %s' % (self.current_year - 1), self.todo_stage.id): 0,
('September %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('September %s' % (self.current_year - 1), self.testing_stage.id): 3,
('September %s' % (self.current_year - 1), self.done_stage.id): 2,
('October %s' % (self.current_year - 1), self.todo_stage.id): 0,
('October %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('October %s' % (self.current_year - 1), self.testing_stage.id): 2,
('October %s' % (self.current_year - 1), self.done_stage.id): 3,
('November %s' % (self.current_year - 1), self.todo_stage.id): 0,
('November %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('November %s' % (self.current_year - 1), self.testing_stage.id): 1,
('November %s' % (self.current_year - 1), self.done_stage.id): 4,
('December %s' % (self.current_year - 1), self.todo_stage.id): 0,
('December %s' % (self.current_year - 1), self.in_progress_stage.id): 0,
('December %s' % (self.current_year - 1), self.done_stage.id): 5,
('December %s' % (self.current_year - 1), self.todo_stage.id): 1,
('January %s' % (self.current_year), self.todo_stage.id): 0,
('January %s' % (self.current_year), self.in_progress_stage.id): 0,
('January %s' % (self.current_year), self.done_stage.id): 5,
('January %s' % (self.current_year), self.todo_stage.id): 1,
('February %s' % (self.current_year), self.todo_stage.id): 0,
('February %s' % (self.current_year), self.in_progress_stage.id): 0,
('February %s' % (self.current_year), self.done_stage.id): 5,
('February %s' % (self.current_year), self.todo_stage.id): 1,
('March %s' % (self.current_year), self.todo_stage.id): 0,
('March %s' % (self.current_year), self.in_progress_stage.id): 0,
('March %s' % (self.current_year), self.done_stage.id): 5,
('March %s' % (self.current_year), self.todo_stage.id): 1,
('April %s' % (self.current_year), self.todo_stage.id): 0,
('April %s' % (self.current_year), self.in_progress_stage.id): 0,
('April %s' % (self.current_year), self.done_stage.id): 5,
('April %s' % (self.current_year), self.todo_stage.id): 1,
('May %s' % (self.current_year), self.todo_stage.id): 0,
('May %s' % (self.current_year), self.in_progress_stage.id): 0,
('May %s' % (self.current_year), self.done_stage.id): 5,
('May %s' % (self.current_year), self.todo_stage.id): 1,
('June %s' % (self.current_year), self.todo_stage.id): 0,
('June %s' % (self.current_year), self.in_progress_stage.id): 0,
('June %s' % (self.current_year), self.done_stage.id): 5,
('June %s' % (self.current_year), self.todo_stage.id): 1,
('July %s' % (self.current_year), self.todo_stage.id): 0,
('July %s' % (self.current_year), self.in_progress_stage.id): 0,
('July %s' % (self.current_year), self.done_stage.id): 5,
('July %s' % (self.current_year), self.todo_stage.id): 1,
('August %s' % (self.current_year), self.todo_stage.id): 0,
('August %s' % (self.current_year), self.in_progress_stage.id): 0,
('August %s' % (self.current_year), self.done_stage.id): 5,
('August %s' % (self.current_year), self.todo_stage.id): 1,
('September %s' % (self.current_year), self.todo_stage.id): 0,
('September %s' % (self.current_year), self.in_progress_stage.id): 0,
('September %s' % (self.current_year), self.done_stage.id): 5,
('September %s' % (self.current_year), self.todo_stage.id): 1,
('October %s' % (self.current_year), self.todo_stage.id): 0,
('October %s' % (self.current_year), self.in_progress_stage.id): 0,
('October %s' % (self.current_year), self.done_stage.id): 5,
('October %s' % (self.current_year), self.todo_stage.id): 1,
('November %s' % (self.current_year), self.todo_stage.id): 0,
('November %s' % (self.current_year), self.in_progress_stage.id): 0,
('November %s' % (self.current_year), self.done_stage.id): 5,
('November %s' % (self.current_year), self.todo_stage.id): 1,
('December %s' % (self.current_year), self.todo_stage.id): 0,
('December %s' % (self.current_year), self.in_progress_stage.id): 0,
('December %s' % (self.current_year), self.done_stage.id): 5,
('December %s' % (self.current_year), self.todo_stage.id): 1,
}
self.check_read_group_results(AND([burndown_chart_domain, project_domain]), project_expected_dict)
# Check that we get the expected results for the complete data of `self.project` & `self.project_2` using an
# `ilike` in the domain.
all_projects_domain_with_ilike = OR([project_domain, [('project_id', 'ilike', 'mySearchTag')]])
project_expected_dict = {key: val if key[1] != self.todo_stage.id else val + 2 for key, val in project_expected_dict.items()}
self.check_read_group_results(AND([burndown_chart_domain, all_projects_domain_with_ilike]), project_expected_dict)
date_from, date_to = ('%s-01-01' % (self.current_year - 1), '%s-02-01' % (self.current_year - 1))
date_and_user_domain = [('date', '>=', date_from), ('date', '<', date_to), ('user_ids', 'ilike', 'ProjectUser')]
complex_domain_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 3,
('February %s' % (self.current_year - 1), self.todo_stage.id): 1,
('February %s' % (self.current_year - 1), self.in_progress_stage.id): 2,
}
complex_domain = AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain])
self.check_read_group_results(complex_domain, complex_domain_expected_dict)
date_and_user_domain = [('date', '>=', date_from), ('date', '<', date_to), ('user_ids', 'ilike', 'ProjectManager')]
milestone_domain = [('milestone_id', 'ilike', 'Test')]
complex_domain = AND([burndown_chart_domain, all_projects_domain_with_ilike, date_and_user_domain, milestone_domain])
complex_domain_expected_dict = {
('January %s' % (self.current_year - 1), self.todo_stage.id): 1,
('February %s' % (self.current_year - 1), self.todo_stage.id): 1,
}
self.check_read_group_results(complex_domain, complex_domain_expected_dict)
@tagged('-at_install', 'post_install')
class TestBurndownChartTour(HttpCase, TestBurndownChartCommon):
def test_burndown_chart_tour(self):
# Test customizing personal stages as a project user
self.start_tour('/web', 'burndown_chart_tour', login="admin")