summaryrefslogtreecommitdiff
path: root/addons/calendar/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/calendar/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/calendar/models')
-rw-r--r--addons/calendar/models/__init__.py14
-rw-r--r--addons/calendar/models/calendar_alarm.py74
-rw-r--r--addons/calendar/models/calendar_alarm_manager.py224
-rw-r--r--addons/calendar/models/calendar_attendee.py172
-rw-r--r--addons/calendar/models/calendar_contact.py21
-rw-r--r--addons/calendar/models/calendar_event.py845
-rw-r--r--addons/calendar/models/calendar_event_type.py16
-rw-r--r--addons/calendar/models/calendar_recurrence.py483
-rw-r--r--addons/calendar/models/ir_http.py30
-rw-r--r--addons/calendar/models/mail_activity.py45
-rw-r--r--addons/calendar/models/res_partner.py37
-rw-r--r--addons/calendar/models/res_users.py55
12 files changed, 2016 insertions, 0 deletions
diff --git a/addons/calendar/models/__init__.py b/addons/calendar/models/__init__.py
new file mode 100644
index 00000000..3cacb6bc
--- /dev/null
+++ b/addons/calendar/models/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_http
+from . import res_partner
+from . import calendar_event
+from . import calendar_alarm
+from . import calendar_alarm_manager
+from . import calendar_attendee
+from . import calendar_contact
+from . import calendar_event_type
+from . import calendar_recurrence
+from . import mail_activity
+from . import res_users
diff --git a/addons/calendar/models/calendar_alarm.py b/addons/calendar/models/calendar_alarm.py
new file mode 100644
index 00000000..970e59f4
--- /dev/null
+++ b/addons/calendar/models/calendar_alarm.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class Alarm(models.Model):
+ _name = 'calendar.alarm'
+ _description = 'Event Alarm'
+
+ @api.depends('interval', 'duration')
+ def _compute_duration_minutes(self):
+ for alarm in self:
+ if alarm.interval == "minutes":
+ alarm.duration_minutes = alarm.duration
+ elif alarm.interval == "hours":
+ alarm.duration_minutes = alarm.duration * 60
+ elif alarm.interval == "days":
+ alarm.duration_minutes = alarm.duration * 60 * 24
+ else:
+ alarm.duration_minutes = 0
+
+ _interval_selection = {'minutes': 'Minutes', 'hours': 'Hours', 'days': 'Days'}
+
+ name = fields.Char('Name', translate=True, required=True)
+ alarm_type = fields.Selection(
+ [('notification', 'Notification'), ('email', 'Email')],
+ string='Type', required=True, default='email')
+ duration = fields.Integer('Remind Before', required=True, default=1)
+ interval = fields.Selection(
+ list(_interval_selection.items()), 'Unit', required=True, default='hours')
+ duration_minutes = fields.Integer(
+ 'Duration in minutes', store=True,
+ search='_search_duration_minutes', compute='_compute_duration_minutes',
+ help="Duration in minutes")
+
+ def _search_duration_minutes(self, operator, value):
+ return [
+ '|', '|',
+ '&', ('interval', '=', 'minutes'), ('duration', operator, value),
+ '&', ('interval', '=', 'hours'), ('duration', operator, value / 60),
+ '&', ('interval', '=', 'days'), ('duration', operator, value / 60 / 24),
+ ]
+
+ @api.onchange('duration', 'interval', 'alarm_type')
+ def _onchange_duration_interval(self):
+ display_interval = self._interval_selection.get(self.interval, '')
+ display_alarm_type = {
+ key: value for key, value in self._fields['alarm_type']._description_selection(self.env)
+ }[self.alarm_type]
+ self.name = "%s - %s %s" % (display_alarm_type, self.duration, display_interval)
+
+ def _update_cron(self):
+ try:
+ cron = self.env['ir.model.data'].sudo().get_object('calendar', 'ir_cron_scheduler_alarm')
+ except ValueError:
+ return False
+ return cron.toggle(model=self._name, domain=[('alarm_type', '=', 'email')])
+
+ @api.model
+ def create(self, values):
+ result = super(Alarm, self).create(values)
+ self._update_cron()
+ return result
+
+ def write(self, values):
+ result = super(Alarm, self).write(values)
+ self._update_cron()
+ return result
+
+ def unlink(self):
+ result = super(Alarm, self).unlink()
+ self._update_cron()
+ return result
diff --git a/addons/calendar/models/calendar_alarm_manager.py b/addons/calendar/models/calendar_alarm_manager.py
new file mode 100644
index 00000000..2b931b4d
--- /dev/null
+++ b/addons/calendar/models/calendar_alarm_manager.py
@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class AlarmManager(models.AbstractModel):
+ _name = 'calendar.alarm_manager'
+ _description = 'Event Alarm Manager'
+
+ def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partners=None):
+ result = {}
+ delta_request = """
+ SELECT
+ rel.calendar_event_id, max(alarm.duration_minutes) AS max_delta,min(alarm.duration_minutes) AS min_delta
+ FROM
+ calendar_alarm_calendar_event_rel AS rel
+ LEFT JOIN calendar_alarm AS alarm ON alarm.id = rel.calendar_alarm_id
+ WHERE alarm.alarm_type = %s
+ GROUP BY rel.calendar_event_id
+ """
+ base_request = """
+ SELECT
+ cal.id,
+ cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm,
+ CASE
+ 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 rrule.until
+ ELSE cal.stop
+ END as last_event_date,
+ calcul_delta.min_delta,
+ calcul_delta.max_delta,
+ 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 = """
+ RIGHT JOIN calendar_event_res_partner_rel AS part_rel ON part_rel.calendar_event_id = cal.id
+ AND part_rel.res_partner_id IN %s
+ """
+
+ # Add filter on alarm type
+ tuple_params = (alarm_type,)
+
+ # Add filter on partner_id
+ if partners:
+ base_request += filter_user
+ tuple_params += (tuple(partners.ids), )
+
+ # Upper bound on first_alarm of requested events
+ first_alarm_max_value = ""
+ if seconds is None:
+ # first alarm in the future + 3 minutes if there is one, now otherwise
+ first_alarm_max_value = """
+ COALESCE((SELECT MIN(cal.start - interval '1' minute * calcul_delta.max_delta)
+ FROM calendar_event cal
+ RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
+ WHERE cal.start - interval '1' minute * calcul_delta.max_delta > now() at time zone 'utc'
+ ) + interval '3' minute, now() at time zone 'utc')"""
+ else:
+ # now + given seconds
+ first_alarm_max_value = "(now() at time zone 'utc' + interval '%s' second )"
+ tuple_params += (seconds,)
+
+ self._cr.execute("""
+ WITH calcul_delta AS (%s)
+ SELECT *
+ FROM ( %s WHERE cal.active = True ) AS ALL_EVENTS
+ WHERE ALL_EVENTS.first_alarm < %s
+ AND ALL_EVENTS.last_event_date > (now() at time zone 'utc')
+ """ % (delta_request, base_request, first_alarm_max_value), tuple_params)
+
+ for event_id, first_alarm, last_alarm, first_meeting, last_meeting, min_duration, max_duration, rule in self._cr.fetchall():
+ result[event_id] = {
+ 'event_id': event_id,
+ 'first_alarm': first_alarm,
+ 'last_alarm': last_alarm,
+ 'first_meeting': first_meeting,
+ 'last_meeting': last_meeting,
+ 'min_duration': min_duration,
+ 'max_duration': max_duration,
+ 'rrule': rule
+ }
+
+ # determine accessible events
+ events = self.env['calendar.event'].browse(result)
+ result = {
+ key: result[key]
+ for key in set(events._filter_access_rules('read').ids)
+ }
+ return result
+
+ def do_check_alarm_for_one_date(self, one_date, event, event_maxdelta, in_the_next_X_seconds, alarm_type, after=False, missing=False):
+ """ Search for some alarms in the interval of time determined by some parameters (after, in_the_next_X_seconds, ...)
+ :param one_date: date of the event to check (not the same that in the event browse if recurrent)
+ :param event: Event browse record
+ :param event_maxdelta: biggest duration from alarms for this event
+ :param in_the_next_X_seconds: looking in the future (in seconds)
+ :param after: if not False: will return alert if after this date (date as string - todo: change in master)
+ :param missing: if not False: will return alert even if we are too late
+ :param notif: Looking for type notification
+ :param mail: looking for type email
+ """
+ result = []
+ # TODO: remove event_maxdelta and if using it
+ if one_date - timedelta(minutes=(missing * event_maxdelta)) < fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date
+ for alarm in event.alarm_ids:
+ if alarm.alarm_type == alarm_type and \
+ one_date - timedelta(minutes=(missing * alarm.duration_minutes)) < fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
+ (not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
+ alert = {
+ 'alarm_id': alarm.id,
+ 'event_id': event.id,
+ 'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
+ }
+ result.append(alert)
+ return result
+
+ @api.model
+ def get_next_mail(self):
+ return self._get_partner_next_mail(partners=None)
+
+ @api.model
+ def _get_partner_next_mail(self, partners=None):
+ self = self.with_context(mail_notify_force_send=True)
+ last_notif_mail = fields.Datetime.to_string(self.env.context.get('lastcall') or fields.Datetime.now())
+
+ cron = self.env.ref('calendar.ir_cron_scheduler_alarm', raise_if_not_found=False)
+ if not cron:
+ _logger.error("Cron for " + self._name + " can not be identified !")
+ return False
+
+ interval_to_second = {
+ "weeks": 7 * 24 * 60 * 60,
+ "days": 24 * 60 * 60,
+ "hours": 60 * 60,
+ "minutes": 60,
+ "seconds": 1
+ }
+
+ if cron.interval_type not in interval_to_second:
+ _logger.error("Cron delay can not be computed !")
+ return False
+
+ cron_interval = cron.interval_number * interval_to_second[cron.interval_type]
+
+ all_meetings = self._get_next_potential_limit_alarm('email', seconds=cron_interval, partners=partners)
+
+ for meeting in self.env['calendar.event'].browse(all_meetings):
+ max_delta = all_meetings[meeting.id]['max_duration']
+ in_date_format = meeting.start
+ 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:
+ self.do_mail_reminder(alert)
+
+ @api.model
+ def get_next_notif(self):
+ partner = self.env.user.partner_id
+ all_notif = []
+
+ if not partner:
+ return []
+
+ all_meetings = self._get_next_potential_limit_alarm('notification', partners=partner)
+ time_limit = 3600 * 24 # return alarms of the next 24 hours
+ for event_id in all_meetings:
+ max_delta = all_meetings[event_id]['max_duration']
+ meeting = self.env['calendar.event'].browse(event_id)
+ in_date_format = fields.Datetime.from_string(meeting.start)
+ 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:
+ for alert in last_found:
+ all_notif.append(self.do_notif_reminder(alert))
+ return all_notif
+
+ def do_mail_reminder(self, alert):
+ meeting = self.env['calendar.event'].browse(alert['event_id'])
+ alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
+
+ result = False
+ if alarm.alarm_type == 'email':
+ result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True, ignore_recurrence=True)
+ return result
+
+ def do_notif_reminder(self, alert):
+ alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
+ meeting = self.env['calendar.event'].browse(alert['event_id'])
+
+ if alarm.alarm_type == 'notification':
+ message = meeting.display_time
+
+ delta = alert['notify_at'] - fields.Datetime.now()
+ delta = delta.seconds + delta.days * 3600 * 24
+
+ return {
+ 'alarm_id': alarm.id,
+ 'event_id': meeting.id,
+ 'title': meeting.name,
+ 'message': message,
+ 'timer': delta,
+ 'notify_at': fields.Datetime.to_string(alert['notify_at']),
+ }
+
+ def _notify_next_alarm(self, partner_ids):
+ """ Sends through the bus the next alarm of given partners """
+ notifications = []
+ users = self.env['res.users'].search([('partner_id', 'in', tuple(partner_ids))])
+ for user in users:
+ notif = self.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).get_next_notif()
+ notifications.append([(self._cr.dbname, 'calendar.alarm', user.partner_id.id), notif])
+ if len(notifications) > 0:
+ self.env['bus.bus'].sendmany(notifications)
diff --git a/addons/calendar/models/calendar_attendee.py b/addons/calendar/models/calendar_attendee.py
new file mode 100644
index 00000000..105ff5b4
--- /dev/null
+++ b/addons/calendar/models/calendar_attendee.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import uuid
+import base64
+import logging
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class Attendee(models.Model):
+ """ Calendar Attendee Information """
+ _name = 'calendar.attendee'
+ _rec_name = 'common_name'
+ _description = 'Calendar Attendee Information'
+
+ def _default_access_token(self):
+ return uuid.uuid4().hex
+
+ STATE_SELECTION = [
+ ('needsAction', 'Needs Action'),
+ ('tentative', 'Uncertain'),
+ ('declined', 'Declined'),
+ ('accepted', 'Accepted'),
+ ]
+
+ event_id = fields.Many2one(
+ 'calendar.event', 'Meeting linked', required=True, ondelete='cascade')
+ partner_id = fields.Many2one('res.partner', 'Contact', required=True, readonly=True)
+ state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction',
+ help="Status of the attendee's participation")
+ common_name = fields.Char('Common name', compute='_compute_common_name', store=True)
+ email = fields.Char('Email', related='partner_id.email', help="Email of Invited Person")
+ availability = fields.Selection(
+ [('free', 'Free'), ('busy', 'Busy')], 'Free/Busy', readonly=True)
+ access_token = fields.Char('Invitation Token', default=_default_access_token)
+ recurrence_id = fields.Many2one('calendar.recurrence', related='event_id.recurrence_id')
+
+ @api.depends('partner_id', 'partner_id.name', 'email')
+ def _compute_common_name(self):
+ for attendee in self:
+ attendee.common_name = attendee.partner_id.name or attendee.email
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if values.get('partner_id') == self.env.user.partner_id.id:
+ values['state'] = 'accepted'
+ if not values.get("email") and values.get("common_name"):
+ common_nameval = values.get("common_name").split(':')
+ email = [x for x in common_nameval if '@' in x]
+ values['email'] = email[0] if email else ''
+ values['common_name'] = values.get("common_name")
+ attendees = super().create(vals_list)
+ attendees._subscribe_partner()
+ return attendees
+
+ def unlink(self):
+ self._unsubscribe_partner()
+ return super().unlink()
+
+ def _subscribe_partner(self):
+ for event in self.event_id:
+ partners = (event.attendee_ids & self).partner_id - event.message_partner_ids
+ # current user is automatically added as followers, don't add it twice.
+ partners -= self.env.user.partner_id
+ event.message_subscribe(partner_ids=partners.ids)
+
+ def _unsubscribe_partner(self):
+ for event in self.event_id:
+ partners = (event.attendee_ids & self).partner_id & event.message_partner_ids
+ event.message_unsubscribe(partner_ids=partners.ids)
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ raise UserError(_('You cannot duplicate a calendar attendee.'))
+
+ def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False):
+ """ Send mail for event invitation to event attendees.
+ :param template_xmlid: xml id of the email template to use to send the invitation
+ :param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
+ :param ignore_recurrence: ignore event recurrence
+ """
+ res = False
+
+ if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self._context.get("no_mail_to_attendees"):
+ return res
+
+ calendar_view = self.env.ref('calendar.view_calendar_event_calendar')
+ invitation_template = self.env.ref(template_xmlid, raise_if_not_found=False)
+ if not invitation_template:
+ _logger.warning("Template %s could not be found. %s not notified." % (template_xmlid, self))
+ return
+ # get ics file for all meetings
+ ics_files = self.mapped('event_id')._get_ics_file()
+
+ # prepare rendering context for mail template
+ colors = {
+ 'needsAction': 'grey',
+ 'accepted': 'green',
+ 'tentative': '#FFFF00',
+ 'declined': 'red'
+ }
+ rendering_context = dict(self._context)
+ rendering_context.update({
+ 'colors': colors,
+ 'ignore_recurrence': ignore_recurrence,
+ 'action_id': self.env['ir.actions.act_window'].sudo().search([('view_id', '=', calendar_view.id)], limit=1).id,
+ 'dbname': self._cr.dbname,
+ 'base_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url', default='http://localhost:8069'),
+ })
+
+ for attendee in self:
+ if attendee.email and attendee.partner_id != self.env.user.partner_id:
+ # FIXME: is ics_file text or bytes?
+ event_id = attendee.event_id.id
+ ics_file = ics_files.get(event_id)
+
+ attachment_values = []
+ if ics_file:
+ attachment_values = [
+ (0, 0, {'name': 'invitation.ics',
+ 'mimetype': 'text/calendar',
+ 'datas': base64.b64encode(ics_file)})
+ ]
+ try:
+ body = invitation_template.with_context(rendering_context)._render_field(
+ 'body_html',
+ attendee.ids,
+ compute_lang=True,
+ post_process=True)[attendee.id]
+ except UserError: #TO BE REMOVED IN MASTER
+ body = invitation_template.sudo().with_context(rendering_context)._render_field(
+ 'body_html',
+ attendee.ids,
+ compute_lang=True,
+ post_process=True)[attendee.id]
+ subject = invitation_template._render_field(
+ 'subject',
+ attendee.ids,
+ compute_lang=True)[attendee.id]
+ attendee.event_id.with_context(no_document=True).message_notify(
+ email_from=attendee.event_id.user_id.email_formatted or self.env.user.email_formatted,
+ author_id=attendee.event_id.user_id.partner_id.id or self.env.user.partner_id.id,
+ body=body,
+ subject=subject,
+ partner_ids=attendee.partner_id.ids,
+ email_layout_xmlid='mail.mail_notification_light',
+ attachment_ids=attachment_values,
+ force_send=force_send)
+
+ def do_tentative(self):
+ """ Makes event invitation as Tentative. """
+ return self.write({'state': 'tentative'})
+
+ def do_accept(self):
+ """ Marks event invitation as Accepted. """
+ for attendee in self:
+ attendee.event_id.message_post(
+ body=_("%s has accepted invitation") % (attendee.common_name),
+ subtype_xmlid="calendar.subtype_invitation")
+ return self.write({'state': 'accepted'})
+
+ def do_decline(self):
+ """ Marks event invitation as Declined. """
+ for attendee in self:
+ attendee.event_id.message_post(
+ body=_("%s has declined invitation") % (attendee.common_name),
+ subtype_xmlid="calendar.subtype_invitation")
+ return self.write({'state': 'declined'})
diff --git a/addons/calendar/models/calendar_contact.py b/addons/calendar/models/calendar_contact.py
new file mode 100644
index 00000000..6885450f
--- /dev/null
+++ b/addons/calendar/models/calendar_contact.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class Contacts(models.Model):
+ _name = 'calendar.contacts'
+ _description = 'Calendar Contacts'
+
+ user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
+ partner_id = fields.Many2one('res.partner', 'Employee', required=True)
+ active = fields.Boolean('Active', default=True)
+
+ _sql_constraints = [
+ ('user_id_partner_id_unique', 'UNIQUE(user_id, partner_id)', 'A user cannot have the same contact twice.')
+ ]
+
+ @api.model
+ def unlink_from_partner_id(self, partner_id):
+ return self.search([('partner_id', '=', partner_id)]).unlink()
diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py
new file mode 100644
index 00000000..4f704bdc
--- /dev/null
+++ b/addons/calendar/models/calendar_event.py
@@ -0,0 +1,845 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import timedelta
+import math
+import babel.dates
+import logging
+import pytz
+
+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.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 pycompat
+from odoo.exceptions import UserError, ValidationError, AccessError
+
+_logger = logging.getLogger(__name__)
+
+SORT_ALIASES = {
+ 'start': 'sort_start',
+ 'start_date': 'sort_start',
+}
+
+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):
+ _name = 'calendar.event'
+ _description = "Calendar Event"
+ _order = "start desc"
+ _inherit = ["mail.thread"]
+
+ @api.model
+ def default_get(self, fields):
+ # super default_model='crm.lead' for easier use in addons
+ if self.env.context.get('default_res_model') and not self.env.context.get('default_res_model_id'):
+ self = self.with_context(
+ default_res_model_id=self.env['ir.model'].sudo().search([
+ ('model', '=', self.env.context['default_res_model'])
+ ], limit=1).id
+ )
+
+ defaults = super(Meeting, self).default_get(fields)
+
+ # support active_model / active_id as replacement of default_* if not already given
+ if 'res_model_id' not in defaults and 'res_model_id' in fields and \
+ self.env.context.get('active_model') and self.env.context['active_model'] != 'calendar.event':
+ defaults['res_model_id'] = self.env['ir.model'].sudo().search([('model', '=', self.env.context['active_model'])], limit=1).id
+ if 'res_id' not in defaults and 'res_id' in fields and \
+ defaults.get('res_model_id') and self.env.context.get('active_id'):
+ defaults['res_id'] = self.env.context['active_id']
+
+ return defaults
+
+ @api.model
+ def _default_partners(self):
+ """ When active_model is res.partner, the current partners should be attendees """
+ partners = self.env.user.partner_id
+ active_id = self._context.get('active_id')
+ if self._context.get('active_model') == 'res.partner' and active_id:
+ if active_id not in partners.ids:
+ partners |= self.env['res.partner'].browse(active_id)
+ return partners
+
+ def _find_my_attendee(self):
+ """ Return the first attendee where the user connected has been invited
+ from all the meeting_ids in parameters.
+ """
+ self.ensure_one()
+ for attendee in self.attendee_ids:
+ if self.env.user.partner_id == attendee.partner_id:
+ return attendee
+ return False
+
+ @api.model
+ def _get_date_formats(self):
+ """ get current date and time format, according to the context lang
+ :return: a tuple with (format date, format time)
+ """
+ lang = get_lang(self.env)
+ return (lang.date_format, lang.time_format)
+
+ @api.model
+ def _get_recurrent_fields(self):
+ return {'byday', 'until', 'rrule_type', 'month_by', 'event_tz', 'rrule',
+ 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa',
+ 'su', 'day', 'weekday'}
+
+ @api.model
+ def _get_time_fields(self):
+ return {'start', 'stop', 'start_date', 'stop_date'}
+
+ @api.model
+ def _get_custom_fields(self):
+ all_fields = self.fields_get(attributes=['manual'])
+ return {fname for fname in all_fields if all_fields[fname]['manual']}
+
+ @api.model
+ def _get_public_fields(self):
+ return self._get_recurrent_fields() | self._get_time_fields() | self._get_custom_fields() | {
+ 'id', 'active', 'allday',
+ 'duration', 'user_id', 'interval',
+ 'count', 'rrule', 'recurrence_id', 'show_as'}
+
+ @api.model
+ def _get_display_time(self, start, stop, zduration, zallday):
+ """ Return date and time (from to from) based on duration with timezone in string. Eg :
+ 1) if user add duration for 2 hours, return : August-23-2013 at (04-30 To 06-30) (Europe/Brussels)
+ 2) if event all day ,return : AllDay, July-31-2013
+ """
+ timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC'
+
+ # get date/time format according to context
+ format_date, format_time = self._get_date_formats()
+
+ # convert date and time into user timezone
+ self_tz = self.with_context(tz=timezone)
+ date = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(start))
+ date_deadline = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(stop))
+
+ # convert into string the date and time, using user formats
+ to_text = pycompat.to_text
+ date_str = to_text(date.strftime(format_date))
+ time_str = to_text(date.strftime(format_time))
+
+ if zallday:
+ display_time = _("All Day, %(day)s", day=date_str)
+ elif zduration < 24:
+ duration = date + timedelta(minutes=round(zduration*60))
+ duration_time = to_text(duration.strftime(format_time))
+ display_time = _(
+ u"%(day)s at (%(start)s To %(end)s) (%(timezone)s)",
+ day=date_str,
+ start=time_str,
+ end=duration_time,
+ timezone=timezone,
+ )
+ else:
+ dd_date = to_text(date_deadline.strftime(format_date))
+ dd_time = to_text(date_deadline.strftime(format_time))
+ display_time = _(
+ u"%(date_start)s at %(time_start)s To\n %(date_end)s at %(time_end)s (%(timezone)s)",
+ date_start=date_str,
+ time_start=time_str,
+ date_end=dd_date,
+ time_end=dd_time,
+ timezone=timezone,
+ )
+ return display_time
+
+ def _get_duration(self, start, stop):
+ """ Get the duration value between the 2 given dates. """
+ if not start or not stop:
+ return 0
+ duration = (stop - start).total_seconds() / 3600
+ return round(duration, 2)
+
+ def _compute_is_highlighted(self):
+ if self.env.context.get('active_model') == 'res.partner':
+ partner_id = self.env.context.get('active_id')
+ for event in self:
+ if event.partner_ids.filtered(lambda s: s.id == partner_id):
+ event.is_highlighted = True
+ else:
+ event.is_highlighted = False
+ else:
+ for event in self:
+ event.is_highlighted = False
+
+ name = fields.Char('Meeting Subject', required=True)
+
+ attendee_status = fields.Selection(
+ Attendee.STATE_SELECTION, string='Attendee Status', compute='_compute_attendee')
+ display_time = fields.Char('Event Time', compute='_compute_display_time')
+ start = fields.Datetime(
+ 'Start', required=True, tracking=True, default=fields.Date.today,
+ help="Start date of an event, without time for full days events")
+ stop = fields.Datetime(
+ 'Stop', required=True, tracking=True, default=fields.Date.today,
+ compute='_compute_stop', readonly=False, store=True,
+ help="Stop date of an event, without time for full days events")
+
+ allday = fields.Boolean('All Day', default=False)
+ start_date = fields.Date(
+ 'Start Date', store=True, tracking=True,
+ compute='_compute_dates', inverse='_inverse_dates')
+ stop_date = fields.Date(
+ 'End Date', store=True, tracking=True,
+ compute='_compute_dates', inverse='_inverse_dates')
+ duration = fields.Float('Duration', compute='_compute_duration', store=True, readonly=False)
+ description = fields.Text('Description')
+ privacy = fields.Selection(
+ [('public', 'Everyone'),
+ ('private', 'Only me'),
+ ('confidential', 'Only internal users')],
+ 'Privacy', default='public', required=True)
+ location = fields.Char('Location', tracking=True, help="Location of Event")
+ show_as = fields.Selection(
+ [('free', 'Free'),
+ ('busy', 'Busy')], 'Show Time as', default='busy', required=True)
+
+ # linked document
+ # LUL TODO use fields.Reference ?
+ res_id = fields.Integer('Document ID')
+ res_model_id = fields.Many2one('ir.model', 'Document Model', ondelete='cascade')
+ res_model = fields.Char(
+ 'Document Model Name', related='res_model_id.model', readonly=True, store=True)
+ activity_ids = fields.One2many('mail.activity', 'calendar_event_id', string='Activities')
+
+ #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', 'Responsible', default=lambda self: self.env.user)
+ partner_id = fields.Many2one(
+ 'res.partner', string='Responsible Contact', 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
+ recurrency = fields.Boolean('Recurrent', help="Recurrent Event")
+ recurrence_id = fields.Many2one(
+ 'calendar.recurrence', string="Recurrence Rule", index=True)
+ follow_recurrence = fields.Boolean(default=False) # Indicates if an event follows the recurrence, i.e. is not an exception
+ 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', compute='_compute_recurrence', readonly=False,
+ help="Repeat every (Days/Week/Month/Year)")
+ 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:
+ attendee = meeting._find_my_attendee()
+ meeting.attendee_status = attendee.state if attendee else 'needsAction'
+
+ def _compute_display_time(self):
+ for meeting in self:
+ meeting.display_time = self._get_display_time(meeting.start, meeting.stop, meeting.duration, meeting.allday)
+
+ @api.depends('allday', 'start', 'stop')
+ def _compute_dates(self):
+ """ Adapt the value of start_date(time)/stop_date(time)
+ according to start/stop fields and allday. Also, compute
+ the duration for not allday meeting ; otherwise the
+ duration is set to zero, since the meeting last all the day.
+ """
+ for meeting in self:
+ if meeting.allday and meeting.start and meeting.stop:
+ meeting.start_date = meeting.start.date()
+ meeting.stop_date = meeting.stop.date()
+ else:
+ meeting.start_date = False
+ meeting.stop_date = False
+
+ @api.depends('stop', 'start')
+ def _compute_duration(self):
+ for event in self.with_context(dont_notify=True):
+ event.duration = self._get_duration(event.start, event.stop)
+
+ @api.depends('start', 'duration')
+ def _compute_stop(self):
+ # stop and duration fields both depends on the start field.
+ # But they also depends on each other.
+ # When start is updated, we want to update the stop datetime based on
+ # the *current* duration. In other words, we want: change start => keep the duration fixed and
+ # recompute stop accordingly.
+ # However, while computing stop, duration is marked to be recomputed. Calling `event.duration` would trigger
+ # its recomputation. To avoid this we manually mark the field as computed.
+ duration_field = self._fields['duration']
+ self.env.remove_to_compute(duration_field, self)
+ for event in self:
+ # Round the duration (in hours) to the minute to avoid weird situations where the event
+ # stops at 4:19:59, later displayed as 4:19.
+ event.stop = event.start + timedelta(minutes=round((event.duration or 1.0) * 60))
+ if event.allday:
+ event.stop -= timedelta(seconds=1)
+
+ def _inverse_dates(self):
+ for meeting in self:
+ if meeting.allday:
+
+ # 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)
+ enddate = enddate.replace(hour=18)
+
+ startdate = fields.Datetime.from_string(meeting.start_date)
+ startdate = startdate.replace(hour=8) # Set 8 AM
+
+ meeting.write({
+ 'start': startdate.replace(tzinfo=None),
+ 'stop': enddate.replace(tzinfo=None)
+ })
+
+ @api.constrains('start', 'stop', 'start_date', 'stop_date')
+ def _check_closing_date(self):
+ for meeting in self:
+ if not meeting.allday and meeting.start and meeting.stop and meeting.stop < meeting.start:
+ raise ValidationError(
+ _('The ending date and time cannot be earlier than the starting date and time.') + '\n' +
+ _("Meeting '%(name)s' starts '%(start_datetime)s' and ends '%(end_datetime)s'",
+ name=meeting.name,
+ start_datetime=meeting.start,
+ end_datetime=meeting.stop
+ )
+ )
+ if meeting.allday and meeting.start_date and meeting.stop_date and meeting.stop_date < meeting.start_date:
+ raise ValidationError(
+ _('The ending date cannot be earlier than the starting date.') + '\n' +
+ _("Meeting '%(name)s' starts '%(start_datetime)s' and ends '%(end_datetime)s'",
+ name=meeting.name,
+ start_datetime=meeting.start,
+ end_datetime=meeting.stop
+ )
+ )
+
+ ####################################################
+ # 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
+ """
+ result = {}
+
+ def ics_datetime(idate, allday=False):
+ if idate:
+ if allday:
+ return idate
+ return idate.replace(tzinfo=pytz.timezone('UTC'))
+ return False
+
+ try:
+ # FIXME: why isn't this in CalDAV?
+ import vobject
+ except ImportError:
+ _logger.warning("The `vobject` Python module is not installed, so iCal file generation is unavailable. Please install the `vobject` Python module")
+ return result
+
+ for meeting in self:
+ cal = vobject.iCalendar()
+ event = cal.add('vevent')
+
+ if not meeting.start or not meeting.stop:
+ raise UserError(_("First you have to specify the date of the invitation."))
+ event.add('created').value = ics_datetime(fields.Datetime.now())
+ event.add('dtstart').value = ics_datetime(meeting.start, meeting.allday)
+ event.add('dtend').value = ics_datetime(meeting.stop, meeting.allday)
+ event.add('summary').value = meeting.name
+ if meeting.description:
+ event.add('description').value = meeting.description
+ if meeting.location:
+ event.add('location').value = meeting.location
+ if meeting.rrule:
+ event.add('rrule').value = meeting.rrule
+
+ if meeting.alarm_ids:
+ for alarm in meeting.alarm_ids:
+ valarm = event.add('valarm')
+ interval = alarm.interval
+ duration = alarm.duration
+ trigger = valarm.add('TRIGGER')
+ trigger.params['related'] = ["START"]
+ if interval == 'days':
+ delta = timedelta(days=duration)
+ elif interval == 'hours':
+ delta = timedelta(hours=duration)
+ elif interval == 'minutes':
+ delta = timedelta(minutes=duration)
+ trigger.value = delta
+ valarm.add('DESCRIPTION').value = alarm.name or u'Odoo'
+ for attendee in meeting.attendee_ids:
+ attendee_add = event.add('attendee')
+ attendee_add.value = u'MAILTO:' + (attendee.email or u'')
+ result[meeting.id] = cal.serialize().encode('utf-8')
+
+ return result
+
+ def _attendees_values(self, partner_commands):
+ """
+ :param partner_commands: ORM commands for partner_id field (0 and 1 commands not supported)
+ :return: associated attendee_ids ORM commands
+ """
+ attendee_commands = []
+
+ removed_partner_ids = []
+ added_partner_ids = []
+ for command in partner_commands:
+ op = command[0]
+ if op in (2, 3): # Remove partner
+ removed_partner_ids += [command[1]]
+ elif op == 6: # Replace all
+ removed_partner_ids += set(self.attendee_ids.mapped('partner_id').ids) - set(command[2]) # Don't recreate attendee if partner already attend the event
+ added_partner_ids += set(command[2]) - set(self.attendee_ids.mapped('partner_id').ids)
+ elif op == 4:
+ added_partner_ids += [command[1]] if command[1] not in self.attendee_ids.mapped('partner_id').ids else []
+ # commands 0 and 1 not supported
+
+ attendees_to_unlink = self.env['calendar.attendee'].search([
+ ('event_id', 'in', self.ids),
+ ('partner_id', 'in', removed_partner_ids),
+ ])
+ attendee_commands += [[2, attendee.id] for attendee in attendees_to_unlink] # Removes and delete
+
+ attendee_commands += [
+ [0, 0, dict(partner_id=partner_id)]
+ for partner_id in added_partner_ids
+ ]
+ return attendee_commands
+
+ 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
+ :param string tz: Timezone indicator (optional)
+ :return unicode: Formatted date or time (as unicode string, to prevent jinja2 crash)
+ """
+ self.ensure_one()
+ date = fields.Datetime.from_string(self.start)
+
+ if tz:
+ timezone = pytz.timezone(tz or 'UTC')
+ date = date.replace(tzinfo=pytz.timezone('UTC')).astimezone(timezone)
+
+ if interval == 'day':
+ # Day number (1-31)
+ result = str(date.day)
+
+ elif interval == 'month':
+ # Localized month name and year
+ result = babel.dates.format_date(date=date, format='MMMM y', locale=get_lang(self.env).code)
+
+ elif interval == 'dayname':
+ # Localized day name
+ result = babel.dates.format_date(date=date, format='EEEE', locale=get_lang(self.env).code)
+
+ elif interval == 'time':
+ # Localized time
+ # FIXME: formats are specifically encoded to bytes, maybe use babel?
+ dummy, format_time = self._get_date_formats()
+ result = tools.ustr(date.strftime(format_time + " %Z"))
+
+ return result
+
+ def get_display_time_tz(self, tz=False):
+ """ get the display_time of the meeting, forcing the timezone. This method is called from email template, to not use sudo(). """
+ self.ensure_one()
+ if tz:
+ self = self.with_context(tz=tz)
+ return self._get_display_time(self.start, self.stop, self.duration, self.allday)
+
+ 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()
+ return False
+
+ def action_sendmail(self):
+ email = self.env.user.email
+ if email:
+ for meeting in self:
+ meeting.attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
+ return True
+
+ 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 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.write({'recurrency': True, 'follow_recurrence': 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,
+ }
+
+ 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 _split_recurrence(self, time_values):
+ """Apply time changes to events and update the recurrence accordingly.
+
+ :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.with_context(archive_on_error=True).unlink()
+ return detached_events - self
+
+ def write(self, 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
+
+ if 'partner_ids' in values:
+ values['attendee_ids'] = self._attendees_values(values['partner_ids'])
+
+ if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values:
+ if any({field: values.get(field) for field in self.env['calendar.event']._get_time_fields() if field in values}):
+ values['follow_recurrence'] = False
+
+ previous_attendees = self.attendee_ids
+
+ 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(fields=values.keys())
+
+ if recurrence_update_setting != 'self_only' and not break_recurrence:
+ detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update_setting == 'future_events')
+
+ (detached_events & self).active = False
+ (detached_events - self).with_context(archive_on_error=True).unlink()
+
+ # 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 self.alarm_ids or values.get('alarm_ids'):
+ self.env['calendar.alarm_manager']._notify_next_alarm(self.partner_ids.ids)
+
+ current_attendees = self.filtered('active').attendee_ids
+ if 'partner_ids' in values:
+ (current_attendees - previous_attendees)._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
+ if 'start' in values:
+ start_date = fields.Datetime.to_datetime(values.get('start'))
+ # Only notify on future events
+ if start_date and start_date >= fields.Datetime.now():
+ (current_attendees & previous_attendees)._send_mail_to_attendees('calendar.calendar_template_meeting_changedate', ignore_recurrence=not update_recurrence)
+
+ return True
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ vals_list = [ # Else bug with quick_create when we are filter on an other user
+ dict(vals, user_id=self.env.user.id) if not 'user_id' in vals else vals
+ for vals in vals_list
+ ]
+
+ for values in vals_list:
+ # 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'])
+ res_model_id = values.get('res_model_id', defaults.get('res_model_id'))
+ res_id = values.get('res_id', defaults.get('res_id'))
+ user_id = values.get('user_id', defaults.get('user_id'))
+ if not defaults.get('activity_ids') and res_model_id and res_id:
+ if hasattr(self.env[self.env['ir.model'].sudo().browse(res_model_id).model], 'activity_ids'):
+ meeting_activity_type = self.env['mail.activity.type'].search([('category', '=', 'meeting')], limit=1)
+ if meeting_activity_type:
+ activity_vals = {
+ 'res_model_id': res_model_id,
+ 'res_id': res_id,
+ 'activity_type_id': meeting_activity_type.id,
+ }
+ if user_id:
+ activity_vals['user_id'] = user_id
+ values['activity_ids'] = [(0, 0, activity_vals)]
+
+ vals_list = [
+ dict(vals, attendee_ids=self._attendees_values(vals['partner_ids'])) if 'partner_ids' in vals else vals
+ for vals in vals_list
+ ]
+ recurrence_fields = self._get_recurrent_fields()
+ recurring_vals = [vals for vals in vals_list if vals.get('recurrency')]
+ other_vals = [vals for vals in vals_list if not vals.get('recurrency')]
+ events = super().create(other_vals)
+
+ for vals in recurring_vals:
+ vals['follow_recurrence'] = True
+ recurring_events = super().create(recurring_vals)
+ events += recurring_events
+
+ for event, vals in zip(recurring_events, recurring_vals):
+ recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals}
+ if vals.get('recurrency'):
+ detached_events = event._apply_recurrence_values(recurrence_values)
+ detached_events.active = False
+
+ events.filtered(lambda event: event.start > fields.Datetime.now()).attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
+ events._sync_activities(fields={f for vals in vals_list for f in vals.keys() })
+
+ # Notify attendees if there is an alarm on the created event, as it might have changed their
+ # next event notification
+ if not self._context.get('dont_notify'):
+ for event in events:
+ if len(event.alarm_ids) > 0:
+ self.env['calendar.alarm_manager']._notify_next_alarm(event.partner_ids.ids)
+ return events
+
+ def read(self, fields=None, load='_classic_read'):
+ def hide(field, value):
+ """
+ :param field: field name
+ :param value: field value
+ :return: obfuscated field value
+ """
+ if field in {'name', 'display_name'}:
+ return _('Busy')
+ return [] if isinstance(value, list) else False
+
+ def split_privacy(events):
+ """
+ :param events: list of event values (dict)
+ :return: tuple(private events, public events)
+ """
+ private = [event for event in events if event.get('privacy') == 'private']
+ public = [event for event in events if event.get('privacy') != 'private']
+ return private, public
+
+ def my_events(events):
+ """
+ :param events: list of event values (dict)
+ :return: tuple(my events, other events)
+ """
+ my = [event for event in events if event.get('user_id') and event.get('user_id')[0] == self.env.uid]
+ others = [event for event in events if not event.get('user_id') or event.get('user_id')[0] != self.env.uid]
+ return my, others
+
+ def obfuscated(events):
+ """
+ :param events: list of event values (dict)
+ :return: events with private field values obfuscated
+ """
+ public_fields = self._get_public_fields()
+ return [{
+ field: hide(field, value) if field not in public_fields else value
+ for field, value in event.items()
+ } for event in events]
+
+ events = super().read(fields=fields + ['privacy', 'user_id'], load=load)
+ private_events, public_events = split_privacy(events)
+ my_private_events, others_private_events = my_events(private_events)
+
+ return public_events + my_private_events + obfuscated(others_private_events)
+
+ @api.model
+ def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
+ groupby = [groupby] if isinstance(groupby, str) else groupby
+ grouped_fields = set(group_field.split(':')[0] for group_field in groupby)
+ private_fields = grouped_fields - self._get_public_fields()
+ if not self.env.su and private_fields:
+ raise AccessError(_(
+ "Grouping by %s is not allowed.",
+ ', '.join([self._fields[field_name].string for field_name in private_fields])
+ ))
+ return super(Meeting, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
+
+ 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.filtered_domain([('alarm_ids', '!=', False)])
+ partner_ids = events.mapped('partner_ids').ids
+
+ 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
+
+ def _range(self):
+ self.ensure_one()
+ return (self.start, self.stop)
+
+ def _sync_activities(self, fields):
+ # update activities
+ for event in self:
+ if event.activity_ids:
+ activity_values = {}
+ if 'name' in fields:
+ activity_values['summary'] = event.name
+ if 'description' in fields:
+ activity_values['note'] = tools.plaintext2html(event.description)
+ if 'start' in fields:
+ # self.start is a datetime UTC *only when the event is not allday*
+ # activty.date_deadline is a date (No TZ, but should represent the day in which the user's TZ is)
+ # See 72254129dbaeae58d0a2055cba4e4a82cde495b7 for the same issue, but elsewhere
+ deadline = event.start
+ user_tz = self.env.context.get('tz')
+ if user_tz and not event.allday:
+ deadline = pytz.UTC.localize(deadline)
+ deadline = deadline.astimezone(pytz.timezone(user_tz))
+ activity_values['date_deadline'] = deadline.date()
+ if 'user_id' in fields:
+ activity_values['user_id'] = event.user_id.id
+ if activity_values.keys():
+ event.activity_ids.write(activity_values)
+
+ def change_attendee_status(self, status):
+ attendee = self.attendee_ids.filtered(lambda x: x.partner_id == self.env.user.partner_id)
+ if status == 'accepted':
+ return attendee.do_accept()
+ if status == 'declined':
+ return attendee.do_decline()
+ return attendee.do_tentative()
diff --git a/addons/calendar/models/calendar_event_type.py b/addons/calendar/models/calendar_event_type.py
new file mode 100644
index 00000000..14d92ebc
--- /dev/null
+++ b/addons/calendar/models/calendar_event_type.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class MeetingType(models.Model):
+
+ _name = 'calendar.event.type'
+ _description = 'Event Meeting Type'
+
+ name = fields.Char('Name', required=True)
+
+ _sql_constraints = [
+ ('name_uniq', 'unique (name)', "Tag name already exists !"),
+ ]
diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py
new file mode 100644
index 00000000..5d428ebc
--- /dev/null
+++ b/addons/calendar/models/calendar_recurrence.py
@@ -0,0 +1,483 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, time
+import pytz
+
+from dateutil import rrule
+from dateutil.relativedelta import relativedelta
+
+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'),
+ ('4', 'Fourth'),
+ ('-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'
+
+ name = fields.Char(compute='_compute_name', store=True)
+ base_event_id = fields.Many2one(
+ 'calendar.event', ondelete='set null', copy=False) # store=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 "
+ "OR weekday in %s AND byday in %s)"
+ % (tuple(wd[0] for wd in WEEKDAY_SELECTION), tuple(bd[0] for bd in BYDAY_SELECTION)),
+ "The day must be between 1 and 31"),
+ ]
+
+ @api.depends('rrule')
+ def _compute_name(self):
+ for recurrence in self:
+ period = dict(RRULE_TYPE_SELECTION)[recurrence.rrule_type]
+ every = _("Every %(count)s %(period)s, ", count=recurrence.interval, period=period)
+
+ if recurrence.end_type == 'count':
+ end = _("for %s events", recurrence.count)
+ elif recurrence.end_type == 'end_date':
+ end = _("until %s", recurrence.until)
+ else:
+ end = ''
+
+ if recurrence.rrule_type == 'weeky':
+ weekdays = recurrence._get_week_days()
+ weekday_fields = (self._fields[weekday_to_field(w)] for w in weekdays)
+ on = _("on %s,") % ", ".join([field.string for field in weekday_fields])
+ elif recurrence.rrule_type == 'monthly':
+ if recurrence.month_by == 'day':
+ weekday_label = dict(BYDAY_SELECTION)[recurrence.byday]
+ on = _("on the %(position)s %(weekday)s, ", position=recurrence.byday, weekday=weekday_label)
+ else:
+ on = _("day %s, ", recurrence.day)
+ else:
+ on = ''
+ recurrence.name = every + on + end
+
+ @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, specific_values_creation=None, no_send_edit=False):
+ """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']
+ if specific_values_creation is None:
+ specific_values_creation = {}
+
+ 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
+ if specific_values_creation:
+ ranges = set([(x[1], x[2]) for x in specific_values_creation if x[0] == recurrence.id])
+ else:
+ 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()
+ values = []
+ for start, stop in ranges:
+ value = dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id, follow_recurrence=True)
+ if (recurrence.id, start, stop) in specific_values_creation:
+ value.update(specific_values_creation[(recurrence.id, start, stop)])
+ values += [value]
+ event_vals += values
+
+ 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.with_context(archive_on_error=True).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_lang_week_start(self):
+ 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'
+ return rrule.weekday(week_start - 1) # rrule expects an int from 0 to 6
+
+ def _get_start_of_period(self, dt):
+ if self.rrule_type == 'weekly':
+ week_start = self._get_lang_week_start()
+ start = dt + relativedelta(weekday=week_start(-1))
+ elif self.rrule_type == 'monthly':
+ start = dt + relativedelta(day=1)
+ else:
+ start = dt
+ return start
+
+ 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
+ rrule_params['wkst'] = self._get_lang_week_start()
+
+ 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_http.py b/addons/calendar/models/ir_http.py
new file mode 100644
index 00000000..08acd868
--- /dev/null
+++ b/addons/calendar/models/ir_http.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from werkzeug.exceptions import BadRequest
+
+from odoo import models
+from odoo.http import request
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def _auth_method_calendar(cls):
+ token = request.params.get('token', '')
+
+ error_message = False
+
+ attendee = request.env['calendar.attendee'].sudo().search([('access_token', '=', token)], limit=1)
+ if not attendee:
+ error_message = """Invalid Invitation Token."""
+ elif request.session.uid and request.session.login != 'anonymous':
+ # if valid session but user is not match
+ user = request.env['res.users'].sudo().browse(request.session.uid)
+ if attendee.partner_id != user.partner_id:
+ error_message = """Invitation cannot be forwarded via email. This event/meeting belongs to %s and you are logged in as %s. Please ask organizer to add you.""" % (attendee.email, user.email)
+ if error_message:
+ raise BadRequest(error_message)
+
+ cls._auth_method_public()
diff --git a/addons/calendar/models/mail_activity.py b/addons/calendar/models/mail_activity.py
new file mode 100644
index 00000000..58ca5ff7
--- /dev/null
+++ b/addons/calendar/models/mail_activity.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, tools, _
+
+
+class MailActivityType(models.Model):
+ _inherit = "mail.activity.type"
+
+ category = fields.Selection(selection_add=[('meeting', 'Meeting')])
+
+
+class MailActivity(models.Model):
+ _inherit = "mail.activity"
+
+ calendar_event_id = fields.Many2one('calendar.event', string="Calendar Meeting", ondelete='cascade')
+
+ def action_create_calendar_event(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("calendar.action_calendar_event")
+ action['context'] = {
+ 'default_activity_type_id': self.activity_type_id.id,
+ 'default_res_id': self.env.context.get('default_res_id'),
+ 'default_res_model': self.env.context.get('default_res_model'),
+ 'default_name': self.summary or self.res_name,
+ 'default_description': self.note and tools.html2plaintext(self.note).strip() or '',
+ 'default_activity_ids': [(6, 0, self.ids)],
+ }
+ return action
+
+ def _action_done(self, feedback=False, attachment_ids=False):
+ events = self.mapped('calendar_event_id')
+ messages, activities = super(MailActivity, self)._action_done(feedback=feedback, attachment_ids=attachment_ids)
+ if feedback:
+ for event in events:
+ description = event.description
+ description = '%s\n%s%s' % (description or '', _("Feedback: "), feedback)
+ event.write({'description': description})
+ return messages, activities
+
+ def unlink_w_meeting(self):
+ events = self.mapped('calendar_event_id')
+ res = self.unlink()
+ events.unlink()
+ return res
diff --git a/addons/calendar/models/res_partner.py b/addons/calendar/models/res_partner.py
new file mode 100644
index 00000000..ec4f5ea7
--- /dev/null
+++ b/addons/calendar/models/res_partner.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+
+from odoo import api, fields, models
+
+
+class Partner(models.Model):
+ _inherit = 'res.partner'
+
+ calendar_last_notif_ack = fields.Datetime(
+ 'Last notification marked as read from base Calendar', default=fields.Datetime.now)
+
+ def get_attendee_detail(self, meeting_id):
+ """ Return a list of tuple (id, name, status)
+ Used by base_calendar.js : Many2ManyAttendee
+ """
+ datas = []
+ meeting = None
+ if meeting_id:
+ meeting = self.env['calendar.event'].browse(meeting_id)
+
+ for partner in self:
+ data = partner.name_get()[0]
+ data = [data[0], data[1], False, partner.color]
+ if meeting:
+ for attendee in meeting.attendee_ids:
+ if attendee.partner_id.id == partner.id:
+ data[2] = attendee.state
+ datas.append(data)
+ return datas
+
+ @api.model
+ def _set_calendar_last_notif_ack(self):
+ partner = self.env['res.users'].browse(self.env.context.get('uid', self.env.uid)).partner_id
+ partner.write({'calendar_last_notif_ack': datetime.now()})
diff --git a/addons/calendar/models/res_users.py b/addons/calendar/models/res_users.py
new file mode 100644
index 00000000..bdb25f75
--- /dev/null
+++ b/addons/calendar/models/res_users.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+
+from odoo import api, fields, models, modules, _
+from pytz import timezone, UTC
+
+
+class Users(models.Model):
+ _inherit = 'res.users'
+
+ def _systray_get_calendar_event_domain(self):
+ tz = self.env.user.tz
+ start_dt = datetime.datetime.utcnow()
+ if tz:
+ start_date = timezone(tz).localize(start_dt).astimezone(UTC).date()
+ else:
+ start_date = datetime.date.today()
+ end_dt = datetime.datetime.combine(start_date, datetime.time.max)
+ if tz:
+ end_dt = timezone(tz).localize(end_dt).astimezone(UTC)
+
+ return ['&', '|',
+ '&',
+ '|',
+ ['start', '>=', fields.Datetime.to_string(start_dt)],
+ ['stop', '>=', fields.Datetime.to_string(start_dt)],
+ ['start', '<=', fields.Datetime.to_string(end_dt)],
+ '&',
+ ['allday', '=', True],
+ ['start_date', '=', fields.Date.to_string(start_date)],
+ ('attendee_ids.partner_id', '=', self.env.user.partner_id.id)]
+
+ @api.model
+ def systray_get_activities(self):
+ res = super(Users, self).systray_get_activities()
+
+ meetings_lines = self.env['calendar.event'].search_read(
+ self._systray_get_calendar_event_domain(),
+ ['id', 'start', 'name', 'allday', 'attendee_status'],
+ order='start')
+ meetings_lines = [line for line in meetings_lines if line['attendee_status'] != 'declined']
+ if meetings_lines:
+ meeting_label = _("Today's Meetings")
+ meetings_systray = {
+ 'type': 'meeting',
+ 'name': meeting_label,
+ 'model': 'calendar.event',
+ 'icon': modules.module.get_module_icon(self.env['calendar.event']._original_module),
+ 'meetings': meetings_lines,
+ }
+ res.insert(0, meetings_systray)
+
+ return res