diff --git a/addons/mail/models/mail_tracking_value.py b/addons/mail/models/mail_tracking_value.py index 40fdb7d057f..adc3f64407c 100644 --- a/addons/mail/models/mail_tracking_value.py +++ b/addons/mail/models/mail_tracking_value.py @@ -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') diff --git a/addons/project/report/project_task_burndown_chart_report.py b/addons/project/report/project_task_burndown_chart_report.py index 70b5ed47c20..45964612338 100644 --- a/addons/project/report/project_task_burndown_chart_report.py +++ b/addons/project/report/project_task_burndown_chart_report.py @@ -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 diff --git a/addons/project/report/project_task_burndown_chart_report_views.xml b/addons/project/report/project_task_burndown_chart_report_views.xml index bcbf99b48dc..d768391db24 100644 --- a/addons/project/report/project_task_burndown_chart_report_views.xml +++ b/addons/project/report/project_task_burndown_chart_report_views.xml @@ -7,6 +7,7 @@ + @@ -18,12 +19,10 @@ - + - - @@ -40,23 +39,12 @@ - - project.task.burndown.chart.report.view.pivot - project.task.burndown.chart.report - - - - - - - - Burndown Chart project.task.burndown.chart.report - graph,pivot + graph - {'search_default_project_id': active_id} + {'search_default_project_id': active_id, 'search_default_date': 1, 'search_default_stage': 1} [('display_project_id', '!=', False)]

diff --git a/addons/project/static/src/burndown_chart/burndown_chart_pivot_model.js b/addons/project/static/src/burndown_chart/burndown_chart_pivot_model.js deleted file mode 100644 index 928b265eeb0..00000000000 --- a/addons/project/static/src/burndown_chart/burndown_chart_pivot_model.js +++ /dev/null @@ -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); - } -} diff --git a/addons/project/static/src/burndown_chart/burndown_chart_pivot_view.js b/addons/project/static/src/burndown_chart/burndown_chart_pivot_view.js deleted file mode 100644 index 52e4d96cf7d..00000000000 --- a/addons/project/static/src/burndown_chart/burndown_chart_pivot_view.js +++ /dev/null @@ -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); diff --git a/addons/project/static/src/burndown_chart/burndown_chart_search_model.js b/addons/project/static/src/burndown_chart/burndown_chart_search_model.js new file mode 100644 index 00000000000..7a199af20ad --- /dev/null +++ b/addons/project/static/src/burndown_chart/burndown_chart_search_model.js @@ -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); + } + +} diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.js b/addons/project/static/src/burndown_chart/burndown_chart_view.js index e6d5c2b0f8d..0906550a881 100644 --- a/addons/project/static/src/burndown_chart/burndown_chart_view.js +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.js @@ -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); diff --git a/addons/project/static/src/burndown_chart/burndown_chart_view.xml b/addons/project/static/src/burndown_chart/burndown_chart_view.xml index 75605aa1428..0925d1dd5c3 100644 --- a/addons/project/static/src/burndown_chart/burndown_chart_view.xml +++ b/addons/project/static/src/burndown_chart/burndown_chart_view.xml @@ -7,6 +7,8 @@ true + + diff --git a/addons/project/static/tests/burndown_chart_tests.js b/addons/project/static/tests/burndown_chart_tests.js index 5c8e13cbc31..467a1792d53 100644 --- a/addons/project/static/tests/burndown_chart_tests.js +++ b/addons/project/static/tests/burndown_chart_tests.js @@ -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", {}, () => { `, + "burndown_chart,false,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: ` + + + + + `, + 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); + }); }); }); diff --git a/addons/project/static/tests/tours/project_burndown_chart_tour.js b/addons/project/static/tests/tours/project_burndown_chart_tour.js new file mode 100644 index 00000000000..ebc3d2e7c28 --- /dev/null +++ b/addons/project/static/tests/tours/project_burndown_chart_tour.js @@ -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))', +}]); diff --git a/addons/project/tests/test_burndown_chart.py b/addons/project/tests/test_burndown_chart.py index f22b6ec1667..dfcb50bf78e 100644 --- a/addons/project/tests/test_burndown_chart.py +++ b/addons/project/tests/test_burndown_chart.py @@ -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") diff --git a/addons/project/tests/test_project_base.py b/addons/project/tests/test_project_base.py index 394e8fe8925..c83db73de51 100644 --- a/addons/project/tests/test_project_base.py +++ b/addons/project/tests/test_project_base.py @@ -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""" diff --git a/addons/web/static/src/search/control_panel/control_panel.js b/addons/web/static/src/search/control_panel/control_panel.js index 9db06294bdf..f4cb99b373b 100644 --- a/addons/web/static/src/search/control_panel/control_panel.js +++ b/addons/web/static/src/search/control_panel/control_panel.js @@ -95,10 +95,7 @@ export class ControlPanel extends Component { } ControlPanel.components = { - ComparisonMenu, - FavoriteMenu, - FilterMenu, - GroupByMenu, + ...Object.values(MAPPING), Pager, SearchBar, }; diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.js b/addons/web/static/src/search/group_by_menu/group_by_menu.js index a99b034cdf2..4c5d8223561 100644 --- a/addons/web/static/src/search/group_by_menu/group_by_menu.js +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.js @@ -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[]} */ diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.xml b/addons/web/static/src/search/group_by_menu/group_by_menu.xml index b2911a39b41..d6e90e5e0a4 100644 --- a/addons/web/static/src/search/group_by_menu/group_by_menu.xml +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.xml @@ -43,7 +43,7 @@ - +