summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/calendar/__init__.py1
-rw-r--r--addons/calendar/models/__init__.py3
-rw-r--r--addons/calendar/models/calendar_alarm_manager.py15
-rw-r--r--addons/calendar/models/calendar_event.py1018
-rw-r--r--addons/calendar/models/calendar_recurrence.py429
-rw-r--r--addons/calendar/models/ir_attachment.py27
-rw-r--r--addons/calendar/models/ir_http.py3
-rw-r--r--addons/calendar/models/mail_message.py31
-rw-r--r--addons/calendar/models/res_partner.py4
-rw-r--r--addons/calendar/security/ir.model.access.csv1
-rw-r--r--addons/calendar/static/src/js/calendar_controller.js65
-rw-r--r--addons/calendar/static/src/js/calendar_model.js24
-rw-r--r--addons/calendar/static/src/js/calendar_view.js4
-rw-r--r--addons/calendar/static/src/xml/base_calendar.xml12
-rw-r--r--addons/calendar/tests/__init__.py1
-rw-r--r--addons/calendar/tests/test_calendar.py149
-rw-r--r--addons/calendar/tests/test_calendar_recurrent_event_case2.py158
-rw-r--r--addons/calendar/tests/test_event_recurrence.py642
-rw-r--r--addons/calendar/views/calendar_templates.xml2
-rw-r--r--addons/calendar/views/calendar_views.xml25
-rw-r--r--addons/calendar/wizard/__init__.py4
-rw-r--r--addons/calendar/wizard/mail_invite.py22
-rw-r--r--addons/calendar_sms/models/calendar.py2
-rw-r--r--addons/google_calendar/models/calendar.py4
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js1
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({