diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/calendar/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/calendar/models')
| -rw-r--r-- | addons/calendar/models/__init__.py | 14 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_alarm.py | 74 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_alarm_manager.py | 224 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_attendee.py | 172 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_contact.py | 21 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_event.py | 845 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_event_type.py | 16 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_recurrence.py | 483 | ||||
| -rw-r--r-- | addons/calendar/models/ir_http.py | 30 | ||||
| -rw-r--r-- | addons/calendar/models/mail_activity.py | 45 | ||||
| -rw-r--r-- | addons/calendar/models/res_partner.py | 37 | ||||
| -rw-r--r-- | addons/calendar/models/res_users.py | 55 |
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 |
