summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorKevin Baptiste <kba@odoo.com>2020-06-23 13:26:42 +0000
committerKevin Baptiste <kba@odoo.com>2020-08-12 08:39:32 +0000
commit1b62bd9508c58c5a4f2e41f2176d737a2e404656 (patch)
tree61ed108841e5a9506d5bb3b49641c2d66733bbb4 /addons
parent0dd48767623dd9e6e53d943e8b87d4bfd18ca53c (diff)
[IMP] project: add recurring tasks
Some interventions are done on a regular basis (e.g. maintenance of fire alarms, safety inspections). Having tasks auto-generate would facilitate the process and would ensure that the next intervention isn't missed/forgotten. closes odoo/odoo#55517 Taskid: 2172156 Related: odoo/enterprise#12246 Related: odoo/upgrade#1604 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Diffstat (limited to 'addons')
-rw-r--r--addons/project/__manifest__.py1
-rw-r--r--addons/project/data/project_data.xml10
-rw-r--r--addons/project/models/__init__.py1
-rw-r--r--addons/project/models/project.py250
-rw-r--r--addons/project/models/project_task_recurrence.py258
-rw-r--r--addons/project/models/res_config_settings.py6
-rw-r--r--addons/project/security/ir.model.access.csv1
-rw-r--r--addons/project/security/project_security.xml5
-rw-r--r--addons/project/static/src/css/project.css4
-rw-r--r--addons/project/static/src/scss/project_dashboard.scss12
-rw-r--r--addons/project/static/src/xml/project_templates.xml17
-rw-r--r--addons/project/tests/__init__.py2
-rw-r--r--addons/project/tests/test_project_recurrence.py351
-rw-r--r--addons/project/views/project_views.xml78
-rw-r--r--addons/project/views/res_config_settings_views.xml11
-rw-r--r--addons/sale_project/models/project.py10
-rw-r--r--addons/sale_timesheet/models/project.py7
17 files changed, 1020 insertions, 4 deletions
diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py
index 34a7624b63b..f17fe39a0ec 100644
--- a/addons/project/__manifest__.py
+++ b/addons/project/__manifest__.py
@@ -40,6 +40,7 @@
'wizard/project_task_type_delete_views.xml',
],
'demo': ['data/project_demo.xml'],
+ 'qweb': ['static/src/xml/project_templates.xml'],
'test': [
],
'installable': True,
diff --git a/addons/project/data/project_data.xml b/addons/project/data/project_data.xml
index 63c31fe3775..6c17952e3f5 100644
--- a/addons/project/data/project_data.xml
+++ b/addons/project/data/project_data.xml
@@ -10,6 +10,16 @@
<field name="numbercall">-1</field>
</record>
+ <record id="ir_cron_recurring_tasks" model="ir.cron">
+ <field name="name">Project: Create Recurring Tasks</field>
+ <field name="model_id" ref="project.model_project_task_recurrence"/>
+ <field name="state">code</field>
+ <field name="code">model._cron_create_recurring_tasks()</field>
+ <field name="interval_type">days</field>
+ <field name="numbercall">-1</field>
+ <field name="nextcall" eval="(DateTime.now().replace(hour=3, minute=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')" />
+ </record>
+
<!-- Task-related subtypes for messaging / Chatter -->
<record id="mt_task_new" model="mail.message.subtype">
<field name="name">Task Created</field>
diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py
index 22a31e4bc71..ff2853f7df7 100644
--- a/addons/project/models/__init__.py
+++ b/addons/project/models/__init__.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic_account
+from . import project_task_recurrence
from . import project
from . import res_config_settings
from . import res_partner
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index 00ccd6dc760..325c446ae59 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -2,13 +2,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
-from datetime import timedelta
+from datetime import timedelta, datetime
from random import randint
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError, ValidationError, RedirectWarning
-from odoo.tools.misc import format_date
+from odoo.tools.misc import format_date, get_lang
+from odoo.osv.expression import OR
+from .project_task_recurrence import DAYS, WEEKS
class ProjectTaskType(models.Model):
_name = 'project.task.type'
@@ -210,6 +212,7 @@ class Project(models.Model):
subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict",
help="Project in which sub-tasks of the current project will be created. It can be the current project itself.")
allow_subtasks = fields.Boolean('Sub-tasks', default=lambda self: self.env.user.has_group('project.group_subtask_project'))
+ allow_recurring_tasks = fields.Boolean('Recurring Tasks', default=lambda self: self.env.user.has_group('project.group_project_recurring_tasks'))
# rating fields
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
@@ -339,6 +342,9 @@ class Project(models.Model):
for task in project.task_ids:
task.allowed_user_ids -= permission_removed
+ if 'allow_recurring_tasks' in vals and not vals.get('allow_recurring_tasks'):
+ self.env['project.task'].search([('project_id', 'in', self.ids), ('recurring_task', '=', True)]).write({'recurring_task': False})
+
if 'active' in vals:
# archiving/unarchiving a project does it on its tasks, too
self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']})
@@ -596,6 +602,172 @@ class Task(models.Model):
# customer portal: include comment and incoming emails in communication history
website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])])
+ # recurrence fields
+ allow_recurring_tasks = fields.Boolean(related='project_id.allow_recurring_tasks')
+ recurring_task = fields.Boolean(string="Recurrent")
+ recurring_count = fields.Integer(string="Tasks in Recurrence", compute='_compute_recurring_count')
+ recurrence_id = fields.Many2one('project.task.recurrence', copy=False)
+ recurrence_update = fields.Selection([
+ ('this', 'This task'),
+ ('subsequent', 'This and following tasks'),
+ ('all', 'All tasks'),
+ ], default='this', store=False)
+ recurrence_message = fields.Char(string='Next Recurrencies', compute='_compute_recurrence_message')
+
+ repeat_interval = fields.Integer(string='Repeat Every', default=1, compute='_compute_repeat', readonly=False)
+ repeat_unit = fields.Selection([
+ ('day', 'Days'),
+ ('week', 'Weeks'),
+ ('month', 'Months'),
+ ('year', 'Years'),
+ ], default='week', compute='_compute_repeat', readonly=False)
+ repeat_type = fields.Selection([
+ ('forever', 'Forever'),
+ ('until', 'End Date'),
+ ('after', 'Number of Repetitions'),
+ ], default="forever", string="Until", compute='_compute_repeat', readonly=False)
+ repeat_until = fields.Date(string="End Date", compute='_compute_repeat', readonly=False)
+ repeat_number = fields.Integer(string="Repetitions", default=1, compute='_compute_repeat', readonly=False)
+
+ repeat_on_month = fields.Selection([
+ ('date', 'Date of the Month'),
+ ('day', 'Day of the Month'),
+ ], default='date', compute='_compute_repeat', readonly=False)
+
+ repeat_on_year = fields.Selection([
+ ('date', 'Date of the Year'),
+ ('day', 'Day of the Year'),
+ ], default='date', compute='_compute_repeat', readonly=False)
+
+ mon = fields.Boolean(string="Mon", compute='_compute_repeat', readonly=False)
+ tue = fields.Boolean(string="Tue", compute='_compute_repeat', readonly=False)
+ wed = fields.Boolean(string="Wed", compute='_compute_repeat', readonly=False)
+ thu = fields.Boolean(string="Thu", compute='_compute_repeat', readonly=False)
+ fri = fields.Boolean(string="Fri", compute='_compute_repeat', readonly=False)
+ sat = fields.Boolean(string="Sat", compute='_compute_repeat', readonly=False)
+ sun = fields.Boolean(string="Sun", compute='_compute_repeat', readonly=False)
+
+ repeat_day = fields.Selection([
+ (str(i), str(i)) for i in range(1, 32)
+ ], compute='_compute_repeat', readonly=False)
+ repeat_week = fields.Selection([
+ ('first', 'First'),
+ ('second', 'Second'),
+ ('third', 'Third'),
+ ('last', 'Last'),
+ ], default='first', compute='_compute_repeat', readonly=False)
+ repeat_weekday = fields.Selection([
+ ('mon', 'Monday'),
+ ('tue', 'Tuesday'),
+ ('wed', 'Wednesday'),
+ ('thu', 'Thursday'),
+ ('fri', 'Friday'),
+ ('sat', 'Saturday'),
+ ('sun', 'Sunday'),
+ ], string='Day Of The Week', compute='_compute_repeat', 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'),
+ ], compute='_compute_repeat', readonly=False)
+
+ repeat_show_dow = fields.Boolean(compute='_compute_repeat_visibility')
+ repeat_show_day = fields.Boolean(compute='_compute_repeat_visibility')
+ repeat_show_week = fields.Boolean(compute='_compute_repeat_visibility')
+ repeat_show_month = fields.Boolean(compute='_compute_repeat_visibility')
+
+ @api.model
+ def _get_recurrence_fields(self):
+ return ['repeat_interval', 'repeat_unit', 'repeat_type', 'repeat_until', 'repeat_number',
+ 'repeat_on_month', 'repeat_on_year', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
+ 'sun', 'repeat_day', 'repeat_week', 'repeat_month', 'repeat_weekday']
+
+ @api.depends('recurring_task', 'repeat_unit', 'repeat_on_month', 'repeat_on_year')
+ def _compute_repeat_visibility(self):
+ for task in self:
+ task.repeat_show_day = task.recurring_task and (task.repeat_unit == 'month' and task.repeat_on_month == 'date') or (task.repeat_unit == 'year' and task.repeat_on_year == 'date')
+ task.repeat_show_week = task.recurring_task and (task.repeat_unit == 'month' and task.repeat_on_month == 'day') or (task.repeat_unit == 'year' and task.repeat_on_year == 'day')
+ task.repeat_show_dow = task.recurring_task and task.repeat_unit == 'week'
+ task.repeat_show_month = task.recurring_task and task.repeat_unit == 'year'
+
+ @api.depends('recurring_task', 'repeat_unit')
+ def _compute_repeat(self):
+ rec_fields = self._get_recurrence_fields()
+ defaults = self.default_get(rec_fields)
+ for task in self:
+ for f in rec_fields:
+ if task.recurrence_id:
+ task[f] = task.recurrence_id[f]
+ else:
+ if task.recurring_task:
+ task[f] = defaults.get(f)
+ else:
+ task[f] = False
+
+ 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.depends(
+ 'recurring_task', 'repeat_interval', 'repeat_unit', 'repeat_type', 'repeat_until',
+ 'repeat_number', 'repeat_on_month', 'repeat_on_year', 'mon', 'tue', 'wed', 'thu', 'fri',
+ 'sat', 'sun', 'repeat_day', 'repeat_week', 'repeat_month', 'repeat_weekday')
+ def _compute_recurrence_message(self):
+ self.recurrence_message = False
+ for task in self.filtered(lambda t: t.recurring_task and t._is_recurrence_valid()):
+ date = fields.Date.today()
+ number_occurrences = min(5, task.repeat_number if task.repeat_type == 'after' else 5)
+ delta = task.repeat_interval if task.repeat_unit == 'day' else 1
+ recurring_dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date + timedelta(days=delta),
+ task.repeat_interval,
+ task.repeat_unit,
+ task.repeat_type,
+ task.repeat_until,
+ task.repeat_on_month,
+ task.repeat_on_year,
+ task._get_weekdays(WEEKS.get(task.repeat_week)),
+ task.repeat_day,
+ task.repeat_week,
+ task.repeat_month,
+ count=number_occurrences)
+ date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format
+ task.recurrence_message = '<ul>'
+ for date in recurring_dates[:5]:
+ task.recurrence_message += '<li>%s</li>' % date.strftime(date_format)
+ if task.repeat_type == 'after' and task.repeat_number > 5 or task.repeat_type == 'forever' or len(recurring_dates) > 5:
+ task.recurrence_message += '<li>...</li>'
+ task.recurrence_message += '</ul>'
+ if task.repeat_type == 'until':
+ task.recurrence_message += _('<p><em>Number of tasks: %(tasks_count)s</em></p>') % {'tasks_count': len(recurring_dates)}
+
+ def _is_recurrence_valid(self):
+ self.ensure_one()
+ return self.repeat_interval > 0 and\
+ (not self.repeat_show_dow or self._get_weekdays()) and\
+ (self.repeat_type != 'after' or self.repeat_number) and\
+ (self.repeat_type != 'until' or self.repeat_until and self.repeat_until > fields.Date.today())
+
+ @api.depends('recurrence_id')
+ def _compute_recurring_count(self):
+ self.recurring_count = 0
+ recurring_tasks = self.filtered(lambda l: l.recurrence_id)
+ count = self.env['project.task'].read_group([('recurrence_id', 'in', recurring_tasks.recurrence_id.ids)], ['id'], 'recurrence_id')
+ tasks_count = {c.get('recurrence_id')[0]: c.get('recurrence_id_count') for c in count}
+ for task in recurring_tasks:
+ task.recurring_count = tasks_count.get(task.recurrence_id.id, 0)
+
@api.depends('partner_id.email')
def _compute_partner_email(self):
for task in self:
@@ -759,6 +931,8 @@ class Task(models.Model):
default = {}
if not default.get('name'):
default['name'] = _("%s (copy)", self.name)
+ if self.recurrence_id:
+ default['recurrence_id'] = self.recurrence_id.copy().id
return super(Task, self).copy(default)
@api.constrains('parent_id')
@@ -821,6 +995,25 @@ class Task(models.Model):
# ------------------------------------------------
# CRUD overrides
# ------------------------------------------------
+ @api.model
+ def default_get(self, default_fields):
+ vals = super(Task, self).default_get(default_fields)
+
+ days = list(DAYS.keys())
+ week_start = fields.Datetime.today().weekday()
+
+ if all([d in default_fields for d in days]):
+ vals[days[week_start]] = True
+ if 'repeat_day' in default_fields:
+ vals['repeat_day'] = str(fields.Datetime.today().day)
+ if 'repeat_month' in default_fields:
+ vals['repeat_month'] = self._fields.get('repeat_month').selection[fields.Datetime.today().month - 1][0]
+ if 'repeat_until' in default_fields:
+ vals['repeat_until'] = fields.Date.today() + timedelta(days=7)
+ if 'repeat_weekday' in default_fields:
+ vals['repeat_weekday'] = self._fields.get('repeat_weekday').selection[week_start][0]
+
+ return vals
@api.model_create_multi
def create(self, vals_list):
@@ -847,12 +1040,22 @@ class Task(models.Model):
if vals.get('stage_id'):
vals.update(self.update_date_end(vals['stage_id']))
vals['date_last_stage_update'] = fields.Datetime.now()
+ # recurrence
+ rec_fields = vals.keys() & self._get_recurrence_fields()
+ if rec_fields and vals.get('recurring_task') is True:
+ rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields}
+ rec_values['next_recurrence_date'] = fields.Datetime.today()
+ recurrence = self.env['project.task.recurrence'].create(rec_values)
+ vals['recurrence_id'] = recurrence.id
return super().create(vals_list)
def write(self, vals):
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')):
+ # TODO: show a dialog to stop the recurrence
+ raise UserError(_('You cannot archive recurring tasks. Please, disable the recurrence first.'))
# stage change: update date_last_stage_update
if 'stage_id' in vals:
vals.update(self.update_date_end(vals['stage_id']))
@@ -864,7 +1067,33 @@ class Task(models.Model):
if vals.get('user_id') and 'date_assign' not in vals:
vals['date_assign'] = now
- result = super(Task, self).write(vals)
+ # recurrence fields
+ rec_fields = vals.keys() & self._get_recurrence_fields()
+ if rec_fields:
+ rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields}
+ for task in self:
+ if task.recurrence_id:
+ task.recurrence_id.write(rec_values)
+ elif vals.get('recurring_task'):
+ rec_values['next_recurrence_date'] = fields.Datetime.today()
+ recurrence = self.env['project.task.recurrence'].create(rec_values)
+ task.recurrence_id = recurrence.id
+
+ 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 = 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)
+
+ result = super(Task, tasks).write(vals)
# rating on stage
if 'stage_id' in vals and vals.get('stage_id'):
self.filtered(lambda x: x.project_id.rating_status == 'stage')._send_task_rating_mail(force_send=True)
@@ -876,6 +1105,12 @@ class Task(models.Model):
return {'date_end': fields.Datetime.now()}
return {'date_end': False}
+ def unlink(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.'))
+ return super().unlink()
+
# ---------------------------------------------------
# Subtasks
# ---------------------------------------------------
@@ -1106,6 +1341,15 @@ class Task(models.Model):
return action
+ def action_recurring_tasks(self):
+ return {
+ 'name': 'Tasks in Recurrence',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'project.task',
+ 'view_mode': 'tree,form',
+ 'domain': [('recurrence_id', 'in', self.recurrence_id.ids)],
+ }
+
# ---------------------------------------------------
# Rating business
# ---------------------------------------------------
diff --git a/addons/project/models/project_task_recurrence.py b/addons/project/models/project_task_recurrence.py
new file mode 100644
index 00000000000..1e00dccce1d
--- /dev/null
+++ b/addons/project/models/project_task_recurrence.py
@@ -0,0 +1,258 @@
+# -*- 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]))
+ 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'):
+ 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
+
+ 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()
+
+ 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
diff --git a/addons/project/models/res_config_settings.py b/addons/project/models/res_config_settings.py
index e63c53edc59..1831c1cd0ca 100644
--- a/addons/project/models/res_config_settings.py
+++ b/addons/project/models/res_config_settings.py
@@ -11,3 +11,9 @@ class ResConfigSettings(models.TransientModel):
module_hr_timesheet = fields.Boolean(string="Task Logs")
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
group_project_rating = fields.Boolean("Customer Ratings", implied_group='project.group_project_rating')
+ group_project_recurring_tasks = fields.Boolean("Recurring Tasks", implied_group="project.group_project_recurring_tasks")
+
+ def set_values(self):
+ if self.user_has_groups('project.group_project_recurring_tasks') != self.group_project_recurring_tasks:
+ self.env['project.project'].sudo().search([]).write({'allow_recurring_tasks': self.group_project_recurring_tasks})
+ super(ResConfigSettings, self).set_values()
diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv
index 53661ef75fc..e4538dbe11b 100644
--- a/addons/project/security/ir.model.access.csv
+++ b/addons/project/security/ir.model.access.csv
@@ -24,3 +24,4 @@ access_account_analytic_account_manager,account.analytic.account,analytic.model_
access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1
access_project_delete_wizard,access_project_delete_wizard,model_project_delete_wizard,project.group_project_manager,1,1,1,1
access_project_task_type_delete_wizard,project.task.type.delete.wizard,model_project_task_type_delete_wizard,project.group_project_manager,1,1,1,1
+access_project_task_recurrence,project.task.recurrence,model_project_task_recurrence,project.group_project_user,1,1,1,1
diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml
index ead2c9c52d5..befca629ed6 100644
--- a/addons/project/security/project_security.xml
+++ b/addons/project/security/project_security.xml
@@ -29,6 +29,11 @@
<field name="category_id" ref="base.module_category_hidden"/>
</record>
+ <record id="group_project_recurring_tasks" model="res.groups">
+ <field name="name">Use Recurring Tasks</field>
+ <field name="category_id" ref="base.module_category_hidden"/>
+ </record>
+
<data noupdate="1">
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4,ref('project.group_project_manager'))]"/>
diff --git a/addons/project/static/src/css/project.css b/addons/project/static/src/css/project.css
index 19f265cd2c6..a88d7d1b7b7 100644
--- a/addons/project/static/src/css/project.css
+++ b/addons/project/static/src/css/project.css
@@ -70,3 +70,7 @@
margin-right: 150px;
padding-left: 1rem;
}
+
+.o_form_project_recurrence_message *:last-child {
+ margin-bottom: 0;
+}
diff --git a/addons/project/static/src/scss/project_dashboard.scss b/addons/project/static/src/scss/project_dashboard.scss
index 5e0b0c7da39..73539824e82 100644
--- a/addons/project/static/src/scss/project_dashboard.scss
+++ b/addons/project/static/src/scss/project_dashboard.scss
@@ -37,3 +37,15 @@
}
}
+.o_dow_widget {
+ th {
+ padding: 10px 10px 0 10px;
+ }
+
+ .o_dow_days {
+ td {
+ text-align: center;
+ vertical-align: middle;
+ }
+ }
+}
diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml
new file mode 100644
index 00000000000..f30d8c95a0e
--- /dev/null
+++ b/addons/project/static/src/xml/project_templates.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+ <t t-name="day_of_week_widget">
+ <table class="o_dow_widget">
+ <thead>
+ <tr>
+ <th t-foreach="widget.labels" t-as="label">
+ <t t-esc="label"/>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr class="o_dow_days" />
+ </tbody>
+ </table>
+ </t>
+</templates>
diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py
index 4b37120321d..aadb3c31f42 100644
--- a/addons/project/tests/__init__.py
+++ b/addons/project/tests/__init__.py
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
-from . import test_project_base, test_project_flow, test_access_rights, test_project_ui
+from . import test_project_base, test_project_flow, test_access_rights, test_project_recurrence, test_project_ui
from . import test_multicompany
diff --git a/addons/project/tests/test_project_recurrence.py b/addons/project/tests/test_project_recurrence.py
new file mode 100644
index 00000000000..b207e7a9522
--- /dev/null
+++ b/addons/project/tests/test_project_recurrence.py
@@ -0,0 +1,351 @@
+# -*- coding: utf-8 -*-
+
+
+from odoo.tests.common import SavepointCase, Form
+from odoo.exceptions import ValidationError
+from odoo import fields
+
+from datetime import date, datetime
+from dateutil.rrule import MO, TU, WE, TH, FR, SA, SU
+
+from unittest.mock import patch
+
+class TestProjectrecurrence(SavepointCase):
+ @classmethod
+ def setUpClass(cls):
+ super(TestProjectrecurrence, cls).setUpClass()
+
+ cls.env.user.groups_id += cls.env.ref('project.group_project_recurring_tasks')
+
+ cls.stage_a = cls.env['project.task.type'].create({'name': 'a'})
+ cls.stage_b = cls.env['project.task.type'].create({'name': 'b'})
+ cls.project_recurring = cls.env['project.project'].with_context({'mail_create_nolog': True}).create({
+ 'name': 'Recurring',
+ 'allow_recurring_tasks': True,
+ 'type_ids': [
+ (4, cls.stage_a.id),
+ (4, cls.stage_b.id),
+ ]
+ })
+
+ 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))
+
+ def test_recurrence_simple(self):
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 2, 1)):
+ with Form(self.env['project.task']) as form:
+ form.name = 'test recurring task'
+ form.project_id = self.project_recurring
+
+ form.recurring_task = True
+ form.repeat_interval = 5
+ form.repeat_unit = 'month'
+ form.repeat_type = 'after'
+ form.repeat_number = 10
+ form.repeat_on_month = 'date'
+ form.repeat_day = '31'
+ task = form.save()
+ self.assertTrue(bool(task.recurrence_id), 'should create a recurrence')
+
+ task.write(dict(repeat_interval=2, repeat_number=11))
+ 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))
+
+ task.recurring_task = False
+ self.assertFalse(bool(task.recurrence_id), 'the recurrence should be deleted')
+
+ def test_recurrence_cron(self):
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 1, 1)):
+ 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.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'
+ task = form.save()
+ task.planned_hours = 2
+
+ self.assertEqual(task.recurrence_id.next_recurrence_date, date(2020, 1, 15))
+ self.assertEqual(self.env['project.task'].search_count([('project_id', '=', self.project_recurring.id)]), 1)
+ self.env['project.task.recurrence']._cron_create_recurring_tasks()
+ self.assertEqual(self.env['project.task'].search_count([('project_id', '=', self.project_recurring.id)]), 1, 'no extra task should be created')
+ self.assertEqual(task.recurrence_id.recurrence_left, 2)
+
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 1, 15)):
+ self.assertEqual(self.env['project.task'].search_count([('project_id', '=', self.project_recurring.id)]), 1)
+ self.env['project.task.recurrence']._cron_create_recurring_tasks()
+ self.assertEqual(self.env['project.task'].search_count([('project_id', '=', self.project_recurring.id)]), 2)
+ self.assertEqual(task.recurrence_id.recurrence_left, 1)
+
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 2, 15)):
+ self.env['project.task.recurrence']._cron_create_recurring_tasks()
+ self.assertEqual(task.recurrence_id.recurrence_left, 0)
+
+ tasks = self.env['project.task'].search([('project_id', '=', self.project_recurring.id)])
+ self.assertEqual(len(tasks), 3)
+
+ self.assertTrue(bool(tasks[2].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_update_task(self):
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 1, 1)):
+ task = self.env['project.task'].create({
+ 'name': 'test recurring task',
+ 'project_id': self.project_recurring.id,
+ 'recurring_task': True,
+ 'repeat_interval': 1,
+ 'repeat_unit': 'week',
+ 'repeat_type': 'after',
+ 'repeat_number': 2,
+ 'mon': True,
+ })
+
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 1, 6)):
+ self.env['project.task.recurrence']._cron_create_recurring_tasks()
+
+ with patch.object(fields.Date, 'today', lambda: datetime(2020, 1, 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_cache()
+
+ 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>')
+
+ def test_recurrence_fields_visibility(self):
+ form = Form(self.env['project.task'])
+
+ form.name = 'test recurring task'
+ form.project_id = self.project_recurring
+ form.recurring_task = True
+
+ form.repeat_unit = 'week'
+ self.assertTrue(form.repeat_show_dow)
+ self.assertFalse(form.repeat_show_day)
+ self.assertFalse(form.repeat_show_week)
+ self.assertFalse(form.repeat_show_month)
+
+ form.repeat_unit = 'month'
+ form.repeat_on_month = 'date'
+ self.assertFalse(form.repeat_show_dow)
+ self.assertTrue(form.repeat_show_day)
+ self.assertFalse(form.repeat_show_week)
+ self.assertFalse(form.repeat_show_month)
+
+ form.repeat_unit = 'month'
+ form.repeat_on_month = 'day'
+ self.assertFalse(form.repeat_show_dow)
+ self.assertFalse(form.repeat_show_day)
+ self.assertTrue(form.repeat_show_week)
+ self.assertFalse(form.repeat_show_month)
+
+ form.repeat_unit = 'year'
+ form.repeat_on_year = 'date'
+ self.assertFalse(form.repeat_show_dow)
+ self.assertTrue(form.repeat_show_day)
+ self.assertFalse(form.repeat_show_week)
+ self.assertTrue(form.repeat_show_month)
+
+ form.repeat_unit = 'year'
+ form.repeat_on_year = 'day'
+ self.assertFalse(form.repeat_show_dow)
+ self.assertFalse(form.repeat_show_day)
+ self.assertTrue(form.repeat_show_week)
+ self.assertTrue(form.repeat_show_month)
+
+ form.recurring_task = False
+ self.assertFalse(form.repeat_show_dow)
+ self.assertFalse(form.repeat_show_day)
+ self.assertFalse(form.repeat_show_week)
+ self.assertFalse(form.repeat_show_month)
+
+ def test_recurrence_week_day(self):
+ form = Form(self.env['project.task'])
+
+ form.name = 'test recurring task'
+ form.project_id = self.project_recurring
+ form.recurring_task = True
+ form.repeat_unit = 'week'
+
+ form.mon = False
+ form.tue = False
+ form.wed = False
+ form.thu = False
+ form.fri = False
+ form.sat = False
+ form.sun = False
+
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ form.save()
+
+ def test_recurrence_next_dates_week(self):
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 1, 1),
+ repeat_interval=1,
+ repeat_unit='week',
+ repeat_type=False,
+ repeat_until=False,
+ repeat_on_month=False,
+ repeat_on_year=False,
+ weekdays=False,
+ repeat_day=False,
+ repeat_week=False,
+ repeat_month=False,
+ count=5)
+
+ self.assertEqual(dates[0], datetime(2020, 1, 6, 0, 0))
+ self.assertEqual(dates[1], datetime(2020, 1, 13, 0, 0))
+ self.assertEqual(dates[2], datetime(2020, 1, 20, 0, 0))
+ self.assertEqual(dates[3], datetime(2020, 1, 27, 0, 0))
+ self.assertEqual(dates[4], datetime(2020, 2, 3, 0, 0))
+
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 1, 1),
+ repeat_interval=3,
+ repeat_unit='week',
+ repeat_type='until',
+ repeat_until=date(2020, 2, 1),
+ repeat_on_month=False,
+ repeat_on_year=False,
+ weekdays=[MO, FR],
+ repeat_day=False,
+ repeat_week=False,
+ repeat_month=False,
+ count=100)
+
+ self.assertEqual(len(dates), 3)
+ self.assertEqual(dates[0], datetime(2020, 1, 3, 0, 0))
+ self.assertEqual(dates[1], datetime(2020, 1, 20, 0, 0))
+ self.assertEqual(dates[2], datetime(2020, 1, 24, 0, 0))
+
+ def test_recurrence_next_dates_month(self):
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 1, 15),
+ repeat_interval=1,
+ repeat_unit='month',
+ repeat_type=False, # Forever
+ repeat_until=False,
+ repeat_on_month='date',
+ repeat_on_year=False,
+ weekdays=False,
+ repeat_day=31,
+ repeat_week=False,
+ repeat_month=False,
+ count=12)
+
+ # should take the last day of each month
+ self.assertEqual(dates[0], date(2020, 1, 31))
+ self.assertEqual(dates[1], date(2020, 2, 29))
+ self.assertEqual(dates[2], date(2020, 3, 31))
+ self.assertEqual(dates[3], date(2020, 4, 30))
+ self.assertEqual(dates[4], date(2020, 5, 31))
+ self.assertEqual(dates[5], date(2020, 6, 30))
+ self.assertEqual(dates[6], date(2020, 7, 31))
+ self.assertEqual(dates[7], date(2020, 8, 31))
+ self.assertEqual(dates[8], date(2020, 9, 30))
+ self.assertEqual(dates[9], date(2020, 10, 31))
+ self.assertEqual(dates[10], date(2020, 11, 30))
+ self.assertEqual(dates[11], date(2020, 12, 31))
+
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 2, 20),
+ repeat_interval=3,
+ repeat_unit='month',
+ repeat_type=False, # Forever
+ repeat_until=False,
+ repeat_on_month='date',
+ repeat_on_year=False,
+ weekdays=False,
+ repeat_day=29,
+ repeat_week=False,
+ repeat_month=False,
+ count=5)
+
+ self.assertEqual(dates[0], date(2020, 2, 29))
+ self.assertEqual(dates[1], date(2020, 5, 29))
+ self.assertEqual(dates[2], date(2020, 8, 29))
+ self.assertEqual(dates[3], date(2020, 11, 29))
+ self.assertEqual(dates[4], date(2021, 2, 28))
+
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 1, 10),
+ repeat_interval=1,
+ repeat_unit='month',
+ repeat_type='until',
+ repeat_until=datetime(2020, 5, 31),
+ repeat_on_month='day',
+ repeat_on_year=False,
+ weekdays=[SA(4), ], # 4th Saturday
+ repeat_day=29,
+ repeat_week=False,
+ repeat_month=False,
+ count=6)
+
+ self.assertEqual(len(dates), 5)
+ self.assertEqual(dates[0], datetime(2020, 1, 25))
+ self.assertEqual(dates[1], datetime(2020, 2, 22))
+ self.assertEqual(dates[2], datetime(2020, 3, 28))
+ self.assertEqual(dates[3], datetime(2020, 4, 25))
+ self.assertEqual(dates[4], datetime(2020, 5, 23))
+
+ def test_recurrence_next_dates_year(self):
+ dates = self.env['project.task.recurrence']._get_next_recurring_dates(
+ date_start=date(2020, 12, 1),
+ repeat_interval=1,
+ repeat_unit='year',
+ repeat_type='until',
+ repeat_until=datetime(2026, 1, 1),
+ repeat_on_month=False,
+ repeat_on_year='date',
+ weekdays=False,
+ repeat_day=31,
+ repeat_week=False,
+ repeat_month='november',
+ count=10)
+
+ self.assertEqual(len(dates), 5)
+ self.assertEqual(dates[0], datetime(2021, 11, 30))
+ self.assertEqual(dates[1], datetime(2022, 11, 30))
+ self.assertEqual(dates[2], datetime(2023, 11, 30))
+ self.assertEqual(dates[3], datetime(2024, 11, 30))
+ self.assertEqual(dates[4], datetime(2025, 11, 30))
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index 25da09f7908..bdc03ae1ae3 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -351,9 +351,21 @@
</div>
</div>
</div>
+ <div class="col-lg-6 o_setting_box" id="recurring_tasks_setting" groups="project.group_project_recurring_tasks">
+ <div class="o_setting_left_pane">
+ <field name="allow_recurring_tasks" />
+ </div>
+ <div class="o_setting_right_pane">
+ <label for="allow_recurring_tasks" />
+ <div class="text-muted">
+ Auto-generate tasks for regular activities
+ </div>
+ </div>
+ </div>
</div>
</page>
</notebook>
+
<div class="oe_chatter">
<field name="message_follower_ids" options="{'post_refresh':True}" help="Follow this project to automatically track the events associated to tasks and issues of this project." groups="base.group_user"/>
</div>
@@ -444,6 +456,7 @@
<field name="name" class="o_project_name oe_inline"
string="Project Name" placeholder="e.g. Office Party"/>
<field name="user_id" invisible="1"/>
+ <field name="allow_recurring_tasks" groups="project.group_project_recurring_tasks" />
</group>
<div name="alias_def" colspan="2" attrs="{'invisible': [('alias_domain', '=', False)]}">
<label for="alias_enabled" class="oe_inline" string="Create tasks by sending an email to"/>
@@ -607,6 +620,12 @@
<form string="Task" class="o_form_project_tasks">
<field name="allow_subtasks" invisible="1" />
<field name="is_closed" invisible="1" />
+ <field name="allow_recurring_tasks" invisible="1" />
+ <field name="repeat_show_dow" invisible="1" />
+ <field name="repeat_show_day" invisible="1" />
+ <field name="repeat_show_week" invisible="1" />
+ <field name="repeat_show_month" invisible="1" />
+ <field name="recurrence_id" invisible="1" />
<header>
<button name="action_assign_to_me" string="Assign to Me" type="object" class="oe_highlight"
attrs="{'invisible' : [('user_id', '!=', False)]}"/>
@@ -615,6 +634,10 @@
<div class="text-center alert alert-primary" role="alert" attrs="{'invisible': ['|', ('ribbon_message', '=', False), ('ribbon_message', '=', '')]}">
<field name="ribbon_message"/>
</div>
+ <div class="alert alert-info oe_edit_only" role="status" attrs="{'invisible': ['|', ('recurring_task', '=', False), ('recurrence_id', '=', False)]}">
+ <p>Edit recurring task</p>
+ <field name="recurrence_update" widget="radio"/>
+ </div>
<sheet string="Task">
<div class="oe_button_box" name="button_box">
<button name="action_subtask" type="object" class="oe_stat_button" icon="fa-tasks"
@@ -624,6 +647,15 @@
<button name="%(rating_rating_action_task)d" type="action" attrs="{'invisible': [('rating_count', '=', 0)]}" class="oe_stat_button" icon="fa-smile-o" groups="project.group_project_rating">
<field name="rating_count" string="Rating" widget="statinfo"/>
</button>
+ <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">
+ <div class="o_field_widget o_stat_info">
+ <span class="o_stat_value">
+ <field name="recurring_count" widget="statinfo" nolabel="1" />
+ Tasks
+ </span>
+ <span class="o_stat_text">in Recurrence</span>
+ </div>
+ </button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title pr-0">
@@ -654,6 +686,7 @@
attrs="{'invisible' : [('allow_subtasks', '=', False)]}"
/>
<field name="date_deadline" widget="remaining_days" attrs="{'invisible': [('is_closed', '=', True)]}"/>
+ <field name="recurring_task" attrs="{'invisible': [('allow_recurring_tasks', '=', False)]}" />
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
</group>
@@ -662,6 +695,51 @@
<field name="description" type="html"/>
<div class="oe_clear"/>
</page>
+ <page name="recurrence" string="Recurrence" attrs="{'invisible': [('recurring_task', '=', False)]}">
+ <group>
+ <group>
+ <label for="repeat_interval" />
+ <div class="o_row">
+ <field name="repeat_interval" attrs="{'required': [('recurring_task', '=', True)]}" />
+ <field name="repeat_unit" attrs="{'required': [('recurring_task', '=', True)]}" />
+ </div>
+
+ <label for="repeat_on_month" string="Repeat On" attrs="{'invisible': [('repeat_unit', 'not in', ('month', 'year'))]}" />
+ <div class="o_row">
+ <field name="repeat_on_month" attrs="{'invisible': [('repeat_unit', '!=', 'month')], 'required': [('repeat_unit', '=', 'month')]}" />
+ <field name="repeat_on_year" attrs="{'invisible': [('repeat_unit', '!=', 'year')], 'required': [('repeat_unit', '=', 'year')]}" />
+
+ <field name="repeat_day" attrs="{'invisible': [('repeat_show_day', '=', False)], 'required': [('repeat_show_day', '=', True)]}" />
+ <field name="repeat_week" attrs="{'invisible': [('repeat_show_week', '=', False)], 'required': [('repeat_show_week', '=', True)]}" />
+ <field name="repeat_weekday" attrs="{'invisible': [('repeat_show_week', '=', False)], 'required': [('repeat_show_week', '=', True)]}" />
+ <span attrs="{'invisible': ['|', ('repeat_show_week', '=', False), ('repeat_show_month', '=', False)]}">of</span>
+ <field name="repeat_month" attrs="{'invisible': [('repeat_show_month', '=', False)], 'required': [('repeat_show_month', '=', True)]}" />
+ </div>
+
+
+ <field name="mon" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="tue" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="wed" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="thu" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="fri" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="sat" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+ <field name="sun" attrs="{'invisible': [('repeat_show_dow', '=', False)]}" />
+
+ <label for="repeat_type" />
+ <div class="o_row">
+ <field name="repeat_type" attrs="{'required': [('recurring_task', '=', True)]}" />
+ <field name="repeat_until" attrs="{'invisible': [('repeat_type', '!=', 'until')], 'required': [('repeat_type', '=', 'until')]}" />
+ <field name="repeat_number" attrs="{'invisible': [('repeat_type', '!=', 'after')], 'required': [('repeat_type', '=', 'after')]}" />
+ </div>
+ </group>
+ </group>
+ <group attrs="{'invisible': ['|', ('recurring_task', '=', False), ('recurrence_message', '=', False)]}">
+ <div class="alert alert-success o_form_project_recurrence_message" role="status">
+ <p><span class="fa fa-check-circle"></span> Next Occurrences:</p>
+ <field name="recurrence_message" widget="html" class="mb-0" />
+ </div>
+ </group>
+ </page>
<page name="extra_info" string="Extra Info" groups="base.group_no_one">
<group>
<group>
diff --git a/addons/project/views/res_config_settings_views.xml b/addons/project/views/res_config_settings_views.xml
index 03db2df0758..074cccfd390 100644
--- a/addons/project/views/res_config_settings_views.xml
+++ b/addons/project/views/res_config_settings_views.xml
@@ -49,6 +49,17 @@
</div>
</div>
</div>
+ <div class="col-12 col-lg-6 o_setting_box" id="recurring_tasks_setting">
+ <div class="o_setting_left_pane">
+ <field name="group_project_recurring_tasks"/>
+ </div>
+ <div class="o_setting_right_pane">
+ <label for="group_project_recurring_tasks"/>
+ <div class="text-muted">
+ Auto-generate tasks for regular activities
+ </div>
+ </div>
+ </div>
</div>
<h2>Time Management</h2>
<div class="row mt16 o_settings_container" name="project_time">
diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py
index e30c3de3f63..d2834b4105a 100644
--- a/addons/sale_project/models/project.py
+++ b/addons/sale_project/models/project.py
@@ -143,3 +143,13 @@ class ProjectTask(models.Model):
})
action['context'] = context
return action
+
+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]
+ if not task.is_fsm:
+ values['sale_line_id'] = task.sale_line_id.id
+ return values
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 0e4f2a69cd0..0354d95276f 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -309,3 +309,10 @@ class ProjectTask(models.Model):
def _get_action_view_so_ids(self):
return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
+
+class ProjectTaskRecurrence(models.Model):
+ _inherit = 'project.task.recurrence'
+
+ @api.model
+ def _get_recurring_fields(self):
+ return ['analytic_account_id'] + super(ProjectTaskRecurrence, self)._get_recurring_fields()