[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) <xbo@odoo.com>
This commit is contained in:
Audric Onockx (auon)
2023-02-14 17:08:49 +01:00
parent 901e3814fc
commit aa250d3245
17 changed files with 455 additions and 614 deletions
-1
View File
@@ -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',
+27 -1
View File
@@ -639,8 +639,22 @@
<field name="repeat_weekday">mon</field>
<field name="create_date" eval="DateTime.now() + relativedelta(weeks=-2)"/>
</record>
<record id="project_1_recurrence_1_task_template" model="project.task">
<field name="active" eval="False"/>
<field name="sequence">20</field>
<field name="planned_hours">20.0</field>
<field name="user_ids" eval="False"/>
<field name="project_id" ref="project.project_project_1"/>
<field name="name">Customer review</field>
<field name="stage_id" ref="project_stage_1"/>
<field name="recurring_task" eval="True"/>
<field name="recurrence_id" ref="project_task_recurrence_1"/>
<field name="create_date" eval="DateTime.now() + relativedelta(weeks=-2)"/>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_task_recurrence_1" model="project.task.recurrence">
<field name="next_recurrence_date" eval="DateTime.now() + relativedelta(weeks=-2)"/>
<field name="task_template_id" ref="project_1_recurrence_1_task_template"/>
</record>
<record id="project_task_recurrence_2" model="project.task.recurrence">
<field name="recurrence_left">20</field>
@@ -652,8 +666,21 @@
<field name="repeat_type">after</field>
<field name="repeat_number">20</field>
</record>
<record id="project_1_recurrence_2_task_template" model="project.task">
<field name="active" eval="False"/>
<field name="sequence">20</field>
<field name="planned_hours">0.25</field>
<field name="user_ids" eval="False"/>
<field name="project_id" ref="project.project_project_1"/>
<field name="name">Daily stand-up meeting - Send minutes</field>
<field name="stage_id" ref="project_stage_1"/>
<field name="recurring_task" eval="True"/>
<field name="recurrence_id" ref="project_task_recurrence_2"/>
<field name="create_date" eval="DateTime.now() - relativedelta(weeks=-1)"/>
</record>
<record id="project_task_recurrence_2" model="project.task.recurrence">
<field name="next_recurrence_date" eval="DateTime.now() + relativedelta(weeks=-2)"/>
<field name="task_template_id" ref="project_1_recurrence_2_task_template"/>
</record>
<record id="project_1_task_10" model="project.task">
<field name="sequence">20</field>
@@ -672,7 +699,6 @@
<field name="planned_hours">0.25</field>
<field name="user_ids" eval="False"/>
<field name="project_id" ref="project.project_project_1"/>
<field name="parent_id" ref="project.project_1_task_10"/>
<field name="name">Daily stand-up meeting - Send minutes</field>
<field name="stage_id" ref="project_stage_1"/>
<field name="recurring_task" eval="True"/>
+32 -32
View File
@@ -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
@@ -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
@@ -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");
}
}
@@ -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: () => {},
};
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="project.AddressRecurrenceConfirmationDialog" t-inherit="web.ConfirmationDialog" t-inherit-mode="primary" owl="1">
<xpath expr="//Dialog" position="attributes">
<attribute name="title">title</attribute>
</xpath>
<xpath expr="//t[@t-set-slot='footer']" position="before">
<div>
<p t-esc="question"/>
<div>
<input type="radio"
class="form-check-input o_radio_input me-2"
name="recurrenceUpdate" value="this" checked="1"
t-on-change="() => props.onChangeRecurrenceUpdate('this')"
/>
<label for="this" t-esc="thisLabel"/>
</div>
<div>
<input type="radio"
class="form-check-input o_radio_input me-2"
name="recurrenceUpdate" value="future"
t-on-change="() => props.onChangeRecurrenceUpdate('future')"
/>
<label for="future" t-esc="futureLabel"/>
</div>
</div>
</xpath>
<xpath expr="//button[@t-on-click='_confirm']" position="attributes">
<attribute name="t-esc">title</attribute>
</xpath>
</t>
</templates>
@@ -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 };
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="project.ProjectStopRecurrenceConfirmationDialog" t-inherit="web.ConfirmationDialog" t-inherit-mode="primary" owl="1">
<xpath expr="//button[@t-on-click='_confirm']" position="replace">
<button class="btn btn-primary" t-on-click="_confirm">
Stop Recurrence
</button>
</xpath>
<xpath expr="//button[@t-on-click='_confirm']" position="after">
<button t-if="props.continueRecurrence" class="btn btn-secondary" t-on-click="_continueRecurrence">
Continue Recurrence
</button>
</xpath>
</t>
</templates>
@@ -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);
@@ -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,
});
}
}
@@ -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;
}
}
@@ -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,
};
+117 -323
View File
@@ -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': '<p>hello!</p>',
'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, '<p>hello!</p>')
self.assertEqual(task_c.description, '<p>hello!</p>')
self.assertEqual(task_a.description, '<p>hello!</p>', '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',
})
@@ -249,7 +249,12 @@
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form</field>
<field name="search_view_id" ref="project.project_sharing_project_task_view_search"/>
<field name="domain">[('display_project_id', '=', active_id)]</field>
<field name="domain">[
('display_project_id', '=', active_id),
'|',
('recurring_task', '=', False),
('recurrence_template_id', '!=', False),
]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No tasks found. Let's create one!
+20 -7
View File
@@ -144,7 +144,12 @@
<field name="name">Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field>
<field name="domain">[('display_project_id', '=', active_id)]</field>
<field name="domain">[
('display_project_id', '=', active_id),
'|',
('recurring_task', '=', False),
('recurrence_template_id', '!=', False),
]</field>
<field name="context">{
'default_project_id': active_id,
'show_project_update': True,
@@ -1074,14 +1079,16 @@
<field name="allow_milestones" invisible="1" />
<field name="parent_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="recurrence_template_id" invisible="1" />
<header>
<button name="action_assign_to_me" string="Assign to Me" type="object" class="oe_highlight" attrs="{'invisible' : &quot;['|', ('user_ids', 'in', uid), ('user_ids', '!=', [])]&quot;}" data-hotkey="q"/>
<button name="action_assign_to_me" string="Assign to Me" type="object" attrs="{'invisible': &quot;['|', ('user_ids', 'in', uid), ('user_ids', '=', [])]&quot;}" data-hotkey="q"/>
<field name="stage_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('project_id', '=', False), ('stage_id', '=', False)]}"/>
<field name="personal_stage_type_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}" attrs="{'invisible': [('project_id', '!=', False)]}" domain="[('user_id', '=', uid)]" string="Personal Stage"/>
</header>
<div class="alert alert-info oe_edit_only mb-0" role="status" attrs="{'invisible': ['|', ('recurring_task', '=', False), ('recurrence_id', '=', False)]}" groups="project.group_project_recurring_tasks">
<p>Edit recurring task</p>
<div class="alert alert-info oe_edit_only mb-0" role="status" attrs="{'invisible': [('recurrence_template_id', '=', False), ('recurrence_id', '=', False)]}" groups="project.group_project_recurring_tasks">
<p attrs="{'invisible': [('recurrence_id', '=', False)]}">Edit recurring task</p>
<p attrs="{'invisible': ['|', ('recurrence_id', '!=', False), ('recurrence_template_id', '=', False)]}">Edit recurring task's child</p>
<field name="recurrence_update" widget="radio"/>
</div>
<sheet string="Task">
@@ -1105,7 +1112,7 @@
<!-- Dummy tag used to organize buttons -->
<span id="end_rating_buttons" invisible="1"/>
<button name="action_open_parent_task" type="object" class="oe_stat_button" icon="fa-tasks" string="Parent Task" attrs="{'invisible': ['|', ('allow_subtasks', '=', False), ('parent_id', '=', False)]}" groups="project.group_subtask_project"/>
<button name="action_recurring_tasks" type="object" attrs="{'invisible': [('recurrence_id', '=', False)]}" class="oe_stat_button" icon="fa-repeat" groups="project.group_project_recurring_tasks">
<button name="action_recurring_tasks" type="object" attrs="{'invisible': ['|', ('active', '=', False), ('recurrence_id', '=', False)]}" class="oe_stat_button" icon="fa-repeat" groups="project.group_project_recurring_tasks">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="recurring_count" widget="statinfo" nolabel="1" />
@@ -1187,7 +1194,7 @@
context="{'search_view_ref' : 'project.view_task_search_form', 'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id,
'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id, 'search_default_display_project_id': project_id, 'tree_view_ref': 'project.open_view_all_tasks_list_view'}"
widget="many2many"
domain="['!', ('id', 'parent_of', id)]">
domain="['!', ('id', 'parent_of', id), ('recurring_task', '=', False)]">
<tree editable="bottom" decoration-muted="is_closed == True">
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
@@ -1264,7 +1271,7 @@
</tree>
</field>
</page>
<page name="recurrence" string="Recurrent" groups="project.group_project_recurring_tasks">
<page name="recurrence" string="Recurrent" attrs="{'invisible': ['|', ('active', '=', False), ('parent_id', '!=', False)]}" groups="project.group_project_recurring_tasks">
<label for="recurring_task" />
<field name="recurring_task" attrs="{'invisible': ['|', ('allow_recurring_tasks', '=', False), ('active', '=', False)]}"/>
<group attrs="{'invisible': [('recurring_task', '=', False)]}">
@@ -1412,6 +1419,7 @@
<field name="rating_active"/>
<field name="has_late_and_unreached_milestone" />
<field name="allow_milestones" />
<field name="recurrence_id" />
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "200"}'/>
<templates>
<t t-name="kanban-menu" t-if="!selection_mode" groups="base.group_user">
@@ -1638,7 +1646,12 @@
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field>
<field name="context">{'search_default_my_tasks': 1}</field>
<field name="domain">[('display_project_id', '!=', False)]</field>
<field name="domain">[
('display_project_id', '!=', False),
'|',
('recurring_task', '=', False),
('recurrence_template_id', '!=', False),
]</field>
<field name="search_view_id" ref="view_task_search_form"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
+3 -4
View File
@@ -699,10 +699,9 @@ class ProjectTask(models.Model):
class ProjectTaskRecurrence(models.Model):
_inherit = 'project.task.recurrence'
def _new_task_values(self, task):
values = super(ProjectTaskRecurrence, self)._new_task_values(task)
task = self.sudo().task_ids[0]
values['sale_line_id'] = self._get_sale_line_id(task)
def _new_task_values(self, task_from, to_template=False):
values = super(ProjectTaskRecurrence, self)._new_task_values(task_from, to_template=to_template)
values['sale_line_id'] = self._get_sale_line_id(task_from)
return values
def _get_sale_line_id(self, task):