diff options
| author | Lucas Lefèvre <lul@odoo.com> | 2019-12-03 14:26:25 +0100 |
|---|---|---|
| committer | Lucas Lefèvre <lul@odoo.com> | 2020-04-09 10:56:37 +0200 |
| commit | a27afdb5434166c3ea48c18ccfba9e8245d18e62 (patch) | |
| tree | f0684bcf301e81eee992765e0948fd5648826c6c /addons/calendar | |
| parent | 779912007441d005641daadbd8336029950cc7d0 (diff) | |
[REF] calendar: Materialize virtual recurring events
TL;DR A bit of magic and ... Pouf ... all virtual events are now real records.
Purpose
=======
The current implementation of recurring events uses "virtual events" to represents all
events in the recurrence. Only one event is stored in the database. All other events are
dynamically built and sent to the web client which sees them as real records.
This implementation is old, difficult to read (6 years of changes and bug fixes) and
has become more and more difficult to maintain.
A fresh start would be welcome.
Specification
=============
When creating a recurring event, a `calendar.recurrence.rule` record should be created.
This model holds the rrule configuration and is responsible to manage events that result
from the rrule.
When a new recurrence is created, all resulting events are also created (stored in the
database). No more virtual events.
To avoid an explosion of events, a maximum of 720 events is created. With 720 events,
a daily recurrence lasts for 2 years and a weekly recurrence lasts for 15 years.
This strategy is used by Google Calendar and seems acceptable in most cases.
A cron job could eventually be introduced to generated more events as the end comes.
This allows to introduce an new rrule end type: 'Forever'. This is actually a shortcut
to create a recurrence running for 720 events.
Inspired by Google Calendar, new possibilities are introduced when modifying a recurring
event (e.g. change the name, add an attendee):
- Modify only this particular event (the event is still part of the recurrence)
- Modify this and following events (events are still in the same recurrence)
- Modify all events
In a similar way, when updating the rrule of an event (e.g. from every Monday to every
Tuesday), the user can choose to:
- Modify this and following events: the recurrence is split. The first part remains
unchanged but now ends when the second recurrence begins. The second parts is the
updated rrule.
- Modify only this particular event.
- Modify all events: Forbidden (same as Google Calendar)
Note: when events are "moved" because the rrule changed, the events are not actually
moved. They are unlinked and new events are created (See "Some design choices explained"
section).
Some design choices explained
=============================
Where to store the rrule?
-------------------------
Two options were considered.
1) Store the rrule on an event, each event of the recurrence having a Many2one
to the parent_id, aka the Master Event of the recurrence.
2) Store the rrule on another model: `calendar.recurrence.rule`. Each event in the
recurrence having a Many2one to the recurrence record.
Both options have pros & cons. But there is no clear winner.
However the actual business logic of handling the recurrence creation/update
would be the same.
Where the rrule configuration is stored is the only main difference between both options.
And this is probably the easiest part of implementing recurring events.
With that in mind, option 2) is chosen. It allows to clearly separate recurrence
logic from the events themselves. It is also the "correct" way of modeling data to avoid
many empty columns for most records.
Reusing events on rrule update
------------------------------
When an rrule is modified, events should also be updated.
In most cases, current events are unlinked and new events are created from scratch.
Only events exactly at the same time before and after the rrule update are kept.
Trying to reuse other events would require an obscure and arbitrary heuristic.
(Imagine an rrule every Monday that is changed to every Tuesday and every Friday.
What would you do?).
This implies that any change to a specific event (name change, attendee added, chatter
messages) is lost. This tradeoff seems acceptable as updating an rrule should not be
that frequent. Moreover, Google Calendar also works that way so why not Odoo?
Reusing events on events shifts
-------------------------------
On the calendar view, drag and drop an event to shift the entire recurrence.
One way to handle the recurrence shift is to apply the same timedelta to all events.
Now events are correctly positioned... except for events that were specifically
moved. Those outliers needs special handling.
This also introduces some behavior inconsistencies:
when an rrule is directly modified, events are not reused, but when the rrule is
modified by drag & dropping an event, events are reused. This option needs more code to
handle the shift and the behaviors are not consistent.
The chosen option is to find the rrule configuration from the dragged event (this is
easy) and update the recurrence with those new rrule values. This brings us back to
an rrule update (see above): code reusability yeah; one behavior to rule them all yeah.
One downside is that outliers are lost (but Google Calendar also works that way).
Task 2126717
PR #42031
PR Enterprise odoo/enterprise#8006
Diffstat (limited to 'addons/calendar')
22 files changed, 1436 insertions, 1204 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 |
