[MERGE] project: improve burndown chart performance
Purpose of this PR =============== 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 -- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#93225 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -12,7 +12,7 @@ class MailTracking(models.Model):
|
||||
_rec_name = 'field'
|
||||
_order = 'tracking_sequence asc'
|
||||
|
||||
field = fields.Many2one('ir.model.fields', required=True, readonly=1, ondelete='cascade')
|
||||
field = fields.Many2one('ir.model.fields', required=True, readonly=1, index=True, ondelete='cascade')
|
||||
field_desc = fields.Char('Field Description', required=True, readonly=1)
|
||||
field_type = fields.Char('Field Type')
|
||||
field_groups = fields.Char(compute='_compute_field_groups')
|
||||
|
||||
@@ -1,208 +1,442 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from psycopg2 import sql
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.osv import expression
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.models import regex_field_agg, VALID_AGGREGATE_FUNCTIONS
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv.expression import AND_OPERATOR, OR_OPERATOR, NOT_OPERATOR, DOMAIN_OPERATORS, FALSE_LEAF, TRUE_LEAF, normalize_domain
|
||||
from odoo.tools import OrderedSet
|
||||
|
||||
|
||||
class ReportProjectTaskBurndownChart(models.Model):
|
||||
def remove_domain_leaf(domain, fields_to_remove):
|
||||
""" Make the provided domain insensitive to the fields provided in fields_to_remove. Fields that are part of
|
||||
`fields_to_remove` are replaced by either a `FALSE_LEAF` or a `TRUE_LEAF` in order to ensure the evaluation of the
|
||||
complete domain.
|
||||
|
||||
:param domain: The domain to process.
|
||||
:param fields_to_remove: List of fields the domain has to be insensitive to.
|
||||
:return: The insensitive domain.
|
||||
"""
|
||||
def _process_leaf(elements, index, operator, new_domain):
|
||||
leaf = elements[index]
|
||||
if len(leaf) == 3:
|
||||
if leaf[0] in fields_to_remove:
|
||||
if operator == AND_OPERATOR:
|
||||
new_domain.append(TRUE_LEAF)
|
||||
elif operator == OR_OPERATOR:
|
||||
new_domain.append(FALSE_LEAF)
|
||||
else:
|
||||
new_domain.append(leaf)
|
||||
return 1
|
||||
elif len(leaf) == 1 and leaf in DOMAIN_OPERATORS:
|
||||
# Special case to avoid OR ('|') that can never resolve to true
|
||||
if leaf == OR_OPERATOR \
|
||||
and len(elements[index + 1]) == 3 and len(elements[index + 2]) == 3 \
|
||||
and elements[index + 1][0] in fields_to_remove and elements[index + 1][0] in fields_to_remove:
|
||||
new_domain.append(TRUE_LEAF)
|
||||
return 3
|
||||
new_domain.append(leaf)
|
||||
if leaf[0] == NOT_OPERATOR:
|
||||
return 1 + _process_leaf(elements, index + 1, '&', new_domain)
|
||||
first_leaf_skip = _process_leaf(elements, index + 1, leaf, new_domain)
|
||||
second_leaf_skip = _process_leaf(elements, index + 1 + first_leaf_skip, leaf, new_domain)
|
||||
return 1 + first_leaf_skip + second_leaf_skip
|
||||
return 0
|
||||
|
||||
if len(domain) == 0:
|
||||
return domain
|
||||
new_domain = []
|
||||
_process_leaf(normalize_domain(domain), 0, AND_OPERATOR, new_domain)
|
||||
return new_domain
|
||||
|
||||
|
||||
class ReportProjectTaskBurndownChart(models.AbstractModel):
|
||||
_name = 'project.task.burndown.chart.report'
|
||||
_description = 'Burndown Chart'
|
||||
_auto = False
|
||||
_order = 'date'
|
||||
|
||||
task_id = fields.Many2one('project.task', readonly=True)
|
||||
project_id = fields.Many2one('project.project', readonly=True)
|
||||
display_project_id = fields.Many2one('project.project', readonly=True)
|
||||
milestone_id = fields.Many2one('project.milestone', readonly=True)
|
||||
stage_id = fields.Many2one('project.task.type', readonly=True)
|
||||
is_closed = fields.Boolean("Closing Stage", readonly=True, help="Folded in Kanban stages are closing stages.")
|
||||
date = fields.Datetime('Date', readonly=True)
|
||||
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
|
||||
string='Assignees', readonly=True)
|
||||
date_assign = fields.Datetime(string='Assignment Date', readonly=True)
|
||||
date_deadline = fields.Date(string='Deadline', readonly=True)
|
||||
display_project_id = fields.Many2one('project.project', readonly=True)
|
||||
is_closed = fields.Boolean("Closing Stage", readonly=True)
|
||||
milestone_id = fields.Many2one('project.milestone', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)
|
||||
date_group_by = fields.Selection(
|
||||
[
|
||||
('day', 'By Day'),
|
||||
('month', 'By Month'),
|
||||
('quarter', 'By quarter'),
|
||||
('year', 'By Year')
|
||||
], string="Date Group By", readonly=True)
|
||||
project_id = fields.Many2one('project.project', readonly=True)
|
||||
stage_id = fields.Many2one('project.task.type', readonly=True)
|
||||
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
|
||||
string='Assignees', readonly=True)
|
||||
|
||||
# Fake field required as used in the filters. It will however be managed through the `project.task` model.
|
||||
has_late_and_unreached_milestone = fields.Boolean(readonly=True)
|
||||
|
||||
# This variable is used in order to distinguish conditions that can be set on `project.task` and thus being used
|
||||
# at a lower level than the "usual" query made by the `read_group_raw`. Indeed, the domain applied on those fields
|
||||
# will be performed on a `CTE` that will be later use in the `SQL` in order to limit the subset of data that is used
|
||||
# in the successive `GROUP BY` statements.
|
||||
task_specific_fields = [
|
||||
'date_assign',
|
||||
'date_deadline',
|
||||
'display_project_id',
|
||||
'has_late_and_unreached_milestone',
|
||||
'is_closed',
|
||||
'milestone_id',
|
||||
'partner_id',
|
||||
'project_id',
|
||||
'stage_id',
|
||||
'user_ids',
|
||||
]
|
||||
|
||||
def _get_group_by_SQL(self, task_specific_domain, count_field, select_terms, from_clause, where_clause,
|
||||
where_clause_params, groupby_terms, orderby_terms, limit, offset, groupby, annotated_groupbys,
|
||||
prefix_term, prefix_terms):
|
||||
""" Prepare and return the SQL to be used for the read_group. """
|
||||
|
||||
# Build the query on `project.task` with the domain fields that are linked to that model. This is done in order
|
||||
# to be able to reduce the number of treated records in the query by limiting them to the one corresponding to
|
||||
# the ids that are returned from this sub query.
|
||||
project_task_query = self.env['project.task']._where_calc(task_specific_domain)
|
||||
project_task_from_clause, project_task_where_clause, project_task_where_clause_params = project_task_query.get_sql()
|
||||
|
||||
# Get the stage_id `ir.model.fields`'s id in order to inject it directly in the query and avoid having to join
|
||||
# on `ir_model_fields` table.
|
||||
IrModelFieldsSudo = self.env['ir.model.fields'].sudo()
|
||||
field_id = IrModelFieldsSudo.search([('name', '=', 'stage_id'), ('model', '=', 'project.task')]).id
|
||||
|
||||
# Get the date aggregation SQL statement in order to be able to inject it in the SQL.
|
||||
date_group_by_field = next(filter(lambda gb: gb.startswith('date'), groupby))
|
||||
date_annotated_groupby = [
|
||||
annotated_groupby for annotated_groupby in annotated_groupbys
|
||||
if annotated_groupby['groupby'] == date_group_by_field
|
||||
][0]
|
||||
date_begin, date_end = (
|
||||
date_annotated_groupby['qualified_field'].replace(
|
||||
'"%s"."%s"' % (self._table, date_annotated_groupby['field']), '"%s_%s"' % (date_annotated_groupby['field'], field)
|
||||
)
|
||||
for field in ['begin', 'end']
|
||||
)
|
||||
|
||||
# Insert `WHERE` clause parameter that apply on `project_task` prior to the one that apply on
|
||||
# `project_task_burndown_chart_report` as the `project_task` CTE is placed at the beginning of the `SQL`.
|
||||
for param in reversed(project_task_where_clause_params):
|
||||
where_clause_params.insert(0, param)
|
||||
|
||||
# Computes the interval which needs to be used in the `SQL` depending on the date group by interval.
|
||||
if date_annotated_groupby['groupby'].split(':')[1] != 'quarter':
|
||||
interval = '1 %s' % date_annotated_groupby['groupby'].split(':')[1]
|
||||
else:
|
||||
interval = '3 month'
|
||||
|
||||
burndown_chart_query = """
|
||||
WITH task_ids AS (
|
||||
SELECT id
|
||||
FROM %(task_query_from)s
|
||||
%(task_query_where)s
|
||||
),
|
||||
all_stage_task_moves AS (
|
||||
SELECT count(*) as %(count_field)s,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s as date_begin,
|
||||
%(date_end)s as date_end,
|
||||
stage_id
|
||||
FROM (
|
||||
-- Gathers the stage_ids history per task_id. This query gets:
|
||||
-- * All changes except the last one for those for which we have at least a mail
|
||||
-- message and a mail tracking value on project.task stage_id.
|
||||
-- * The stage at creation for those for which we do not have any mail message and a
|
||||
-- mail tracking value on project.task stage_id.
|
||||
SELECT DISTINCT task_id,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s as date_begin,
|
||||
%(date_end)s as date_end,
|
||||
first_value(stage_id) OVER task_date_begin_window AS stage_id
|
||||
FROM (
|
||||
SELECT pt.id as task_id,
|
||||
pt.project_id,
|
||||
pt.display_project_id,
|
||||
COALESCE(LAG(mm.date) OVER (PARTITION BY mm.res_id ORDER BY mm.id), pt.create_date) as date_begin,
|
||||
CASE WHEN mtv.id IS NOT NULL THEN mm.date
|
||||
ELSE (now() at time zone 'utc')::date + INTERVAL '%(interval)s'
|
||||
END as date_end,
|
||||
CASE WHEN mtv.id IS NOT NULL THEN mtv.old_value_integer
|
||||
ELSE pt.stage_id
|
||||
END as stage_id
|
||||
FROM project_task pt
|
||||
LEFT JOIN (
|
||||
mail_message mm
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
AND mtv.field = %(field_id)s
|
||||
AND mm.model='project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
JOIN project_task_type ptt ON ptt.id = mtv.old_value_integer
|
||||
) ON mm.res_id = pt.id
|
||||
WHERE pt.active=true AND pt.id IN (SELECT id from task_ids)
|
||||
) task_stage_id_history
|
||||
GROUP BY task_id,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s,
|
||||
%(date_end)s,
|
||||
stage_id
|
||||
WINDOW task_date_begin_window AS (PARTITION BY task_id, %(date_begin)s)
|
||||
UNION ALL
|
||||
-- Gathers the current stage_ids per task_id for those which values changed at least
|
||||
-- once (=those for which we have at least a mail message and a mail tracking value
|
||||
-- on project.task stage_id).
|
||||
SELECT pt.id as task_id,
|
||||
pt.project_id,
|
||||
pt.display_project_id,
|
||||
last_stage_id_change_mail_message.date as date_begin,
|
||||
(now() at time zone 'utc')::date + INTERVAL '%(interval)s' as date_end,
|
||||
pt.stage_id as old_value_integer
|
||||
FROM project_task pt
|
||||
JOIN project_task_type ptt ON ptt.id = pt.stage_id
|
||||
JOIN LATERAL (
|
||||
SELECT mm.date
|
||||
FROM mail_message mm
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
AND mtv.field = %(field_id)s
|
||||
AND mm.model='project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
AND mm.res_id = pt.id
|
||||
ORDER BY mm.id DESC
|
||||
FETCH FIRST ROW ONLY
|
||||
) AS last_stage_id_change_mail_message ON TRUE
|
||||
WHERE pt.active=true AND pt.id IN (SELECT id from task_ids)
|
||||
) AS project_task_burndown_chart
|
||||
GROUP BY project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s,
|
||||
%(date_end)s,
|
||||
stage_id
|
||||
)
|
||||
SELECT (project_id*10^13 + stage_id*10^7 + to_char(date, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
display_project_id,
|
||||
stage_id,
|
||||
date,
|
||||
%(count_field)s
|
||||
FROM all_stage_task_moves t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end-INTERVAL '1 day', '%(interval)s')
|
||||
AS date ON TRUE
|
||||
""" % {
|
||||
'task_query_from': project_task_from_clause,
|
||||
'task_query_where': prefix_term('WHERE', project_task_where_clause),
|
||||
'count_field': count_field,
|
||||
'date_begin': date_begin,
|
||||
'date_end': date_end,
|
||||
'interval': interval,
|
||||
'field_id': field_id,
|
||||
}
|
||||
|
||||
# Replace, in the `FROM` clause generated on `project_task_burndown_chart_report`, the
|
||||
# `project_task_burndown_chart_report` table name by the burndown_chart_query `SQL` aliased as
|
||||
# `project_task_burndown_chart_report`.
|
||||
from_clause = from_clause.replace('"project_task_burndown_chart_report"', '(%s) AS "project_task_burndown_chart_report"' % burndown_chart_query, 1)
|
||||
|
||||
return """
|
||||
SELECT min("%(table)s".id) AS id, sum(%(table)s.%(count_field)s) AS "%(count_field)s" %(extra_fields)s
|
||||
FROM %(from)s
|
||||
%(where)s
|
||||
%(groupby)s
|
||||
%(orderby)s
|
||||
%(limit)s
|
||||
%(offset)s
|
||||
""" % {
|
||||
'table': self._table,
|
||||
'count_field': count_field,
|
||||
'extra_fields': prefix_terms(',', select_terms),
|
||||
'from': from_clause,
|
||||
'where': prefix_term('WHERE', where_clause),
|
||||
'groupby': prefix_terms('GROUP BY', groupby_terms),
|
||||
'orderby': prefix_terms('ORDER BY', orderby_terms),
|
||||
'limit': prefix_term('LIMIT', int(limit) if limit else None),
|
||||
'offset': prefix_term('OFFSET', int(offset) if limit else None),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
date_group_bys = []
|
||||
groupby = [groupby] if isinstance(groupby, str) else list(OrderedSet(groupby))
|
||||
def _validate_group_by(self, groupby):
|
||||
""" Check that the both `date` and `stage_id` are part of `group_by`, otherwise raise a `UserError`.
|
||||
|
||||
:param groupby: List of group by fields.
|
||||
"""
|
||||
stage_id_in_groupby = False
|
||||
date_in_groupby = False
|
||||
|
||||
for gb in groupby:
|
||||
if gb.startswith('date:'):
|
||||
date_group_bys.append(gb.split(':')[-1])
|
||||
if gb.startswith('date'):
|
||||
date_in_groupby = True
|
||||
else:
|
||||
if gb == 'stage_id':
|
||||
stage_id_in_groupby = True
|
||||
|
||||
date_domains = []
|
||||
for gb in date_group_bys:
|
||||
date_domains = expression.OR([date_domains, [('date_group_by', '=', gb)]])
|
||||
domain = expression.AND([domain, date_domains])
|
||||
if not date_in_groupby or not stage_id_in_groupby:
|
||||
raise UserError(_('The view must be grouped by date and by stage_id'))
|
||||
|
||||
res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
||||
return res
|
||||
@api.model
|
||||
def _determine_domains(self, domain):
|
||||
""" Compute two separated domain from the provided one:
|
||||
* A domain that only contains fields that are specific to `project.task.burndown.chart.report`
|
||||
* A domain that only contains fields that are specific to `project.task`
|
||||
|
||||
def init(self):
|
||||
query = """
|
||||
WITH all_moves_stage_task AS (
|
||||
-- Here we compute all previous stage in tracking values
|
||||
-- We're missing the last reached stage
|
||||
-- And the tasks without any stage change (which, by definition, are at the last stage)
|
||||
SELECT pt.project_id,
|
||||
pt.id as task_id,
|
||||
pt.display_project_id,
|
||||
pt.milestone_id,
|
||||
COALESCE(LAG(mm.date) OVER (PARTITION BY mm.res_id ORDER BY mm.id), pt.create_date) as date_begin,
|
||||
mm.date as date_end,
|
||||
mtv.old_value_integer as stage_id,
|
||||
pt.is_closed,
|
||||
pt.date_assign,
|
||||
pt.date_deadline,
|
||||
pt.partner_id
|
||||
FROM project_task pt
|
||||
JOIN mail_message mm ON mm.res_id = pt.id
|
||||
AND mm.message_type = 'notification'
|
||||
AND mm.model = 'project.task'
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
JOIN ir_model_fields imf ON mtv.field = imf.id
|
||||
AND imf.model = 'project.task'
|
||||
AND imf.name = 'stage_id'
|
||||
JOIN project_task_type_rel pttr ON pttr.type_id = mtv.old_value_integer
|
||||
AND pttr.project_id = pt.project_id
|
||||
WHERE pt.active
|
||||
Fields that are not part of the constraint are replaced by either a `FALSE_LEAF` or a `TRUE_LEAF` in order
|
||||
to ensure the complete domain evaluation. See `remove_domain_leaf` for more details.
|
||||
|
||||
--We compute the last reached stage
|
||||
UNION ALL
|
||||
:param domain: The domain that has been passed to the read_group.
|
||||
:return: A tuple containing the non `project.task` specific domain and the `project.task` specific domain.
|
||||
"""
|
||||
burndown_chart_specific_fields = list(set(self._fields) - set(self.task_specific_fields))
|
||||
task_specific_domain = remove_domain_leaf(domain, burndown_chart_specific_fields)
|
||||
non_task_specific_domain = remove_domain_leaf(domain, self.task_specific_fields)
|
||||
return non_task_specific_domain, task_specific_domain
|
||||
|
||||
SELECT pt.project_id,
|
||||
pt.id as task_id,
|
||||
pt.display_project_id,
|
||||
pt.milestone_id,
|
||||
COALESCE(md.date, pt.create_date) as date_begin,
|
||||
(CURRENT_DATE + interval '1 month')::date as date_end,
|
||||
pt.stage_id,
|
||||
pt.is_closed,
|
||||
pt.date_assign,
|
||||
pt.date_deadline,
|
||||
pt.partner_id
|
||||
FROM project_task pt
|
||||
LEFT JOIN LATERAL (SELECT mm.date
|
||||
FROM mail_message mm
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
JOIN ir_model_fields imf ON mtv.field = imf.id
|
||||
AND imf.model = 'project.task'
|
||||
AND imf.name = 'stage_id'
|
||||
WHERE mm.res_id = pt.id
|
||||
AND mm.message_type = 'notification'
|
||||
AND mm.model = 'project.task'
|
||||
ORDER BY mm.id DESC
|
||||
FETCH FIRST ROW ONLY) md ON TRUE
|
||||
WHERE pt.active
|
||||
)
|
||||
SELECT (task_id*10^7 + 10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
task_id,
|
||||
display_project_id,
|
||||
milestone_id,
|
||||
stage_id,
|
||||
is_closed,
|
||||
d as date,
|
||||
date_assign,
|
||||
date_deadline,
|
||||
partner_id,
|
||||
'day' AS date_group_by
|
||||
FROM all_moves_stage_task t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end-interval '1 day', '1 day') d ON TRUE
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT (task_id*10^7 + 2*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
task_id,
|
||||
display_project_id,
|
||||
milestone_id,
|
||||
stage_id,
|
||||
is_closed,
|
||||
date_trunc('week', d) as date,
|
||||
date_assign,
|
||||
date_deadline,
|
||||
partner_id,
|
||||
'week' AS date_group_by
|
||||
FROM all_moves_stage_task t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end, '1 week') d ON TRUE
|
||||
WHERE date_trunc('week', t.date_begin) <= date_trunc('week', d)
|
||||
AND date_trunc('week', t.date_end) > date_trunc('week', d)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT (task_id*10^7 + 3*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
task_id,
|
||||
display_project_id,
|
||||
milestone_id,
|
||||
stage_id,
|
||||
is_closed,
|
||||
date_trunc('month', d) as date,
|
||||
date_assign,
|
||||
date_deadline,
|
||||
partner_id,
|
||||
'month' AS date_group_by
|
||||
FROM all_moves_stage_task t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end, '1 month') d ON TRUE
|
||||
WHERE date_trunc('month', t.date_begin) <= date_trunc('month', d)
|
||||
AND date_trunc('month', t.date_end) > date_trunc('month', d)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT (task_id*10^7 + 4*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
task_id,
|
||||
display_project_id,
|
||||
milestone_id,
|
||||
stage_id,
|
||||
is_closed,
|
||||
date_trunc('quarter', d) as date,
|
||||
date_assign,
|
||||
date_deadline,
|
||||
partner_id,
|
||||
'quarter' AS date_group_by
|
||||
FROM all_moves_stage_task t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end, '3 month') d ON TRUE
|
||||
WHERE date_trunc('quarter', t.date_begin) <= date_trunc('quarter', d)
|
||||
AND date_trunc('quarter', t.date_end) > date_trunc('quarter', d)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT (task_id*10^7 + 5*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id,
|
||||
project_id,
|
||||
task_id,
|
||||
display_project_id,
|
||||
milestone_id,
|
||||
stage_id,
|
||||
is_closed,
|
||||
date_trunc('year', d) as date,
|
||||
date_assign,
|
||||
date_deadline,
|
||||
partner_id,
|
||||
'year' AS date_group_by
|
||||
FROM all_moves_stage_task t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end, '1 year') d ON TRUE
|
||||
WHERE date_trunc('year', t.date_begin) <= date_trunc('year', d)
|
||||
AND date_trunc('year', t.date_end) > date_trunc('year', d)
|
||||
@api.model
|
||||
def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
""" Although not being a good practice, this code is, for a big part, duplicated from `read_group_raw` from
|
||||
`models.py`. In order to be able to use the report on big databases, it is necessary to inject `WHERE`
|
||||
statements at the lowest levels in the report `SQL`. As a result, using a view was no more an option as
|
||||
`Postgres` could not optimise the `SQL`.
|
||||
The code of `fill_temporal` has been removed from what's available in `models.py` as it is not relevant in the
|
||||
context of the Burndown Chart. Indeed, series are generated so no empty are returned by the `SQL`, except if
|
||||
explicitly specified in the domain through the `date` field, which is then expected.
|
||||
"""
|
||||
|
||||
tools.drop_view_if_exists(self.env.cr, self._table)
|
||||
self.env.cr.execute(
|
||||
sql.SQL("CREATE or REPLACE VIEW {} as ({})").format(
|
||||
sql.Identifier(self._table),
|
||||
sql.SQL(query)
|
||||
# --- Below code is custom
|
||||
|
||||
self._validate_group_by(groupby)
|
||||
burndown_specific_domain, task_specific_domain = self._determine_domains(domain)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
self.check_access_rights('read')
|
||||
query = self._where_calc(burndown_specific_domain)
|
||||
fields = fields or [f.name for f in self._fields.values() if f.store]
|
||||
|
||||
groupby = [groupby] if isinstance(groupby, str) else list(OrderedSet(groupby))
|
||||
groupby_list = groupby[:1] if lazy else groupby
|
||||
annotated_groupbys = [self._read_group_process_groupby(gb, query) for gb in groupby_list]
|
||||
groupby_fields = [g['field'] for g in annotated_groupbys]
|
||||
order = orderby or ','.join([g for g in groupby_list])
|
||||
groupby_dict = {gb['groupby']: gb for gb in annotated_groupbys}
|
||||
|
||||
self._apply_ir_rules(query, 'read')
|
||||
for gb in groupby_fields:
|
||||
if gb not in self._fields:
|
||||
raise UserError(_("Unknown field %r in 'groupby'", gb))
|
||||
if not self._fields[gb].base_field.groupable:
|
||||
raise UserError(_(
|
||||
"Field %s is not a stored field, only stored fields (regular or "
|
||||
"many2many) are valid for the 'groupby' parameter", self._fields[gb],
|
||||
))
|
||||
|
||||
aggregated_fields = []
|
||||
select_terms = []
|
||||
fnames = [] # list of fields to flush
|
||||
|
||||
for fspec in fields:
|
||||
if fspec == 'sequence':
|
||||
continue
|
||||
if fspec == '__count':
|
||||
# the web client sometimes adds this pseudo-field in the list
|
||||
continue
|
||||
|
||||
match = regex_field_agg.match(fspec)
|
||||
if not match:
|
||||
raise UserError(_("Invalid field specification %r.", fspec))
|
||||
|
||||
name, func, fname = match.groups()
|
||||
if func:
|
||||
# we have either 'name:func' or 'name:func(fname)'
|
||||
fname = fname or name
|
||||
field = self._fields.get(fname)
|
||||
if not field:
|
||||
raise ValueError("Invalid field %r on model %r" % (fname, self._name))
|
||||
if not (field.base_field.store and field.base_field.column_type):
|
||||
raise UserError(_("Cannot aggregate field %r.", fname))
|
||||
if func not in VALID_AGGREGATE_FUNCTIONS:
|
||||
raise UserError(_("Invalid aggregation function %r.", func))
|
||||
else:
|
||||
# we have 'name', retrieve the aggregator on the field
|
||||
field = self._fields.get(name)
|
||||
if not field:
|
||||
raise ValueError("Invalid field %r on model %r" % (name, self._name))
|
||||
if not (field.base_field.store and
|
||||
field.base_field.column_type and field.group_operator):
|
||||
continue
|
||||
func, fname = field.group_operator, name
|
||||
|
||||
fnames.append(fname)
|
||||
|
||||
if fname in groupby_fields:
|
||||
continue
|
||||
if name in aggregated_fields:
|
||||
raise UserError(_("Output name %r is used twice.", name))
|
||||
aggregated_fields.append(name)
|
||||
|
||||
expr = self._inherits_join_calc(self._table, fname, query)
|
||||
if func.lower() == 'count_distinct':
|
||||
term = 'COUNT(DISTINCT %s) AS "%s"' % (expr, name)
|
||||
else:
|
||||
term = '%s(%s) AS "%s"' % (func, expr, name)
|
||||
select_terms.append(term)
|
||||
|
||||
for gb in annotated_groupbys:
|
||||
select_terms.append('%s as "%s" ' % (gb['qualified_field'], gb['groupby']))
|
||||
|
||||
# --- Below code is custom
|
||||
# --- As the report is base on `project.task` we flush that specific model
|
||||
|
||||
# self._flush_search(domain, fields=fnames + groupby_fields)
|
||||
self.env['project.task']._flush_search(task_specific_domain, fields=self.task_specific_fields)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
groupby_terms, orderby_terms = self._read_group_prepare(order, aggregated_fields, annotated_groupbys, query)
|
||||
from_clause, where_clause, where_clause_params = query.get_sql()
|
||||
if lazy and (len(groupby_fields) >= 2 or not self._context.get('group_by_no_leaf')):
|
||||
count_field = groupby_fields[0] if len(groupby_fields) >= 1 else '_'
|
||||
else:
|
||||
count_field = '_'
|
||||
count_field += '_count'
|
||||
|
||||
prefix_terms = lambda prefix, terms: (prefix + " " + ",".join(terms)) if terms else ''
|
||||
prefix_term = lambda prefix, term: ('%s %s' % (prefix, term)) if term else ''
|
||||
|
||||
# --- Below code is custom
|
||||
|
||||
query = self._get_group_by_SQL(task_specific_domain, count_field, select_terms, from_clause, where_clause,
|
||||
where_clause_params, groupby_terms, orderby_terms, limit, offset, groupby,
|
||||
annotated_groupbys, prefix_term, prefix_terms)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
self._cr.execute(query, where_clause_params)
|
||||
fetched_data = self._cr.dictfetchall()
|
||||
|
||||
if not groupby_fields:
|
||||
return fetched_data
|
||||
|
||||
self._read_group_resolve_many2x_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]
|
||||
|
||||
result = [self._read_group_format_result(d, annotated_groupbys, groupby, domain) for d in data]
|
||||
|
||||
# --- Below code is custom
|
||||
# --- We removed fill_temporal handling as not relevant in the context of the Burndown Chart
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
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
|
||||
# method _read_group_fill_results need to be completely reimplemented
|
||||
# in a sane way
|
||||
result = self._read_group_fill_results(
|
||||
domain, groupby_fields[0], groupby[len(annotated_groupbys):],
|
||||
aggregated_fields, count_field, result, read_group_order=order,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<search string="Burndown Chart">
|
||||
<field name="project_id" />
|
||||
<field name="user_ids" />
|
||||
<field name="milestone_id" groups="project.group_project_milestone"/>
|
||||
<field name="date_assign"/>
|
||||
<field name="date_deadline"/>
|
||||
@@ -18,12 +19,10 @@
|
||||
<filter name="filter_date_assign" date="date_assign"/>
|
||||
<filter string="Last Month" invisible="1" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Open tasks" name="open_tasks" domain="[('is_closed', '=', False)]"/>
|
||||
<filter string="Late Milestones" name="late_milestone" domain="[('is_closed', '=', False), ('task_id.has_late_and_unreached_milestone', '=', True)]" groups="project.group_project_milestone"/>
|
||||
<filter string="Late Milestones" name="late_milestone" domain="[('is_closed', '=', False), ('has_late_and_unreached_milestone', '=', True)]" groups="project.group_project_milestone"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Date" name="date" context="{'group_by': 'date'}" />
|
||||
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}" />
|
||||
<filter string="Project" name="project" context="{'group_by': 'project_id'}" />
|
||||
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone" />
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
@@ -40,23 +39,12 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_task_burndown_chart_report_view_pivot" model="ir.ui.view">
|
||||
<field name="name">project.task.burndown.chart.report.view.pivot</field>
|
||||
<field name="model">project.task.burndown.chart.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Burndown Chart" display_quantity="1" disable_linking="1" sample="1" js_class="burndown_chart_pivot">
|
||||
<field name="date" type="row"/>
|
||||
<field name="stage_id" type="row"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_project_task_burndown_chart_report" model="ir.actions.act_window">
|
||||
<field name="name">Burndown Chart</field>
|
||||
<field name="res_model">project.task.burndown.chart.report</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="view_mode">graph</field>
|
||||
<field name="search_view_id" ref="project_task_burndown_chart_report_view_search"/>
|
||||
<field name="context">{'search_default_project_id': active_id}</field>
|
||||
<field name="context">{'search_default_project_id': active_id, 'search_default_date': 1, 'search_default_stage': 1}</field>
|
||||
<field name="domain">[('display_project_id', '!=', False)]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { PivotModel } from "@web/views/pivot/pivot_model";
|
||||
|
||||
export class BurndownChartPivotModel extends PivotModel {
|
||||
/**
|
||||
* @protected
|
||||
* @override
|
||||
*/
|
||||
async _loadData(config, prune = true) {
|
||||
config.metaData.measures.__count.string = '# of Tasks';
|
||||
await super._loadData(config, prune);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { BurndownChartPivotModel } from "./burndown_chart_pivot_model";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
const burndownChartPivotView = {...pivotView, Model: BurndownChartPivotModel};
|
||||
viewRegistry.add("burndown_chart_pivot", burndownChartPivotView);
|
||||
@@ -0,0 +1,135 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
|
||||
|
||||
export class BurndownChartSearchModel extends SearchModel {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup(services) {
|
||||
this.notificationService = useService("notification");
|
||||
super.setup(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load(config) {
|
||||
await super.load(...arguments);
|
||||
// Store date and stage_id searchItemId in the SearchModel for reuse in other functions.
|
||||
for (const searchItem of Object.values(this.searchItems)) {
|
||||
if (['dateGroupBy', 'groupBy'].includes(searchItem.type)) {
|
||||
if (this.stageIdSearchItemId && this.dateSearchItemId) {
|
||||
return;
|
||||
}
|
||||
switch (searchItem.fieldName) {
|
||||
case 'date':
|
||||
this.dateSearchItemId = searchItem.id;
|
||||
break;
|
||||
case 'stage_id':
|
||||
this.stageIdSearchItemId = searchItem.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
deactivateGroup(groupId) {
|
||||
// Prevent removing Date & Stage group by from the search
|
||||
if (this.searchItems[this.stageIdSearchItemId].groupId == groupId && this.searchItems[this.dateSearchItemId].groupId) {
|
||||
this._addGroupByNotification(this.env._t("Date and Stage"));
|
||||
return;
|
||||
}
|
||||
super.deactivateGroup(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
toggleDateGroupBy(searchItemId, intervalId) {
|
||||
// Ensure that there is always one and only one date group by selected.
|
||||
if (searchItemId === this.dateSearchItemId) {
|
||||
let filtered_query = [];
|
||||
let triggerNotification = false;
|
||||
for (const queryElem of this.query) {
|
||||
if (queryElem.searchItemId !== searchItemId) {
|
||||
filtered_query.push(queryElem);
|
||||
} else if (queryElem.intervalId === intervalId) {
|
||||
triggerNotification = true;
|
||||
}
|
||||
}
|
||||
if (filtered_query.length !== this.query.length) {
|
||||
this.query = filtered_query;
|
||||
if (triggerNotification) {
|
||||
this._addGroupByNotification(this.env._t("Date"));
|
||||
}
|
||||
}
|
||||
}
|
||||
super.toggleDateGroupBy(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
toggleSearchItem(searchItemId) {
|
||||
// Ensure that stage_id is always selected.
|
||||
if (searchItemId === this.stageIdSearchItemId
|
||||
&& this.query.some(queryElem => queryElem.searchItemId === searchItemId)) {
|
||||
this._addGroupByNotification(this.env._t("Stage"));
|
||||
return;
|
||||
}
|
||||
super.toggleSearchItem(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a notification relative to the group by constraint of the Burndown Chart.
|
||||
* @param fieldName The field name(s) the notification has to be related to.
|
||||
* @private
|
||||
*/
|
||||
_addGroupByNotification(fieldName) {
|
||||
const notif = this.env._t("The Burndown Chart must be grouped by");
|
||||
this.notificationService.add(
|
||||
`${notif} ${fieldName}`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _notify() {
|
||||
// Ensure that we always group by date firstly and by stage_id secondly
|
||||
let stageIdIndex = -1;
|
||||
let dateIndex = -1;
|
||||
for (const [index, queryElem] of this.query.entries()) {
|
||||
if (stageIdIndex !== -1 && dateIndex !== -1) {
|
||||
break;
|
||||
}
|
||||
switch (queryElem.searchItemId) {
|
||||
case this.dateSearchItemId:
|
||||
dateIndex = index;
|
||||
break;
|
||||
case this.stageIdSearchItemId:
|
||||
stageIdIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stageIdIndex > 0) {
|
||||
if (stageIdIndex > dateIndex) {
|
||||
dateIndex += 1;
|
||||
}
|
||||
this.query.splice(0, 0, this.query.splice(stageIdIndex, 1)[0]);
|
||||
}
|
||||
if (dateIndex > 0) {
|
||||
this.query.splice(0, 0, this.query.splice(dateIndex, 1)[0]);
|
||||
}
|
||||
await super._notify(...arguments);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BurndownChartModel } from "./burndown_chart_model";
|
||||
import { BurndownChartRenderer } from "./burndown_chart_renderer";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { BurndownChartSearchModel } from "./burndown_chart_search_model";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
@@ -11,7 +12,10 @@ const burndownChartGraphView = {
|
||||
...graphView,
|
||||
Renderer: BurndownChartRenderer,
|
||||
buttonTemplate: "project.BurndownChartView.Buttons",
|
||||
hideCustomGroupBy: true,
|
||||
Model: BurndownChartModel,
|
||||
searchMenuTypes: graphView.searchMenuTypes.filter(menuType => menuType !== "comparison"),
|
||||
SearchModel: BurndownChartSearchModel,
|
||||
};
|
||||
|
||||
viewRegistry.add("burndown_chart", burndownChartGraphView);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<xpath expr="//div[@role='toolbar'][3]" position="attributes">
|
||||
<attribute name="t-if">true</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@role='toolbar'][4]" position="replace">
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { setupControlPanelServiceRegistry, toggleGroupByMenu, toggleMenuItem, toggleMenuItemOption } from "@web/../tests/search/helpers";
|
||||
import { COLORS, hexToRGBA } from "@web/views/graph/colors";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { getGraphRenderer } from "@web/../tests/views/graph_view_tests";
|
||||
import { makeView } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { setupControlPanelServiceRegistry } from "@web/../tests/search/helpers";
|
||||
import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
QUnit.module("Project", {}, () => {
|
||||
QUnit.module("Views", (hooks) => {
|
||||
let serverData;
|
||||
let makeViewParams;
|
||||
let target;
|
||||
hooks.beforeEach(async () => {
|
||||
hooks.beforeEach(async (assert) => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
const serverData = {
|
||||
models: {
|
||||
burndown_chart: {
|
||||
fields: {
|
||||
@@ -55,9 +57,24 @@ QUnit.module("Project", {}, () => {
|
||||
<field name="nb_tasks" type="measure"/>
|
||||
</graph>
|
||||
`,
|
||||
"burndown_chart,false,search": `
|
||||
<search/>
|
||||
`,
|
||||
},
|
||||
};
|
||||
makeViewParams = {
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
};
|
||||
setupControlPanelServiceRegistry();
|
||||
const notificationMock = () => {
|
||||
assert.step("notification_triggered");
|
||||
return () => {};
|
||||
};
|
||||
registry.category("services").add("notification", makeFakeNotificationService(notificationMock), {
|
||||
force: true,
|
||||
});
|
||||
serviceRegistry.add("dialog", dialogService);
|
||||
});
|
||||
|
||||
@@ -66,11 +83,7 @@ QUnit.module("Project", {}, () => {
|
||||
QUnit.test("check if default mode is line chart and line chart is stacked for burndown chart", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
const burndownChart = await makeView(makeViewParams);
|
||||
|
||||
assert.strictEqual(burndownChart.model.metaData.mode, "line", "should be in line chart mode.");
|
||||
assert.ok(burndownChart.model.metaData.stacked, "should be stacked by default.");
|
||||
@@ -106,11 +119,7 @@ QUnit.module("Project", {}, () => {
|
||||
|
||||
QUnit.test("check if the stacked button is visible in the line chart", async function (assert) {
|
||||
assert.expect(3);
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
const burndownChart = await makeView(makeViewParams);
|
||||
assert.ok(burndownChart.model.metaData.stacked, "graph should be a burndown chart.");
|
||||
assert.containsOnce(target, `button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
const stackButton = target.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
@@ -121,11 +130,7 @@ QUnit.module("Project", {}, () => {
|
||||
QUnit.test("check if it is classic line chart when stacked prop is false in line chart", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const burndownChart = await makeView({
|
||||
serverData,
|
||||
resModel: "burndown_chart",
|
||||
type: "burndown_chart",
|
||||
});
|
||||
const burndownChart = await makeView(makeViewParams);
|
||||
|
||||
const stackButton = target.querySelector(`button.o_graph_button[data-tooltip="Stacked"]`);
|
||||
await click(stackButton);
|
||||
@@ -159,5 +164,161 @@ QUnit.module("Project", {}, () => {
|
||||
|
||||
assert.deepEqual(actualDatasets, expectedDatasets);
|
||||
});
|
||||
|
||||
QUnit.test("check that the sort buttons are invisible", async function (assert) {
|
||||
await makeView(makeViewParams);
|
||||
assert.containsNone(target, '.o_cp_bottom_left:has(.btn-group[role=toolbar][aria-label="Sort graph"])', "The sort buttons are not rendered.");
|
||||
});
|
||||
|
||||
async function makeBurnDownChartWithSearchView(makeViewOverwriteParams = { }) {
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
await makeView({
|
||||
...makeViewParams,
|
||||
searchViewId: false,
|
||||
searchViewArch: `
|
||||
<search string="Burndown Chart">
|
||||
<filter string="Date" name="date" context="{'group_by': 'date'}" />
|
||||
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}" />
|
||||
</search>
|
||||
`,
|
||||
searchViewFields: {
|
||||
date: {
|
||||
name: "date",
|
||||
string: "Date",
|
||||
type: "date",
|
||||
store: true,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
},
|
||||
stage_id: {
|
||||
name: "stage_id",
|
||||
string: "Stage",
|
||||
type: "many2one",
|
||||
store: true,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
context: { ...makeViewParams.context, 'search_default_date': 1, 'search_default_stage': 1 },
|
||||
...makeViewOverwriteParams,
|
||||
});
|
||||
}
|
||||
|
||||
async function testBurnDownChartWithSearchView(stepsTriggeringNotification, assert) {
|
||||
await makeBurnDownChartWithSearchView();
|
||||
await stepsTriggeringNotification();
|
||||
assert.verifySteps(['notification_triggered']);
|
||||
}
|
||||
|
||||
function getFirstElementForXpath(xpath) {
|
||||
const xPathResult = document.evaluate(xpath, target, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
||||
return xPathResult.singleNodeValue;
|
||||
}
|
||||
|
||||
async function openGroupByMainMenu(target) {
|
||||
await toggleGroupByMenu(target);
|
||||
}
|
||||
|
||||
async function openGroupByDateMenu(target) {
|
||||
await openGroupByMainMenu(target);
|
||||
await toggleMenuItem(target, 'Date');
|
||||
}
|
||||
|
||||
async function toggleGroupByStageMenu(target) {
|
||||
await openGroupByMainMenu(target);
|
||||
await toggleMenuItem(target, 'Stage');
|
||||
}
|
||||
|
||||
async function toggleSelectedGroupByDateItem(target) {
|
||||
await openGroupByDateMenu(target);
|
||||
const selectedGroupByDateItemXpath = `//div
|
||||
[contains(@class, 'o_group_by_menu')]
|
||||
//button
|
||||
[contains(@class, 'o_menu_item')]
|
||||
[contains(., 'Date')]
|
||||
/following-sibling::div
|
||||
/span
|
||||
[contains(@class, 'o_item_option')]
|
||||
[contains(@class, 'selected')]`;
|
||||
const selectedGroupByDateItemElement = getFirstElementForXpath(selectedGroupByDateItemXpath);
|
||||
await toggleMenuItemOption(target, 'Date', selectedGroupByDateItemElement.innerText);
|
||||
}
|
||||
|
||||
QUnit.test("check that removing the group by 'Date: Month > Stage' in the search bar triggers a notification", async function (assert) {
|
||||
|
||||
const stepsTriggeringNotification = async () => {
|
||||
const removeFilterXpath = `//div[contains(@class, 'o_searchview_facet')]
|
||||
[.//span[@class='o_facet_value']
|
||||
[contains(., 'Date: Month')]]
|
||||
/i[contains(@class, 'o_facet_remove')]`;
|
||||
const removeFilterElement = getFirstElementForXpath(removeFilterXpath);
|
||||
await click(removeFilterElement);
|
||||
};
|
||||
await testBurnDownChartWithSearchView(stepsTriggeringNotification, assert);
|
||||
});
|
||||
|
||||
QUnit.test("check that removing the group by 'Date' triggers a notification", async function (assert) {
|
||||
const stepsTriggeringNotification = async () => {
|
||||
await toggleSelectedGroupByDateItem(target);
|
||||
};
|
||||
await testBurnDownChartWithSearchView(stepsTriggeringNotification, assert);
|
||||
});
|
||||
|
||||
QUnit.test("check that removing the group by 'Stage' triggers a notification", async function (assert) {
|
||||
const stepsTriggeringNotification = async () => {
|
||||
await toggleGroupByStageMenu(target);
|
||||
};
|
||||
await testBurnDownChartWithSearchView(stepsTriggeringNotification, assert);
|
||||
});
|
||||
|
||||
QUnit.test("check that adding a group by 'Date' actually toggle it", async function (assert) {
|
||||
await makeBurnDownChartWithSearchView();
|
||||
await openGroupByDateMenu(target);
|
||||
const firstNotSelectedGroupByDateItemXpath = `//div
|
||||
[contains(@class, 'o_group_by_menu')]
|
||||
//button
|
||||
[contains(@class, 'o_menu_item')]
|
||||
[contains(., 'Date')]
|
||||
/following-sibling::div
|
||||
/span
|
||||
[contains(@class, 'o_item_option')]
|
||||
[not(contains(@class, 'selected'))]`;
|
||||
const firstNotSelectedGroupByDateItemElement = getFirstElementForXpath(firstNotSelectedGroupByDateItemXpath);
|
||||
await toggleMenuItemOption(target, 'Date', firstNotSelectedGroupByDateItemElement.innerText);
|
||||
const groupByDateSubMenuXpath = `//div
|
||||
[contains(@class, 'o_group_by_menu')]
|
||||
//button
|
||||
[contains(@class, 'o_menu_item')]
|
||||
[contains(., 'Date')]
|
||||
/following-sibling::div`;
|
||||
const groupByDateSubMenuElement = getFirstElementForXpath(groupByDateSubMenuXpath);
|
||||
const selectedGroupByDateItemElements = groupByDateSubMenuElement.querySelectorAll('span.o_item_option.selected');
|
||||
assert.equal(selectedGroupByDateItemElements.length, 1, 'There is only one selected item.');
|
||||
assert.equal(firstNotSelectedGroupByDateItemElement.innerText, selectedGroupByDateItemElements[0].innerText, 'The selected item is the one we clicked on.');
|
||||
});
|
||||
|
||||
function checkGroupByOrder(assert) {
|
||||
const dateSearchFacetXpath = `//div[contains(@class, 'o_searchview_facet')]
|
||||
[.//span[@class='o_facet_value']
|
||||
[contains(., 'Date: Month')]]`;
|
||||
const dateSearchFacetElement = getFirstElementForXpath(dateSearchFacetXpath);
|
||||
const dateSearchFacetParts = dateSearchFacetElement.querySelectorAll('.o_facet_value');
|
||||
assert.equal(dateSearchFacetParts.length, 2);
|
||||
assert.equal(dateSearchFacetParts[0].innerText, 'Date: Month');
|
||||
assert.equal(dateSearchFacetParts[1].innerText, 'Stage');
|
||||
}
|
||||
|
||||
QUnit.test("check that the group by is always sorted 'Date' first, 'Stage' second", async function (assert) {
|
||||
await makeBurnDownChartWithSearchView({context: {...makeViewParams.context, 'search_default_date': 1, 'search_default_stage': 1}});
|
||||
checkGroupByOrder(assert);
|
||||
});
|
||||
|
||||
QUnit.test("check that the group by is always sorted 'Date' first, 'Stage' second", async function (assert) {
|
||||
await makeBurnDownChartWithSearchView({context: {...makeViewParams.context, 'search_default_stage': 1, 'search_default_date': 1}});
|
||||
checkGroupByOrder(assert);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('burndown_chart_tour', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
},
|
||||
[tour.stepUtils.showAppsMenuItem(), {
|
||||
trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
|
||||
}, {
|
||||
content: 'Open "Burndown Chart Test" project menu',
|
||||
trigger: '.o_kanban_record:contains("Burndown Chart Test") .o_kanban_manage_toggle_button',
|
||||
}, {
|
||||
content: `Open "Burndown Chart Test" project's "Burndown Chart" view`,
|
||||
trigger: '.o_kanban_record:contains("Burndown Chart Test") .o_kanban_manage_reporting div[role="menuitem"] a:contains("Burndown Chart")',
|
||||
}, {
|
||||
content: 'The sort buttons are not rendered',
|
||||
trigger: '.o_cp_bottom_left:not(:has(.btn-group[role=toolbar][aria-label="Sort graph"]))',
|
||||
extra_trigger: '.o_graph_renderer',
|
||||
}, {
|
||||
content: 'Remove the project search "Burndown Chart Test"',
|
||||
trigger: '.o_searchview_facet:contains("Burndown Chart Test") .o_facet_remove',
|
||||
}, {
|
||||
content: 'Search Burndown Chart',
|
||||
trigger: 'input.o_searchview_input',
|
||||
run: `text Burndown`,
|
||||
}, {
|
||||
content: 'Validate search',
|
||||
trigger: '.o_searchview_autocomplete .o_menu_item:contains("Project")',
|
||||
}, {
|
||||
content: 'Remove the group by "Date: Month > Stage"',
|
||||
trigger: '.o_searchview_facet:contains("Date: Month") .o_facet_remove',
|
||||
}, {
|
||||
content: 'A "The Burndown Chart must be grouped by Date and Stage" notification is shown when trying to remove the group by "Date: Month > Stage"',
|
||||
trigger: '.o_notification_manager .o_notification:contains("The Burndown Chart must be grouped by Date and Stage") button.o_notification_close',
|
||||
}, {
|
||||
content: 'Open the group by menu',
|
||||
trigger: '.o_group_by_menu button',
|
||||
}, {
|
||||
content: 'Click on the Stage group menu item',
|
||||
trigger: '.o_group_by_menu .o_menu_item:contains("Stage")',
|
||||
}, {
|
||||
content: 'A "The Burndown Chart must be grouped by Stage" notification is shown when trying to remove the group by "Date: Month > Stage"',
|
||||
trigger: '.o_notification_manager .o_notification:contains("The Burndown Chart must be grouped by Stage") button.o_notification_close',
|
||||
}, {
|
||||
content: 'Open the group by menu',
|
||||
trigger: '.o_group_by_menu button',
|
||||
}, {
|
||||
content: 'Open the Date group by sub menu',
|
||||
trigger: '.o_group_by_menu button.o_menu_item:contains("Date")',
|
||||
run: function () {
|
||||
this.$anchor[0].dispatchEvent(new Event('mouseenter'));
|
||||
},
|
||||
}, {
|
||||
content: 'Click on the selected Date sub menu',
|
||||
trigger: '.o_group_by_menu button.o_menu_item:contains("Date") + * .dropdown-item.selected',
|
||||
run: function () {
|
||||
this.$anchor[0].dispatchEvent(new Event('click'));
|
||||
},
|
||||
}, {
|
||||
content: 'A "The Burndown Chart must be grouped by Date" notification is shown when trying to remove the group by "Date: Month > Stage"',
|
||||
trigger: '.o_notification_manager .o_notification:contains("The Burndown Chart must be grouped by Date") button.o_notification_close',
|
||||
}, {
|
||||
content: 'Open the filter menu',
|
||||
trigger: '.o_filter_menu button',
|
||||
}, {
|
||||
content: 'Open the Date filter sub menu',
|
||||
trigger: '.o_filter_menu button.o_menu_item:contains("Date")',
|
||||
run: function () {
|
||||
this.$anchor[0].dispatchEvent(new Event('mouseenter'));
|
||||
},
|
||||
}, {
|
||||
content: 'Click on the first Date filter sub menu',
|
||||
trigger: '.o_filter_menu .o_menu_item:contains("Date") + * .dropdown-item:first-child',
|
||||
run: function () {
|
||||
this.$anchor[0].dispatchEvent(new Event('click'));
|
||||
},
|
||||
}, {
|
||||
content: 'Close the Date filter menu',
|
||||
trigger: '.o_graph_renderer',
|
||||
}, {
|
||||
content: 'The comparison menu is not rendered',
|
||||
trigger: '.o_search_options:not(:has(.o_comparison_menu))',
|
||||
}]);
|
||||
@@ -5,174 +5,331 @@ from freezegun import freeze_time
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import Command
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.osv.expression import AND, OR
|
||||
from odoo.tests.common import tagged, HttpCase
|
||||
from .test_project_base import TestProjectCommon
|
||||
|
||||
|
||||
class TestBurndownChart(TransactionCase):
|
||||
def set_create_date(self, table, res_id, create_date):
|
||||
self.env.cr.execute("UPDATE {} SET create_date=%s WHERE id=%s".format(table), (create_date, res_id))
|
||||
class TestBurndownChartCommon(TestProjectCommon):
|
||||
|
||||
def test_burndown_chart(self):
|
||||
current_year = datetime.now().year
|
||||
create_date = datetime(current_year - 1, 1, 1)
|
||||
@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 = self.env['project.task.type']
|
||||
todo_stage = Stage.create({
|
||||
Stage = cls.env['project.task.type']
|
||||
cls.todo_stage = Stage.create({
|
||||
'sequence': 1,
|
||||
'name': 'TODO',
|
||||
**kanban_state_vals,
|
||||
})
|
||||
self.set_create_date('project_task_type', todo_stage.id, create_date)
|
||||
in_progress_stage = Stage.create({
|
||||
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,
|
||||
})
|
||||
self.set_create_date('project_task_type', in_progress_stage.id, create_date)
|
||||
testing_stage = Stage.create({
|
||||
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,
|
||||
})
|
||||
self.set_create_date('project_task_type', testing_stage.id, create_date)
|
||||
done_stage = Stage.create({
|
||||
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,
|
||||
})
|
||||
self.set_create_date('project_task_type', done_stage.id, create_date)
|
||||
stages = todo_stage + in_progress_stage + testing_stage + done_stage
|
||||
project = self.env['project.project'].create({
|
||||
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 stages.ids],
|
||||
'type_ids': [Command.link(stage_id) for stage_id in cls.stages.ids],
|
||||
})
|
||||
self.set_create_date('project_project', project.id, create_date)
|
||||
project.invalidate_model()
|
||||
task_a = self.env['project.task'].create({
|
||||
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': project.id,
|
||||
'stage_id': todo_stage.id,
|
||||
'project_id': cls.project.id,
|
||||
'stage_id': cls.todo_stage.id,
|
||||
})
|
||||
self.set_create_date('project_task', task_a.id, create_date)
|
||||
task_b = task_a.copy({
|
||||
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])],
|
||||
})
|
||||
self.set_create_date('project_task', task_b.id, create_date)
|
||||
task_c = task_a.copy({
|
||||
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)],
|
||||
})
|
||||
self.set_create_date('project_task', task_c.id, create_date)
|
||||
task_d = task_a.copy({
|
||||
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)],
|
||||
})
|
||||
self.set_create_date('project_task', task_d.id, create_date)
|
||||
task_e = task_a.copy({
|
||||
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,
|
||||
})
|
||||
self.set_create_date('project_task', task_e.id, create_date)
|
||||
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 = self.env['project.task'].create({
|
||||
task_f = cls.env['project.task'].create({
|
||||
'name': 'Task F',
|
||||
'priority': 0,
|
||||
'project_id': project.id,
|
||||
'stage_id': todo_stage.id,
|
||||
'project_id': cls.project.id,
|
||||
'milestone_id': cls.milestone.id,
|
||||
'stage_id': cls.todo_stage.id,
|
||||
})
|
||||
self.set_create_date('project_task', task_f.id, datetime(current_year - 1, 12, 20))
|
||||
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
|
||||
self.env.cr.flush()
|
||||
cls.env.cr.flush()
|
||||
|
||||
with freeze_time('%s-02-10' % (current_year - 1)):
|
||||
(task_a + task_b).write({'stage_id': in_progress_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_c.write({'stage_id': in_progress_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
(task_d + task_e).write({'stage_id': in_progress_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
(task_a + task_b).write({'stage_id': testing_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_c.write({'stage_id': testing_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_d.write({'stage_id': testing_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_e.write({'stage_id': testing_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_a.write({'stage_id': done_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_b.write({'stage_id': done_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_c.write({'stage_id': done_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_d.write({'stage_id': done_stage.id})
|
||||
self.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' % (current_year - 1)):
|
||||
task_e.write({'stage_id': done_stage.id})
|
||||
self.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()
|
||||
|
||||
read_group_result = self.env['project.task.burndown.chart.report'].with_context(fill_temporal=True).read_group([('project_id', '=', project.id), ('display_project_id', '!=', False)], ['date', 'stage_id'], ['date:month', 'stage_id'], lazy=False)
|
||||
read_group_result_dict = {(res['date:month'], res['stage_id'][0]): res['__count'] for res in read_group_result}
|
||||
stages_dict = {stage.id: stage.name for stage in stages}
|
||||
expected_dict = {
|
||||
('January %s' % (current_year - 1), todo_stage.id): 5,
|
||||
('February %s' % (current_year - 1), todo_stage.id): 2,
|
||||
('February %s' % (current_year - 1), in_progress_stage.id): 3,
|
||||
('March %s' % (current_year - 1), in_progress_stage.id): 5,
|
||||
('April %s' % (current_year - 1), in_progress_stage.id): 3,
|
||||
('April %s' % (current_year - 1), testing_stage.id): 2,
|
||||
('May %s' % (current_year - 1), in_progress_stage.id): 2,
|
||||
('May %s' % (current_year - 1), testing_stage.id): 3,
|
||||
('June %s' % (current_year - 1), in_progress_stage.id): 1,
|
||||
('June %s' % (current_year - 1), testing_stage.id): 4,
|
||||
('July %s' % (current_year - 1), testing_stage.id): 5,
|
||||
('August %s' % (current_year - 1), testing_stage.id): 4,
|
||||
('August %s' % (current_year - 1), done_stage.id): 1,
|
||||
('September %s' % (current_year - 1), testing_stage.id): 3,
|
||||
('September %s' % (current_year - 1), done_stage.id): 2,
|
||||
('October %s' % (current_year - 1), testing_stage.id): 2,
|
||||
('October %s' % (current_year - 1), done_stage.id): 3,
|
||||
('November %s' % (current_year - 1), testing_stage.id): 1,
|
||||
('November %s' % (current_year - 1), done_stage.id): 4,
|
||||
('December %s' % (current_year - 1), done_stage.id): 5,
|
||||
('December %s' % (current_year - 1), todo_stage.id): 1,
|
||||
('January %s' % (current_year), done_stage.id): 5,
|
||||
('January %s' % (current_year), todo_stage.id): 1,
|
||||
}
|
||||
|
||||
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():
|
||||
# when we don't found any record in the dict then we are in the current_year
|
||||
# and the number of tasks should always be 5 in Done stage and 1 in Todo Stage
|
||||
# since we have created the last task without changing its stage.
|
||||
expected_count = expected_dict.get((month, stage_id), 5 if stage_id != todo_stage.id else 1)
|
||||
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")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestProjectCommon(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
@@ -94,6 +95,9 @@ class TestProjectCommon(TransactionCase):
|
||||
self.env['mail.thread'].message_process(model, mail)
|
||||
return self.env[target_model].search([(target_field, '=', subject)])
|
||||
|
||||
|
||||
class TestProjectBase(TestProjectCommon):
|
||||
|
||||
def test_delete_project_with_tasks(self):
|
||||
"""User should never be able to delete a project with tasks"""
|
||||
|
||||
|
||||
@@ -95,10 +95,7 @@ export class ControlPanel extends Component {
|
||||
}
|
||||
|
||||
ControlPanel.components = {
|
||||
ComparisonMenu,
|
||||
FavoriteMenu,
|
||||
FilterMenu,
|
||||
GroupByMenu,
|
||||
...Object.values(MAPPING),
|
||||
Pager,
|
||||
SearchBar,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,13 @@ export class GroupByMenu extends Component {
|
||||
useBus(this.env.searchModel, "update", this.render);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get hideCustomGroupBy() {
|
||||
return this.env.searchModel.hideCustomGroupBy || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</t>
|
||||
<t t-set="currentGroup" t-value="item.groupNumber"/>
|
||||
</t>
|
||||
<t t-if="fields.length">
|
||||
<t t-if="!hideCustomGroupBy and fields.length">
|
||||
<div t-if="items.length" role="separator" class="dropdown-divider"/>
|
||||
<CustomGroupByItem fields="fields" onAddCustomGroup.bind="onAddCustomGroup"/>
|
||||
</t>
|
||||
|
||||
@@ -212,13 +212,14 @@ export class SearchModel extends EventBus {
|
||||
// used to avoid useless recomputations
|
||||
this._reset();
|
||||
|
||||
const { comparison, context, domain, groupBy, orderBy } = config;
|
||||
const { comparison, context, domain, groupBy, hideCustomGroupBy, orderBy } = config;
|
||||
|
||||
this.globalComparison = comparison;
|
||||
this.globalContext = toRaw(context || {});
|
||||
this.globalDomain = domain || [];
|
||||
this.globalGroupBy = groupBy || [];
|
||||
this.globalOrderBy = orderBy || [];
|
||||
this.hideCustomGroupBy = hideCustomGroupBy;
|
||||
|
||||
this.searchMenuTypes = new Set(config.searchMenuTypes || ["filter", "groupBy", "favorite"]);
|
||||
|
||||
|
||||
@@ -86,5 +86,6 @@ WithSearch.props = {
|
||||
// extra options
|
||||
activateFavorite: { type: Boolean, optional: true },
|
||||
dynamicFilters: { type: Array, element: Object, optional: true },
|
||||
hideCustomGroupBy: { type: Boolean, optional: true },
|
||||
searchMenuTypes: { type: Array, element: String, optional: true },
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ export function getDefaultConfig() {
|
||||
*
|
||||
* @property {boolean} [activateFavorite]
|
||||
* @property {Object[]} [dynamicFilters]
|
||||
* @property {boolean} [hideCustomGroupBy]
|
||||
* @property {string[]} [searchMenuTypes]
|
||||
* @property {Object} [globalState]
|
||||
*/
|
||||
@@ -123,6 +124,7 @@ const STANDARD_PROPS = [
|
||||
|
||||
"activateFavorite",
|
||||
"dynamicFilters",
|
||||
"hideCustomGroupBy",
|
||||
"searchMenuTypes",
|
||||
|
||||
// LEGACY: remove this later (clean when mappings old state <-> new state are established)
|
||||
@@ -321,6 +323,7 @@ export class View extends Component {
|
||||
this.componentProps = finalProps;
|
||||
this.withSearchProps = {
|
||||
...toRaw(props),
|
||||
hideCustomGroupBy: props.hideCustomGroupBy || descr.hideCustomGroupBy,
|
||||
searchMenuTypes,
|
||||
SearchModel: descr.SearchModel,
|
||||
};
|
||||
|
||||
@@ -457,4 +457,69 @@ QUnit.module("Search", (hooks) => {
|
||||
assert.deepEqual(controlPanel.env.searchModel.groupBy, []);
|
||||
assert.deepEqual(getFacetTexts(target), []);
|
||||
});
|
||||
|
||||
QUnit.test("Custom group by menu is displayed when hideCustomGroupBy is not set", async function (assert) {
|
||||
|
||||
await makeWithSearch({
|
||||
serverData,
|
||||
resModel: "foo",
|
||||
Component: ControlPanel,
|
||||
searchViewId: false,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter string="Birthday" name="birthday" context="{'group_by': 'birthday'}"/>
|
||||
<filter string="Date" name="date" context="{'group_by': 'foo'}"/>
|
||||
</search>
|
||||
`,
|
||||
searchMenuTypes: ["groupBy"],
|
||||
});
|
||||
|
||||
await toggleGroupByMenu(target);
|
||||
|
||||
assert.containsOnce(target, ".o_add_custom_group_menu");
|
||||
});
|
||||
|
||||
QUnit.test("Custom group by menu is displayed when hideCustomGroupBy is false", async function (assert) {
|
||||
|
||||
await makeWithSearch({
|
||||
serverData,
|
||||
resModel: "foo",
|
||||
Component: ControlPanel,
|
||||
searchViewId: false,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter string="Birthday" name="birthday" context="{'group_by': 'birthday'}"/>
|
||||
<filter string="Date" name="date" context="{'group_by': 'foo'}"/>
|
||||
</search>
|
||||
`,
|
||||
hideCustomGroupBy: false,
|
||||
searchMenuTypes: ["groupBy"],
|
||||
});
|
||||
|
||||
await toggleGroupByMenu(target);
|
||||
|
||||
assert.containsOnce(target, ".o_add_custom_group_menu");
|
||||
});
|
||||
|
||||
QUnit.test("Custom group by menu is displayed when hideCustomGroupBy is true", async function (assert) {
|
||||
|
||||
await makeWithSearch({
|
||||
serverData,
|
||||
resModel: "foo",
|
||||
Component: ControlPanel,
|
||||
searchViewId: false,
|
||||
searchViewArch: `
|
||||
<search>
|
||||
<filter string="Birthday" name="birthday" context="{'group_by': 'birthday'}"/>
|
||||
<filter string="Date" name="date" context="{'group_by': 'foo'}"/>
|
||||
</search>
|
||||
`,
|
||||
hideCustomGroupBy: true,
|
||||
searchMenuTypes: ["groupBy"],
|
||||
});
|
||||
|
||||
await toggleGroupByMenu(target);
|
||||
|
||||
assert.containsNone(target, ".o_add_custom_group_menu");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user