for repeat_type = 'forever' recurrences. Consider a recurrence creating a task every 15th day of the month. When the cron runs, it updates the next recurrence dates of the recurrence. But currently, the updated "next_recurrence_date" can be set to an earlier date than today, if the fixed day of the recurring task is earlier than the current day. This means that the recurring task to create every 15th of the month will be created the 15, the 16, the 17, the 18, ... until the next month begins (no task will be created when the cron runs before the 15th of a month). This commit enforces the next recurrence_date to be set after the current cron run, to ensure a task isn't wrongly created multiple times. This commit also includes the tests that led to the discovery of this bug. closes odoo/odoo#63989 X-original-commit: e4943afef0f66703bafd78780764ff6f0b039d3e Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com> Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
272 lines
10 KiB
Python
272 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
from calendar import monthrange
|
|
from dateutil.relativedelta import relativedelta
|
|
from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY, MONTHLY, YEARLY, MO, TU, WE, TH, FR, SA, SU
|
|
|
|
MONTHS = {
|
|
'january': 31,
|
|
'february': 28,
|
|
'march': 31,
|
|
'april': 30,
|
|
'may': 31,
|
|
'june': 30,
|
|
'july': 31,
|
|
'august': 31,
|
|
'september': 30,
|
|
'october': 31,
|
|
'november': 30,
|
|
'december': 31,
|
|
}
|
|
|
|
DAYS = {
|
|
'mon': MO,
|
|
'tue': TU,
|
|
'wed': WE,
|
|
'thu': TH,
|
|
'fri': FR,
|
|
'sat': SA,
|
|
'sun': SU,
|
|
}
|
|
|
|
WEEKS = {
|
|
'first': 1,
|
|
'second': 2,
|
|
'third': 3,
|
|
'last': 4,
|
|
}
|
|
|
|
class ProjectTaskRecurrence(models.Model):
|
|
_name = 'project.task.recurrence'
|
|
_description = 'Task Recurrence'
|
|
|
|
task_ids = fields.One2many('project.task', 'recurrence_id')
|
|
next_recurrence_date = fields.Date()
|
|
recurrence_left = fields.Integer(string="Number of Tasks Left to Create")
|
|
|
|
repeat_interval = fields.Integer(string='Repeat Every', default=1)
|
|
repeat_unit = fields.Selection([
|
|
('day', 'Days'),
|
|
('week', 'Weeks'),
|
|
('month', 'Months'),
|
|
('year', 'Years'),
|
|
], default='week')
|
|
repeat_type = fields.Selection([
|
|
('forever', 'Forever'),
|
|
('until', 'End Date'),
|
|
('after', 'Number of Repetitions'),
|
|
], default="forever", string="Until")
|
|
repeat_until = fields.Date(string="End Date")
|
|
repeat_number = fields.Integer(string="Repetitions")
|
|
|
|
repeat_on_month = fields.Selection([
|
|
('date', 'Date of the Month'),
|
|
('day', 'Day of the Month'),
|
|
])
|
|
|
|
repeat_on_year = fields.Selection([
|
|
('date', 'Date of the Year'),
|
|
('day', 'Day of the Year'),
|
|
])
|
|
|
|
mon = fields.Boolean(string="Mon")
|
|
tue = fields.Boolean(string="Tue")
|
|
wed = fields.Boolean(string="Wed")
|
|
thu = fields.Boolean(string="Thu")
|
|
fri = fields.Boolean(string="Fri")
|
|
sat = fields.Boolean(string="Sat")
|
|
sun = fields.Boolean(string="Sun")
|
|
|
|
repeat_day = fields.Selection([
|
|
(str(i), str(i)) for i in range(1, 32)
|
|
])
|
|
repeat_week = fields.Selection([
|
|
('first', 'First'),
|
|
('second', 'Second'),
|
|
('third', 'Third'),
|
|
('last', 'Last'),
|
|
])
|
|
repeat_weekday = fields.Selection([
|
|
('mon', 'Monday'),
|
|
('tue', 'Tuesday'),
|
|
('wed', 'Wednesday'),
|
|
('thu', 'Thursday'),
|
|
('fri', 'Friday'),
|
|
('sat', 'Saturday'),
|
|
('sun', 'Sunday'),
|
|
], string='Day Of The Week', readonly=False)
|
|
repeat_month = fields.Selection([
|
|
('january', 'January'),
|
|
('february', 'February'),
|
|
('march', 'March'),
|
|
('april', 'April'),
|
|
('may', 'May'),
|
|
('june', 'June'),
|
|
('july', 'July'),
|
|
('august', 'August'),
|
|
('september', 'September'),
|
|
('october', 'October'),
|
|
('november', 'November'),
|
|
('december', 'December'),
|
|
])
|
|
|
|
@api.constrains('repeat_unit', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun')
|
|
def _check_recurrence_days(self):
|
|
for project in self.filtered(lambda p: p.repeat_unit == 'week'):
|
|
if not any([project.mon, project.tue, project.wed, project.thu, project.fri, project.sat, project.sun]):
|
|
raise ValidationError('You should select a least one day')
|
|
|
|
@api.constrains('repeat_interval')
|
|
def _check_repeat_interval(self):
|
|
if self.filtered(lambda t: t.repeat_interval <= 0):
|
|
raise ValidationError('The interval should be greater than 0')
|
|
|
|
@api.constrains('repeat_number', 'repeat_type')
|
|
def _check_repeat_number(self):
|
|
if self.filtered(lambda t: t.repeat_type == 'after' and t.repeat_number <= 0):
|
|
raise ValidationError('Should repeat at least once')
|
|
|
|
@api.constrains('repeat_type', 'repeat_until')
|
|
def _check_repeat_until_date(self):
|
|
today = fields.Date.today()
|
|
if self.filtered(lambda t: t.repeat_type == 'until' and t.repeat_until < today):
|
|
raise ValidationError('The end date should be in the future')
|
|
|
|
@api.constrains('repeat_unit', 'repeat_on_month', 'repeat_day', 'repeat_type', 'repeat_until')
|
|
def _check_repeat_until_month(self):
|
|
if self.filtered(lambda r: r.repeat_type == 'until' and r.repeat_unit == 'month' and r.repeat_until and r.repeat_on_month == 'date' and int(r.repeat_day) > r.repeat_until.day):
|
|
raise ValidationError('The end date should be after the day of the month')
|
|
|
|
@api.model
|
|
def _get_recurring_fields(self):
|
|
return ['allowed_user_ids', 'company_id', 'description', 'displayed_image_id', 'email_cc',
|
|
'parent_id', 'partner_email', 'partner_id', 'partner_phone', 'planned_hours',
|
|
'project_id', 'project_privacy_visibility', 'sequence', 'tag_ids', 'recurrence_id',
|
|
'name', 'recurring_task']
|
|
|
|
def _get_weekdays(self, n=1):
|
|
self.ensure_one()
|
|
if self.repeat_unit == 'week':
|
|
return [fn(n) for day, fn in DAYS.items() if self[day]]
|
|
return [DAYS.get(self.repeat_weekday)(n)]
|
|
|
|
@api.model
|
|
def _get_next_recurring_dates(self, date_start, repeat_interval, repeat_unit, repeat_type, repeat_until, repeat_on_month, repeat_on_year, weekdays, repeat_day, repeat_week, repeat_month, **kwargs):
|
|
count = kwargs.get('count', 1)
|
|
rrule_kwargs = {'interval': repeat_interval or 1, 'dtstart': date_start}
|
|
repeat_day = int(repeat_day)
|
|
start = False
|
|
dates = []
|
|
if repeat_type == 'until':
|
|
rrule_kwargs['until'] = repeat_until if repeat_until else fields.Date.today()
|
|
else:
|
|
rrule_kwargs['count'] = count
|
|
|
|
if repeat_unit == 'week'\
|
|
or (repeat_unit == 'month' and repeat_on_month == 'day')\
|
|
or (repeat_unit == 'year' and repeat_on_year == 'day'):
|
|
rrule_kwargs['byweekday'] = weekdays
|
|
|
|
if repeat_unit == 'day':
|
|
rrule_kwargs['freq'] = DAILY
|
|
elif repeat_unit == 'month':
|
|
rrule_kwargs['freq'] = MONTHLY
|
|
if repeat_on_month == 'date':
|
|
start = date_start - relativedelta(days=1)
|
|
if repeat_type == 'until' and repeat_until > date_start:
|
|
delta = relativedelta(repeat_until, date_start)
|
|
count = delta.years * 12 + delta.months
|
|
for i in range(count):
|
|
start = start.replace(day=min(repeat_day, monthrange(start.year, start.month)[1]))
|
|
if i == 0 and start < date_start:
|
|
# Ensure the next recurrence is in the future
|
|
start += relativedelta(months=repeat_interval)
|
|
dates.append(start)
|
|
start += relativedelta(months=repeat_interval)
|
|
return dates
|
|
elif repeat_unit == 'year':
|
|
rrule_kwargs['freq'] = YEARLY
|
|
month = list(MONTHS.keys()).index(repeat_month) + 1
|
|
rrule_kwargs['bymonth'] = month
|
|
if repeat_on_year == 'date':
|
|
rrule_kwargs['bymonthday'] = min(repeat_day, MONTHS.get(repeat_month))
|
|
rrule_kwargs['bymonth'] = month
|
|
else:
|
|
rrule_kwargs['freq'] = WEEKLY
|
|
|
|
rules = rrule(**rrule_kwargs)
|
|
return list(rules) if rules else []
|
|
|
|
def _new_task_values(self, task):
|
|
self.ensure_one()
|
|
fields_to_copy = self._get_recurring_fields()
|
|
task_values = task.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
|
|
create_values['user_id'] = False
|
|
return create_values
|
|
|
|
def _create_next_task(self):
|
|
for recurrence in self:
|
|
task = self.sudo().task_ids[-1]
|
|
create_values = recurrence._new_task_values(task)
|
|
new_task = self.env['project.task'].sudo().create(create_values)
|
|
if not new_task.parent_id and task.child_ids:
|
|
children = []
|
|
# copy the subtasks of the original task
|
|
for child in task.child_ids:
|
|
child_values = self._new_task_values(child)
|
|
child_values['parent_id'] = new_task.id
|
|
children.append(child_values)
|
|
self.env['project.task'].create(children)
|
|
|
|
def _set_next_recurrence_date(self):
|
|
today = fields.Date.today()
|
|
tomorrow = today + relativedelta(days=1)
|
|
for recurrence in self.filtered(
|
|
lambda r:
|
|
r.repeat_type == 'after' and r.recurrence_left >= 0
|
|
or r.repeat_type == 'until' and r.repeat_until >= today
|
|
or r.repeat_type == 'forever'
|
|
):
|
|
if recurrence.repeat_type == 'after' and recurrence.recurrence_left == 0:
|
|
recurrence.next_recurrence_date = False
|
|
else:
|
|
next_date = self._get_next_recurring_dates(tomorrow, recurrence.repeat_interval, recurrence.repeat_unit, recurrence.repeat_type, recurrence.repeat_until, recurrence.repeat_on_month, recurrence.repeat_on_year, recurrence._get_weekdays(), recurrence.repeat_day, recurrence.repeat_week, recurrence.repeat_month, count=1)
|
|
recurrence.next_recurrence_date = next_date[0] if next_date else False
|
|
|
|
@api.model
|
|
def _cron_create_recurring_tasks(self):
|
|
if not self.env.user.has_group('project.group_project_recurring_tasks'):
|
|
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()
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
if vals.get('repeat_number'):
|
|
vals['recurrence_left'] = vals.get('repeat_number')
|
|
res = super(ProjectTaskRecurrence, self).create(vals)
|
|
res._set_next_recurrence_date()
|
|
return res
|
|
|
|
def write(self, vals):
|
|
if vals.get('repeat_number'):
|
|
vals['recurrence_left'] = vals.get('repeat_number')
|
|
|
|
res = super(ProjectTaskRecurrence, self).write(vals)
|
|
|
|
if 'next_recurrence_date' not in vals:
|
|
self._set_next_recurrence_date()
|
|
return res
|