diff options
25 files changed, 1440 insertions, 1207 deletions
diff --git a/addons/calendar/__init__.py b/addons/calendar/__init__.py index 88710707227..7d34c7c054a 100644 --- a/addons/calendar/__init__.py +++ b/addons/calendar/__init__.py @@ -3,4 +3,3 @@ from . import controllers from . import models -from . import wizard diff --git a/addons/calendar/models/__init__.py b/addons/calendar/models/__init__.py index 7dc8d472244..3f5cb8f1be5 100644 --- a/addons/calendar/models/__init__.py +++ b/addons/calendar/models/__init__.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import ir_attachment from . import ir_http from . import res_partner -from . import mail_message from . import calendar_event from . import calendar_alarm from . import calendar_alarm_manager @@ -12,5 +10,6 @@ from . import calendar_attendee from . import calendar_contact from . import calendar_event_type from . import calendar_event +from . import calendar_recurrence from . import mail_activity from . import res_users diff --git a/addons/calendar/models/calendar_alarm_manager.py b/addons/calendar/models/calendar_alarm_manager.py index e6809a6b74a..943073c11c2 100644 --- a/addons/calendar/models/calendar_alarm_manager.py +++ b/addons/calendar/models/calendar_alarm_manager.py @@ -31,20 +31,21 @@ class AlarmManager(models.AbstractModel): cal.id, cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm, CASE - WHEN cal.recurrency THEN cal.final_date - interval '1' minute * calcul_delta.min_delta + WHEN cal.recurrency THEN rrule.until - interval '1' minute * calcul_delta.min_delta ELSE cal.stop - interval '1' minute * calcul_delta.min_delta END as last_alarm, cal.start as first_event_date, CASE - WHEN cal.recurrency THEN cal.final_date + WHEN cal.recurrency THEN rrule.until ELSE cal.stop END as last_event_date, calcul_delta.min_delta, calcul_delta.max_delta, - cal.rrule AS rule + rrule.rrule AS rule FROM calendar_event AS cal RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id + LEFT JOIN calendar_recurrence as rrule ON rrule.id = cal.recurrence_id """ filter_user = """ @@ -157,10 +158,10 @@ class AlarmManager(models.AbstractModel): for meeting in self.env['calendar.event'].browse(all_meetings): max_delta = all_meetings[meeting.id]['max_duration'] - if meeting.recurrency: + if meeting.recurrency and meeting.recurrence_id: at_least_one = False last_found = False - for one_date in meeting._get_recurrent_date_by_event(): + for one_date in meeting.recurrence_id._get_occurrences(meeting.start): in_date_format = one_date.replace(tzinfo=None) last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True) for alert in last_found: @@ -187,10 +188,10 @@ class AlarmManager(models.AbstractModel): for event_id in all_meetings: max_delta = all_meetings[event_id]['max_duration'] meeting = self.env['calendar.event'].browse(event_id) - if meeting.recurrency: + if meeting.recurrency and meeting.recurrence_id: b_found = False last_found = False - for one_date in meeting._get_recurrent_date_by_event(): + for one_date in meeting.recurrence_id._get_occurrences(meeting.start): in_date_format = one_date.replace(tzinfo=None) last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack) if last_found: diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 91e5790fb9a..84842effcae 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -3,83 +3,25 @@ import babel.dates import datetime -from datetime import timedelta, MAXYEAR -from dateutil import rrule +import math +from datetime import timedelta from dateutil.relativedelta import relativedelta import logging import pytz -import re -import time from odoo import api, fields, models from odoo import tools from odoo.addons.base.models.res_partner import _tz_get from odoo.addons.calendar.models.calendar_attendee import Attendee -from odoo.osv import expression +from odoo.addons.calendar.models.calendar_recurrence import weekday_to_field, RRULE_TYPE_SELECTION, END_TYPE_SELECTION, MONTH_BY_SELECTION, WEEKDAY_SELECTION, BYDAY_SELECTION from odoo.tools.translate import _ from odoo.tools.misc import get_lang -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import pycompat from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) -VIRTUALID_DATETIME_FORMAT = "%Y%m%d%H%M%S" - - -def calendar_id2real_id(calendar_id=None, with_date=False): - """ Convert a "virtual/recurring event id" (type string) into a real event id (type int). - E.g. virtual/recurring event id is 4-20091201100000, so it will return 4. - :param calendar_id: id of calendar - :param with_date: if a value is passed to this param it will return dates based on value of withdate + calendar_id - :return: real event id - """ - if calendar_id and isinstance(calendar_id, str): - res = [bit for bit in calendar_id.split('-') if bit] - if len(res) == 2: - real_id = res[0] - if with_date: - real_date = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT, time.strptime(res[1], VIRTUALID_DATETIME_FORMAT)) - start = datetime.datetime.strptime(real_date, DEFAULT_SERVER_DATETIME_FORMAT) - end = start + timedelta(hours=with_date) - return (int(real_id), real_date, end.strftime(DEFAULT_SERVER_DATETIME_FORMAT)) - return int(real_id) - return calendar_id and int(calendar_id) or calendar_id - - -def get_real_ids(ids): - if isinstance(ids, (str, int)): - return calendar_id2real_id(ids) - - if isinstance(ids, (list, tuple)): - return [calendar_id2real_id(_id) for _id in ids] - - -def real_id2calendar_id(record_id, date): - return '%s-%s' % (record_id, date.strftime(VIRTUALID_DATETIME_FORMAT)) - - -def any_id2key(record_id): - """ Creates a (real_id: int, thing: str) pair which allows ordering mixed - collections of real and virtual events. - - The first item of the pair is the event's real id, the second one is - either an empty string (for real events) or the datestring (for virtual - ones) - - :param record_id: - :type record_id: int | str - :rtype: (int, str) - """ - if isinstance(record_id, int): - return record_id, u'' - - (real_id, virtual_id) = record_id.split('-') - return int(real_id), virtual_id - -def is_calendar_id(record_id): - return len(str(record_id).split('-')) != 1 - SORT_ALIASES = { 'start': 'sort_start', @@ -87,8 +29,20 @@ SORT_ALIASES = { 'start_datetime': 'sort_start', } -def sort_remap(f): - return SORT_ALIASES.get(f, f) +def get_weekday_occurence(date): + """ + :returns: ocurrence + + >>> get_weekday_occurence(date(2019, 12, 17)) + 3 # third Tuesday of the month + + >>> get_weekday_occurence(date(2019, 12, 25)) + -1 # last Friday of the month + """ + occurence_in_month = math.ceil(date.day/7) + if occurence_in_month in {4, 5}: # fourth or fifth week on the month -> last + return -1 + return occurence_in_month class Meeting(models.Model): @@ -100,7 +54,7 @@ class Meeting(models.Model): _name = 'calendar.event' _description = "Calendar Event" - _order = "id desc" + _order = "start desc" _inherit = ["mail.thread"] @api.model @@ -135,93 +89,6 @@ class Meeting(models.Model): partners |= self.env['res.partner'].browse(active_id) return partners - def _get_recurrent_dates_by_event(self): - """ Get recurrent start and stop dates based on Rule string""" - start_dates = self._get_recurrent_date_by_event(date_field='start') - stop_dates = self._get_recurrent_date_by_event(date_field='stop') - return list(zip(start_dates, stop_dates)) - - def _get_recurrent_date_by_event(self, date_field='start'): - """ Get recurrent dates based on Rule string and all event where recurrent_id is child - - date_field: the field containing the reference date information for recurrence computation - """ - self.ensure_one() - if date_field in self._fields and self._fields[date_field].type in ('date', 'datetime'): - reference_date = self[date_field] - else: - reference_date = self.start - - timezone = pytz.timezone(self.event_tz) if self.event_tz else pytz.timezone(self._context.get('tz') or 'UTC') - event_date = pytz.UTC.localize(fields.Datetime.from_string(reference_date)) # Add "+hh:mm" timezone - if not event_date: - event_date = datetime.datetime.now() - - use_naive_datetime = self.allday and self.rrule and 'UNTIL' in self.rrule and 'Z' not in self.rrule - if not use_naive_datetime: - # Convert the event date to saved timezone (or context tz) as it'll - # define the correct hour/day asked by the user to repeat for recurrence. - event_date = event_date.astimezone(timezone) - - # The start date is naive - # the timezone will be applied, if necessary, at the very end of the process - # to allow for DST timezone reevaluation - rset1 = rrule.rrulestr(str(self.rrule), dtstart=event_date.replace(tzinfo=None), forceset=True, ignoretz=True) - - recurring_meetings = self.search([('recurrent_id', '=', self.id), '|', ('active', '=', False), ('active', '=', True)]) - - # We handle a maximum of 50,000 meetings at a time, and clear the cache at each step to - # control the memory usage. - invalidate = False - for meetings in self.env.cr.split_for_in_conditions(recurring_meetings, size=50000): - if invalidate: - self.invalidate_cache() - for meeting in meetings: - recurring_date = fields.Datetime.from_string(meeting.recurrent_id_date) - if use_naive_datetime: - recurring_date = recurring_date.replace(tzinfo=None) - else: - if not recurring_date.tzinfo: - recurring_date = pytz.UTC.localize(recurring_date) - recurring_date = recurring_date.astimezone(timezone).replace(tzinfo=None) - if date_field == "stop": - recurring_date += timedelta(hours=self.duration) - rset1.exdate(recurring_date) - invalidate = True - - def naive_tz_to_utc(d): - return timezone.localize(d.replace(tzinfo=None), is_dst=True).astimezone(pytz.UTC) - return [naive_tz_to_utc(d) if not use_naive_datetime else d for d in rset1 if d.year < MAXYEAR] - - def _get_recurrency_end_date(self): - """ Return the last date a recurring event happens, according to its end_type. """ - self.ensure_one() - data = self.read(['final_date', 'recurrency', 'rrule_type', 'count', 'end_type', 'stop', 'interval'])[0] - - if not data.get('recurrency'): - return False - - end_type = data.get('end_type') - final_date = data.get('final_date') - if end_type == 'count' and all(data.get(key) for key in ['count', 'rrule_type', 'stop', 'interval']): - count = (data['count'] + 1) * data['interval'] - delay, mult = { - 'daily': ('days', 1), - 'weekly': ('days', 7), - 'monthly': ('months', 1), - 'yearly': ('years', 1), - }[data['rrule_type']] - - deadline = fields.Datetime.from_string(data['stop']) - computed_final_date = False - while not computed_final_date and count > 0: - try: # may crash if year > 9999 (in case of recurring events) - computed_final_date = deadline + relativedelta(**{delay: count * mult}) - except ValueError: - count -= data['interval'] - return computed_final_date or deadline - return final_date - def _find_my_attendee(self): """ Return the first attendee where the user connected has been invited from all the meeting_ids in parameters. @@ -242,9 +109,13 @@ class Meeting(models.Model): @api.model def _get_recurrent_fields(self): - return ['byday', 'recurrency', 'final_date', 'rrule_type', 'month_by', + return {'byday', 'until', 'rrule_type', 'month_by', 'event_tz', 'rrule', 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa', - 'su', 'day', 'week_list'] + 'su', 'day', 'weekday'} + + @api.model + def _get_time_fields(self): + return {'start', 'stop', 'start_date', 'stop_date', 'start_datetime', 'stop_datetime'} @api.model def _get_display_time(self, start, stop, zduration, zallday): @@ -327,7 +198,7 @@ class Meeting(models.Model): stop_date = fields.Date('End Date', compute='_compute_dates', inverse='_inverse_dates', store=True, states={'done': [('readonly', True)]}, tracking=True) stop_datetime = fields.Datetime('End Datetime', compute='_compute_dates', inverse='_inverse_dates', store=True, states={'done': [('readonly', True)]}, tracking=True) # old date_deadline event_tz = fields.Selection('_event_tz_get', string='Timezone', default=lambda self: self.env.context.get('tz') or self.user_id.tz) - duration = fields.Float('Duration', states={'done': [('readonly', True)]}) + duration = fields.Float('Duration', compute='_compute_dates', inverse='_inverse_duration', compute_sudo=True, states={'done': [('readonly', True)]}) description = fields.Text('Description', states={'done': [('readonly', True)]}) privacy = fields.Selection([('public', 'Everyone'), ('private', 'Only me'), ('confidential', 'Only internal users')], 'Privacy', default='public', states={'done': [('readonly', True)]}, required=True) location = fields.Char('Location', states={'done': [('readonly', True)]}, tracking=True, help="Location of Event") @@ -342,61 +213,60 @@ class Meeting(models.Model): #redifine message_ids to remove autojoin to avoid search to crash in get_recurrent_ids message_ids = fields.One2many(auto_join=False) + user_id = fields.Many2one('res.users', 'Owner', default=lambda self: self.env.user) + partner_id = fields.Many2one( + 'res.partner', string='Responsible', related='user_id.partner_id', readonly=True) + active = fields.Boolean( + 'Active', default=True, + help="If the active field is set to false, it will allow you to hide the event alarm information without removing it.") + categ_ids = fields.Many2many( + 'calendar.event.type', 'meeting_category_rel', 'event_id', 'type_id', 'Tags') + attendee_ids = fields.One2many( + 'calendar.attendee', 'event_id', 'Participant') + partner_ids = fields.Many2many( + 'res.partner', 'calendar_event_res_partner_rel', + string='Attendees', default=_default_partners) + alarm_ids = fields.Many2many( + 'calendar.alarm', 'calendar_alarm_calendar_event_rel', + string='Reminders', ondelete="restrict") + is_highlighted = fields.Boolean( + compute='_compute_is_highlighted', string='Is the Event Highlighted') + # RECURRENCE FIELD - rrule = fields.Char('Recurrent Rule', compute='_compute_rrule', inverse='_inverse_rrule', store=True) - rrule_type = fields.Selection([ - ('daily', 'Days'), - ('weekly', 'Weeks'), - ('monthly', 'Months'), - ('yearly', 'Years') - ], string='Recurrence', states={'done': [('readonly', True)]}, help="Let the event automatically repeat at that interval") - recurrency = fields.Boolean('Recurrent', help="Recurrent Meeting") - recurrent_id = fields.Integer('Recurrent ID') - recurrent_id_date = fields.Datetime('Recurrent ID date') - end_type = fields.Selection([ - ('count', 'Number of repetitions'), - ('end_date', 'End date') - ], string='Recurrence Termination', default='count') - interval = fields.Integer(string='Repeat Every', default=1, help="Repeat every (Days/Week/Month/Year)") - count = fields.Integer(string='Repeat', help="Repeat x times", default=1) - mo = fields.Boolean('Mon') - tu = fields.Boolean('Tue') - we = fields.Boolean('Wed') - th = fields.Boolean('Thu') - fr = fields.Boolean('Fri') - sa = fields.Boolean('Sat') - su = fields.Boolean('Sun') - month_by = fields.Selection([ - ('date', 'Date of month'), - ('day', 'Day of month') - ], string='Option', default='date') - day = fields.Integer('Date of month', default=1) - week_list = fields.Selection([ - ('MO', 'Monday'), - ('TU', 'Tuesday'), - ('WE', 'Wednesday'), - ('TH', 'Thursday'), - ('FR', 'Friday'), - ('SA', 'Saturday'), - ('SU', 'Sunday') - ], string='Weekday') - byday = fields.Selection([ - ('1', 'First'), - ('2', 'Second'), - ('3', 'Third'), - ('4', 'Fourth'), - ('5', 'Fifth'), - ('-1', 'Last') - ], string='By day') - final_date = fields.Date('Repeat Until') - user_id = fields.Many2one('res.users', 'Owner', states={'done': [('readonly', True)]}, default=lambda self: self.env.user) - partner_id = fields.Many2one('res.partner', string='Responsible', related='user_id.partner_id', readonly=True) - active = fields.Boolean('Active', default=True, help="If the active field is set to false, it will allow you to hide the event alarm information without removing it.") - categ_ids = fields.Many2many('calendar.event.type', 'meeting_category_rel', 'event_id', 'type_id', 'Tags') - attendee_ids = fields.One2many('calendar.attendee', 'event_id', 'Participant') - partner_ids = fields.Many2many('res.partner', 'calendar_event_res_partner_rel', string='Attendees', states={'done': [('readonly', True)]}, default=_default_partners) - alarm_ids = fields.Many2many('calendar.alarm', 'calendar_alarm_calendar_event_rel', string='Reminders', ondelete="restrict", copy=False) - is_highlighted = fields.Boolean(compute='_compute_is_highlighted', string='Is the Event Highlighted') + recurrency = fields.Boolean('Recurrent', help="Recurrent Event") + recurrence_id = fields.Many2one('calendar.recurrence', string="Recurrence Rule", index=True) + recurrence_update = fields.Selection([ + ('self_only', "This event"), + ('future_events', "This and following events"), + ('all_events', "All events"), + ], store=False, copy=False, default='self_only', + help="Choose what to do with other events in the recurrence. Updating All Events is not allowed when dates or time is modified") + + # Those field are pseudo-related fields of recurrence_id. + # They can't be "real" related fields because it should work at record creation + # when recurrence_id is not created yet. + # If some of these fields are set and recurrence_id does not exists, a `calendar.recurrence.rule` + # will be dynamically created. + rrule = fields.Char('Recurrent Rule', compute='_compute_recurrence', readonly=False) + rrule_type = fields.Selection(RRULE_TYPE_SELECTION, string='Recurrence', + help="Let the event automatically repeat at that interval", + compute='_compute_recurrence', readonly=False) + event_tz = fields.Selection(_tz_get, string='Timezone', compute='_compute_recurrence', readonly=False) + end_type = fields.Selection(END_TYPE_SELECTION, string='Recurrence Termination', compute='_compute_recurrence', readonly=False) + interval = fields.Integer(string='Repeat Every', help="Repeat every (Days/Week/Month/Year)", compute='_compute_recurrence', readonly=False) + count = fields.Integer(string='Repeat', help="Repeat x times", compute='_compute_recurrence', readonly=False) + mo = fields.Boolean('Mon', compute='_compute_recurrence', readonly=False) + tu = fields.Boolean('Tue', compute='_compute_recurrence', readonly=False) + we = fields.Boolean('Wed', compute='_compute_recurrence', readonly=False) + th = fields.Boolean('Thu', compute='_compute_recurrence', readonly=False) + fr = fields.Boolean('Fri', compute='_compute_recurrence', readonly=False) + sa = fields.Boolean('Sat', compute='_compute_recurrence', readonly=False) + su = fields.Boolean('Sun', compute='_compute_recurrence', readonly=False) + month_by = fields.Selection(MONTH_BY_SELECTION, string='Option', compute='_compute_recurrence', readonly=False) + day = fields.Integer('Date of month', compute='_compute_recurrence', readonly=False) + weekday = fields.Selection(WEEKDAY_SELECTION, compute='_compute_recurrence', readonly=False) + byday = fields.Selection(BYDAY_SELECTION, compute='_compute_recurrence', readonly=False) + until = fields.Date(compute='_compute_recurrence', readonly=False) def _compute_attendee(self): for meeting in self: @@ -441,6 +311,7 @@ class Meeting(models.Model): # Convention break: # stop and start are NOT in UTC in allday event # in this case, they actually represent a date + # because fullcalendar just drops times for full day events. # i.e. Christmas is on 25/12 for everyone # even if people don't celebrate it simultaneously enddate = fields.Datetime.from_string(meeting.stop_date) @@ -457,28 +328,9 @@ class Meeting(models.Model): meeting.write({'start': meeting.start_datetime, 'stop': meeting.stop_datetime}) - @api.depends('byday', 'recurrency', 'final_date', 'rrule_type', 'month_by', 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su', 'day', 'week_list') - def _compute_rrule(self): - """ Gets Recurrence rule string according to value type RECUR of iCalendar from the values given. - :return dictionary of rrule value. - """ - for meeting in self: - if meeting.recurrency: - meeting.rrule = meeting._rrule_serialize() - else: - meeting.rrule = '' - - def _inverse_rrule(self): - for meeting in self: - if meeting.rrule: - data = self._rrule_default_values() - data['recurrency'] = True - data.update(self._rrule_parse(meeting.rrule, data, meeting.start)) - meeting.update(data) - - @api.model - def _event_tz_get(self): - return _tz_get(self) + def _inverse_duration(self): + for event in self: + event.stop = event.start + relativedelta(hours=event.duration) @api.constrains('start_datetime', 'stop_datetime', 'start_date', 'stop_date') def _check_closing_date(self): @@ -519,6 +371,23 @@ class Meeting(models.Model): # Calendar Business, Reccurency, ... #################################################### + @api.depends('recurrence_id', 'recurrency') + def _compute_recurrence(self): + recurrence_fields = self._get_recurrent_fields() + false_values = {field: False for field in recurrence_fields} # computes need to set a value + defaults = self.env['calendar.recurrence'].default_get(recurrence_fields) + for event in self: + if event.recurrency: + event_values = event._get_recurrence_params() + rrule_values = { + field: event.recurrence_id[field] + for field in recurrence_fields + if event.recurrence_id[field] + } + event.update({**false_values, **defaults, **event_values, **rrule_values}) + else: + event.update(false_values) + def _get_ics_file(self): """ Returns iCalendar file for the event invitation. :returns a dict of .ics file content for each meeting @@ -593,6 +462,7 @@ class Meeting(models.Model): 'event_id': meeting.id, } + # TODO move this in the correct module if self._context.get('google_internal_event_id', False): values['google_internal_event_id'] = self._context.get('google_internal_event_id') @@ -634,259 +504,6 @@ class Meeting(models.Model): } return result - def get_search_fields(self, order_fields, r_date=None): - sort_fields = {} - for field in order_fields: - if field == 'id' and r_date: - sort_fields[field] = real_id2calendar_id(self.id, r_date) - else: - sort_fields[field] = self[field] - if isinstance(self[field], models.BaseModel): - name_get = self[field].mapped('display_name') - sort_fields[field] = name_get and name_get[0] or '' - if r_date: - sort_fields['sort_start'] = r_date.strftime(VIRTUALID_DATETIME_FORMAT) - else: - display_start = self.display_start - sort_fields['sort_start'] = display_start.replace(' ', '').replace('-', '') if display_start else False - return sort_fields - - def get_recurrent_ids(self, domain, order=None): - """ Gives virtual event ids for recurring events. This method gives ids of dates - that comes between start date and end date of calendar views - :param order: The fields (comma separated, format "FIELD {DESC|ASC}") on which - the events should be sorted - """ - if order: - order_fields = [field.split()[0] for field in order.split(',')] - else: - # fallback on self._order defined on the model - order_fields = [field.split()[0] for field in self._order.split(',')] - - if 'id' not in order_fields: - order_fields.append('id') - - leaf_evaluations = None - recurrent_ids = [meeting.id for meeting in self if meeting.recurrency and meeting.rrule] - #compose a query of the type SELECT id, condition1 as domain1, condition2 as domaine2 - #This allows to load leaf interpretation of the where clause in one query - #leaf_evaluations is then used when running custom interpretation of domain for recuring events - if self and recurrent_ids: - select_fields = ["id"] - where_params_list = [] - for pos, arg in enumerate(domain): - # hack: if we received recurrent ids, we need to clean them - if arg[0] == 'id' and arg[1] in ('in', 'not in'): - arg = (arg[0], arg[1], [calendar_id2real_id(id) for id in arg[2]]) - if not arg[0] in ('start', 'stop', 'final_date', '&', '|'): - e = expression.expression([arg], self) - where_clause, where_params = e.to_sql() # CAUTION, wont work if field is autojoin, not supported - select_fields.append("%s as \"%s\"" % (where_clause, str(pos))) - where_params_list += where_params - if len(select_fields) > 1: - query = "SELECT %s FROM calendar_event WHERE id in %%s" % (", ".join(select_fields)) # could be improved by only taking event with recurency ? - where_params_list += [tuple(recurrent_ids)] - self._cr.execute(query, where_params_list) - leaf_evaluations = dict([(row['id'], row) for row in self._cr.dictfetchall()]) - result_data = [] - result = [] - for meeting in self: - if not meeting.recurrency or not meeting.rrule: - result.append(meeting.id) - result_data.append(meeting.get_search_fields(order_fields)) - continue - rdates = meeting._get_recurrent_dates_by_event() - - for r_start_date, r_stop_date in rdates: - # fix domain evaluation - # step 1: check date and replace expression by True or False, replace other expressions by True - # step 2: evaluation of & and | - # check if there are one False - pile = [] - ok = True - r_date = r_start_date # default for empty domain - for pos, arg in enumerate(domain): - if str(arg[0]) in ('start', 'stop', 'final_date'): - if str(arg[0]) == 'start': - r_date = r_start_date - else: - r_date = r_stop_date - if arg[2] and len(arg[2]) > len(r_date.strftime(DEFAULT_SERVER_DATE_FORMAT)): - dformat = DEFAULT_SERVER_DATETIME_FORMAT - else: - dformat = DEFAULT_SERVER_DATE_FORMAT - if (arg[1] == '='): - ok = r_date.strftime(dformat) == arg[2] - if (arg[1] == '>'): - ok = r_date.strftime(dformat) > arg[2] - if (arg[1] == '<'): - ok = r_date.strftime(dformat) < arg[2] - if (arg[1] == '>='): - ok = r_date.strftime(dformat) >= arg[2] - if (arg[1] == '<='): - ok = r_date.strftime(dformat) <= arg[2] - if (arg[1] == '!='): - ok = r_date.strftime(dformat) != arg[2] - pile.append(ok) - elif str(arg) == str('&') or str(arg) == str('|'): - pile.append(arg) - elif leaf_evaluations and meeting.id in leaf_evaluations: - pile.append(bool(leaf_evaluations[meeting.id][str(pos)])) - else: - pile.append(True) - pile.reverse() - new_pile = [] - for item in pile: - if not isinstance(item, str): - res = item - elif str(item) == str('&'): - first = new_pile.pop() - second = new_pile.pop() - res = first and second - elif str(item) == str('|'): - first = new_pile.pop() - second = new_pile.pop() - res = first or second - new_pile.append(res) - - if [True for item in new_pile if not item]: - continue - result_data.append(meeting.get_search_fields(order_fields, r_date=r_start_date)) - - # seq of (field, should_reverse) - sort_spec = list(tools.unique( - (sort_remap(key.split()[0]), key.lower().endswith(' desc')) - for key in (order or self._order).split(',') - )) - def key(record): - # we need to deal with undefined fields, as sorted requires an homogeneous iterable - def boolean_product(x): - x = False if (isinstance(x, models.Model) and not x) else x - if isinstance(x, bool): - return (x, x) - return (True, x) - # first extract the values for each key column (ids need special treatment) - vals_spec = ( - (any_id2key(record[name]) if name == 'id' else boolean_product(record[name]), desc) - for name, desc in sort_spec - ) - # then Reverse if the value matches a "desc" column - return [ - (tools.Reverse(v) if desc else v) - for v, desc in vals_spec - ] - return [r['id'] for r in sorted(result_data, key=key)] - - def _rrule_serialize(self): - """ Compute rule string according to value type RECUR of iCalendar - :return: string containing recurring rule (empty if no rule) - """ - if self.interval <= 0: - raise UserError(_('The interval cannot be negative.')) - if self.end_type == 'count' and self.count < 0: - raise UserError(_('The number of repetitions cannot be negative.')) - - def get_week_string(freq): - weekdays = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] - if freq == 'weekly': - byday = [field.upper() for field in weekdays if self[field]] - if byday: - return ';BYDAY=' + ','.join(byday) - return '' - - def get_month_string(freq): - if freq == 'monthly': - if self.month_by == 'date' and (self.day < 1 or self.day > 31): - raise UserError(_("Please select a proper day of the month.")) - - if self.month_by == 'day' and self.byday and self.week_list: # Eg : Second Monday of the month - return ';BYDAY=' + self.byday + self.week_list - elif self.month_by == 'date': # Eg : 16th of the month - return ';BYMONTHDAY=' + str(self.day) - return '' - - def get_end_date(): - final_date = fields.Date.to_string(self.final_date) - end_date_new = ''.join((re.compile('\d')).findall(final_date)) + 'T235959Z' if final_date else False - return (self.end_type == 'count' and (';COUNT=' + str(max(1, self.count))) or '') +\ - ((end_date_new and self.end_type == 'end_date' and (';UNTIL=' + end_date_new)) or '') - - freq = self.rrule_type # day/week/month/year - result = '' - if freq: - interval_string = self.interval and (';INTERVAL=' + str(self.interval)) or '' - result = 'FREQ=' + freq.upper() + get_week_string(freq) + interval_string + get_end_date() + get_month_string(freq) - return result - - def _rrule_default_values(self): - return { - 'byday': False, - 'recurrency': False, - 'final_date': False, - 'rrule_type': False, - 'month_by': False, - 'interval': 0, - 'count': False, - 'end_type': False, - 'mo': False, - 'tu': False, - 'we': False, - 'th': False, - 'fr': False, - 'sa': False, - 'su': False, - 'day': False, - 'week_list': False - } - - def _rrule_parse(self, rule_str, data, date_start): - day_list = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] - rrule_type = ['yearly', 'monthly', 'weekly', 'daily'] - ddate = fields.Datetime.from_string(date_start) - if 'Z' in rule_str and not ddate.tzinfo: - ddate = ddate.replace(tzinfo=pytz.timezone('UTC')) - rule = rrule.rrulestr(rule_str, dtstart=ddate) - else: - rule = rrule.rrulestr(rule_str, dtstart=ddate) - - if rule._freq > 0 and rule._freq < 4: - data['rrule_type'] = rrule_type[rule._freq] - data['count'] = rule._count - data['interval'] = rule._interval - data['final_date'] = rule._until and rule._until.strftime(DEFAULT_SERVER_DATETIME_FORMAT) - #repeat weekly - if rule._byweekday: - for i in range(0, 7): - if i in rule._byweekday: - data[day_list[i]] = True - data['rrule_type'] = 'weekly' - #repeat monthly by nweekday ((weekday, weeknumber), ) - if rule._bynweekday: - data['week_list'] = day_list[list(rule._bynweekday)[0][0]].upper() - data['byday'] = str(list(rule._bynweekday)[0][1]) - data['month_by'] = 'day' - data['rrule_type'] = 'monthly' - - if rule._bymonthday: - data['day'] = list(rule._bymonthday)[0] - data['month_by'] = 'date' - data['rrule_type'] = 'monthly' - - #repeat yearly but for odoo it's monthly, take same information as monthly but interval is 12 times - if rule._bymonth: - data['interval'] = data['interval'] * 12 - - #FIXEME handle forever case - #end of recurrence - #in case of repeat for ever that we do not support right now - if not (data.get('count') or data.get('final_date')): - data['count'] = 100 - if data.get('count'): - data['end_type'] = 'count' - else: - data['end_type'] = 'end_date' - return data - def get_interval(self, interval, tz=None): """ Format and localize some dates to be used in email templates :param string interval: Among 'day', 'month', 'dayname' and 'time' indicating the desired formatting @@ -927,45 +544,6 @@ class Meeting(models.Model): self = self.with_context(tz=tz) return self._get_display_time(self.start, self.stop, self.duration, self.allday) - def detach_recurring_event(self, values=None): - """ Detach a virtual recurring event by duplicating the original and change reccurent values - :param values : dict of value to override on the detached event - """ - if not values: - values = {} - - real_id = calendar_id2real_id(self.id) - meeting_origin = self.browse(real_id) - - data = self.read(['allday', 'start', 'stop', 'rrule', 'duration'])[0] - if data.get('rrule'): - data.update( - values, - recurrent_id=real_id, - recurrent_id_date=data.get('start'), - rrule_type=False, - rrule='', - recurrency=False, - final_date=False, - end_type=False - ) - - # do not copy the id - if data.get('id'): - del data['id'] - return meeting_origin.with_context(detaching=True).copy(default=data) - - def action_detach_recurring_event(self): - meeting = self.detach_recurring_event() - return { - 'type': 'ir.actions.act_window', - 'res_model': 'calendar.event', - 'view_mode': 'form', - 'res_id': meeting.id, - 'target': 'current', - 'flags': {'form': {'action_buttons': True, 'options': {'mode': 'edit'}}} - } - def action_open_calendar_event(self): if self.res_model and self.res_id: return self.env[self.res_model].browse(self.res_id).get_formview_action() @@ -978,133 +556,130 @@ class Meeting(models.Model): meeting.attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation') return True - #################################################### - # Messaging - #################################################### - - def _get_message_unread(self): - id_map = {x: calendar_id2real_id(x) for x in self.ids} - real = self.browse(set(id_map.values())) - super(Meeting, real)._get_message_unread() - for event in self: - if event.id == id_map[event.id]: - continue - rec = self.browse(id_map[event.id]) - event.message_unread_counter = rec.message_unread_counter - event.message_unread = rec.message_unread - - def _get_message_needaction(self): - id_map = {x: calendar_id2real_id(x) for x in self.ids} - real = self.browse(set(id_map.values())) - super(Meeting, real)._get_message_needaction() + def _apply_recurrence_values(self, values, future=True): + """Apply the new recurrence rules in `values`. Create a recurrence if it does not exist + and create all missing events according to the rrule. + If the changes are applied to future + events only, a new recurrence is created with the updated rrule. + + :param values: new recurrence values to apply + :param future: rrule values are applied to future events only if True. + Rrule changes are applied to all events in the recurrence otherwise. + (ignored if no recurrence exists yet). + :return: events detached from the recurrence + """ + if not values: + return self.browse() + recurrence_vals = [] + to_update = self.env['calendar.recurrence'] for event in self: - if event.id == id_map[event.id]: - continue - rec = self.browse(id_map[event.id]) - event.message_needaction_counter = rec.message_needaction_counter - event.message_needaction = rec.message_needaction - - @api.returns('mail.message', lambda value: value.id) - def message_post(self, **kwargs): - thread_id = self.id - if isinstance(self.id, str): - thread_id = get_real_ids(self.id) - if self.env.context.get('default_date'): - context = dict(self.env.context) - del context['default_date'] - self = self.with_context(context) - return super(Meeting, self.browse(thread_id)).message_post(**kwargs) - - def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None): - records = self.browse(get_real_ids(self.ids)) - return super(Meeting, records).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids) - - def _message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None, customer_ids=None): - records = self.browse(get_real_ids(self.ids)) - return super(Meeting, records)._message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids, customer_ids=customer_ids) - - def message_unsubscribe(self, partner_ids=None, channel_ids=None): - records = self.browse(get_real_ids(self.ids)) - return super(Meeting, records).message_unsubscribe(partner_ids=partner_ids, channel_ids=channel_ids) + if not event.recurrence_id: + recurrence_vals += [dict(values, base_event_id=event.id, calendar_event_ids=[(4, event.id)])] + elif future: + to_update |= event.recurrence_id._split_from(event, values) + self.recurrency = True + to_update |= self.env['calendar.recurrence'].create(recurrence_vals) + return to_update._apply_recurrence() + + def _get_recurrence_params(self): + if not self: + return {} + event_date = self._get_start_date() + weekday_field_name = weekday_to_field(event_date.weekday()) + return { + weekday_field_name: True, + 'weekday': weekday_field_name.upper(), + 'byday': str(get_weekday_occurence(event_date)), + 'day': event_date.day, + } - #################################################### - # ORM Overrides - #################################################### + def _get_start_date(self): + """Return the event starting date in the event's timezone. + If no starting time is assigned (yet), return today as default + :return: date + """ + if not self.start: + return fields.Date.today() + if self.recurrence_id.event_tz: + tz = pytz.timezone(self.recurrence_id.event_tz) + return pytz.utc.localize(self.start).astimezone(tz).date() + return self.start.date() - def get_metadata(self): - real = self.browse({calendar_id2real_id(x) for x in self.ids}) - return super(Meeting, real).get_metadata() + def _split_recurrence(self, time_values): + """Apply time changes to events and update the recurrence accordingly. - @api.model - def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None): - for arg in args: - if arg[0] == 'id': - for n, calendar_id in enumerate(arg[2]): - if isinstance(calendar_id, str): - arg[2][n] = calendar_id.split('-')[0] - return super(Meeting, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + :return: detached events + """ + if not time_values: + return self.browse() + + previous_week_day_field = weekday_to_field(self._get_start_date().weekday()) + self.write(time_values) + return self._apply_recurrence_values({ + previous_week_day_field: False, + **self._get_recurrence_params(), + }, future=True) + + def _break_recurrence(self, future=True): + """Breaks the event's recurrence. + Stop the recurrence at the current event if `future` is True, leaving past events in the recurrence. + If `future` is False, all events in the recurrence are detached and the recurrence itself is unlinked. + :return: detached events excluding the current events + """ + recurrences_to_unlink = self.env['calendar.recurrence'] + detached_events = self.env['calendar.event'] + for event in self: + recurrence = event.recurrence_id + if future: + detached_events |= recurrence._stop_at(event) + else: + detached_events |= recurrence.calendar_event_ids + recurrence.calendar_event_ids.recurrence_id = False + recurrences_to_unlink |= recurrence + recurrences_to_unlink.unlink() + return detached_events - self def write(self, values): - # FIXME: neverending recurring events - if 'rrule' in values: - values['rrule'] = self._fix_rrule(values) + detached_events = self.env['calendar.event'] + recurrence_update_setting = values.pop('recurrence_update', None) + update_recurrence = recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1 + break_recurrence = values.get('recurrency') is False + self._sync_activities(values) - # compute duration, only if start and stop are modified - if not 'duration' in values and 'start' in values and 'stop' in values: - values['duration'] = self._get_duration(values['start'], values['stop']) + recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values} + if update_recurrence: + if break_recurrence: + detached_events |= self._break_recurrence(future=recurrence_update_setting == 'future_events') + else: + update_start = self.start if recurrence_update_setting == 'future_events' else None + time_values = {field: values.pop(field) for field in self.env['calendar.event']._get_time_fields() if field in values} + if not update_start and (time_values or recurrence_values): + raise UserError(_("Updating All Events is not allowed when dates or time is modified. You can only update one particular event and following events.")) + detached_events |= self._split_recurrence(time_values) + self.recurrence_id._write_events(values, dtstart=update_start) + else: + super().write(values) - self._sync_activities(values) - # process events one by one for meeting in self: - # special write of complex IDS - real_ids = [] - new_ids = [] - if not is_calendar_id(meeting.id): - real_ids = [int(meeting.id)] - else: - real_event_id = calendar_id2real_id(meeting.id) - - # if we are setting the recurrency flag to False or if we are only changing fields that - # should be only updated on the real ID and not on the virtual (like message_follower_ids): - # then set real ids to be updated. - blacklisted = any(key in values for key in ('start', 'stop', 'active')) - if not values.get('recurrency', True) or not blacklisted: - real_ids = [real_event_id] - else: - data = meeting.read(['start', 'stop', 'rrule', 'duration'])[0] - if data.get('rrule'): - new_ids = meeting.with_context(dont_notify=True).detach_recurring_event(values).ids # to prevent multiple _notify_next_alarm - - new_meetings = self.browse(new_ids) - real_meetings = self.browse(real_ids) - all_meetings = real_meetings + new_meetings - super(Meeting, real_meetings).write(values) - - # set end_date for calendar searching - if any(field in values for field in ['recurrency', 'end_type', 'count', 'rrule_type', 'start', 'stop']): - for real_meeting in real_meetings: - if real_meeting.recurrency and real_meeting.end_type == u'count': - final_date = real_meeting._get_recurrency_end_date() - super(Meeting, real_meeting).write({'final_date': final_date}) attendees_create = False - if values.get('partner_ids', False): - attendees_create = all_meetings.with_context(dont_notify=True).create_attendees() # to prevent multiple _notify_next_alarm + if values.get('partner_ids'): + attendees_create = self.with_context(dont_notify=True).create_attendees() # to prevent multiple _notify_next_alarm # Notify attendees if there is an alarm on the modified event, or if there was an alarm # that has just been removed, as it might have changed their next event notification if not self._context.get('dont_notify'): if len(meeting.alarm_ids) > 0 or values.get('alarm_ids'): partners_to_notify = meeting.partner_ids.ids - event_attendees_changes = attendees_create and real_ids and attendees_create[real_ids[0]] + event_attendees_changes = attendees_create and meeting and attendees_create[meeting.id] if event_attendees_changes: partners_to_notify.extend(event_attendees_changes['removed_partners'].ids) self.env['calendar.alarm_manager']._notify_next_alarm(partners_to_notify) if (values.get('start_date') or values.get('start_datetime') or (values.get('start') and self.env.context.get('from_ui'))) and values.get('active', True): - for current_meeting in all_meetings: + for current_meeting in self: if attendees_create: attendees_create = attendees_create[current_meeting.id] attendee_to_email = attendees_create['old_attendees'] - attendees_create['removed_attendees'] @@ -1113,21 +688,18 @@ class Meeting(models.Model): if attendee_to_email: attendee_to_email._send_mail_to_attendees('calendar.calendar_template_meeting_changedate') + + if recurrence_update != 'self_only' and not break_recurrence: + detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update == 'future_events') + (detached_events & self).active = False + (detached_events - self).unlink() return True - @api.model + @api.model # LUL TODO create multi def create(self, values): - # FIXME: neverending recurring events - if 'rrule' in values: - values['rrule'] = self._fix_rrule(values) - if not 'user_id' in values: # Else bug with quick_create when we are filter on an other user values['user_id'] = self.env.user.id - # compute duration, if not given - if not 'duration' in values: - values['duration'] = self._get_duration(values['start'], values['stop']) - # created from calendar: try to create an activity on the related record if not values.get('activity_ids'): defaults = self.default_get(['activity_ids', 'res_model_id', 'res_id', 'user_id']) @@ -1147,12 +719,16 @@ class Meeting(models.Model): activity_vals['user_id'] = user_id values['activity_ids'] = [(0, 0, activity_vals)] + recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values} meeting = super(Meeting, self).create(values) + + if values.get('recurrency'): + detached_events = meeting._apply_recurrence_values(recurrence_values) + detached_events.active = False + meeting._sync_activities(values) - final_date = meeting._get_recurrency_end_date() # `dont_notify=True` in context to prevent multiple _notify_next_alarm - meeting.with_context(dont_notify=True).write({'final_date': final_date}) meeting.with_context(dont_notify=True).create_attendees() # Notify attendees if there is an alarm on the created event, as it might have changed their @@ -1162,175 +738,27 @@ class Meeting(models.Model): self.env['calendar.alarm_manager']._notify_next_alarm(meeting.partner_ids.ids) return meeting - def export_data(self, fields_to_export): - """ Override to convert virtual ids to ids """ - records = self.browse(set(get_real_ids(self.ids))) - return super(Meeting, records).export_data(fields_to_export) - - def _read(self, fields): - select = [(x, calendar_id2real_id(x)) for x in self.ids] - result = super(Meeting, self.browse(real_id for calendar_id, real_id in select))._read(fields) - for calendar_id, real_id in select: - if real_id != calendar_id: - calendar = self.browse(calendar_id) - real = self.browse(real_id) - ls = calendar_id2real_id(calendar_id, with_date=True) - for field in fields: - f = self._fields[field] - if field in ('start', 'start_date', 'start_datetime'): - value = ls[1] - elif field in ('stop', 'stop_date', 'stop_datetime'): - value = ls[2] - elif field == 'display_time': - value = self._get_display_time(ls[1], ls[2], real.duration, real.allday) - else: - value = self.env.cache.get(real, f) - self.env.cache.set(calendar, f, value) - return result - @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): if 'date' in groupby: raise UserError(_('Group by date is not supported, use the calendar view instead.')) - return super(Meeting, self.with_context(virtual_id=False)).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) - - def read(self, fields=None, load='_classic_read'): - if not fields: - fields = list(self._fields) - fields2 = fields and fields[:] - EXTRAFIELDS = ('privacy', 'user_id', 'duration', 'allday', 'start', 'rrule') - for f in EXTRAFIELDS: - if fields and (f not in fields): - fields2.append(f) - - select = [(x, calendar_id2real_id(x)) for x in self.ids] - real_events = self.browse([real_id for calendar_id, real_id in select]) - real_data = super(Meeting, real_events).read(fields=fields2, load=load) - real_data = dict((d['id'], d) for d in real_data) - - result = [] - for calendar_id, real_id in select: - if not real_data.get(real_id): - continue - res = real_data[real_id].copy() - ls = calendar_id2real_id(calendar_id, with_date=res and res.get('duration', 0) > 0 and res.get('duration') or 1) - if not isinstance(ls, (str, int)) and len(ls) >= 2: - res['start'] = ls[1] - res['stop'] = ls[2] - - if res['allday']: - res['start_date'] = ls[1] - res['stop_date'] = ls[2] - else: - res['start_datetime'] = ls[1] - res['stop_datetime'] = ls[2] - - if 'display_time' in fields: - res['display_time'] = self._get_display_time(ls[1], ls[2], res['duration'], res['allday']) - - res['id'] = calendar_id - result.append(res) - - for r in result: - if r['user_id']: - user_id = type(r['user_id']) in (tuple, list) and r['user_id'][0] or r['user_id'] - partner_id = self.env.user.partner_id.id - if user_id == self.env.user.id or partner_id in r.get("partner_ids", []): - continue - if r['privacy'] == 'private': - for f in r: - recurrent_fields = self._get_recurrent_fields() - public_fields = list(set(recurrent_fields + ['id', 'active', 'allday', 'start', 'stop', 'display_start', 'display_stop', 'duration', 'user_id', 'state', 'interval', 'count', 'recurrent_id_date', 'rrule', 'privacy'])) - if f not in public_fields: - if isinstance(r[f], list): - r[f] = [] - else: - r[f] = False - if f in ['name', 'display_name']: - r[f] = _('Busy') - - for r in result: - for k in EXTRAFIELDS: - if (k in r) and (fields and (k not in fields)): - del r[k] - return result + return super(Meeting, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) - def unlink(self, can_be_deleted=True): + def unlink(self): # Get concerned attendees to notify them if there is an alarm on the unlinked events, # as it might have changed their next event notification - events = self.search([('id', 'in', self.ids), ('alarm_ids', '!=', False)]) + events = self.filtered_domain([('alarm_ids', '!=', False)]) partner_ids = events.mapped('partner_ids').ids - records_to_exclude = self.env['calendar.event'] - records_to_unlink = self.env['calendar.event'].with_context(recompute=False) - - for meeting in self: - if can_be_deleted and not is_calendar_id(meeting.id): # if ID REAL - if meeting.recurrent_id: - records_to_exclude |= meeting - else: - # int() required because 'id' from calendar view is a string, since it can be calendar virtual id - records_to_unlink |= self.browse(int(meeting.id)) - else: - records_to_exclude |= meeting - - result = False - if records_to_unlink: - result = super(Meeting, records_to_unlink).unlink() - if records_to_exclude: - result = records_to_exclude.with_context(dont_notify=True).write({'active': False}) + result = super().unlink() # Notify the concerned attendees (must be done after removing the events) self.env['calendar.alarm_manager']._notify_next_alarm(partner_ids) return result - @api.model - def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): - if self._context.get('mymeetings'): - args += [('partner_ids', 'in', self.env.user.partner_id.ids)] - - new_args = [] - for arg in args: - new_arg = arg - if arg[0] in ('stop_date', 'stop_datetime', 'stop',) and arg[1] == ">=": - if self._context.get('virtual_id', True): - new_args += ['|', '&', ('recurrency', '=', 1), ('final_date', arg[1], arg[2])] - elif arg[0] == "id": - new_arg = (arg[0], arg[1], get_real_ids(arg[2])) - new_args.append(new_arg) - - # update_custom_fields: context used by the ORM to check if custom fields (studio) should be updated - virtual_id_fallback = not self._context.get('update_custom_fields') - if not self._context.get('virtual_id', virtual_id_fallback): - return super(Meeting, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) - - if any(arg[0] == 'start' for arg in args) and \ - not any(arg[0] in ('stop', 'final_date') for arg in args): - # domain with a start filter but with no stop clause should be extended - # e.g. start=2017-01-01, count=5 => virtual occurences must be included in ('start', '>', '2017-01-02') - start_args = new_args - new_args = [] - for arg in start_args: - new_arg = arg - if arg[0] in ('start_date', 'start_datetime', 'start',): - new_args += ['|', '&', ('recurrency', '=', 1), ('final_date', arg[1], arg[2])] - new_args.append(new_arg) - - # offset, limit, order and count must be treated separately as we may need to deal with virtual ids - event_ids = super(Meeting, self)._search(new_args, offset=0, limit=0, order=None, count=False, access_rights_uid=access_rights_uid) - events = self.browse(event_ids) - events = self.browse(events.get_recurrent_ids(args, order=order)) - if count: - return len(events) - elif limit: - return events[offset: offset + limit].ids - return events.ids - - @api.returns('self', lambda value: value.id) - def copy(self, default=None): + def _range(self): self.ensure_one() - default = default or {} - return super(Meeting, self.browse(calendar_id2real_id(self.id))).copy(default) + return (self.start, self.stop) def _sync_activities(self, values): # update activities @@ -1355,14 +783,6 @@ class Meeting(models.Model): if activity_values.keys(): self.mapped('activity_ids').write(activity_values) - @api.model - def _fix_rrule(self, values): - rule_str = values.get('rrule') - if rule_str: - if 'UNTIL' not in rule_str and 'COUNT' not in rule_str: - rule_str += ';COUNT=100' - return rule_str - def change_attendee_status(self, status): attendee = self.attendee_ids.filtered(lambda x: x.partner_id == self.env.user.partner_id) if status == 'accepted': diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py new file mode 100644 index 00000000000..2553b43218d --- /dev/null +++ b/addons/calendar/models/calendar_recurrence.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz + +from datetime import datetime, time +from dateutil import rrule +from dateutil.relativedelta import relativedelta, MO + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +from odoo.addons.base.models.res_partner import _tz_get + + +MAX_RECURRENT_EVENT = 720 + +SELECT_FREQ_TO_RRULE = { + 'daily': rrule.DAILY, + 'weekly': rrule.WEEKLY, + 'monthly': rrule.MONTHLY, + 'yearly': rrule.YEARLY, +} + +RRULE_FREQ_TO_SELECT = { + rrule.DAILY: 'daily', + rrule.WEEKLY: 'weekly', + rrule.MONTHLY: 'monthly', + rrule.YEARLY: 'yearly', +} + +RRULE_WEEKDAY_TO_FIELD = { + rrule.MO.weekday: 'mo', + rrule.TU.weekday: 'tu', + rrule.WE.weekday: 'we', + rrule.TH.weekday: 'th', + rrule.FR.weekday: 'fr', + rrule.SA.weekday: 'sa', + rrule.SU.weekday: 'su', +} + +RRULE_TYPE_SELECTION = [ + ('daily', 'Days'), + ('weekly', 'Weeks'), + ('monthly', 'Months'), + ('yearly', 'Years'), +] + +END_TYPE_SELECTION = [ + ('count', 'Number of repetitions'), + ('end_date', 'End date'), + ('forever', 'Forever'), +] + +MONTH_BY_SELECTION = [ + ('date', 'Date of month'), + ('day', 'Day of month'), +] + +WEEKDAY_SELECTION = [ + ('MO', 'Monday'), + ('TU', 'Tuesday'), + ('WE', 'Wednesday'), + ('TH', 'Thursday'), + ('FR', 'Friday'), + ('SA', 'Saturday'), + ('SU', 'Sunday'), +] + +BYDAY_SELECTION = [ + ('1', 'First'), + ('2', 'Second'), + ('3', 'Third'), + ('-1', 'Last'), +] + +def freq_to_select(rrule_freq): + return RRULE_FREQ_TO_SELECT[rrule_freq] + + +def freq_to_rrule(freq): + return SELECT_FREQ_TO_RRULE[freq] + + +def weekday_to_field(weekday_index): + return RRULE_WEEKDAY_TO_FIELD.get(weekday_index) + + +class RecurrenceRule(models.Model): + _name = 'calendar.recurrence' + _description = 'Event Recurrence Rule' + + # TODO exdate? + + base_event_id = fields.Many2one('calendar.event', ondelete='set null', copy=False) + calendar_event_ids = fields.One2many('calendar.event', 'recurrence_id') + event_tz = fields.Selection(_tz_get, string='Timezone', default=lambda self: self.env.context.get('tz') or self.env.user.tz) + rrule = fields.Char(compute='_compute_rrule', inverse='_inverse_rrule', store=True) + dtstart = fields.Datetime(compute='_compute_dtstart') + rrule_type = fields.Selection(RRULE_TYPE_SELECTION, default='weekly') + end_type = fields.Selection(END_TYPE_SELECTION, default='count') + interval = fields.Integer(default=1) + count = fields.Integer(default=1) + mo = fields.Boolean() + tu = fields.Boolean() + we = fields.Boolean() + th = fields.Boolean() + fr = fields.Boolean() + sa = fields.Boolean() + su = fields.Boolean() + month_by = fields.Selection(MONTH_BY_SELECTION, default='date') + day = fields.Integer(default=1) + weekday = fields.Selection(WEEKDAY_SELECTION, string='Weekday') + byday = fields.Selection(BYDAY_SELECTION, string='By day') + until = fields.Date('Repeat Until') + + _sql_constraints = [ + ('month_day', "CHECK (rrule_type != 'monthly' OR month_by != 'day' OR day >= 1 AND day <= 31)", "The day must be between 1 and 31"), + ] + + @api.depends('calendar_event_ids.start') + def _compute_dtstart(self): + groups = self.env['calendar.event'].read_group([('recurrence_id', 'in', self.ids)], ['start:min'], ['recurrence_id']) + start_mapping = { + group['recurrence_id'][0]: group['start'] + for group in groups + } + for recurrence in self: + recurrence.dtstart = start_mapping.get(recurrence.id) + + @api.depends('byday', 'until', 'rrule_type', 'month_by', 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su', 'day', 'weekday') + def _compute_rrule(self): + for recurrence in self: + recurrence.rrule = recurrence._rrule_serialize() + + def _inverse_rrule(self): + for recurrence in self: + if recurrence.rrule: + values = self._rrule_parse(recurrence.rrule, recurrence.dtstart) + recurrence.write(values) + + def _reconcile_events(self, ranges): + """ + :param ranges: iterable of tuples (datetime_start, datetime_stop) + :return: tuple (events of the recurrence already in sync with ranges, and ranges not covered by any events) + """ + ranges = set(ranges) + + synced_events = self.calendar_event_ids.filtered(lambda e: e._range() in ranges) + + existing_ranges = set(event._range() for event in synced_events) + ranges_to_create = (event_range for event_range in ranges if event_range not in existing_ranges) + return synced_events, ranges_to_create + + def _apply_recurrence(self): + """Create missing events in the recurrence and detach events which no longer + follow the recurrence rules. + :return: detached events + """ + event_vals = [] + keep = self.env['calendar.event'] + for recurrence in self.filtered('base_event_id'): + self.calendar_event_ids |= recurrence.base_event_id + event = recurrence.base_event_id or recurrence._get_first_event(include_outliers=False) + duration = event.stop - event.start + ranges = set(recurrence._get_ranges(event.start, duration)) + + events_to_keep, ranges = recurrence._reconcile_events(ranges) + keep |= events_to_keep + [base_values] = event.copy_data() + event_vals += [dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id) for start, stop in ranges] + + events = self.calendar_event_ids - keep + detached_events = self._detach_events(events) + self.env['calendar.event'].with_context(no_mail_to_attendees=True, mail_create_nolog=True).create(event_vals) + return detached_events + + def _split_from(self, event, recurrence_values=None): + """Stops the current recurrence at the given event and creates a new one starting + with the event. + :param event: starting point of the new recurrence + :param recurrence_values: values applied to the new recurrence + :return: new recurrence + """ + if recurrence_values is None: + recurrence_values = {} + event.ensure_one() + if not self: + return + [values] = self.copy_data() + detached_events = self._stop_at(event) + + count = recurrence_values.get('count', 0) or len(detached_events) + return self.create({ + **values, + **recurrence_values, + 'base_event_id': event.id, + 'calendar_event_ids': [(6, 0, detached_events.ids)], + 'count': max(count, 1), + }) + + def _stop_at(self, event): + """Stops the recurrence at the given event. Detach the event and all following + events from the recurrence. + + :return: detached events from the recurrence + """ + self.ensure_one() + events = self._get_events_from(event.start) + detached_events = self._detach_events(events) + if not self.calendar_event_ids: + self.unlink() + return detached_events + + if event.allday: + until = self._get_start_of_period(event.start_date) + else: + until_datetime = self._get_start_of_period(event.start) + until_timezoned = pytz.utc.localize(until_datetime).astimezone(self._get_timezone()) + until = until_timezoned.date() + self.write({ + 'end_type': 'end_date', + 'until': until - relativedelta(days=1), + }) + return detached_events + + @api.model + def _detach_events(self, events): + events.write({ + 'recurrence_id': False, + 'recurrency': False, + }) + return events + + def _write_events(self, values, dtstart=None): + """ + Write values on events in the recurrence. + :param values: event values + :param dstart: if provided, only write events starting from this point in time + """ + events = self._get_events_from(dtstart) if dtstart else self.calendar_event_ids + return events.with_context(no_mail_to_attendees=True, dont_notify=True).write(dict(values, recurrence_update='self_only')) + + def _rrule_serialize(self): + """ + Compute rule string according to value type RECUR of iCalendar + :return: string containing recurring rule (empty if no rule) + """ + if self.interval <= 0: + raise UserError(_('The interval cannot be negative.')) + if self.end_type == 'count' and self.count <= 0: + raise UserError(_('The number of repetitions cannot be negative.')) + + return str(self._get_rrule()) if self.rrule_type else '' + + @api.model + def _rrule_parse(self, rule_str, date_start): + # LUL TODO clean this mess + data = {} + day_list = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] + + if 'Z' in rule_str and not date_start.tzinfo: + date_start = pytz.utc.localize(date_start) + rule = rrule.rrulestr(rule_str, dtstart=date_start) + + data['rrule_type'] = freq_to_select(rule._freq) + data['count'] = rule._count + data['interval'] = rule._interval + data['until'] = rule._until + # Repeat weekly + if rule._byweekday: + for weekday in day_list: + data[weekday] = False # reset + for weekday_index in rule._byweekday: + weekday = rrule.weekday(weekday_index) + data[weekday_to_field(weekday.weekday)] = True + data['rrule_type'] = 'weekly' + + # Repeat monthly by nweekday ((weekday, weeknumber), ) + if rule._bynweekday: + data['weekday'] = day_list[list(rule._bynweekday)[0][0]].upper() + data['byday'] = str(list(rule._bynweekday)[0][1]) + data['month_by'] = 'day' + data['rrule_type'] = 'monthly' + + if rule._bymonthday: + data['day'] = list(rule._bymonthday)[0] + data['month_by'] = 'date' + data['rrule_type'] = 'monthly' + + # Repeat yearly but for odoo it's monthly, take same information as monthly but interval is 12 times + if rule._bymonth: + data['interval'] *= 12 + + if data.get('until'): + data['end_type'] = 'end_date' + elif data.get('count'): + data['end_type'] = 'count' + else: + data['end_type'] = 'forever' + return data + + def _get_start_of_period(self, dt): + if self.rrule_type == 'daily': + return dt + elif self.rrule_type == 'weekly': + lang = self.env['res.lang']._lang_get(self.env.user.lang) + week_start = int(lang.week_start) # lang.week_start ranges from '1' to '7' + week_start = rrule.weekday(week_start - 1) # expects an int from 0 to 6 + return dt + relativedelta(weekday=week_start(-1)) + elif self.rrule_type == 'monthly': + return dt + relativedelta(day=1) + elif self.rrule_type == 'yearly': + return dt + + def _get_first_event(self, include_outliers=False): + if not self.calendar_event_ids: + return self.env['calendar.event'] + events = self.calendar_event_ids.sorted('start') + if not include_outliers: + events -= self._get_outliers() + return events[:1] + + def _get_outliers(self): + synced_events = self.env['calendar.event'] + for recurrence in self: + if recurrence.calendar_event_ids: + start = min(recurrence.calendar_event_ids.mapped('start')) + starts = set(recurrence._get_occurrences(start)) + synced_events |= recurrence.calendar_event_ids.filtered(lambda e: e.start in starts) + return self.calendar_event_ids - synced_events + + def _get_ranges(self, start, event_duration): + starts = self._get_occurrences(start) + return ((start, start + event_duration) for start in starts) + + def _get_timezone(self): + return pytz.timezone(self.event_tz or self.env.context.get('tz') or 'UTC') + + def _get_occurrences(self, dtstart): + """ + Get ocurrences of the rrule + :param dtstart: start of the recurrence + :return: iterable of datetimes + """ + self.ensure_one() + dtstart = self._get_start_of_period(dtstart) + if self._is_allday(): + return self._get_rrule(dtstart=dtstart) + + timezone = self._get_timezone() + # Localize the starting datetime to avoid missing the first occurrence + dtstart = pytz.utc.localize(dtstart).astimezone(timezone) + # dtstart is given as a naive datetime, but it actually represents a timezoned datetime + # (rrule package expects a naive datetime) + occurences = self._get_rrule(dtstart=dtstart.replace(tzinfo=None)) + + # Special timezoning is needed to handle DST (Daylight Saving Time) changes. + # Given the following recurrence: + # - monthly + # - 1st of each month + # - timezone US/Eastern (UTC−05:00) + # - at 6am US/Eastern = 11am UTC + # - from 2019/02/01 to 2019/05/01. + # The naive way would be to store: + # 2019/02/01 11:00 - 2019/03/01 11:00 - 2019/04/01 11:00 - 2019/05/01 11:00 (UTC) + # + # But a DST change occurs on 2019/03/10 in US/Eastern timezone. US/Eastern is now UTC−04:00. + # From this point in time, 11am (UTC) is actually converted to 7am (US/Eastern) instead of the expected 6am! + # What should be stored is: + # 2019/02/01 11:00 - 2019/03/01 11:00 - 2019/04/01 10:00 - 2019/05/01 10:00 (UTC) + # ***** ***** + return (timezone.localize(occurrence, is_dst=False).astimezone(pytz.utc).replace(tzinfo=None) for occurrence in occurences) + + def _get_events_from(self, dtstart): + return self.env['calendar.event'].search([ + ('id', 'in', self.calendar_event_ids.ids), + ('start', '>=', dtstart) + ]) + + def _get_week_days(self): + """ + :return: tuple of rrule weekdays for this recurrence. + """ + return tuple( + rrule.weekday(weekday_index) + for weekday_index, weekday in { + rrule.MO.weekday: self.mo, + rrule.TU.weekday: self.tu, + rrule.WE.weekday: self.we, + rrule.TH.weekday: self.th, + rrule.FR.weekday: self.fr, + rrule.SA.weekday: self.sa, + rrule.SU.weekday: self.su, + }.items() if weekday + ) + + def _is_allday(self): + """Returns whether a majority of events are allday or not (there might be some outlier events) + """ + score = sum(1 if e.allday else -1 for e in self.calendar_event_ids) + return score >= 0 + + def _get_rrule(self, dtstart=None): + self.ensure_one() + freq = self.rrule_type + rrule_params = dict( + dtstart=dtstart, + interval=self.interval, + ) + if freq == 'monthly' and self.month_by == 'date': # e.g. every 15th of the month + rrule_params['bymonthday'] = self.day + elif freq == 'monthly' and self.month_by == 'day': # e.g. every 2nd Monday in the month + rrule_params['byweekday'] = getattr(rrule, self.weekday)(int(self.byday)) # e.g. MO(+2) for the second Monday of the month + elif freq == 'weekly': + weekdays = self._get_week_days() + if not weekdays: + raise UserError(_("You have to choose at least one day in the week")) + rrule_params['byweekday'] = weekdays + + if self.end_type == 'count': # e.g. stop after X occurence + rrule_params['count'] = min(self.count, MAX_RECURRENT_EVENT) + elif self.end_type == 'forever': + rrule_params['count'] = MAX_RECURRENT_EVENT + elif self.end_type == 'end_date': # e.g. stop after 12/10/2020 + rrule_params['until'] = datetime.combine(self.until, time.max) + return rrule.rrule( + freq_to_rrule(freq), **rrule_params + ) diff --git a/addons/calendar/models/ir_attachment.py b/addons/calendar/models/ir_attachment.py deleted file mode 100644 index b985c2d206d..00000000000 --- a/addons/calendar/models/ir_attachment.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - -from odoo.addons.calendar.models.calendar_event import get_real_ids - - -class Attachment(models.Model): - - _inherit = "ir.attachment" - - @api.model - def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): - """ Convert the search on real ids in the case it was asked on virtual ids, then call super() """ - args = list(args) - if any([leaf for leaf in args if leaf[0] == "res_model" and leaf[2] == 'calendar.event']): - for index in range(len(args)): - if args[index][0] == "res_id" and isinstance(args[index][2], str): - args[index] = (args[index][0], args[index][1], get_real_ids(args[index][2])) - return super(Attachment, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) - - def write(self, vals): - """ When posting an attachment (new or not), convert the virtual ids in real ids. """ - if isinstance(vals.get('res_id'), str): - vals['res_id'] = get_real_ids(vals.get('res_id')) - return super(Attachment, self).write(vals) diff --git a/addons/calendar/models/ir_http.py b/addons/calendar/models/ir_http.py index fd7e02bea24..f28088382a1 100644 --- a/addons/calendar/models/ir_http.py +++ b/addons/calendar/models/ir_http.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import odoo -from odoo import models, SUPERUSER_ID +from odoo import models from odoo.http import request from odoo.api import Environment diff --git a/addons/calendar/models/mail_message.py b/addons/calendar/models/mail_message.py deleted file mode 100644 index 26f79a68870..00000000000 --- a/addons/calendar/models/mail_message.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - -from odoo.addons.calendar.models.calendar_event import get_real_ids - - -class Message(models.Model): - - _inherit = "mail.message" - - @api.model - def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): - """ Convert the search on real ids in the case it was asked on virtual ids, then call super() """ - args = list(args) - for index in range(len(args)): - if args[index][0] == "res_id": - if isinstance(args[index][2], str): - args[index] = (args[index][0], args[index][1], get_real_ids(args[index][2])) - elif isinstance(args[index][2], list): - args[index] = (args[index][0], args[index][1], [get_real_ids(x) for x in args[index][2]]) - return super(Message, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) - - @api.model - def _find_allowed_model_wise(self, doc_model, doc_dict): - if doc_model == 'calendar.event': - order = self._context.get('order', self.env[doc_model]._order) - for virtual_id in self.env[doc_model].with_context(active_test=False).search([('id', 'in', list(doc_dict))], order=order).ids: - doc_dict.setdefault(virtual_id, doc_dict[get_real_ids(virtual_id)]) - return super(Message, self)._find_allowed_model_wise(doc_model, doc_dict) diff --git a/addons/calendar/models/res_partner.py b/addons/calendar/models/res_partner.py index 1cb4d8cdb3a..b8a25a295b8 100644 --- a/addons/calendar/models/res_partner.py +++ b/addons/calendar/models/res_partner.py @@ -5,8 +5,6 @@ from datetime import datetime from odoo import api, fields, models -from odoo.addons.calendar.models.calendar_event import get_real_ids - class Partner(models.Model): _inherit = 'res.partner' @@ -20,7 +18,7 @@ class Partner(models.Model): datas = [] meeting = None if meeting_id: - meeting = self.env['calendar.event'].browse(get_real_ids(meeting_id)) + meeting = self.env['calendar.event'].browse(meeting_id) for partner in self: data = partner.name_get()[0] diff --git a/addons/calendar/security/ir.model.access.csv b/addons/calendar/security/ir.model.access.csv index 7b8c1e3af2a..07065a19e96 100644 --- a/addons/calendar/security/ir.model.access.csv +++ b/addons/calendar/security/ir.model.access.csv @@ -10,3 +10,4 @@ access_calendar_event_type_manager,calendar.event.type.manager,model_calendar_ev access_calendar_alarm_manager,access_calendar_alarm_manager,model_calendar_alarm_manager,base.group_user,1,1,1,1
access_calendar_contacts_all,access_calendar_contacts_all,model_calendar_contacts,base.group_user,1,1,1,1
access_calendar_contacts,access_calendar_contacts,model_calendar_contacts,base.group_system,1,1,1,1
+access_calendar_recurrence,access_calendar_recurrence,model_calendar_recurrence,base.group_user,1,1,1,1
diff --git a/addons/calendar/static/src/js/calendar_controller.js b/addons/calendar/static/src/js/calendar_controller.js new file mode 100644 index 00000000000..388b4a33061 --- /dev/null +++ b/addons/calendar/static/src/js/calendar_controller.js @@ -0,0 +1,65 @@ +odoo.define('calendar.CalendarController', function (require) { + "use strict"; + + const Controller = require('web.CalendarController'); + const Dialog = require('web.Dialog'); + const { qweb, _t } = require('web.core'); + + const CalendarController = Controller.extend({ + + _askRecurrenceUpdatePolicy() { + return new Promise((resolve, reject) => { + new Dialog(this, { + title: _t('Edit Recurrent event'), + size: 'small', + $content: $(qweb.render('calendar.RecurrentEventUpdate')), + buttons: [{ + text: _t('Confirm'), + classes: 'btn-primary', + close: true, + click: function () { + resolve(this.$('input:checked').val()); + }, + }], + }).open(); + }) + }, + + // TODO factorize duplicated code + /** + * @override + * @private + * @param {OdooEvent} event + */ + async _onDropRecord(event) { + const _super = this._super; // reference to this._super is lost after async call + if (event.data.record.recurrency) { + const recurrenceUpdate = await this._askRecurrenceUpdatePolicy(); + event.data = _.extend({}, event.data, { + 'recurrenceUpdate': recurrenceUpdate, + }); + } + _super.apply(this, arguments); + }, + + /** + * @override + * @private + * @param {OdooEvent} event + */ + async _onUpdateRecord(event) { + const _super = this._super; // reference to this._super is lost after async call + if (event.data.record.recurrency) { + const recurrenceUpdate = await this._askRecurrenceUpdatePolicy(); + event.data = _.extend({}, event.data, { + 'recurrenceUpdate': recurrenceUpdate, + }); + } + _super.apply(this, arguments); + }, + + }); + + return CalendarController; + +}); diff --git a/addons/calendar/static/src/js/calendar_model.js b/addons/calendar/static/src/js/calendar_model.js new file mode 100644 index 00000000000..4d5a2b49b44 --- /dev/null +++ b/addons/calendar/static/src/js/calendar_model.js @@ -0,0 +1,24 @@ +odoo.define('calendar.CalendarModel', function (require) { + "use strict"; + + const Model = require('web.CalendarModel'); + + const CalendarModel = Model.extend({ + + /** + * @override + * Transform fullcalendar event object to odoo Data object + */ + calendarEventToRecord(event) { + const data = this._super(event); + return _.extend({}, data, { + 'recurrence_update': event.recurrenceUpdate, + }); + } + + + }); + + return CalendarModel; + +}); diff --git a/addons/calendar/static/src/js/calendar_view.js b/addons/calendar/static/src/js/calendar_view.js index 6c6f8ea0d54..88b0e7cd264 100644 --- a/addons/calendar/static/src/js/calendar_view.js +++ b/addons/calendar/static/src/js/calendar_view.js @@ -3,6 +3,8 @@ odoo.define('calendar.CalendarView', function (require) { var CalendarPopover = require('web.CalendarPopover'); var CalendarRenderer = require('web.CalendarRenderer'); +var CalendarController = require('calendar.CalendarController'); +var CalendarModel = require('calendar.CalendarModel'); var CalendarView = require('web.CalendarView'); const session = require('web.session'); var viewRegistry = require('web.view_registry'); @@ -107,6 +109,8 @@ var AttendeeCalendarRenderer = CalendarRenderer.extend({ var AttendeeCalendarView = CalendarView.extend({ config: _.extend({}, CalendarView.prototype.config, { Renderer: AttendeeCalendarRenderer, + Controller: CalendarController, + Model: CalendarModel, }), }); diff --git a/addons/calendar/static/src/xml/base_calendar.xml b/addons/calendar/static/src/xml/base_calendar.xml index 22af9cbee03..8ecdd17b5fd 100644 --- a/addons/calendar/static/src/xml/base_calendar.xml +++ b/addons/calendar/static/src/xml/base_calendar.xml @@ -6,6 +6,18 @@ </t> </t> + <div t-name="calendar.RecurrentEventUpdate"> + <div class="form-check o_radio_item"> + <input name="recurrence-update" type="radio" class="form-check-input o_radio_input" checked="true" value="self_only" id="self_only"/> + <label class="form-check-label o_form_label" for="self_only">This event</label> + </div> + + <div class="form-check o_radio_item"> + <input name="recurrence-update" type="radio" class="form-check-input o_radio_input" value="future_events" id="future_events"/> + <label class="form-check-label o_form_label" for="future_events">This and following events</label> + </div> + </div> + <t t-extend="mail.systray.ActivityMenu.Previews"> <t t-jquery="div.o_preview_title" t-operation="after"> <div t-if="activity and activity.type == 'meeting'"> diff --git a/addons/calendar/tests/__init__.py b/addons/calendar/tests/__init__.py index 44a989d21e6..137c25cc007 100644 --- a/addons/calendar/tests/__init__.py +++ b/addons/calendar/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_calendar from . import test_calendar_recurrent_event_case2 +from . import test_event_recurrence diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index 8ac53b7889d..522a2738e94 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -41,131 +41,6 @@ class TestCalendar(SavepointCaseWithUserDemo): "Sanity check" ) - def test_calender_event(self): - # Now I will set recurrence for this event to occur monday and friday of week - data = { - 'fr': 1, - 'mo': 1, - 'interval': 1, - 'rrule_type': 'weekly', - 'end_type': 'end_date', - 'final_date': '2011-05-31 00:00:00', - 'recurrency': True - } - - self.event_tech_presentation.write(data) - - # In order to check that recurrent events are views successfully in calendar view, I will open calendar view of events| - self.CalendarEvent.fields_view_get(False, 'calendar') - - # In order to check that recurrent events are views successfully in calendar view, I will search for one of the recurrent event and count the number of events - rec_events = self.CalendarEvent.with_context({'virtual_id': True}).search([ - ('start', '>=', '2011-04-30 16:00:00'), ('start', '<=', '2011-05-31 00:00:00') - ]) - self.assertEqual(len(rec_events), 9, 'Wrong number of events found') - - # Now I move a virtual event, to see that a real event is well created and depending from the native recurrence - before = self.CalendarEvent.with_context({'virtual_id': False}).search([ - ('start', '>=', '2011-04-30 16:00:00'), ('start', '<=', '2011-05-31 00:00:00') - ]) - - # We start by detach the event - newevent = rec_events[1].detach_recurring_event() - newevent.with_context({'virtual_id': True}).write({'name': 'New Name', 'recurrency': True}) - after = self.CalendarEvent.with_context({'virtual_id': False}).search([ - ('start', '>=', '2011-04-30 16:00:00'), ('start', '<=', '2011-05-31 00:00:00') - ]) - self.assertEqual(len(after), len(before) + 1, 'Wrong number of events found, after to have moved a virtual event') - new_event = after - before - self.assertEqual(new_event[0].recurrent_id, before.id, 'Recurrent_id not correctly passed to the new event') - - # Now I will test All day event - allday_event = self.CalendarEvent.create({ - 'allday': 1, - 'privacy': 'confidential', - 'start': '2011-04-30 00:00:00', - 'stop': '2011-04-30 00:00:00', - 'description': 'All day technical test', - 'location': 'School', - 'name': 'All day test event' - }) - - # In order to check reminder I will first create reminder - res_alarm_day_before_event_starts = self.env['calendar.alarm'].create({ - 'name': '1 Day before event starts', - 'duration': 1, - 'interval': 'days', - 'alarm_type': 'notification' - }) - - # Now I will assign this reminder to all day event| - allday_event.write({'alarm_ids': [(6, 0, [res_alarm_day_before_event_starts.id])]}) - - # I create a recuring rule for my event - calendar_event_sprint_review = self.CalendarEvent.create({ - 'name': 'Begin of month meeting', - 'start': datetime.combine(fields.Date.today(), time(12, 0)), - 'stop': datetime.combine(fields.Date.today(), time(18, 0)), - 'recurrency': True, - 'rrule': 'FREQ=MONTHLY;INTERVAL=1;COUNT=12;BYDAY=1MO' - }) - - # I check that the attributes are set correctly - self.assertEqual(calendar_event_sprint_review.rrule_type, 'monthly', 'rrule_type should be mothly') - self.assertEqual(calendar_event_sprint_review.count, 12, 'rrule_type should be mothly') - self.assertEqual(calendar_event_sprint_review.month_by, 'day', 'rrule_type should be mothly') - self.assertEqual(calendar_event_sprint_review.byday, '1', 'rrule_type should be mothly') - self.assertEqual(calendar_event_sprint_review.week_list, 'MO', 'rrule_type should be mothly') - - def test_validation_error(self): - """ - Ideally this should build the base event in such a way that calling - write() triggers detach_recurring_event, but I've no idea how that - actually works so just calling it directly for now - """ - m = self.CalendarEvent.create({ - 'name': "wheee", - 'start': '2017-07-12 14:30:00', - 'allday': False, - 'rrule': u'FREQ=WEEKLY;BYDAY=WE;INTERVAL=1;COUNT=100', - 'duration': 0.5, - 'stop': '2017-07-12 15:00:00', - }) - self.assertEqual( - (str(m.start_datetime), str(m.stop_datetime)), - (u'2017-07-12 14:30:00', u'2017-07-12 15:00:00'), - "Sanity check" - ) - partner_1 = self.env['res.partner'].create({'name': 'A First Partner'}) - partner_2 = self.env['res.partner'].create({'name': 'A Second Partner'}) - values = { - 'allday': False, - 'name': u'wheee', - 'attendee_ids': [ - (0, 0, {'state': u'needsAction', 'partner_id': partner_1.id, 'email': u'bob@example.com'}), - (0, 0, {'state': u'needsAction', 'partner_id': partner_2.id, 'email': u'ed@example.com'}), - ], - 'recurrency': True, - 'privacy': u'public', - 'stop': '2017-07-10 16:00:00', - 'alarm_ids': [(6, 0, [])], - 'start': '2017-07-10 15:30:00', - 'location': u"XXX", - 'duration': 0.5, - 'partner_ids': [(4, partner_1.id), (4, partner_2.id)], - 'description': u"A thing" - } - - records = m.detach_recurring_event(values) - self.assertEqual( - (str(m.start_datetime), str(m.stop_datetime)), - ('2017-07-12 14:30:00', u'2017-07-12 15:00:00'), - ) - self.assertEqual( - (str(records.start_datetime), str(records.stop_datetime)), - (u'2017-07-10 15:30:00', u'2017-07-10 16:00:00'), - ) - def test_event_order(self): """ check the ordering of events when searching """ def create_event(name, date): @@ -303,15 +178,15 @@ class TestCalendar(SavepointCaseWithUserDemo): 'start': '2018-10-27 14:30:00', 'allday': False, 'rrule': u'FREQ=DAILY;INTERVAL=1;COUNT=4', - 'duration': 2, + 'recurrency': True, 'stop': '2018-10-27 16:30:00', + 'event_tz': 'Europe/Brussels', }) - start_recurring_dates = m.with_context({'tz': 'Europe/Brussels'})._get_recurrent_date_by_event() + start_recurring_dates = m.recurrence_id.calendar_event_ids.sorted('start').mapped('start') self.assertEqual(len(start_recurring_dates), 4) for d in start_recurring_dates: - self.assertEqual(d.tzinfo, pytz.UTC) if d.day < 28: # DST switch happens between 2018-10-27 and 2018-10-28 self.assertEqual(d.hour, 14) else: @@ -402,6 +277,7 @@ class TestCalendar(SavepointCaseWithUserDemo): ('recipient_ids', 'in', partner.id), ('subject', 'like', m.name), ]) + print(mail.mapped('subject')) self.assertEqual(len(mail), 1) partners = [ @@ -414,7 +290,7 @@ class TestCalendar(SavepointCaseWithUserDemo): 'name': "mailTest1", 'allday': False, 'rrule': u'FREQ=DAILY;INTERVAL=1;COUNT=5', - 'duration': 0.5, + 'recurrency': True, 'partner_ids': partner_ids, 'start': fields.Datetime.to_string(now + timedelta(days=10)), 'stop': fields.Datetime.to_string(now + timedelta(days=15)), @@ -430,17 +306,10 @@ class TestCalendar(SavepointCaseWithUserDemo): self.env['res.partner'].create({'name':'testuser4','email': u'alain@example.com'}), ]) partner_ids = [(6, False, [p.id for p in partners]),] - m.write({'partner_ids': partner_ids}) + m.write({ + 'partner_ids': partner_ids, + 'recurrence_update': 'all_events', + }) # more email should be sent _test_one_mail_per_attendee(self, m, partners) - - # calculate virtualid to detach one event - virtid = str(m.id) + '-' + ''.join(re.split('[\D]', fields.Datetime.to_string(now + timedelta(days=12)))) - - # detaching a virtual event in the chain - self.env['calendar.event'].browse(virtid).detach_recurring_event(values={'active':False}) - - # since the detach actually create an event in the backend - # we check that no mail notifications are sent to the attendees - _test_one_mail_per_attendee(self, m, partners) diff --git a/addons/calendar/tests/test_calendar_recurrent_event_case2.py b/addons/calendar/tests/test_calendar_recurrent_event_case2.py index 131950af172..036bbc0a0c4 100644 --- a/addons/calendar/tests/test_calendar_recurrent_event_case2.py +++ b/addons/calendar/tests/test_calendar_recurrent_event_case2.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests import common -from odoo.addons.calendar.models.calendar_event import calendar_id2real_id class TestRecurrentEvent(common.TransactionCase): @@ -35,9 +34,9 @@ class TestRecurrentEvent(common.TransactionCase): self.CalendarEvent.create({ 'start': '2011-04-18 11:47:00', 'stop': '2011-04-18 12:47:00', - 'day': 0.0, + 'day': 1, 'duration': 1.0, - 'final_date': '2011-04-30', + 'until': '2011-04-30', 'end_type': 'end_date', 'fr': True, 'mo': True, @@ -50,158 +49,7 @@ class TestRecurrentEvent(common.TransactionCase): }) # I search for all the recurrent weekly meetings. - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ + meetings_count = self.CalendarEvent.search_count([ ('start', '>=', '2011-03-13'), ('stop', '<=', '2011-05-13') ]) self.assertEqual(meetings_count, 10, 'Recurrent weekly meetings are not created !') - - def test_recurrent_meeting3(self): - #I want to schedule a meeting every month for Sprint review. - self.calendar_event_sprint_review = self.CalendarEvent.create({ - 'count': 12, - 'start': '2011-04-01 12:01:00', - 'stop': '2011-04-01 13:01:00', - 'day': 1, - 'duration': 1.0, - 'name': 'Sprint Review', - 'recurrency': True, - 'rrule_type': 'monthly' - }) - - # I search for all the recurrent monthly meetings. - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '>=', '2011-03-01'), ('stop', '<=', '2012-05-13') - ]) - self.assertEqual(meetings_count, 12, 'Recurrent weekly meetings are not created !') - - # I change name of my monthly Sprint Review meeting. - idval = '%d-%s' % (self.calendar_event_sprint_review.id, '20110901130100') - self.CalendarEvent.browse(idval).write({'name': 'Sprint Review for google modules'}) - - # I check whether all the records are edited or not. - meetings = self.CalendarEvent.with_context({'virtual_id': True}).search([ - ('start', '>=', '2011-03-01'), ('stop', '<=', '2012-05-13') - ]) - for meeting in meetings: - self.assertEqual(meeting.name, 'Sprint Review for google modules', 'Name not changed for id: %s' % meeting.id) - - # I detach first occurrence to check it is not modified by changing recurrent event. - min(meetings, key=lambda m: m.start).detach_recurring_event() - - # I change description of my weekly meeting Review code with programmer. - idval = '%d-%s' % (self.calendar_event_sprint_review.id, '20110425124700') - self.CalendarEvent.browse(idval).write({'description': 'Review code of the module: sync_google_calendar.'}) - - # I check that detached event has not been edited. - detached_meeting = self.CalendarEvent.search([('recurrent_id', '=', self.calendar_event_sprint_review.id)]) - self.assertEqual(detached_meeting.description, False, 'Detached event description changed for id: %s' % meeting.id) - - # I verify wether I find an event by date range when subsequent to a detached one. - last_meeting = max(meetings, key=lambda m: m.start) - meetings = self.CalendarEvent.with_context({'virtual_id': True}).search([ - ('start', '<=', str(last_meeting.stop)), ('stop', '>=', str(last_meeting.start)) - ]) - self.assertEqual(meetings.id, last_meeting.id, 'Last event should be found searching it by date range') - - # I update the description of two meetings, and check that both have been updated - self.calendar_event_sprint_review.write({'description': "Some description"}) - self.assertEqual(self.calendar_event_sprint_review.description, "Some description", "Event %d has not been updated" % self.calendar_event_sprint_review.id) - - def test_recurrent_meeting4(self): - # I create a weekly meeting till a particular end date. - self.CalendarEvent.create({ - 'start': '2017-01-22 11:47:00', - 'stop': '2017-01-22 12:47:00', - 'day': 0.0, - 'duration': 1.0, - 'final_date': '2017-06-30', - 'end_type': 'end_date', - 'fr': True, - 'mo': True, - 'th': True, - 'tu': True, - 'we': True, - 'name': 'Review code with programmer', - 'recurrency': True, - 'rrule_type': 'weekly' - }) - - # I search for a recurrent weekly meetings that take place at a given date. - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '<=', '2017-01-24'), ('stop', '>=', '2017-01-24'), ('name', '=', 'Review code with programmer') - ]) - self.assertEqual(meetings_count, 1, 'Recurrent weekly meetings are not found using date filter !') - - # I search for a recurrent weekly meetings that take place at a given date and time. - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '<=', '2017-01-24 11:55:00'), ('stop', '>=', '2017-01-24 11:55:00'), ('name', '=', 'Review code with programmer') - ]) - self.assertEqual(meetings_count, 1, 'Recurrent weekly meetings are not found using time filter !') - - # I search using the filter 'start date is set' - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '!=', False), ('stop', '>=', '2017-06-30 11:55:00'), ('name', '=', 'Review code with programmer') - ]) - self.assertEqual(meetings_count, 1, "Last recurrent weekly meetings are not found using 'is set' filter !") - - # I search for a recurrent weekly meetings that take place at a given date and time. - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '<=', '2017-01-24 11:55:00'), ('stop', '>=', '2017-01-24 15:55:00') - ]) - self.assertEqual(meetings_count, 0, 'Too late recurrent meetings are found using time filter !') - - # I search using a start filter but no stop - meetings_count = self.CalendarEvent.with_context({'virtual_id': True}).search_count([ - ('start', '>=', '2017-06-30 08:00:00'), ('name', '=', 'Review code with programmer') - ]) - self.assertEqual(meetings_count, 1, "Last recurrent weekly meetings are not found without stop filter !") - - def test_recurrent_meeting5(self): - # I create a recurrent event and I check if the virtual_id are correct - self.CalendarEvent.create({ - 'count': 5, - 'start': '2012-04-13 11:00:00', - 'stop': '2012-04-13 12:00:00', - 'duration': 1.0, - 'name': 'Test Meeting', - 'recurrency': True, - 'rrule_type': 'daily' - }) - # I search for the first recurrent meeting - meeting = self.CalendarEvent.with_context({'virtual_id': True}).search([ - ('start', '=', '2012-04-13 11:00:00'), ('stop', '=', '2012-04-13 12:00:00') - ]) - virutal_dates = calendar_id2real_id(meeting.id, with_date=True) - - # virtual_dates are used by the calendar view and I check if the start date for the first virtual event is correct. - self.assertEqual(virutal_dates[1], '2012-04-13 11:00:00', "The virtual event doesn't have the correct start date !") - - # virtual_dates are used by the calendar view and I check if the stop date for the first virtual event is correct. - self.assertEqual(virutal_dates[2], '2012-04-13 12:00:00', "The virtual event doesn't have the correct stop date !") - - def test_recurrent_meeting6(self): - ev = self.CalendarEvent.create({ - 'name': 'Rec1', - 'start': '2018-06-28 11:00:00', - 'stop': '2018-06-28 12:00:00', - 'day': 0.0, - 'duration': 1.0, - 'count': ' 2', - 'end_type': 'count', - 'fr': True, - 'recurrency': True, - 'allday': False, - 'rrule_type': 'weekly' - }) - #event 2018-07-06 and 2018-06-29 - meetings = self.CalendarEvent.with_context({'virtual_id': True}).search([ - '|', '&', ('start', '>=', '2018-06-30 00:00:00'), ('start', '<=', '2018-06-30 23:59:59'), ('allday', '=', True) - ]) - base_ids = [calendar_id2real_id(meeting.id, with_date=False) for meeting in meetings] - self.assertNotIn(ev.id, base_ids, "Event does not match the domain") - - meetings = self.CalendarEvent.with_context({'virtual_id': True}).search([ - '|', '&', ('start', '>=', '2018-06-29 00:00:00'), ('start', '<=', '2018-06-29 23:59:59'), ('allday', '=', True) - ]) - base_ids = [calendar_id2real_id(meeting.id, with_date=False) for meeting in meetings] - self.assertIn(ev.id, base_ids, "Event does match the domain") diff --git a/addons/calendar/tests/test_event_recurrence.py b/addons/calendar/tests/test_event_recurrence.py new file mode 100644 index 00000000000..e9b8ec5447e --- /dev/null +++ b/addons/calendar/tests/test_event_recurrence.py @@ -0,0 +1,642 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError +import pytz +from datetime import datetime, date +from dateutil.relativedelta import relativedelta + +from odoo.tests.common import SavepointCase + + +class TestRecurrentEvents(SavepointCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + lang = cls.env['res.lang']._lang_get(cls.env.user.lang) + lang.week_start = '1' # Monday + + def assertEventDates(self, events, dates): + events = events.sorted('start') + self.assertEqual(len(events), len(dates), "Wrong number of events in the recurrence") + self.assertTrue(all(events.mapped('active')), "All events should be active") + for event, dates in zip(events, dates): + start, stop = dates + self.assertEqual(event.start, start) + self.assertEqual(event.stop, stop) + + +class TestCreateRecurrentEvents(TestRecurrentEvents): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = cls.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start': datetime(2019, 10, 25, 8, 0), + 'stop': datetime(2019, 10, 27, 18, 0), + 'recurrency': True, + }) + + def test_weekly_count(self): + """ Every week, on Tuesdays, for 3 occurences """ + detached_events = self.event._apply_recurrence_values({ + 'rrule_type': 'weekly', + 'tu': True, + 'interval': 1, + 'count': 3, + 'event_tz': 'UTC', + }) + self.assertEqual(detached_events, self.event, "It should be detached from the recurrence") + self.assertFalse(self.event.recurrence_id, "It should be detached from the recurrence") + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 3, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 22, 8, 0), datetime(2019, 10, 24, 18, 0)), + (datetime(2019, 10, 29, 8, 0), datetime(2019, 10, 31, 18, 0)), + (datetime(2019, 11, 5, 8, 0), datetime(2019, 11, 7, 18, 0)), + ]) + + def test_weekly_interval_2(self): + self.event._apply_recurrence_values({ + 'interval': 2, + 'rrule_type': 'weekly', + 'tu': True, + 'count': 2, + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEventDates(events, [ + (datetime(2019, 10, 22, 8, 0), datetime(2019, 10, 24, 18, 0)), + (datetime(2019, 11, 5, 8, 0), datetime(2019, 11, 7, 18, 0)), + ]) + + def test_weekly_until(self): + self.event._apply_recurrence_values({ + 'rrule_type': 'weekly', + 'tu': True, + 'interval': 2, + 'end_type': 'end_date', + 'until': datetime(2019, 11, 15), + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 2, "It should have 2 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 22, 8, 0), datetime(2019, 10, 24, 18, 0)), + (datetime(2019, 11, 5, 8, 0), datetime(2019, 11, 7, 18, 0)), + ]) + + def test_monthly_count_by_date(self): + self.event._apply_recurrence_values({ + 'rrule_type': 'monthly', + 'interval': 2, + 'month_by': 'date', + 'day': 15, + 'end_type': 'count', + 'count': 3, + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 3, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 15, 8, 0), datetime(2019, 10, 17, 18, 0)), + (datetime(2019, 12, 15, 8, 0), datetime(2019, 12, 17, 18, 0)), + (datetime(2020, 2, 15, 8, 0), datetime(2020, 2, 17, 18, 0)), + ]) + + def test_monthly_count_by_date_31(self): + self.event._apply_recurrence_values({ + 'rrule_type': 'monthly', + 'interval': 1, + 'month_by': 'date', + 'day': 31, + 'end_type': 'count', + 'count': 3, + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 3, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 31, 8, 0), datetime(2019, 11, 2, 18, 0)), + # Missing 31th in November + (datetime(2019, 12, 31, 8, 0), datetime(2020, 1, 2, 18, 0)), + (datetime(2020, 1, 31, 8, 0), datetime(2020, 2, 2, 18, 0)), + ]) + + def test_monthly_until_by_day(self): + """ Every 2 months, on the third Tuesday, until 15th March 2020 """ + self.event._apply_recurrence_values({ + 'rrule_type': 'monthly', + 'interval': 2, + 'month_by': 'day', + 'byday': '3', + 'weekday': 'TU', + 'end_type': 'end_date', + 'until': date(2020, 3, 15), + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 3, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 15, 8, 0), datetime(2019, 10, 17, 18, 0)), + (datetime(2019, 12, 17, 8, 0), datetime(2019, 12, 19, 18, 0)), + (datetime(2020, 2, 18, 8, 0), datetime(2020, 2, 20, 18, 0)), + ]) + + def test_monthly_until_by_day_last(self): + """ Every 2 months, on the last Wednesday, until 15th January 2020 """ + self.event._apply_recurrence_values({ + 'interval': 2, + 'rrule_type': 'monthly', + 'month_by': 'day', + 'weekday': 'WE', + 'byday': '-1', + 'end_type': 'end_date', + 'until': date(2020, 1, 15), + 'event_tz': 'UTC', + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + events = recurrence.calendar_event_ids + self.assertEqual(len(events), 2, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (datetime(2019, 10, 30, 8, 0), datetime(2019, 11, 1, 18, 0)), + (datetime(2019, 12, 25, 8, 0), datetime(2019, 12, 27, 18, 0)), + ]) + + def test_yearly_count(self): + self.event._apply_recurrence_values({ + 'interval': 2, + 'rrule_type': 'yearly', + 'count': 2, + 'event_tz': 'UTC', + }) + events = self.event.recurrence_id.calendar_event_ids + self.assertEqual(len(events), 2, "It should have 3 events in the recurrence") + self.assertEventDates(events, [ + (self.event.start, self.event.stop), + (self.event.start + relativedelta(years=2), self.event.stop + relativedelta(years=2)), + ]) + + def test_dst_timezone(self): + """ Test hours stays the same, regardless of DST changes """ + self.event.start = datetime(2002, 10, 26, 10, 0) + self.event.stop = datetime(2002, 10, 26, 12, 0) + self.event._apply_recurrence_values({ + 'interval': 2, + 'rrule_type': 'weekly', + 'mo': True, + 'count': '2', + 'event_tz': 'US/Eastern', # DST change on 2002/10/27 + }) + recurrence = self.env['calendar.recurrence'].search([('base_event_id', '=', self.event.id)]) + self.assertEventDates(recurrence.calendar_event_ids, [ + (datetime(2002, 10, 21, 10, 0), datetime(2002, 10, 21, 12, 0)), + (datetime(2002, 11, 4, 11, 0), datetime(2002, 11, 4, 13, 0)), + ]) + + def test_ambiguous_dst_time_winter(self): + """ Test hours stays the same, regardless of DST changes """ + eastern = pytz.timezone('US/Eastern') + dt = eastern.localize(datetime(2002, 10, 20, 1, 30, 00)).astimezone(pytz.utc).replace(tzinfo=None) + # Next occurence happens at 1:30am on 27th Oct 2002 which happened twice in the US/Eastern + # timezone when the clocks where put back at the end of Daylight Saving Time + self.event.start = dt + self.event.stop = dt + relativedelta(hours=1) + self.event._apply_recurrence_values({ + 'interval': 1, + 'rrule_type': 'weekly', + 'su': True, + 'count': '2', + 'event_tz': 'US/Eastern' # DST change on 2002/4/7 + }) + events = self.event.recurrence_id.calendar_event_ids + self.assertEqual(events.mapped('duration'), [1, 1]) + self.assertEventDates(events, [ + (datetime(2002, 10, 20, 5, 30), datetime(2002, 10, 20, 6, 30)), + (datetime(2002, 10, 27, 6, 30), datetime(2002, 10, 27, 7, 30)), + ]) + + def test_ambiguous_dst_time_spring(self): + """ Test hours stays the same, regardless of DST changes """ + eastern = pytz.timezone('US/Eastern') + dt = eastern.localize(datetime(2002, 3, 31, 2, 30, 00)).astimezone(pytz.utc).replace(tzinfo=None) + # Next occurence happens 2:30am on 7th April 2002 which never happened at all in the + # US/Eastern timezone, as the clocks where put forward at 2:00am skipping the entire hour + self.event.start = dt + self.event.stop = dt + relativedelta(hours=1) + self.event._apply_recurrence_values({ + 'interval': 1, + 'rrule_type': 'weekly', + 'su': True, + 'count': '2', + 'event_tz': 'US/Eastern' # DST change on 2002/4/7 + }) + events = self.event.recurrence_id.calendar_event_ids + self.assertEqual(events.mapped('duration'), [1, 1]) + # The event begins at "the same time" (i.e. 2h30 after midnight), but that day, 2h30 after midnight happens to be at 3:30 am + self.assertEventDates(events, [ + (datetime(2002, 3, 31, 7, 30), datetime(2002, 3, 31, 8, 30)), + (datetime(2002, 4, 7, 7, 30), datetime(2002, 4, 7, 8, 30)), + ]) + + def test_ambiguous_full_day(self): + """ Test date stays the same, regardless of DST changes """ + self.event.start = datetime(2020, 3, 23, 0, 0) + self.event.stop = datetime(2020, 3, 23, 23, 59) + self.event.allday = True + self.event._apply_recurrence_values({ + 'interval': 1, + 'rrule_type': 'weekly', + 'mo': True, + 'count': 2, + 'event_tz': 'Europe/Brussels' # DST change on 2020/3/23 + }) + events = self.event.recurrence_id.calendar_event_ids + self.assertEventDates(events, [ + (datetime(2020, 3, 23, 0, 0), datetime(2020, 3, 23, 23, 59)), + (datetime(2020, 3, 30, 0, 0), datetime(2020, 3, 30, 23, 59)), + ]) + + +class TestUpdateRecurrentEvents(TestRecurrentEvents): + + @classmethod + def setUpClass(cls): + super().setUpClass() + event = cls.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start': datetime(2019, 10, 22, 1, 0), + 'stop': datetime(2019, 10, 24, 18, 0), + 'recurrency': True, + 'rrule_type': 'weekly', + 'tu': True, + 'interval': 1, + 'count': 3, + 'event_tz': 'Etc/GMT-4', + }) + cls.recurrence = event.recurrence_id + cls.events = event.recurrence_id.calendar_event_ids.sorted('start') + + def test_shift_future(self): + event = self.events[1] + self.events[1].write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + self.assertEqual(self.recurrence.end_type, 'end_date') + self.assertEqual(self.recurrence.until, date(2019, 10, 27)) + self.assertEventDates(self.recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 1, 0), datetime(2019, 10, 24, 18, 0)), + ]) + new_recurrence = event.recurrence_id + self.assertNotEqual(self.recurrence, new_recurrence) + self.assertEqual(new_recurrence.count, 2) + self.assertEqual(new_recurrence.dtstart, datetime(2019, 11, 2, 1, 0)) + self.assertFalse(new_recurrence.tu) + self.assertTrue(new_recurrence.sa) + self.assertEventDates(new_recurrence.calendar_event_ids, [ + (datetime(2019, 11, 2, 1, 0), datetime(2019, 11, 5, 18, 0)), + (datetime(2019, 11, 9, 1, 0), datetime(2019, 11, 12, 18, 0)), + ]) + + def test_shift_future_first(self): + event = self.events[0] + self.events[0].write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + new_recurrence = event.recurrence_id + self.assertFalse(self.recurrence.exists()) + self.assertEqual(new_recurrence.count, 3) + self.assertEqual(new_recurrence.dtstart, datetime(2019, 10, 26, 1, 0)) + self.assertFalse(new_recurrence.tu) + self.assertTrue(new_recurrence.sa) + self.assertEventDates(new_recurrence.calendar_event_ids, [ + (datetime(2019, 10, 26, 1, 0), datetime(2019, 10, 29, 18, 0)), + (datetime(2019, 11, 2, 1, 0), datetime(2019, 11, 5, 18, 0)), + (datetime(2019, 11, 9, 1, 0), datetime(2019, 11, 12, 18, 0)), + ]) + + def test_shift_reapply(self): + event = self.events[2] + self.events[2].write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + # re-Applying the first recurrence should be idempotent + self.recurrence._apply_recurrence() + self.assertEventDates(self.recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 1, 0), datetime(2019, 10, 24, 18, 0)), + (datetime(2019, 10, 29, 1, 0), datetime(2019, 10, 31, 18, 0)), + ]) + + def test_shift_all(self): + event = self.events[1] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + + def test_change_week_day_rrule(self): + recurrence = self.events.recurrence_id + recurrence.rrule = 'FREQ=WEEKLY;COUNT=3;BYDAY=WE' # from TU to WE + self.assertFalse(self.recurrence.tu) + self.assertTrue(self.recurrence.we) + + def test_shift_all_base_inactive(self): + self.recurrence.base_event_id.active = False + event = self.events[1] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + + def test_shift_all_with_outlier(self): + outlier = self.events[1] + outlier.write({ + 'recurrence_update': 'self_only', + 'start': datetime(2019, 9, 26, 1, 0), # Thursday + 'stop': datetime(2019, 9, 26, 18, 0), + }) + event = self.events[0] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + + def test_update_recurrence_future(self): + event = self.events[1] + event.write({ + 'recurrence_update': 'future_events', + 'fr': True, # recurrence is now Tuesday AND Friday + 'count': 4, + }) + self.assertEventDates(self.recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 1, 0), datetime(2019, 10, 24, 18, 0)), # Tu + ]) + + self.assertEventDates(event.recurrence_id.calendar_event_ids, [ + (datetime(2019, 10, 29, 1, 0), datetime(2019, 10, 31, 18, 0)), # Tu + (datetime(2019, 11, 1, 1, 0), datetime(2019, 11, 3, 18, 0)), # Fr + (datetime(2019, 11, 5, 1, 0), datetime(2019, 11, 7, 18, 0)), # Tu + (datetime(2019, 11, 8, 1, 0), datetime(2019, 11, 10, 18, 0)), # Fr + ]) + + events = event.recurrence_id.calendar_event_ids.sorted('start') + self.assertEqual(events[0], self.events[1], "Events on Tuesdays should not have changed") + self.assertEqual(events[2], self.events[2], "Events on Tuesdays should not have changed") + self.assertNotEqual(events.recurrence_id, self.recurrence, "Events should no longer be linked to the original recurrence") + self.assertEqual(events.recurrence_id.count, 4, "The new recurrence should have 4") + self.assertTrue(event.recurrence_id.tu) + self.assertTrue(event.recurrence_id.fr) + + def test_update_recurrence_all(self): + with self.assertRaises(UserError): + self.events[1].write({ + 'recurrence_update': 'all_events', + 'mo': True, # recurrence is now Tuesday AND Monday + }) + + def test_shift_single(self): + event = self.events[1] + event.write({ + 'recurrence_update': 'self_only', + 'name': "Updated event", + 'start': event.start - relativedelta(hours=2) + }) + self.events[0].write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(hours=4), + 'stop': event.stop + relativedelta(hours=5), + }) + + def test_break_recurrence_future(self): + event = self.events[1] + event.write({ + 'recurrence_update': 'future_events', + 'recurrency': False, + }) + self.assertFalse(event.recurrence_id) + self.assertTrue(self.events[0].active) + self.assertTrue(self.events[1].active) + self.assertFalse(self.events[2].exists()) + self.assertEqual(self.recurrence.until, date(2019, 10, 27)) + self.assertEqual(self.recurrence.end_type, 'end_date') + self.assertEventDates(self.recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 1, 0), datetime(2019, 10, 24, 18, 0)), + ]) + + def test_break_recurrence_all(self): + event = self.events[1] + event.write({ + 'recurrence_update': 'all_events', + 'recurrency': False, + 'count': 0, # In practice, JS framework sends updated recurrency fields, since they have been recomputed, triggered by the `recurrency` change + }) + self.assertFalse(self.events[0].exists()) + self.assertTrue(event.active) + self.assertFalse(self.events[2].exists()) + self.assertFalse(event.recurrence_id) + self.assertFalse(self.recurrence.exists()) + + def test_all_day_shift(self): + recurrence = self.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start_date': datetime(2019, 10, 22), + 'stop_date': datetime(2019, 10, 24), + 'recurrency': True, + 'rrule_type': 'weekly', + 'tu': True, + 'interval': 1, + 'count': 3, + 'event_tz': 'Etc/GMT-4', + 'allday': True, + }).recurrence_id + events = recurrence.calendar_event_ids.sorted('start') + event = events[1] + event.write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + self.assertEqual(recurrence.end_type, 'end_date') + self.assertEqual(recurrence.until, date(2019, 10, 27)) + self.assertEventDates(recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 8, 0), datetime(2019, 10, 24, 18, 0)), + ]) + new_recurrence = event.recurrence_id + self.assertNotEqual(recurrence, new_recurrence) + self.assertEqual(new_recurrence.count, 2) + self.assertEqual(new_recurrence.dtstart, datetime(2019, 11, 2, 8, 0)) + self.assertFalse(new_recurrence.tu) + self.assertTrue(new_recurrence.sa) + self.assertEventDates(new_recurrence.calendar_event_ids, [ + (datetime(2019, 11, 2, 8, 0), datetime(2019, 11, 5, 18, 0)), + (datetime(2019, 11, 9, 8, 0), datetime(2019, 11, 12, 18, 0)), + ]) + + # TODO test followers, and alarms are copied + + +class TestUpdateMultiDayWeeklyRecurrentEvents(TestRecurrentEvents): + + @classmethod + def setUpClass(cls): + super().setUpClass() + event = cls.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start': datetime(2019, 10, 22, 1, 0), + 'stop': datetime(2019, 10, 24, 18, 0), + 'recurrency': True, + 'rrule_type': 'weekly', + 'tu': True, + 'fr': True, + 'interval': 1, + 'count': 3, + 'event_tz': 'Etc/GMT-4', + }) + cls.recurrence = event.recurrence_id + cls.events = event.recurrence_id.calendar_event_ids.sorted('start') + # Tuesday datetime(2019, 10, 22, 1, 0) + # Friday datetime(2019, 10, 25, 1, 0) + # Tuesday datetime(2019, 10, 29, 1, 0) + + def test_shift_all_multiple_weekdays(self): + event = self.events[0] # Tuesday + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=2), + 'stop': event.stop + relativedelta(days=2), + }) + + def test_shift_all_multiple_weekdays_duration(self): + event = self.events[0] # Tuesday + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=2), + 'stop': event.stop + relativedelta(days=3), + }) + + def test_shift_future_multiple_weekdays(self): + event = self.events[1] # Friday + event.write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=3), + 'stop': event.stop + relativedelta(days=3), + }) + self.assertTrue(self.recurrence.fr) + self.assertTrue(self.recurrence.tu) + self.assertTrue(event.recurrence_id.tu) + self.assertTrue(event.recurrence_id.mo) + self.assertFalse(event.recurrence_id.fr) + self.assertEqual(event.recurrence_id.count, 2) + + +class TestUpdateMonthlyByDay(TestRecurrentEvents): + + @classmethod + def setUpClass(cls): + super().setUpClass() + event = cls.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start': datetime(2019, 10, 15, 1, 0), + 'stop': datetime(2019, 10, 16, 18, 0), + 'recurrency': True, + 'rrule_type': 'monthly', + 'interval': 1, + 'count': 3, + 'month_by': 'day', + 'weekday': 'TU', + 'byday': '3', + 'event_tz': 'Etc/GMT-4', + }) + cls.recurrence = event.recurrence_id + cls.events = event.recurrence_id.calendar_event_ids.sorted('start') + # datetime(2019, 10, 15, 1, 0) + # datetime(2019, 11, 19, 1, 0) + # datetime(2019, 12, 17, 1, 0) + + def test_shift_all(self): + event = self.events[1] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start - relativedelta(days=5), + 'stop': event.stop - relativedelta(days=4), + }) + + # LUL TODO test shift future + +class TestUpdateMonthlyByDate(TestRecurrentEvents): + + @classmethod + def setUpClass(cls): + super().setUpClass() + event = cls.env['calendar.event'].create({ + 'name': 'Recurrent Event', + 'start': datetime(2019, 10, 22, 1, 0), + 'stop': datetime(2019, 10, 24, 18, 0), + 'recurrency': True, + 'rrule_type': 'monthly', + 'interval': 1, + 'count': 3, + 'month_by': 'date', + 'day': 22, + 'event_tz': 'Etc/GMT-4', + }) + cls.recurrence = event.recurrence_id + cls.events = event.recurrence_id.calendar_event_ids.sorted('start') + # datetime(2019, 10, 22, 1, 0) + # datetime(2019, 11, 22, 1, 0) + # datetime(2019, 12, 22, 1, 0) + + def test_shift_future(self): + event = self.events[1] + event.write({ + 'recurrence_update': 'future_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + self.assertEventDates(self.recurrence.calendar_event_ids, [ + (datetime(2019, 10, 22, 1, 0), datetime(2019, 10, 24, 18, 0)), + ]) + self.assertEventDates(event.recurrence_id.calendar_event_ids, [ + (datetime(2019, 11, 26, 1, 0), datetime(2019, 11, 29, 18, 0)), + (datetime(2019, 12, 26, 1, 0), datetime(2019, 12, 29, 18, 0)), + ]) + + def test_shift_all(self): + event = self.events[1] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'start': event.start + relativedelta(days=4), + 'stop': event.stop + relativedelta(days=5), + }) + + def test_update_all(self): + event = self.events[1] + with self.assertRaises(UserError): + event.write({ + 'recurrence_update': 'all_events', + 'day': 25, + }) diff --git a/addons/calendar/views/calendar_templates.xml b/addons/calendar/views/calendar_templates.xml index d22059ecd21..af492c9b7ea 100644 --- a/addons/calendar/views/calendar_templates.xml +++ b/addons/calendar/views/calendar_templates.xml @@ -4,6 +4,8 @@ <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/calendar/static/src/scss/calendar.scss"/> <script type="text/javascript" src="/calendar/static/src/js/base_calendar.js"></script> + <script type="text/javascript" src="/calendar/static/src/js/calendar_controller.js"></script> + <script type="text/javascript" src="/calendar/static/src/js/calendar_model.js"></script> <script type="text/javascript" src="/calendar/static/src/js/calendar_view.js"></script> <script type="text/javascript" src="/calendar/static/src/js/mail_activity.js"></script> <script type="text/javascript" src="/calendar/static/src/js/systray_activity_menu.js"></script> diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index 99111bc42be..bea22a2bf7a 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -85,6 +85,10 @@ <field name="priority" eval="1"/> <field name="arch" type="xml"> <form string="Meetings"> + <div attrs="{'invisible': [('recurrence_id','=',False)]}" class="alert alert-info oe_edit_only" role="status"> + <p>Edit recurring event</p> + <field name="recurrence_update" widget="radio"/> + </div> <sheet> <div class="oe_button_box" name="button_box"> <button string="Document" icon="fa-bars" type="object" name="action_open_calendar_event" attrs="{'invisible': ['|', ('res_model', '=', False), ('res_id', '=', False)]}"/> @@ -113,21 +117,16 @@ </div> <notebook> <page name="page_details" string="Meeting Details"> - <group attrs="{'invisible': [('recurrency','==',False)]}" class="oe_edit_only "> - <p class='alert alert-warning' role="status"> This event is linked to a recurrence...<br/> - <button type="object" name="action_detach_recurring_event" string="Update only this instance" help="Click here to update only this instance and not all recurrences. " class="oe_link"/> - </p> - </group> <group> <group> <field name="start" attrs="{'invisible': True}"/> <field name="stop" attrs="{'invisible': True}"/> <field name="id" attrs="{'invisible': True}"/> - <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/> - <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/> + <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)]}" force_save="1"/> + <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)]}" force_save="1"/> - <field name="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)]}"/> <field name="stop_datetime" invisible="1"/> <label for="duration" attrs="{'invisible': [('allday','=',True)]}"/> <div attrs="{'invisible': [('allday','=',True)]}"> @@ -165,7 +164,7 @@ <div class="o_row"> <field name="end_type" attrs="{'required': [('recurrency', '=', True)]}"/> <field name="count" attrs="{'invisible': [('end_type', '!=', 'count')], 'required': [('recurrency', '=', True)]}"/> - <field name="final_date" attrs="{'invisible': [('end_type', '!=', 'end_date')], 'required': [('end_type', '=', 'end_date'), ('recurrency', '=', True)]}"/> + <field name="until" attrs="{'invisible': [('end_type', '!=', 'end_date')], 'required': [('end_type', '=', 'end_date'), ('recurrency', '=', True)]}"/> </div> </group> <group attrs="{'invisible': [('rrule_type', '!=', 'weekly')]}" name="weekdays"> @@ -187,7 +186,7 @@ <field name="byday" string="The" attrs="{'required': [('recurrency', '=', True), ('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'day')]}"/> - <field name="week_list" nolabel="1" + <field name="weekday" nolabel="1" attrs="{'required': [('recurrency', '=', True), ('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'day')]}"/> </div> @@ -197,7 +196,7 @@ <group> <field name="privacy"/> <field name="show_as"/> - <field name="recurrent_id" invisible="1" /> + <field name="recurrence_id" invisible="1" /> </group> </group> </page> @@ -268,6 +267,9 @@ <field name="is_highlighted" invisible="1"/> <field name="description"/> <field name="privacy"/> + <!-- For recurrence update Dialog --> + <field name="recurrency" invisible="1"/> + <field name="recurrence_update" invisible="1"/> </calendar> </field> </record> @@ -283,6 +285,7 @@ <field name="user_id"/> <field name="show_as"/> <field name="privacy" string="Privacy"/> + <!-- TODO clean this filter and context key --> <filter string="My Meetings" help="My Meetings" name="mymeetings" context='{"mymeetings": 1}'/> <separator/> <filter string="Archived" name="inactive" domain="[('active', '=', False)]"/> diff --git a/addons/calendar/wizard/__init__.py b/addons/calendar/wizard/__init__.py deleted file mode 100644 index 5cabe4cf72c..00000000000 --- a/addons/calendar/wizard/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import mail_invite diff --git a/addons/calendar/wizard/mail_invite.py b/addons/calendar/wizard/mail_invite.py deleted file mode 100644 index 2199827e862..00000000000 --- a/addons/calendar/wizard/mail_invite.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - -from odoo.addons.calendar.models.calendar_event import get_real_ids - - -class MailInvite(models.TransientModel): - - _inherit = 'mail.wizard.invite' - - @api.model - def default_get(self, fields): - """ In case someone clicked on 'invite others' wizard in the followers widget, transform virtual ids in real ids """ - if 'default_res_id' in self._context: - self = self.with_context(default_res_id=get_real_ids(self._context['default_res_id'])) - - result = super(MailInvite, self).default_get(fields) - if 'res_id' in result: - result['res_id'] = get_real_ids(result['res_id']) - return result diff --git a/addons/calendar_sms/models/calendar.py b/addons/calendar_sms/models/calendar.py index 815a93c86d0..6b08ddcbe9d 100644 --- a/addons/calendar_sms/models/calendar.py +++ b/addons/calendar_sms/models/calendar.py @@ -64,7 +64,7 @@ class AlarmManager(models.AbstractModel): if event.recurrency: found = False - for event_start in event._get_recurrent_date_by_event(): + for event_start in event.recurrence_id._get_occurrences(event.start): event_start = event_start.replace(tzinfo=None) last_found = self.do_check_alarm_for_one_date(event_start, event, max_delta, 0, 'sms', after=last_sms_cron, missing=True) for alert in last_found: diff --git a/addons/google_calendar/models/calendar.py b/addons/google_calendar/models/calendar.py index 5be802cb6cb..31a67ca710d 100644 --- a/addons/google_calendar/models/calendar.py +++ b/addons/google_calendar/models/calendar.py @@ -13,9 +13,9 @@ class Meeting(models.Model): @api.model def get_fields_need_update_google(self): recurrent_fields = self._get_recurrent_fields() - return recurrent_fields + ['name', 'description', 'allday', 'start', 'date_end', 'stop', + return recurrent_fields | {'name', 'description', 'allday', 'start', 'date_end', 'stop', 'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active', - 'start_date', 'start_datetime', 'stop_date', 'stop_datetime'] + 'start_date', 'start_datetime', 'stop_date', 'stop_datetime'} def write(self, values): sync_fields = set(self.get_fields_need_update_google()) diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index 881012952b7..592fffbafee 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -17,6 +17,7 @@ var fieldsToGather = [ "date_delay", "date_stop", "all_day", + "recurrence_update" ]; var CalendarView = AbstractView.extend({ |
