diff options
| -rw-r--r-- | addons/project/__manifest__.py | 1 | ||||
| -rw-r--r-- | addons/project/data/project_data.xml | 10 | ||||
| -rw-r--r-- | addons/project/models/__init__.py | 1 | ||||
| -rw-r--r-- | addons/project/models/project.py | 250 | ||||
| -rw-r--r-- | addons/project/models/project_task_recurrence.py | 258 | ||||
| -rw-r--r-- | addons/project/models/res_config_settings.py | 6 | ||||
| -rw-r--r-- | addons/project/security/ir.model.access.csv | 1 | ||||
| -rw-r--r-- | addons/project/security/project_security.xml | 5 | ||||
| -rw-r--r-- | addons/project/static/src/css/project.css | 4 | ||||
| -rw-r--r-- | addons/project/static/src/scss/project_dashboard.scss | 12 | ||||
| -rw-r--r-- | addons/project/static/src/xml/project_templates.xml | 17 | ||||
| -rw-r--r-- | addons/project/tests/__init__.py | 2 | ||||
| -rw-r--r-- | addons/project/tests/test_project_recurrence.py | 351 | ||||
| -rw-r--r-- | addons/project/views/project_views.xml | 78 | ||||
| -rw-r--r-- | addons/project/views/res_config_settings_views.xml | 11 | ||||
| -rw-r--r-- | addons/sale_project/models/project.py | 10 | ||||
| -rw-r--r-- | addons/sale_timesheet/models/project.py | 7 |
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() |
