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 @@