From aa250d3245a3735df1f74fd5c96dbfa4490639b3 Mon Sep 17 00:00:00 2001 From: "Audric Onockx (auon)" Date: Wed, 3 Aug 2022 13:48:33 +0000 Subject: [PATCH] [IMP] project: improve and create task template for recurrence The goal of this task is to ease the deletion/archiving of recurrent tasks by giving the user the option to either continue or stop the recurrence. The two main use-cases are: \- You have a recurring task for a weekly meeting. Next week, the user will be on time off. You are thus deleting the task. However, you would still like tasks generated for the following meetings and to keep track of the previous meetings. \- You have a recurring task for a weekly meeting with an employee that has just been fired. You are thus deleting the task and you won't need this recurrence anymore. (You may or may not want to keep track of the previous meetings.) The main issue we are currently facing is that we need a task that acts as a template for the recurrence to continue. This means that a user cannot delete all of his tasks without breaking their recurrence. Consequently, we will be using a "task template" for each recurrence, which will only serve technical purposes and be invisible to the users. When the recurrence is stopped, the task template is deleted. This simplifies the technical and functional implementation as the user won't be able to modify the template themselves. \- Added task templates to recurrences in the data. \- Deleted "all tasks" option form recurence update and changed "this and following tasks" to "this and future tasks". Most of the time, the user is going to update the most recent task, and it doesn't make any functional sense to update past ones. \- Same options for delete and archive. \- No recurrent subtasks allowed anymore. \- `date_deadline` is copied. We compute it by adding the delta between the create date and the `date_deadline` of the template to the next recurrence date. \- Added filter to several view not to display task templates. task-2937565 closes odoo/odoo#98088 Related: odoo/upgrade#4232 Related: odoo/enterprise#30416 Signed-off-by: Xavier Bol (xbo) --- addons/project/__manifest__.py | 1 - addons/project/data/project_demo.xml | 28 +- addons/project/models/project.py | 64 +-- .../project/models/project_task_recurrence.py | 129 +++-- ...s_parent_recurrence_confirmation_dialog.js | 25 + .../address_recurrence_confirmation_dialog.js | 38 ++ ...address_recurrence_confirmation_dialog.xml | 34 ++ ...ect_stop_recurrence_confirmation_dialog.js | 14 - ...ct_stop_recurrence_confirmation_dialog.xml | 17 - .../src/services/project_task_recurrence.js | 107 ----- .../project_task_form_controller.js | 86 +++- .../project_task_list_controller.js | 43 -- .../project_task_list_view.js | 2 - .../project/tests/test_project_recurrence.py | 440 +++++------------- .../project/views/project_sharing_views.xml | 7 +- addons/project/views/project_views.xml | 27 +- addons/sale_project/models/project.py | 7 +- 17 files changed, 455 insertions(+), 614 deletions(-) create mode 100644 addons/project/static/src/components/address_parent_recurrence_confirmation_dialog/address_parent_recurrence_confirmation_dialog.js create mode 100644 addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.js create mode 100644 addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.xml delete mode 100644 addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js delete mode 100644 addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml delete mode 100644 addons/project/static/src/services/project_task_recurrence.js delete mode 100644 addons/project/static/src/views/project_task_list/project_task_list_controller.js diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 129d5684a55..b7948fffa24 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -61,7 +61,6 @@ 'web.assets_backend': [ 'project/static/src/css/project.css', 'project/static/src/utils/**/*', - 'project/static/src/services/**/*', 'project/static/src/components/**/*', 'project/static/src/views/**/*', 'project/static/src/js/project_control_panel.js', diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 64cb489e37d..f7306cf7208 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -639,8 +639,22 @@ mon + + + 20 + 20.0 + + + Customer review + + + + + + + 20 @@ -652,8 +666,21 @@ after 20 + + + 20 + 0.25 + + + Daily stand-up meeting - Send minutes + + + + + + 20 @@ -672,7 +699,6 @@ 0.25 - Daily stand-up meeting - Send minutes diff --git a/addons/project/models/project.py b/addons/project/models/project.py index a3af5b96464..4079902b226 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -54,6 +54,8 @@ PROJECT_TASK_READABLE_FIELDS = { 'dependent_ids', 'is_blocked', 'message_is_follower', + 'recurring_task', + 'recurrence_template_id', } PROJECT_TASK_WRITABLE_FIELDS = { @@ -1228,7 +1230,7 @@ class Task(models.Model): is_closed = fields.Boolean(related="stage_id.fold", string="Closing Stage", store=True, index=True, help="Folded in Kanban stages are closing stages.") parent_id = fields.Many2one('project.task', string='Parent Task', index=True) ancestor_id = fields.Many2one('project.task', string='Ancestor Task', compute='_compute_ancestor_id', index='btree_not_null', recursive=True, store=True) - child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") + child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks", domain="[('recurring_task', '=', False)]") child_text = fields.Char(compute="_compute_child_text") allow_subtasks = fields.Boolean(string="Allow Sub-tasks", related="project_id.allow_subtasks", readonly=True) subtask_count = fields.Integer("Sub-task Count", compute='_compute_subtask_count') @@ -1282,10 +1284,10 @@ class Task(models.Model): recurrence_id = fields.Many2one('project.task.recurrence', copy=False) recurrence_update = fields.Selection([ ('this', 'This task'), - ('subsequent', 'This and following tasks'), - ('all', 'All tasks'), + ('future', 'This and future tasks'), ], default='this', store=False) recurrence_message = fields.Char(string='Next Recurrencies', compute='_compute_recurrence_message') + recurrence_template_id = fields.Many2one('project.task') repeat_interval = fields.Integer(string='Repeat Every', default=1, compute='_compute_repeat', readonly=False) repeat_unit = fields.Selection([ @@ -1367,6 +1369,10 @@ class Task(models.Model): is_analytic_account_id_changed = fields.Boolean('Is Analytic Account Manually Changed', compute='_compute_is_analytic_account_id_changed', store=True) project_analytic_account_id = fields.Many2one('account.analytic.account', string='Project Analytic Account', related='project_id.analytic_account_id') + _sql_constraints = [ + ('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent.") + ] + @property def SELF_READABLE_FIELDS(self): return PROJECT_TASK_READABLE_FIELDS | self.SELF_WRITABLE_FIELDS @@ -1972,15 +1978,11 @@ class Task(models.Model): raise AccessError(_('You have not write access of %s field.') % field) def _load_records_create(self, vals_list): - projects_with_recurrence = self.env['project.project'].search([('allow_recurring_tasks', '=', True)]) for vals in vals_list: if vals.get('recurring_task'): - if vals.get('project_id') in projects_with_recurrence.ids and not vals.get('recurrence_id'): + if not vals.get('recurrence_id'): default_val = self.default_get(self._get_recurrence_fields()) vals.update(**default_val) - else: - for field_name in self._get_recurrence_fields() + ['recurring_task']: - vals.pop(field_name, None) project_id = vals.get('project_id') if project_id: self = self.with_context(default_project_id=project_id) @@ -2001,6 +2003,7 @@ class Task(models.Model): if is_portal_user: self.check_access_rights('create') default_stage = dict() + recurrences = self.env['project.task.recurrence'] for vals in vals_list: if is_portal_user: self._ensure_fields_are_accessible(vals.keys(), operation='write', check_group_user=False) @@ -2043,6 +2046,7 @@ class Task(models.Model): rec_values['next_recurrence_date'] = fields.Datetime.today() recurrence = self.env['project.task.recurrence'].create(rec_values) vals['recurrence_id'] = recurrence.id + recurrences += recurrence # The sudo is required for a portal user as the record creation # requires the read access on other models, as mail.template # in order to compute the field tracking @@ -2073,6 +2077,8 @@ class Task(models.Model): task.message_subscribe(follower.partner_id.ids, follower.subtype_ids.ids) if current_partner not in task.message_partner_ids: task.message_subscribe(current_partner.ids) + for recurrence in recurrences: + recurrence._create_task(task_from=recurrence.task_ids[-1]) return tasks def write(self, vals): @@ -2089,10 +2095,6 @@ class Task(models.Model): now = fields.Datetime.now() if 'parent_id' in vals and vals['parent_id'] in self.ids: raise UserError(_("Sorry. You can't set a task as its parent task.")) - if 'active' in vals and not vals.get('active') and any(self.mapped('recurrence_id')): - vals['recurring_task'] = False - if 'recurrence_id' in vals and vals.get('recurrence_id') and any(not task.active for task in self): - raise UserError(_('Archived tasks cannot be recurring. Please unarchive the task first.')) # stage change: update date_last_stage_update if 'stage_id' in vals: if not 'project_id' in vals and self.filtered(lambda t: not t.project_id): @@ -2107,6 +2109,7 @@ class Task(models.Model): # recurrence fields rec_fields = vals.keys() & self._get_recurrence_fields() + task_for_recurrence = {} if rec_fields: rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields} for task in self: @@ -2116,20 +2119,17 @@ class Task(models.Model): rec_values['next_recurrence_date'] = fields.Datetime.today() recurrence = self.env['project.task.recurrence'].create(rec_values) task.recurrence_id = recurrence.id + task_for_recurrence[recurrence] = task if 'recurring_task' in vals and not vals.get('recurring_task'): self.recurrence_id.unlink() tasks = self recurrence_update = vals.pop('recurrence_update', 'this') - if recurrence_update != 'this': - recurrence_domain = [] - if recurrence_update == 'subsequent': - for task in self: - recurrence_domain = expression.OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]]) - else: - recurrence_domain = [('recurrence_id', 'in', self.recurrence_id.ids)] - tasks |= self.env['project.task'].search(recurrence_domain) + if recurrence_update == 'future': + for task in self: + recurrent_task = task.ancestor_id if task.ancestor_id.recurring_task else task + task_for_recurrence[recurrent_task.recurrence_id] = task # The sudo is required for a portal user as the record update # requires the write access on others models, as rating.rating @@ -2165,20 +2165,22 @@ class Task(models.Model): task.display_project_id = task.project_id self._task_message_auto_subscribe_notify({task: task.user_ids - old_user_ids[task] - self.env.user for task in self}) + for recurrence, task in task_for_recurrence.items(): + recurrence._create_task(task_from=task) return result + def unlink_task_and_subtasks_recursively(self): + childs = self.with_context(active_test=False).child_ids + if childs: + childs.unlink_task_and_subtasks_recursively() + self.unlink() + def update_date_end(self, stage_id): project_task_type = self.env['project.task.type'].browse(stage_id) if project_task_type.fold: return {'date_end': fields.Datetime.now()} return {'date_end': False} - @api.ondelete(at_uninstall=False) - def _unlink_except_recurring(self): - if any(self.mapped('recurrence_id')): - # TODO: show a dialog to stop the recurrence - raise UserError(_('You cannot delete recurring tasks. Please disable the recurrence first.')) - # --------------------------------------------------- # Subtasks # --------------------------------------------------- @@ -2642,14 +2644,12 @@ class Task(models.Model): else: return action - def action_stop_recurrence(self): - tasks = self.env['project.task'].with_context(active_test=False).search([('recurrence_id', 'in', self.recurrence_id.ids)]) - tasks.write({'recurring_task': False}) + def action_unlink_recurrence(self): + self.recurrence_id.task_ids.recurring_task = False self.recurrence_id.unlink() - def action_continue_recurrence(self): - self.recurrence_id = False - self.recurring_task = False + def action_unlink_task_from_recurrence(self): + self.recurrence_template_id.unlink_task_and_subtasks_recursively() # --------------------------------------------------- # Rating business diff --git a/addons/project/models/project_task_recurrence.py b/addons/project/models/project_task_recurrence.py index 5e1fd93552b..73b1d2be70c 100644 --- a/addons/project/models/project_task_recurrence.py +++ b/addons/project/models/project_task_recurrence.py @@ -44,6 +44,7 @@ class ProjectTaskRecurrence(models.Model): _name = 'project.task.recurrence' _description = 'Task Recurrence' + task_template_id = fields.Many2one('project.task') task_ids = fields.One2many('project.task', 'recurrence_id', copy=False) next_recurrence_date = fields.Date() recurrence_left = fields.Integer(string="Number of Tasks Left to Create", copy=False) @@ -203,53 +204,56 @@ class ProjectTaskRecurrence(models.Model): rules = rrule(**rrule_kwargs) return list(rules) if rules else [] - def _new_task_values(self, task): + def _new_task_values(self, task_from, to_template=False): self.ensure_one() fields_to_copy = self._get_recurring_fields() - task_values = task.read(fields_to_copy).pop() + task_values = task_from.read(fields_to_copy).pop() create_values = { field: value[0] if isinstance(value, tuple) else value for field, value in task_values.items() } - create_values['stage_id'] = task.project_id.type_ids[0].id if task.project_id.type_ids else task.stage_id.id + create_values['stage_id'] = task_from.project_id.type_ids[0].id if task_from.project_id.type_ids\ + else task_from.stage_id.id + create_values['date_deadline'] = self._get_postponed_date(task_from, 'date_deadline', to_template=to_template) + create_values['active'] = not to_template + create_values['recurrence_template_id'] = not to_template and task_from.id return create_values - def _create_subtasks(self, task, new_task, depth=3): - if depth == 0 or not task.child_ids: - return - children = [] - child_recurrence = [] + def _create_subtasks(self, task_from, task_to, to_template=False): + self.ensure_one() + childs_from = task_from.with_context(active_test=False).child_ids + if to_template: + childs_to = task_from.recurrence_template_id.with_context(active_test=False).child_ids + childs_from_to_create = childs_from.filtered(lambda t: not t.recurrence_template_id) + childs_to_to_delete = childs_to.filtered(lambda t: t not in childs_from.recurrence_template_id) + childs_to_to_delete.unlink_task_and_subtasks_recursively() + (childs_to - childs_to_to_delete).parent_id = task_to + else: + childs_from_to_create = childs_from # copy the subtasks of the original task - for child in task.child_ids: - if child.recurrence_id and child.recurrence_id.id in child_recurrence: - # The subtask has been generated by another subtask in the childs - # This subtasks is skipped as it will be meant to be a copy of the first - # task of the recurrence we just created. - continue - child_values = self._new_task_values(child) - child_values['parent_id'] = new_task.id - if child.recurrence_id: - # The subtask has a recurrence, the recurrence is thus copied rather than used - # with raw reference in order to decouple the recurrence of the initial subtask - # from the recurrence of the copied subtask which will live its own life and generate - # subsequent tasks. - child_recurrence += [child.recurrence_id.id] - child_values['recurrence_id'] = child.recurrence_id.copy().id - if child.child_ids and depth > 1: - # If child has childs in the following layer and we will have to copy layer, we have to - # first create the new_child record in order to have a new parent_id reference for the - # "grandchildren" tasks - new_child = self.env['project.task'].sudo().create(child_values) - self._create_subtasks(child, new_child, depth=depth - 1) - else: - children.append(child_values) - self.env['project.task'].sudo().create(children) + for child_from in childs_from_to_create: + child_values = self._new_task_values(child_from, to_template=to_template) + child_values['parent_id'] = task_to.id + child_to = self.env['project.task'].sudo().create(child_values) + if to_template: + child_from.recurrence_template_id = child_to + if child_from.with_context(active_test=False).child_ids: + self._create_subtasks(child_from, child_to, to_template=to_template) - def _create_next_task(self): - for recurrence in self: - task = recurrence.sudo().task_ids[-1] - create_values = recurrence._new_task_values(task) - new_task = self.env['project.task'].sudo().create(create_values) - recurrence._create_subtasks(task, new_task, depth=3) + def _create_task(self, task_from=False): + self.ensure_one() + to_template = bool(task_from) + task_from = task_from or self.task_template_id + create_values = self._new_task_values(task_from, to_template=to_template) + task_to = self.env['project.task'].sudo().create(create_values) + self._create_subtasks(task_from, task_to, to_template=to_template) + if to_template: + task_from.recurrence_template_id.unlink() + task_from.recurrence_template_id = task_to + if task_from.parent_id: + task_to.parent_id = task_from.parent_id.recurrence_template_id + else: + self.task_template_id.unlink_task_and_subtasks_recursively() + self.task_template_id = task_to def _set_next_recurrence_date(self, date_start=None): today = fields.Date.today() @@ -273,10 +277,15 @@ class ProjectTaskRecurrence(models.Model): return today = fields.Date.today() recurring_today = self.search([('next_recurrence_date', '<=', today)]) - recurring_today._create_next_task() - for recurrence in recurring_today.filtered(lambda r: r.repeat_type == 'after'): - recurrence.recurrence_left -= 1 - recurring_today._set_next_recurrence_date() + recurrence_ids = [] # to set the next recurrence date + for recurrence in recurring_today: + if not recurrence.task_template_id.project_id.allow_recurring_tasks: + continue + recurrence_ids.append(recurrence.id) + recurrence._create_task() + if recurrence.repeat_type == 'after': + recurrence.recurrence_left -= 1 + self.browse(recurrence_ids)._set_next_recurrence_date() @api.model_create_multi def create(self, vals_list): @@ -296,3 +305,39 @@ class ProjectTaskRecurrence(models.Model): if 'next_recurrence_date' not in vals: self._set_next_recurrence_date() return res + + def unlink(self): + self.task_template_id.unlink_task_and_subtasks_recursively() + return super().unlink() + + def _get_postponed_date(self, task_from, field, to_template=False): + # We cannot just apply the recurrence's repeat interval to the task's deadline because, + # if the recurrence is created later than the task, the planned date won't be set as expected. + # Instead, we'll apply the delta create-deadline of the previous task to the new one's create date. + + # Apply the repeat interval to the create date : # ~ the repeat interval + # p1~p2~p3 ? ? # / the expected delta create-deadline + # / / / => p2 should be set at ? # c the create dates + # c1---------c2~c3 # p the deadline + # ^ Recurrence initiation + + # Apply the previous delta create-deadline to the create date : + # p1 p2 p3 + # / / / => p2 is set as expected + # c1---------c2~c3 + # ^ Recurrence initiation + self.ensure_one() + if not task_from[field]: + return False + field_is_datetime = task_from._fields[field].type == 'datetime' + datetime = task_from[field] + date = datetime.date() if field_is_datetime else datetime + delta = date - task_from.create_date.date() + if to_template: + next_recurrence_date = (self.task_template_id.create_date or self.create_date).date() + else: + next_recurrence_date = self.next_recurrence_date or fields.Date.today() + postponed_date = next_recurrence_date + relativedelta(days=delta.days) + if field_is_datetime: + postponed_date += relativedelta(hour=datetime.hour, minute=datetime.minute, second=datetime.second) + return postponed_date diff --git a/addons/project/static/src/components/address_parent_recurrence_confirmation_dialog/address_parent_recurrence_confirmation_dialog.js b/addons/project/static/src/components/address_parent_recurrence_confirmation_dialog/address_parent_recurrence_confirmation_dialog.js new file mode 100644 index 00000000000..5d05211beb3 --- /dev/null +++ b/addons/project/static/src/components/address_parent_recurrence_confirmation_dialog/address_parent_recurrence_confirmation_dialog.js @@ -0,0 +1,25 @@ +/** @odoo-module */ + +import { AddressRecurrenceConfirmationDialog } from "../address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog"; + +export class AddressParentRecurrenceConfirmationDialog extends AddressRecurrenceConfirmationDialog { + get title() { + return this.props.mode === "delete" + ? this.env._t("Delete Task") + : this.env._t("Archive Task"); + } + + get question() { + return this.props.mode === "delete" + ? this.env._t("This is a recurring task's child. Delete it and..") + : this.env._t("This is a recurring task's child. Archive it and.."); + } + + get thisLabel() { + return this.env._t("keep it in future occurrences"); + } + + get futureLabel() { + return this.env._t("remove it from future occurrences"); + } +} diff --git a/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.js b/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.js new file mode 100644 index 00000000000..4d440dc31fc --- /dev/null +++ b/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.js @@ -0,0 +1,38 @@ +/** @odoo-module */ + +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +export class AddressRecurrenceConfirmationDialog extends ConfirmationDialog { + get title() { + return this.props.mode === "delete" + ? this.env._t("Delete Recurring Task") + : this.env._t("Archive Recurring Task"); + } + + get question() { + return this.props.mode === "delete" + ? this.env._t("This task is recurrent. Delete it and...") + : this.env._t("This task is recurrent. Archive it and..."); + } + + get thisLabel() { + return this.env._t("continue the recurrence"); + } + + get futureLabel() { + return this.env._t("stop the recurrence"); + } +} + +AddressRecurrenceConfirmationDialog.template = "project.AddressRecurrenceConfirmationDialog"; +AddressRecurrenceConfirmationDialog.props = { + ...ConfirmationDialog.props, + body: { type: String, optional: true }, + mode: { type: String }, + onChangeRecurrenceUpdate: { type: Function }, +}; +AddressRecurrenceConfirmationDialog.defaultProps = { + ...ConfirmationDialog.defaultProps, + body: "", + cancel: () => {}, +}; diff --git a/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.xml b/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.xml new file mode 100644 index 00000000000..799a8a898f1 --- /dev/null +++ b/addons/project/static/src/components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog.xml @@ -0,0 +1,34 @@ + + + + + + title + + +
+

+

+ +
+
+ +
+
+
+ + title + +
+ +
diff --git a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js deleted file mode 100644 index af15fa8c851..00000000000 --- a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @odoo-module */ - -import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog'; - -export class ProjectStopRecurrenceConfirmationDialog extends ConfirmationDialog { - _continueRecurrence() { - if (this.props.continueRecurrence) { - this.props.continueRecurrence(); - } - this.props.close(); - } -} -ProjectStopRecurrenceConfirmationDialog.template = 'project.ProjectStopRecurrenceConfirmationDialog'; -ProjectStopRecurrenceConfirmationDialog.props.continueRecurrence = { type: Function, optional: true }; diff --git a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml b/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml deleted file mode 100644 index b28565f2dd4..00000000000 --- a/addons/project/static/src/components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/project/static/src/services/project_task_recurrence.js b/addons/project/static/src/services/project_task_recurrence.js deleted file mode 100644 index 3d357afd7a4..00000000000 --- a/addons/project/static/src/services/project_task_recurrence.js +++ /dev/null @@ -1,107 +0,0 @@ -/** @odoo-module */ - -import { registry } from "@web/core/registry"; - -import { ProjectStopRecurrenceConfirmationDialog } from '../components/project_stop_recurrence_confirmation_dialog/project_stop_recurrence_confirmation_dialog'; - -class ProjectTaskRecurrence { - constructor(env, dialog, orm) { - this.env = env; - this.dialog = dialog; - this.orm = orm; - this.resModel = 'project.task'; - } - - async stopRecurrence(tasks, callback = () => {}) { - const taskIdsWithRecurrence = []; - const tasksPerRecurrence = {}; - const recurrenceIds = []; - for (const task of tasks) { - if (task.data.recurrence_id) { - taskIdsWithRecurrence.push(task.resId); - const recurrenceId = task.data.recurrence_id && task.data.recurrence_id[0]; - if (!(recurrenceId in tasksPerRecurrence)) { - tasksPerRecurrence[recurrenceId] = [task.resId]; - recurrenceIds.push(recurrenceId); - } else { - tasksPerRecurrence[recurrenceId].push(task.resId); - } - } - } - - if (!recurrenceIds.length) { - callback(); - return; - } - - let allowContinue = false; - if (recurrenceIds.length === 1) { - const count = await this.orm.searchCount( - this.resModel, - [['recurrence_id', '=', recurrenceIds[0]]], - ); - allowContinue = count != 1; - } else { - const taskReadGroup = await this.orm.readGroup( - this.resModel, - [['recurrence_id', 'in', recurrenceIds]], - ['recurrence_id'], - ['recurrence_id'], - ); - allowContinue = true; - for (const res of taskReadGroup) { - const taskCount = tasksPerRecurrence[res.recurrence_id[0]].length; - if (taskCount === res.recurrence_id_count) { - allowContinue = false; - break; - } - } - } - let dialogBody; - if (tasks.length > 1) { - dialogBody = allowContinue - ? this.env._t('It seems that some tasks are part of a recurrence.') - : this.env._t('It seems that some tasks are part of a recurrence. At least one of them must be kept as a model to create the next occurences.'); - } else { - dialogBody = allowContinue - ? this.env._t('It seems that this task is part of a recurrence.') - : this.env._t('It seems that this task is recurrent. Would you like to stop its recurrence?'); - } - - const dialogProps = { - body: dialogBody, - confirm: async () => { - await this.orm.call( - this.resModel, - 'action_stop_recurrence', - [taskIdsWithRecurrence], - ); - callback(); - }, - cancel: () => {}, - }; - if (allowContinue) { - dialogProps.continueRecurrence = async () => { - await this.orm.call( - this.resModel, - 'action_continue_recurrence', - [taskIdsWithRecurrence], - ); - callback(); - }; - } - this.dialog.add(ProjectStopRecurrenceConfirmationDialog, dialogProps); - } -} - -export const taskRecurrenceService = { - dependencies: ['dialog', 'orm'], - async: [ - 'stopRecurrence', - ], - start(env, { dialog, orm }) { - return new ProjectTaskRecurrence(env, dialog, orm); - } -}; - -registry.category('services').add('project_task_recurrence', taskRecurrenceService); diff --git a/addons/project/static/src/views/project_task_form/project_task_form_controller.js b/addons/project/static/src/views/project_task_form/project_task_form_controller.js index 979f7c1a514..fa223c57537 100644 --- a/addons/project/static/src/views/project_task_form/project_task_form_controller.js +++ b/addons/project/static/src/views/project_task_form/project_task_form_controller.js @@ -1,37 +1,83 @@ /** @odoo-module */ -import { useService } from '@web/core/utils/hooks'; -import { FormController } from '@web/views/form/form_controller'; +import { useService } from "@web/core/utils/hooks"; +import { FormController } from "@web/views/form/form_controller"; +import { AddressRecurrenceConfirmationDialog } from "../../components/address_recurrence_confirmation_dialog/address_recurrence_confirmation_dialog"; +import { AddressParentRecurrenceConfirmationDialog } from "../../components/address_parent_recurrence_confirmation_dialog/address_parent_recurrence_confirmation_dialog"; export class ProjectTaskFormController extends FormController { setup() { super.setup(); - this.taskRecurrence = useService('project_task_recurrence'); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + this.state.recurrenceUpdate = "this"; } getStaticActionMenuItems() { const menuItems = super.getStaticActionMenuItems(); - if (this.model.root.data.recurrence_id) { - menuItems.archive.callback = () => - this.taskRecurrence.stopRecurrence([this.model.root], () => - this.model.root.archive() - ); + const task = this.model.root; + if (task.data.recurrence_id) { + menuItems.archive.callback = () => this.addressRecurrence("archive", () => task.archive()); + } else if (task.data.recurrence_template_id) { + menuItems.archive.callback = () => this.addressParentRecurrence("archive", () => task.archive()); } return menuItems; } - deleteRecord() { - if (!this.model.root.data.recurrence_id) { - return super.deleteRecord(); - } - this.taskRecurrence.stopRecurrence( - [this.model.root], - () => { - this.model.root.delete(); - if (!this.model.root.resId) { - this.env.config.historyBack(); - } + async deleteRecord() { + const task = this.model.root; + const callback = async () => { + await task.delete(); + if (!task.resId) { + this.env.config.historyBack(); } - ); + } + if (task.data.recurrence_id) { + this.addressRecurrence("delete", callback); + } else if (task.data.recurrence_template_id) { + this.addressParentRecurrence("delete", callback); + } else { + await super.deleteRecord(...arguments); + } + } + + async addressRecurrence(mode, callback) { + this.dialog.add(AddressRecurrenceConfirmationDialog, { + confirm: async () => { + if (this.state.recurrenceUpdate === "future") { + const task = this.model.root; + await this.orm.call( + task.resModel, + "action_unlink_recurrence", + [task.resId], + ); + } + callback(); + }, + onChangeRecurrenceUpdate: (recurrenceUpdate) => { + this.state.recurrenceUpdate = recurrenceUpdate; + }, + mode: mode, + }); + } + + async addressParentRecurrence(mode, callback) { + this.dialog.add(AddressParentRecurrenceConfirmationDialog, { + confirm: async () => { + if (this.state.recurrenceUpdate === "future") { + const task = this.model.root; + await this.orm.call( + task.resModel, + "action_unlink_task_from_recurrence", + [task.resId], + ); + } + callback(); + }, + onChangeRecurrenceUpdate: (recurrenceUpdate) => { + this.state.recurrenceUpdate = recurrenceUpdate; + }, + mode: mode, + }); } } diff --git a/addons/project/static/src/views/project_task_list/project_task_list_controller.js b/addons/project/static/src/views/project_task_list/project_task_list_controller.js deleted file mode 100644 index bc1d9480429..00000000000 --- a/addons/project/static/src/views/project_task_list/project_task_list_controller.js +++ /dev/null @@ -1,43 +0,0 @@ -/** @odoo-module */ - -import { useService } from '@web/core/utils/hooks'; -import { ListController } from '@web/views/list/list_controller'; - -export class ProjectTaskListController extends ListController { - setup() { - super.setup(); - this.taskRecurrence = useService('project_task_recurrence'); - } - - getStaticActionMenuItems() { - const menuItems = super.getStaticActionMenuItems(); - const hasAnyRecurrences = this._anySelectedTasksWithRecurrence(); - - if (hasAnyRecurrences) { - menuItems.archive.callback = () => - this.taskRecurrence.stopRecurrence(this.model.root.selection, () => - this.toggleArchiveState(true) - ); - } - return menuItems; - } - - onDeleteSelectedRecords() { - if (this._anySelectedTasksWithRecurrence()) { - return this.taskRecurrence.stopRecurrence( - this.model.root.selection, - () => this.model.root.deleteRecords(), - ); - } - return super.onDeleteSelectedRecords(); - } - - _anySelectedTasksWithRecurrence() { - for (const selectedTask of this.model.root.selection) { - if (selectedTask.data.recurrence_id) { - return true; - } - } - return false; - } -} diff --git a/addons/project/static/src/views/project_task_list/project_task_list_view.js b/addons/project/static/src/views/project_task_list/project_task_list_view.js index b081f9877b7..13299b1eea8 100644 --- a/addons/project/static/src/views/project_task_list/project_task_list_view.js +++ b/addons/project/static/src/views/project_task_list/project_task_list_view.js @@ -3,11 +3,9 @@ import { registry } from "@web/core/registry"; import { listView } from '@web/views/list/list_view'; import { ProjectControlPanel } from "../../components/project_control_panel/project_control_panel"; -import { ProjectTaskListController } from './project_task_list_controller'; export const projectTaskListView = { ...listView, - Controller: ProjectTaskListController, ControlPanel: ProjectControlPanel, }; diff --git a/addons/project/tests/test_project_recurrence.py b/addons/project/tests/test_project_recurrence.py index f122adcd9ab..7793c387599 100644 --- a/addons/project/tests/test_project_recurrence.py +++ b/addons/project/tests/test_project_recurrence.py @@ -1,19 +1,19 @@ # -*- coding: utf-8 -*- - -from odoo.tests.common import TransactionCase, Form +from odoo import fields, Command +from odoo.tests.common import Form, TransactionCase from odoo.exceptions import ValidationError -from odoo import fields -from datetime import date, datetime +from datetime import date, datetime, time from dateutil.rrule import MO, TU, WE, TH, FR, SA, SU +from dateutil.relativedelta import relativedelta from freezegun import freeze_time -class TestProjectrecurrence(TransactionCase): +class TestProjectRecurrence(TransactionCase): @classmethod def setUpClass(cls): - super(TestProjectrecurrence, cls).setUpClass() + super(TestProjectRecurrence, cls).setUpClass() cls.env.user.groups_id += cls.env.ref('project.group_project_recurring_tasks') @@ -28,11 +28,15 @@ class TestProjectrecurrence(TransactionCase): ] }) - def set_task_create_date(self, task_id, create_date): - self.env.cr.execute("UPDATE project_task SET create_date=%s WHERE id=%s", (create_date, task_id)) + cls.classPatch(cls.env.cr, 'now', fields.Datetime.now) + + cls.date_01_01 = datetime.combine(datetime.now() + relativedelta(years=-1, month=1, day=1), time(0, 0)) + cls.date_01_15 = cls.date_01_01 + relativedelta(day=15) + cls.date_02_01 = cls.date_01_01 + relativedelta(month=2) + cls.date_02_15 = cls.date_01_01 + relativedelta(month=2, day=15) def test_recurrence_simple(self): - with freeze_time("2020-02-01"): + with freeze_time(self.date_01_01): with Form(self.env['project.task']) as form: form.name = 'test recurring task' form.project_id = self.project_recurring @@ -51,19 +55,19 @@ class TestProjectrecurrence(TransactionCase): self.assertEqual(task.recurrence_id.repeat_interval, 2, 'recurrence should be updated') self.assertEqual(task.recurrence_id.repeat_number, 11, 'recurrence should be updated') self.assertEqual(task.recurrence_id.recurrence_left, 11) - self.assertEqual(task.recurrence_id.next_recurrence_date, date(2020, 2, 29)) + self.assertEqual(task.recurrence_id.next_recurrence_date, (self.date_01_01 + relativedelta(day=31)).date()) task.recurring_task = False self.assertFalse(bool(task.recurrence_id), 'the recurrence should be deleted') def test_recurrence_cron_repeat_after(self): domain = [('project_id', '=', self.project_recurring.id)] - with freeze_time("2020-01-01"): + with freeze_time(self.date_01_01): form = Form(self.env['project.task']) form.name = 'test recurring task' form.description = 'my super recurring task bla bla bla' form.project_id = self.project_recurring - form.date_deadline = datetime(2020, 2, 1) + form.date_deadline = self.date_02_01 form.recurring_task = True form.repeat_interval = 1 @@ -73,21 +77,23 @@ class TestProjectrecurrence(TransactionCase): form.repeat_on_month = 'date' form.repeat_day = '15' task = form.save() - task.planned_hours = 2 - - self.assertEqual(task.recurrence_id.next_recurrence_date, date(2020, 1, 15)) + task.write({ + 'planned_hours': 2.0, + 'recurrence_update': 'future', + }) + self.assertEqual(task.recurrence_id.next_recurrence_date, self.date_01_15.date()) self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 1, 'no extra task should be created') self.assertEqual(task.recurrence_id.recurrence_left, 2) - with freeze_time("2020-01-15"): + with freeze_time(self.date_01_15): self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 2) self.assertEqual(task.recurrence_id.recurrence_left, 1) - with freeze_time("2020-02-15"): + with freeze_time(self.date_02_15): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) self.assertEqual(task.recurrence_id.recurrence_left, 0) @@ -98,43 +104,48 @@ class TestProjectrecurrence(TransactionCase): tasks = self.env['project.task'].search(domain) self.assertEqual(len(tasks), 3) - - self.assertTrue(bool(tasks[0].date_deadline)) - self.assertFalse(tasks[1].date_deadline, "Deadline should not be copied") + self.assertTrue( + tasks[0].date_deadline - tasks[0].create_date.date()\ + == tasks[1].date_deadline - tasks[1].create_date.date()\ + == tasks[2].date_deadline - tasks[2].create_date.date(), + 'all tasks sould have the same period between their create_date and date_deadline' + ) for f in self.env['project.task.recurrence']._get_recurring_fields(): self.assertTrue(tasks[0][f] == tasks[1][f] == tasks[2][f], "Field %s should have been copied" % f) def test_recurrence_cron_repeat_until(self): domain = [('project_id', '=', self.project_recurring.id)] - with freeze_time("2020-01-01"): + with freeze_time(self.date_01_01): form = Form(self.env['project.task']) form.name = 'test recurring task' form.description = 'my super recurring task bla bla bla' form.project_id = self.project_recurring - form.date_deadline = datetime(2020, 2, 1) + form.date_deadline = self.date_02_01 form.recurring_task = True form.repeat_interval = 1 form.repeat_unit = 'month' form.repeat_type = 'until' - form.repeat_until = date(2020, 2, 20) + form.repeat_until = self.date_02_01 + relativedelta(days=20) form.repeat_on_month = 'date' form.repeat_day = '15' task = form.save() - task.planned_hours = 2 - - self.assertEqual(task.recurrence_id.next_recurrence_date, date(2020, 1, 15)) + task.write({ + 'planned_hours': 2.0, + 'recurrence_update': 'future', + }) + self.assertEqual(task.recurrence_id.next_recurrence_date, self.date_01_15.date()) self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 1, 'no extra task should be created') - with freeze_time("2020-01-15"): + with freeze_time(self.date_01_15): self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 2) - with freeze_time("2020-02-15"): + with freeze_time(self.date_02_15): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) self.env['project.task.recurrence']._cron_create_recurring_tasks() @@ -142,21 +153,24 @@ class TestProjectrecurrence(TransactionCase): tasks = self.env['project.task'].search(domain) self.assertEqual(len(tasks), 3) - - self.assertTrue(bool(tasks[0].date_deadline)) - self.assertFalse(tasks[1].date_deadline, "Deadline should not be copied") + self.assertTrue( + tasks[0].date_deadline - tasks[0].create_date.date()\ + == tasks[1].date_deadline - tasks[1].create_date.date()\ + == tasks[2].date_deadline - tasks[2].create_date.date(), + 'all tasks sould have the same period between their create_date and date_deadline' + ) for f in self.env['project.task.recurrence']._get_recurring_fields(): self.assertTrue(tasks[0][f] == tasks[1][f] == tasks[2][f], "Field %s should have been copied" % f) def test_recurrence_cron_repeat_forever(self): domain = [('project_id', '=', self.project_recurring.id)] - with freeze_time("2020-01-01"): + with freeze_time(self.date_01_01): form = Form(self.env['project.task']) form.name = 'test recurring task' form.description = 'my super recurring task bla bla bla' form.project_id = self.project_recurring - form.date_deadline = datetime(2020, 2, 1) + form.date_deadline = self.date_02_01 form.recurring_task = True form.repeat_interval = 1 @@ -165,51 +179,57 @@ class TestProjectrecurrence(TransactionCase): form.repeat_on_month = 'date' form.repeat_day = '15' task = form.save() - task.planned_hours = 2 + task.write({ + 'planned_hours': 2.0, + 'recurrence_update': 'future', + }) - self.assertEqual(task.recurrence_id.next_recurrence_date, date(2020, 1, 15)) + self.assertEqual(task.recurrence_id.next_recurrence_date, self.date_01_15.date()) self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 1, 'no extra task should be created') - with freeze_time("2020-01-15"): + with freeze_time(self.date_01_15): self.assertEqual(self.env['project.task'].search_count(domain), 1) self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 2) - with freeze_time("2020-02-15"): + with freeze_time(self.date_02_15): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) - with freeze_time("2020-02-16"): + with freeze_time(self.date_02_15): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) - with freeze_time("2020-02-17"): + with freeze_time(self.date_02_15 + relativedelta(days=1)): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) - with freeze_time("2020-02-17"): + with freeze_time(self.date_02_15 + relativedelta(days=2)): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 3) - with freeze_time("2020-03-15"): + with freeze_time(self.date_02_15 + relativedelta(months=1)): self.env['project.task.recurrence']._cron_create_recurring_tasks() self.assertEqual(self.env['project.task'].search_count(domain), 4) tasks = self.env['project.task'].search(domain) self.assertEqual(len(tasks), 4) - - self.assertTrue(bool(tasks[0].date_deadline)) - self.assertFalse(tasks[1].date_deadline, "Deadline should not be copied") - + self.assertTrue( + tasks[0].date_deadline - tasks[0].create_date.date()\ + == tasks[1].date_deadline - tasks[1].create_date.date()\ + == tasks[2].date_deadline - tasks[2].create_date.date()\ + == tasks[3].date_deadline - tasks[3].create_date.date(), + 'all tasks sould have the same period between their create_date and date_deadline' + ) for f in self.env['project.task.recurrence']._get_recurring_fields(): self.assertTrue( tasks[0][f] == tasks[1][f] == tasks[2][f] == tasks[3][f], "Field %s should have been copied" % f) def test_recurrence_update_task(self): - with freeze_time("2020-01-01"): + with freeze_time(self.date_01_01): task = self.env['project.task'].create({ 'name': 'test recurring task', 'project_id': self.project_recurring.id, @@ -221,45 +241,29 @@ class TestProjectrecurrence(TransactionCase): 'mon': True, }) - with freeze_time("2020-01-06"): + with freeze_time(self.date_01_01 + relativedelta(day=6)): self.env['project.task.recurrence']._cron_create_recurring_tasks() + task.write({ + 'name': 'my super updated task', + 'recurrence_update': 'future', + }) + task.write({ + 'description': '

hello!

', + 'recurrence_update': 'this', + }) - with freeze_time("2020-01-13"): + with freeze_time(self.date_01_01 + relativedelta(day=13)): self.env['project.task.recurrence']._cron_create_recurring_tasks() task_c, task_b, task_a = self.env['project.task'].search([('project_id', '=', self.project_recurring.id)]) - self.set_task_create_date(task_a.id, datetime(2020, 1, 1)) - self.set_task_create_date(task_b.id, datetime(2020, 1, 6)) - self.set_task_create_date(task_c.id, datetime(2020, 1, 13)) - (task_a+task_b+task_c).invalidate_model() + self.assertEqual(task_a.name, 'my super updated task', 'This task has been directly updated') + self.assertEqual(task_b.name, 'test recurring task', 'As this task already existed, it should not have been updated') + self.assertEqual(task_c.name, 'my super updated task', 'As this task was created after template modification, it should be updated') - task_c.write({ - 'name': 'my super updated task', - 'recurrence_update': 'all', - }) - - self.assertEqual(task_a.name, 'my super updated task') - self.assertEqual(task_b.name, 'my super updated task') - self.assertEqual(task_c.name, 'my super updated task') - - task_a.write({ - 'name': 'don\'t you dare change my title', - 'recurrence_update': 'this', - }) - - self.assertEqual(task_a.name, 'don\'t you dare change my title') - self.assertEqual(task_b.name, 'my super updated task') - self.assertEqual(task_c.name, 'my super updated task') - - task_b.write({ - 'description': 'hello!', - 'recurrence_update': 'subsequent', - }) - - self.assertEqual(task_a.description, False) - self.assertEqual(task_b.description, '

hello!

') - self.assertEqual(task_c.description, '

hello!

') + self.assertEqual(task_a.description, '

hello!

', 'This task has been directly updated') + self.assertEqual(task_b.description, False, 'Future tasks should not be affected by "this" modifications') + self.assertEqual(task_c.description, False, 'Future tasks should not be affected by "this" modifications') def test_recurrence_fields_visibility(self): form = Form(self.env['project.task']) @@ -540,253 +544,43 @@ class TestProjectrecurrence(TransactionCase): self.assertEqual(dates[3], datetime(2024, 11, 30)) self.assertEqual(dates[4], datetime(2025, 11, 30)) - def test_recurrence_cron_repeat_after_subtasks(self): + def test_change_subtask_of_recurrent_task(self): + with freeze_time(self.date_01_01): + task = self.env['project.task'].create({ + 'name': 'recurring task', + 'project_id': self.project_recurring.id, + 'recurring_task': True, + 'repeat_interval': 2, + 'repeat_unit': 'week', + 'repeat_type': 'after', + 'repeat_number': 2, + 'mon': True, + 'child_ids': [ + Command.create({'name': 'subtask'}) + ], + }) + recurrence = task.recurrence_id + task.child_ids.write({ + 'name': 'subtask 1', + 'recurrence_update': 'future', + }) - def get_task_and_subtask_counts(domain): - tasks = self.env['project.task'].search(domain) - return tasks, len(tasks), len(tasks.filtered('parent_id')) - - # Required for `child_ids` to be visible in the view - # {'invisible': [('allow_subtasks', '=', False)]} - self.project_recurring.allow_subtasks = True - parent_task = self.env['project.task'].create({ - 'name': 'Parent Task', - 'project_id': self.project_recurring.id - }) - child_task = self.env['project.task'].create({ - 'name': 'Child Task', - 'parent_id': parent_task.id, - }) - domain = [('project_id', '=', self.project_recurring.id)] - with freeze_time("2020-01-01"): - with Form(child_task.with_context({'tracking_disable': True})) as form: - form.description = 'my super recurring task bla bla bla' - form.date_deadline = datetime(2020, 2, 1) - form.display_project_id = parent_task.project_id - - form.recurring_task = True - form.repeat_interval = 1 - form.repeat_unit = 'month' - form.repeat_type = 'after' - form.repeat_number = 2 - form.repeat_on_month = 'date' - form.repeat_day = '15' - subtask = form.save() - subtask.planned_hours = 2 - - self.assertEqual(subtask.recurrence_id.next_recurrence_date, date(2020, 1, 15)) - project_tasks, project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 2) - self.assertEqual(project_subtask_count, 1) + with freeze_time(self.date_01_15): self.env['project.task.recurrence']._cron_create_recurring_tasks() - self.assertEqual(self.env['project.task'].search_count(domain), 2, 'no extra task should be created') - self.assertEqual(subtask.recurrence_id.recurrence_left, 2) - for task in project_tasks: - self.assertEqual(task.display_project_id, parent_task.project_id, "All tasks should have a display project id set") + child = recurrence.task_ids.sorted('create_date', reverse=True)[0].child_ids + self.assertEqual(child.name, 'subtask 1') + child.write({ + 'name': 'subtask 2', + 'recurrence_update': 'this', + }) - with freeze_time("2020-01-15"): - project_tasks, project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 2) - self.assertEqual(project_subtask_count, 1) + with freeze_time(self.date_02_01): self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_tasks, project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 3) - self.assertEqual(project_subtask_count, 2) - self.assertEqual(subtask.recurrence_id.recurrence_left, 1) - for task in project_tasks: - self.assertEqual(task.display_project_id, parent_task.project_id, "All tasks should have a display project id set") + child = recurrence.task_ids.sorted('create_date', reverse=True)[0].child_ids + self.assertEqual(child.name, 'subtask 1') - with freeze_time("2020-02-15"): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_tasks, project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 4) - self.assertEqual(project_subtask_count, 3) - self.assertEqual(subtask.recurrence_id.recurrence_left, 0) - for task in project_tasks: - self.assertEqual(task.display_project_id, parent_task.project_id, "All tasks should have a display project id set") - self.env['project.task.recurrence']._cron_create_recurring_tasks() - _, project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 4) - self.assertEqual(project_subtask_count, 3) - self.assertEqual(subtask.recurrence_id.recurrence_left, 0) - - tasks = self.env['project.task'].search(domain) - self.assertEqual(len(tasks), 4) - self.assertTrue(bool(tasks[0].date_deadline)) - self.assertFalse(tasks[1].date_deadline, "Deadline should not be copied") - - for f in self.env['project.task.recurrence']._get_recurring_fields(): - self.assertTrue(tasks[0][f] == tasks[1][f] == tasks[2][f], "Field %s should have been copied" % f) - - def test_recurrence_cron_repeat_after_subsubtasks(self): - """ - Tests how the recurrence is working when a task has subtasks that have recurrence too - We have at the beginning: - index Task name Recurrent parent - 0 Parent Task no no - 1 Subtask 1 no Parent task - 2 Subtask 2 Montly, 15, for 2 tasks Parent task - 3 Grand child task 1 Daily, 5 tasks Subtask 2 that has recurrence - 4 Grand child task 2 no Subtask 2 that has recurrence - 5 Grand child task 3 no Grand child task 2 - 6 Grand child task 4 no Grand child task 3 - 7 Grand child task 5 no Grand child task 4 - 1) After 5 days (including today), there will be 5 occurences of *task index 3*. - 2) After next 15th of the month, there will be 2 occurences of *task index 2* and a *copy of tasks 3, 4, 5, 6* (not 7) - 3) 5 days afterwards, there will be 5 occurences of the *copy of task index 3* - 4) The 15th of the next month, there won't be any other new occurence since all recurrences have been consumed. - """ - - def get_task_and_subtask_counts(domain): - tasks = self.env['project.task'].search(domain) - return len(tasks), len(tasks.filtered('parent_id')) - - # Phase 0 : Initialize test case - # Required for `child_ids` to be visible in the view - # {'invisible': [('allow_subtasks', '=', False)]} - self.project_recurring.allow_subtasks = True - parent_task = self.env['project.task'].create({ - 'name': 'Parent Task', - 'project_id': self.project_recurring.id - }) - domain = [('project_id', '=', self.project_recurring.id)] - child_task_1, child_task_2_recurrence = self.env['project.task'].create([ - {'name': 'Child task 1'}, - {'name': 'Child task 2 that have recurrence'}, - ]) - with Form(parent_task.with_context({'tracking_disable': True})) as task_form: - task_form.child_ids.add(child_task_1) - task_form.child_ids.add(child_task_2_recurrence) - - grand_child_task_1 = self.env['project.task'].create({ - 'name': 'Grandchild task 1 (recurrent)', - }) - grand_child_task_2_recurrence = self.env['project.task'].create({ - 'name': 'Grandchild task 2', - }) - with freeze_time("2020-01-01"): - recurrent_subtask = parent_task.child_ids[0] - with Form(recurrent_subtask.with_context(tracking_disable=True)) as task_form: - task_form.recurring_task = True - task_form.repeat_interval = 1 - task_form.repeat_unit = 'month' - task_form.repeat_type = 'after' - task_form.repeat_number = 1 - task_form.repeat_on_month = 'date' - task_form.repeat_day = '15' - task_form.date_deadline = datetime(2020, 2, 1) - task_form.child_ids.add(grand_child_task_1) - task_form.child_ids.add(grand_child_task_2_recurrence) - # configure recurring subtask - recurrent_subsubtask = recurrent_subtask.child_ids.filtered(lambda t: t.name == 'Grandchild task 1 (recurrent)') - non_recurrent_subsubtask = recurrent_subtask.child_ids.filtered(lambda t: t.name == 'Grandchild task 2') - with Form(recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form: - subtask_form.recurring_task = True - subtask_form.repeat_interval = 1 - subtask_form.repeat_unit = 'day' - subtask_form.repeat_type = 'after' - subtask_form.repeat_number = 4 - subtask_form.date_deadline = datetime(2020, 2, 3) - - grand_child_task_3, grand_child_task_4, grand_child_task_5 = self.env['project.task'].create([ - {'name': 'Grandchild task 3'}, - {'name': 'Grandchild task 4'}, - {'name': 'Grandchild task 5'}, - ]) - # create non-recurring grandchild subtasks - with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form: - subtask_form.child_ids.add(grand_child_task_3) - non_recurrent_subsubtask = non_recurrent_subsubtask.child_ids - with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form: - subtask_form.child_ids.add(grand_child_task_4) - non_recurrent_subsubtask = non_recurrent_subsubtask.child_ids - with Form(non_recurrent_subsubtask.with_context(tracking_disable=True)) as subtask_form: - subtask_form.child_ids.add(grand_child_task_5) - - self.assertTrue(recurrent_subtask.recurrence_id) - self.assertEqual(recurrent_subtask.recurrence_id.next_recurrence_date, date(2020, 1, 15)) - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 8) - self.assertEqual(project_subtask_count, 7) - self.env['project.task.recurrence']._cron_create_recurring_tasks() - self.assertEqual(self.env['project.task'].search_count(domain), 8, 'no extra task should be created') - all_tasks = self.env['project.task'].search(domain) - task_names = all_tasks.parent_id.mapped('name') - self.assertEqual(task_names.count('Parent Task'), 1) - self.assertEqual(task_names.count('Child task 2 that have recurrence'), 1) - self.assertEqual(task_names.count('Grandchild task 2'), 1) - self.assertEqual(task_names.count('Grandchild task 3'), 1) - self.assertEqual(task_names.count('Grandchild task 4'), 1) - - # Phase 1 : Verify recurrences of Grandchild task 1 (recurrent) - n = 8 - for i in range(1, 5): - with freeze_time("2020-01-%02d" % (i + 1)): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, n + i) # + 1 occurence of task 3 - self.assertEqual(project_subtask_count, n + i - 1) - with freeze_time("2020-01-11"): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 12) # total = 5 occurences of task 3 - self.assertEqual(project_subtask_count, 11) - - # Phase 2 : Verify recurrences of Child task 2 that have recurrence - with freeze_time("2020-01-15"): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - all_tasks = self.env['project.task'].search(domain) - task_names = all_tasks.parent_id.mapped('name') - self.assertEqual(task_names.count('Parent Task'), 1) - self.assertEqual(task_names.count('Child task 2 that have recurrence'), 2) - self.assertEqual(task_names.count('Grandchild task 2'), 2) - self.assertEqual(task_names.count('Grandchild task 3'), 2) - self.assertEqual(task_names.count('Grandchild task 4'), 1) - self.assertEqual(len(task_names), 8) - self.assertEqual(project_task_count, 12 + 1 + 4) # 12 + the recurring task 5 + the 2 childs (3, 4) + 1 grandchild (5) + 1 grandgrandchild (6) - self.assertEqual(project_subtask_count, 16) - bottom_genealogy = all_tasks.filtered(lambda t: not t.child_ids.exists()) - bottom_genealogy_name = bottom_genealogy.mapped('name') - self.assertEqual(bottom_genealogy_name.count('Child task 1'), 1) - self.assertEqual(bottom_genealogy_name.count('Grandchild task 1 (recurrent)'), 6) - # Grandchild task 5 should not be copied ! - self.assertEqual(bottom_genealogy_name.count('Grandchild task 5'), 1) - - # Phase 3 : Verify recurrences of the copy of Grandchild task 1 (recurrent) - n = 17 - for i in range(1, 5): - with freeze_time("2020-01-%02d" % (i + 15)): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, n + i) - self.assertEqual(project_subtask_count, n + i - 1) - with freeze_time("2020-01-25"): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 21) - self.assertEqual(project_subtask_count, 20) - - # Phase 4 : No more recurrence - with freeze_time("2020-02-15"): - self.env['project.task.recurrence']._cron_create_recurring_tasks() - project_task_count, project_subtask_count = get_task_and_subtask_counts(domain) - self.assertEqual(project_task_count, 21) - self.assertEqual(project_subtask_count, 20) - - all_tasks = self.env['project.task'].search(domain) - self.assertEqual(len(all_tasks), 21) - deadlines = all_tasks.sorted('create_date').mapped('date_deadline') - self.assertTrue(bool(deadlines[0])) - self.assertTrue(bool(deadlines[1])) - del deadlines[1] - del deadlines[0] - self.assertTrue(not any(deadlines), "Deadline should not be copied") - - bottom_genealogy = all_tasks.filtered(lambda t: not t.child_ids.exists()) - bottom_genealogy_name = bottom_genealogy.mapped('name') - self.assertEqual(bottom_genealogy_name.count('Child task 1'), 1) - self.assertEqual(bottom_genealogy_name.count('Grandchild task 1 (recurrent)'), 10) - self.assertEqual(bottom_genealogy_name.count('Grandchild task 5'), 1) - for f in self.env['project.task.recurrence']._get_recurring_fields(): - self.assertTrue(all_tasks[2][f] == all_tasks[3][f] == all_tasks[4][f], "Field %s should have been copied" % f) + with self.assertRaises(ValidationError): + child.write({ + 'recurring_task': True, + 'repeat_unit': 'week', + }) diff --git a/addons/project/views/project_sharing_views.xml b/addons/project/views/project_sharing_views.xml index 3a50cdf5b54..29c2467c542 100644 --- a/addons/project/views/project_sharing_views.xml +++ b/addons/project/views/project_sharing_views.xml @@ -249,7 +249,12 @@ project.task kanban,tree,form - [('display_project_id', '=', active_id)] + [ + ('display_project_id', '=', active_id), + '|', + ('recurring_task', '=', False), + ('recurrence_template_id', '!=', False), + ]

No tasks found. Let's create one! diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index b83008ef31b..28664242cee 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -144,7 +144,12 @@ Tasks project.task kanban,tree,form,calendar,pivot,graph,activity - [('display_project_id', '=', active_id)] + [ + ('display_project_id', '=', active_id), + '|', + ('recurring_task', '=', False), + ('recurrence_template_id', '!=', False), + ] { 'default_project_id': active_id, 'show_project_update': True, @@ -1074,14 +1079,16 @@ +

-
-

Edit recurring task

+
+

Edit recurring task

+

Edit recurring task's child

@@ -1105,7 +1112,7 @@