[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:
Odoo's Mergebot
2022-07-21 00:49:54 +02:00
committed by GitHub
19 changed files with 1175 additions and 355 deletions
+1 -1
View File
@@ -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','&gt;=', (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))',
}]);
+263 -106
View File
@@ -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>
+2 -1
View File
@@ -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 },
};
+3
View File
@@ -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");
});
});