From 1e9b1da0eb813e268bac261bce748a9d78cba79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 22 Mar 2021 14:33:41 +0000 Subject: [PATCH 1/5] [MOV] calendar: reorganize calendar event and attendee fields Purpose is to ease model understanding. This commit causes some diff but those models gain readability after being ordered by main field section. It would have been better to do it at refactoring time but time was short to do it at that time. Task ID-2199620 COM PR odoo/odoo#64925 --- addons/calendar/models/calendar_attendee.py | 16 +++-- addons/calendar/models/calendar_event.py | 74 ++++++++++----------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/addons/calendar/models/calendar_attendee.py b/addons/calendar/models/calendar_attendee.py index ebcb8511de4..b36ef8fdb04 100644 --- a/addons/calendar/models/calendar_attendee.py +++ b/addons/calendar/models/calendar_attendee.py @@ -26,18 +26,20 @@ class Attendee(models.Model): ('accepted', 'Accepted'), ] - event_id = fields.Many2one( - 'calendar.event', 'Meeting linked', required=True, ondelete='cascade') + # event + event_id = fields.Many2one('calendar.event', 'Meeting linked', required=True, ondelete='cascade') + recurrence_id = fields.Many2one('calendar.recurrence', related='event_id.recurrence_id') + # attendee partner_id = fields.Many2one('res.partner', 'Attendee', 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") phone = fields.Char('Phone', related='partner_id.phone', help="Phone number of Invited Person") + common_name = fields.Char('Common name', compute='_compute_common_name', store=True) + access_token = fields.Char('Invitation Token', default=_default_access_token) + # state + state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction', + help="Status of the attendee's participation") availability = fields.Selection( [('free', 'Available'), ('busy', 'Busy')], 'Available/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): diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 500241de5fd..ec20e7bc0a1 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -194,36 +194,21 @@ class Meeting(models.Model): for event in self: event.is_highlighted = False + # description 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=lambda self: fields.Datetime.today() + timedelta(hours=1), - 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') + 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) + location = fields.Char('Location', tracking=True, help="Location of Event") + videocall_location = fields.Char('Join Video Call', default=_default_videocall_location) + # visibility privacy = fields.Selection( [('public', 'Public'), ('private', 'Private'), ('confidential', 'Only internal users')], 'Privacy', default='public', required=True, help="People to whom this event will be visible.") - location = fields.Char('Location', tracking=True, help="Location of Event") - videocall_location = fields.Char('Join Video Call', default=_default_videocall_location) show_as = fields.Selection( [('free', 'Available'), ('busy', 'Busy')], 'Show as', default='busy', required=True, @@ -232,38 +217,54 @@ class Meeting(models.Model): that you are unavailable during that period of time. \n If the time is shown as 'free', this event won't \ be visible to other people at all. Use this option to let other people know that you are available during \ that period of time.") - + is_highlighted = fields.Boolean( + compute='_compute_is_highlighted', string='Is the Event Highlighted') + # filtering + 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') + # timing + 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=lambda self: fields.Datetime.today() + timedelta(hours=1), + compute='_compute_stop', readonly=False, store=True, + help="Stop date of an event, without time for full days events") + display_time = fields.Char('Event Time', compute='_compute_display_time') + 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) # 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) + # messaging 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') + # attendees attendee_ids = fields.One2many( 'calendar.attendee', 'event_id', 'Participant') + attendee_status = fields.Selection( + Attendee.STATE_SELECTION, string='Attendee Status', compute='_compute_attendee') partner_ids = fields.Many2many( 'res.partner', 'calendar_event_res_partner_rel', string='Attendees', default=_default_partners) + # alarms alarm_ids = fields.Many2many( 'calendar.alarm', 'calendar_alarm_calendar_event_rel', string='Reminders', ondelete="restrict", help="Notifications sent to all attendees to remind of the meeting.") - is_highlighted = fields.Boolean( - compute='_compute_is_highlighted', string='Is the Event Highlighted') - # RECURRENCE FIELD recurrency = fields.Boolean('Recurrent') recurrence_id = fields.Many2one( @@ -275,7 +276,6 @@ class Meeting(models.Model): ('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. From 8952ce3bf4b750f815ad6c9acd07271a9111bfa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 22 Mar 2021 14:58:44 +0000 Subject: [PATCH 2/5] [MOV] calendar: reorganize calendar event and attendee methods Purpose is to ease understanding of code. Notably we apply guidelines that should have been applied at refactoring time, notably * set all compute fields together right below fields as it eases model discovering; * set CRUD methods as a whole after compute fields; * group other methods by main use and put tools at the file's end; This commit causes some diff but we feel this is necessary to ease future modifications in those models, as they are quite big and important. Task ID-2199620 COM PR odoo/odoo#64925 --- addons/calendar/models/calendar_attendee.py | 8 +- addons/calendar/models/calendar_event.py | 1054 ++++++++++--------- 2 files changed, 543 insertions(+), 519 deletions(-) diff --git a/addons/calendar/models/calendar_attendee.py b/addons/calendar/models/calendar_attendee.py index b36ef8fdb04..a3636cd634b 100644 --- a/addons/calendar/models/calendar_attendee.py +++ b/addons/calendar/models/calendar_attendee.py @@ -64,6 +64,10 @@ class Attendee(models.Model): self._unsubscribe_partner() return super().unlink() + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + raise UserError(_('You cannot duplicate a calendar attendee.')) + def _subscribe_partner(self): for event in self.event_id: partners = (event.attendee_ids & self).partner_id - event.message_partner_ids @@ -76,10 +80,6 @@ class Attendee(models.Model): 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 diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index ec20e7bc0a1..735d74f7c12 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -89,111 +89,6 @@ class Meeting(models.Model): jitsi_url = 'https://' + jitsi_url return url_join(jitsi_url, 'odoo-%s' % (uuid4().hex[:12])) - 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', 'privacy'} - - @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 - # description name = fields.Char('Meeting Subject', required=True) description = fields.Text('Description') @@ -309,10 +204,17 @@ class Meeting(models.Model): 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_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 def _compute_display_time(self): for meeting in self: @@ -377,6 +279,11 @@ class Meeting(models.Model): 'stop': enddate.replace(tzinfo=None) }) + def _compute_attendee(self): + for meeting in self: + attendee = meeting._find_my_attendee() + meeting.attendee_status = attendee.state if attendee else 'needsAction' + @api.constrains('start', 'stop', 'start_date', 'stop_date') def _check_closing_date(self): for meeting in self: @@ -399,10 +306,6 @@ class Meeting(models.Model): ) ) - #################################################### - # Calendar Business, Reccurency, ... - #################################################### - @api.depends('recurrence_id', 'recurrency') def _compute_recurrence(self): recurrence_fields = self._get_recurrent_fields() @@ -420,6 +323,460 @@ class Meeting(models.Model): else: event.update(false_values) + # ------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------ + + @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: + + recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals} + vals['follow_recurrence'] = True + event = super().create(vals) + events |= event + 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()}) + + events._setup_alarms() + + 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) + + 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() + + self._setup_alarms() + + 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 + 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 _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.partner_ids.ids) - set(command[2]) # Don't recreate attendee if partner already attend the event + added_partner_ids += set(command[2]) - set(self.partner_ids.ids) + elif op == 4: + added_partner_ids += [command[1]] if command[1] not in self.partner_ids.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 + + # ------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------ + + 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 action_mass_mailing(self): + partners_ids = self.mapped('partner_ids') + if not partners_ids: + raise UserError(_("There are no attendees on these events")) + compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) + default_partners = partners_ids and partners_ids.ids + compose_ctx = dict( + default_use_template=False, + default_composition_mode='mass_mail', + default_partner_ids=default_partners, + default_subject=_("Event update") + ) + return { + 'name': _('Contact Attendees'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'views': [(compose_form.id, 'form')], + 'view_id': compose_form.id, + 'target': 'new', + 'context': compose_ctx, + } + + # ------------------------------------------------------------ + # MAILING + # ------------------------------------------------------------ + + 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) + + # ------------------------------------------------------------ + # ALARMS + # ------------------------------------------------------------ + + def _get_trigger_alarm_types(self): + return ['email'] + + def _setup_alarms(self): + """ Schedule cron triggers for future events """ + cron = self.env.ref('calendar.ir_cron_scheduler_alarm') + alarm_manager = self.env['calendar.alarm_manager'] + alarm_types = self._get_trigger_alarm_types() + + for event in self: + for alarm in (alarm for alarm in event.alarm_ids if alarm.alarm_type in alarm_types): + at = event.start - timedelta(minutes=alarm.duration_minutes) + if not cron.lastcall or at > cron.lastcall: + # Don't trigger for past alarms, they would be skipped by design + cron._trigger(at=at) + if any(alarm.alarm_type == 'notification' for alarm in event.alarm_ids): + alarm_manager._notify_next_alarm(event.partner_ids.ids) + + # ------------------------------------------------------------ + # RECURRENCY + # ------------------------------------------------------------ + + 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 _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 + + # ------------------------------------------------------------ + # MANAGEMENT + # ------------------------------------------------------------ + + 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() + + 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 + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + 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 _range(self): + self.ensure_one() + return (self.start, self.stop) + + 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 _get_ics_file(self): """ Returns iCalendar file for the event invitation. :returns a dict of .ics file content for each meeting @@ -479,418 +836,85 @@ class Meeting(models.Model): return result - def _attendees_values(self, partner_commands): + @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 """ - :param partner_commands: ORM commands for partner_id field (0 and 1 commands not supported) - :return: associated attendee_ids ORM commands - """ - attendee_commands = [] + timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC' - 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.partner_ids.ids) - set(command[2]) # Don't recreate attendee if partner already attend the event - added_partner_ids += set(command[2]) - set(self.partner_ids.ids) - elif op == 4: - added_partner_ids += [command[1]] if command[1] not in self.partner_ids.ids else [] - # commands 0 and 1 not supported + # get date/time format according to context + format_date, format_time = self._get_date_formats() - 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 + # 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)) - attendee_commands += [ - [0, 0, dict(partner_id=partner_id)] - for partner_id in added_partner_ids - ] - return attendee_commands + # 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)) - 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 action_mass_mailing(self): - partners_ids = self.mapped('partner_ids') - if not partners_ids: - raise UserError(_("There are no attendees on these events")) - compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) - default_partners = partners_ids and partners_ids.ids - compose_ctx = dict( - default_use_template=False, - default_composition_mode='mass_mail', - default_partner_ids=default_partners, - default_subject=_("Event update") - ) - return { - 'name': _('Contact Attendees'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'mail.compose.message', - 'views': [(compose_form.id, 'form')], - 'view_id': compose_form.id, - 'target': 'new', - 'context': compose_ctx, - } - - 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) + 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: - super().write(values) - self._sync_activities(fields=values.keys()) + 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 - 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() - - self._setup_alarms() - - 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 - - def _get_trigger_alarm_types(self): - return ['email'] - - def _setup_alarms(self): - """ Schedule cron triggers for future events """ - cron = self.env.ref('calendar.ir_cron_scheduler_alarm') - alarm_manager = self.env['calendar.alarm_manager'] - alarm_types = self._get_trigger_alarm_types() - - for event in self: - for alarm in (alarm for alarm in event.alarm_ids if alarm.alarm_type in alarm_types): - at = event.start - timedelta(minutes=alarm.duration_minutes) - if not cron.lastcall or at > cron.lastcall: - # Don't trigger for past alarms, they would be skipped by design - cron._trigger(at=at) - if any(alarm.alarm_type == 'notification' for alarm in event.alarm_ids): - alarm_manager._notify_next_alarm(event.partner_ids.ids) - - @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: - - recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals} - vals['follow_recurrence'] = True - event = super().create(vals) - events |= event - 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() }) - - events._setup_alarms() - - 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) + 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) @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 _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) - 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 + @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'} - result = super().unlink() + @api.model + def _get_time_fields(self): + return {'start', 'stop', 'start_date', 'stop_date'} - # Notify the concerned attendees (must be done after removing the events) - self.env['calendar.alarm_manager']._notify_next_alarm(partner_ids) - return result + @api.model + def _get_custom_fields(self): + all_fields = self.fields_get(attributes=['manual']) + return {fname for fname in all_fields if all_fields[fname]['manual']} - 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() + @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', 'privacy'} From 0c7765c4cf489e48126aa5dc031bd319f6ccb6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 24 Mar 2021 10:28:23 +0000 Subject: [PATCH 3/5] [FIX] calendar: try to import vobject only once Clean code and avoid import within methods. Task ID-2199620 COM PR odoo/odoo#64925 --- addons/calendar/models/calendar_event.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 735d74f7c12..03ba355a67d 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -21,6 +21,12 @@ from odoo.exceptions import UserError, ValidationError, AccessError _logger = logging.getLogger(__name__) +try: + import vobject +except ImportError: + _logger.warning("`vobject` Python module not found, iCal file generation disabled. Consider installing this module if you want to generate iCal files") + vobject = None + SORT_ALIASES = { 'start': 'sort_start', 'start_date': 'sort_start', @@ -790,11 +796,7 @@ class Meeting(models.Model): 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") + if not vobject: return result for meeting in self: From f742d0f72c492110bd66c17dccec0eff24f9b9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 24 Mar 2021 10:30:12 +0000 Subject: [PATCH 4/5] [FIX] calendar: fix timezone localize Seems like the wrong utc was used. Task ID-2199620 COM PR odoo/odoo#64925 --- addons/calendar/models/calendar_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 03ba355a67d..ba4e988560a 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -596,7 +596,7 @@ class Meeting(models.Model): deadline = event.start user_tz = self.env.context.get('tz') if user_tz and not event.allday: - deadline = pytz.UTC.localize(deadline) + deadline = pytz.utc.localize(deadline) deadline = deadline.astimezone(pytz.timezone(user_tz)) activity_values['date_deadline'] = deadline.date() if 'user_id' in fields: From bc9bc2686ca142a7348dc48e0b7f8e74fdd42e10 Mon Sep 17 00:00:00 2001 From: Krupal Oza Date: Mon, 15 Mar 2021 14:13:22 +0000 Subject: [PATCH 5/5] [IMP] calendar: improve main data templates Purpose of this commit is to make templates easier to preview and edit as weel as to improve their content. This is achieved notably by lessening use of custom rendering context and trying to fix as much error-prone jinja statements. In this commit we also make ``calendar_template_meeting_reminder`` template looking more like other templates. There were some rendering differences that do not seem really wanted. ``calendar_template_meeting_reminder`` was also using an old ``force_event_id`` key not replaced by recurrent_id field on attendee. This commit improves few strings of the invitation mail being sent while booking an online appointment. Online appointment is computed based on ``appointment_type_id`` field being defined. Some tweaks in wording made it necessary to know if recipient is the responsible, customer or an added attendee on the event, leading to some tooling code added on event model. The main changes includes * for customer, remove the CTAs to accept / decline event as attendee are automatically set as accepted; * depending on recipient, improve wording of the mail accordingly and mention appointment type in the mail; Task ID-2199620 COM PR odoo/odoo#64925 ENT PR odoo/enterprise#15914 UPG PR odoo/upgrade#2282 Co-Authored-By: Krupal Oza Co-Authored-By: Thibault Delavallee --- addons/calendar/__manifest__.py | 2 +- addons/calendar/data/mail_template_data.xml | 143 +++++++++++++----- .../calendar/models/calendar_alarm_manager.py | 3 +- addons/calendar/models/calendar_attendee.py | 24 +-- addons/calendar/models/calendar_event.py | 81 ++++++++-- .../models/calendar_attendee.py | 4 +- .../models/calendar_attendee.py | 4 +- 7 files changed, 184 insertions(+), 77 deletions(-) diff --git a/addons/calendar/__manifest__.py b/addons/calendar/__manifest__.py index 106bc5c3869..2b1340d36e2 100644 --- a/addons/calendar/__manifest__.py +++ b/addons/calendar/__manifest__.py @@ -3,7 +3,7 @@ { 'name': 'Calendar', - 'version': '1.0', + 'version': '1.1', 'sequence': 165, 'depends': ['base', 'mail'], 'summary': "Schedule employees' meetings", diff --git a/addons/calendar/data/mail_template_data.xml b/addons/calendar/data/mail_template_data.xml index e411500fae8..4071428d26f 100644 --- a/addons/calendar/data/mail_template_data.xml +++ b/addons/calendar/data/mail_template_data.xml @@ -11,26 +11,46 @@ ${object.partner_id.lang}
- % set colors = ctx.get('colors', {}) - % set recurrent = object.recurrence_id and not ctx['ignore_recurrence'] + % set colors = {'needsAction': 'grey', 'accepted': 'green', 'tentative': '#FFFF00', 'declined': 'red'} + % set is_online = 'appointment_type_id' in object.event_id and object.event_id.appointment_type_id + % set customer = object.event_id.find_partner_customer() + % set target_responsible = object.partner_id == object.event_id.partner_id + % set target_customer = object.partner_id == customer

Hello ${object.common_name},

- ${object.event_id.user_id.partner_id.name} invited you to the ${object.event_id.name} meeting of ${object.event_id.user_id.company_id.name}. + + % if is_online and target_customer: + Your appointment ${object.event_id.appointment_type_id.name} with ${object.event_id.user_id.name} has been booked. + + % elif is_online and target_responsible: + % if customer + ${customer.name} scheduled the following appointment ${object.event_id.appointment_type_id.name} with you. + % else + Your appointment ${object.event_id.appointment_type_id.name} has been booked. + % endif + + % elif not target_responsible: + ${object.event_id.user_id.partner_id.name} invited you for the ${object.event_id.name} meeting. + + % else: + Your meeting ${object.event_id.name} has been booked. + % endif

- % set target = 'recurrence' if recurrent else 'meeting' - Accept - Decline + % endif - View + ${'Reschedule' if is_online and target_customer else 'View'} +
- % if not recurrent:
${object.event_id.get_interval('dayname', tz=object.partner_id.tz if not object.event_id.allday else None)} @@ -46,24 +66,35 @@
- % endif

Details of the event