diff options
| -rw-r--r-- | addons/calendar/controllers/main.py | 26 | ||||
| -rw-r--r-- | addons/calendar/data/mail_data.xml | 28 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_alarm_manager.py | 24 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_attendee.py | 101 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_event.py | 143 | ||||
| -rw-r--r-- | addons/calendar/models/calendar_recurrence.py | 30 | ||||
| -rw-r--r-- | addons/calendar/models/ir_http.py | 2 | ||||
| -rw-r--r-- | addons/calendar/tests/__init__.py | 2 | ||||
| -rw-r--r-- | addons/calendar/tests/test_attendees.py | 70 | ||||
| -rw-r--r-- | addons/calendar/tests/test_calendar.py | 6 | ||||
| -rw-r--r-- | addons/calendar/tests/test_event_notifications.py | 153 | ||||
| -rw-r--r-- | addons/calendar/tests/test_event_recurrence.py | 1 |
12 files changed, 432 insertions, 154 deletions
diff --git a/addons/calendar/controllers/main.py b/addons/calendar/controllers/main.py index cfecfb96345..11ca7675daf 100644 --- a/addons/calendar/controllers/main.py +++ b/addons/calendar/controllers/main.py @@ -24,6 +24,32 @@ class CalendarController(http.Controller): attendee.do_accept() return self.view(db, token, action, id, view='form') + @http.route('/calendar/recurrence/accept', type='http', auth="calendar") + def accept_recurrence(self, db, token, action, id, **kwargs): + # LUL TODO db required? + attendee = request.env['calendar.attendee'].sudo().search([('access_token', '=', token), ('state', '!=', 'accepted')]) + if attendee: + attendees = request.env['calendar.attendee'].sudo().search([ + ('event_id', 'in', attendee.event_id.recurrence_id.calendar_event_ids.ids), + ('partner_id', '=', attendee.partner_id.id), + ('state', '!=', 'accepted'), + ]) + attendees.do_accept() + return self.view(db, token, action, id, view='form') + + @http.route('/calendar/recurrence/decline', type='http', auth="calendar") + def decline_recurrence(self, db, token, action, id, **kwargs): + # LUL TODO db required? + attendee = request.env['calendar.attendee'].sudo().search([('access_token', '=', token), ('state', '!=', 'declined')]) + if attendee: + attendees = request.env['calendar.attendee'].sudo().search([ + ('event_id', 'in', attendee.event_id.recurrence_id.calendar_event_ids.ids), + ('partner_id', '=', attendee.partner_id.id), + ('state', '!=', 'declined'), + ]) + attendees.do_decline() + return self.view(db, token, action, id, view='form') + @http.route('/calendar/meeting/decline', type='http', auth="calendar") def declined(self, db, token, action, id): registry = registry_get(db) diff --git a/addons/calendar/data/mail_data.xml b/addons/calendar/data/mail_data.xml index 70e1e1a5977..5c056eea138 100644 --- a/addons/calendar/data/mail_data.xml +++ b/addons/calendar/data/mail_data.xml @@ -11,23 +11,26 @@ <field name="lang">${object.partner_id.lang}</field> <field name="body_html" type="html"> <div> - % set colors = {'needsAction': 'grey', 'accepted': 'green', 'tentative': '#FFFF00', 'declined': 'red'} + % set colors = ctx.get('colors', {}) + % set recurrent = object.recurrence_id and not ctx['ignore_recurrence'] <p> Hello ${object.common_name},<br/><br/> ${object.event_id.user_id.partner_id.name} invited you for the ${object.event_id.name} meeting of ${object.event_id.user_id.company_id.name}. </p> <div style="text-align: center; margin: 16px 0px 16px 0px;"> - <a href="/calendar/meeting/accept?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" + % set target = 'recurrence' if recurrent else 'meeting' + <a href="/calendar/${target}/accept?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"> Accept</a> - <a href="/calendar/meeting/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" + <a href="/calendar/${target}/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"> Decline</a> - <a href="/calendar/meeting/view?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" + <a href="/calendar/meeting/view?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"> View</a> </div> <table border="0" cellpadding="0" cellspacing="0"><tr> + % if not recurrent: <td width="130px;"> <div style="border-top-left-radius: 3px; border-top-right-radius: 3px; font-size: 12px; border-collapse: separate; text-align: center; font-weight: bold; color: #ffffff; min-height: 18px; background-color: #875A7B; border: 1px solid #875A7B;"> ${object.event_id.get_interval('dayname', tz=object.partner_id.tz if not object.event_id.allday else None)} @@ -43,6 +46,7 @@ </div> </td> <td width="20px;"/> + % endif <td style="padding-top: 5px;"> <p><strong>Details of the event</strong></p> <ul> @@ -54,6 +58,9 @@ % if object.event_id.description : <li>Description: ${object.event_id.description}</li> % endif + % if recurrent: + <li>When: ${object.recurrence_id.name}</li> + % endif % if not object.event_id.allday and object.event_id.duration <li>Duration: ${('%dH%02d' % (object.event_id.duration,round(object.event_id.duration*60)%60))}</li> % endif @@ -95,16 +102,18 @@ <field name="lang">${object.partner_id.lang}</field> <field name="body_html" type="html"> <div> - % set colors = {'needsAction': 'grey', 'accepted': 'green', 'tentative': '#FFFF00', 'declined': 'red'} + % set colors = ctx.get('colors', {}) + % set recurrent = object.recurrence_id and not ctx['ignore_recurrence'] <p> Hello ${object.common_name},<br/><br/> The date of the meeting has been updated. The meeting ${object.event_id.name} created by ${object.event_id.user_id.partner_id.name} is now scheduled for ${object.event_id.get_display_time_tz(tz=object.partner_id.tz)}. </p> <div style="text-align: center; margin: 16px 0px 16px 0px;"> - <a href="/calendar/meeting/accept?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" + % set target = 'recurrence' if recurrent else 'meeting' + <a href="/calendar/${target}/accept?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"> Accept</a> - <a href="/calendar/meeting/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" + <a href="/calendar/${target}/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"> Decline</a> <a href="/calendar/meeting/view?db=${'dbname' in ctx and ctx['dbname'] or ''}&token=${object.access_token}&action=${'action_id' in ctx and ctx['action_id'] or ''}&id=${object.event_id.id}" @@ -112,6 +121,7 @@ View</a> </div> <table border="0" cellpadding="0" cellspacing="0"><tr> + % if not recurrent: <td width="130px;"> <div style="border-top-left-radius: 3px; border-top-right-radius: 3px; font-size: 12px; border-collapse: separate; text-align: center; font-weight: bold; color: #ffffff; min-height: 18px; background-color: #875A7B; border: 1px solid #875A7B;"> ${object.event_id.get_interval('dayname', tz=object.partner_id.tz if not object.event_id.allday else None)} @@ -127,6 +137,7 @@ </div> </td> <td width="20px;"/> + % endif <td style="padding-top: 5px;"> <p><strong>Details of the event</strong></p> <ul> @@ -138,6 +149,9 @@ % if object.event_id.description : <li>Description: ${object.event_id.description}</li> % endif + % if recurrent: + <li>When: ${object.recurrence_id.name}</li> + % endif % if not object.event_id.allday and object.event_id.duration <li>Duration: ${('%dH%02d' % (object.event_id.duration,round(object.event_id.duration*60)%60))}</li> % endif diff --git a/addons/calendar/models/calendar_alarm_manager.py b/addons/calendar/models/calendar_alarm_manager.py index 943073c11c2..98fe461b7cd 100644 --- a/addons/calendar/models/calendar_alarm_manager.py +++ b/addons/calendar/models/calendar_alarm_manager.py @@ -15,7 +15,7 @@ class AlarmManager(models.AbstractModel): _name = 'calendar.alarm_manager' _description = 'Event Alarm Manager' - def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partner_id=None): + def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partners=None): result = {} delta_request = """ SELECT @@ -50,16 +50,16 @@ class AlarmManager(models.AbstractModel): 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 = %s + AND part_rel.res_partner_id IN %s """ # Add filter on alarm type tuple_params = (alarm_type,) # Add filter on partner_id - if partner_id: + if partners: base_request += filter_user - tuple_params += (partner_id, ) + tuple_params += (tuple(partners.ids), ) # Upper bound on first_alarm of requested events first_alarm_max_value = "" @@ -117,10 +117,10 @@ class AlarmManager(models.AbstractModel): """ result = [] # TODO: remove event_maxdelta and if using it - if one_date - timedelta(minutes=(missing and 0 or event_maxdelta)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date + if one_date - timedelta(minutes=(missing and 0 or 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 and 0 or alarm.duration_minutes)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \ + one_date - timedelta(minutes=(missing and 0 or 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, @@ -132,6 +132,10 @@ class AlarmManager(models.AbstractModel): @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): 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) @@ -153,7 +157,7 @@ class AlarmManager(models.AbstractModel): cron_interval = cron.interval_number * interval_to_second[cron.interval_type] - all_meetings = self._get_next_potential_limit_alarm('email', seconds=cron_interval) + 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'] @@ -183,7 +187,7 @@ class AlarmManager(models.AbstractModel): if not partner: return [] - all_meetings = self._get_next_potential_limit_alarm('notification', partner_id=partner.id) + 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'] @@ -215,7 +219,7 @@ class AlarmManager(models.AbstractModel): 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, force_event_id=meeting) + 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): @@ -225,7 +229,7 @@ class AlarmManager(models.AbstractModel): if alarm.alarm_type == 'notification': message = meeting.display_time - delta = alert['notify_at'] - datetime.datetime.now() + delta = alert['notify_at'] - fields.Datetime.now() delta = delta.seconds + delta.days * 3600 * 24 return { diff --git a/addons/calendar/models/calendar_attendee.py b/addons/calendar/models/calendar_attendee.py index 765258274f0..375a18a1057 100644 --- a/addons/calendar/models/calendar_attendee.py +++ b/addons/calendar/models/calendar_attendee.py @@ -2,10 +2,14 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import uuid import base64 +import logging +from collections import defaultdict from odoo import api, fields, models, _ from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class Attendee(models.Model): """ Calendar Attendee Information """ @@ -24,43 +28,59 @@ class Attendee(models.Model): ('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) - partner_id = fields.Many2one('res.partner', 'Contact', readonly=True) - email = fields.Char('Email', help="Email of Invited Person") + 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) - event_id = fields.Many2one('calendar.event', 'Meeting linked', ondelete='cascade') + 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.onchange('partner_id') - def _onchange_partner_id(self): - """ Make entry on email and availability on change of partner_id field. """ - self.email = self.partner_id.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] # TODO JEM : should be refactored values['email'] = email and email[0] or '' values['common_name'] = values.get("common_name") - return super(Attendee, self).create(vals_list) + 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 + partners -= self.env.user.partner_id # current user is automatically added as followers, don't add it twice. + 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, force_event_id=None): + 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 @@ -68,10 +88,12 @@ class Attendee(models.Model): return res calendar_view = self.env.ref('calendar.view_calendar_event_calendar') - invitation_template = self.env.ref(template_xmlid) - + 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 = force_event_id._get_ics_file() if force_event_id else self.mapped('event_id')._get_ics_file() + ics_files = self.mapped('event_id')._get_ics_file() # prepare rendering context for mail template colors = { @@ -82,40 +104,41 @@ class Attendee(models.Model): } rendering_context = dict(self._context) rendering_context.update({ - 'color': colors, + 'colors': colors, + 'ignore_recurrence': ignore_recurrence, 'action_id': self.env['ir.actions.act_window'].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'), - 'force_event_id': force_event_id, }) - invitation_template = invitation_template.with_context(rendering_context) - # send email with attachments - mail_ids = [] for attendee in self: - if attendee.email or attendee.partner_id.email: + if attendee.email and attendee.partner_id != self.env.user.partner_id: # FIXME: is ics_file text or bytes? - event_id = force_event_id.id if force_event_id else attendee.event_id.id + event_id = attendee.event_id.id ics_file = ics_files.get(event_id) - email_values = { - 'model': None, # We don't want to have the mail in the tchatter while in queue! - 'res_id': None, - } if ics_file: - email_values['attachment_ids'] = [ + attachment_values = [ (0, 0, {'name': 'invitation.ics', 'mimetype': 'text/calendar', 'datas': base64.b64encode(ics_file)}) ] - mail_ids.append(invitation_template.with_context(no_document=True).send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light')) - else: - mail_ids.append(invitation_template.send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light')) - - if force_send and mail_ids: - res = self.env['mail.mail'].browse(mail_ids).send() - - return res + body = self.env['mail.template'].with_context(rendering_context)._render_template( + invitation_template.body_html, + self._name, + attendee.ids) + # TODO check lang + subject = self.env['mail.template']._render_template( + invitation_template.subject, + self._name, + attendee.ids) + attendee.event_id.with_context(no_document=True).message_notify( + 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. """ @@ -123,17 +146,13 @@ class Attendee(models.Model): def do_accept(self): """ Marks event invitation as Accepted. """ - result = self.write({'state': 'accepted'}) for attendee in self: - if attendee.event_id: - attendee.event_id.message_post(body=_("%s has accepted invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation") - return result + 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. """ - res = self.write({'state': 'declined'}) for attendee in self: - if attendee.event_id: - attendee.event_id.message_post(body=_("%s has declined invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation") - return res + 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_event.py b/addons/calendar/models/calendar_event.py index 84842effcae..98d92296257 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -448,61 +448,37 @@ class Meeting(models.Model): return result - def create_attendees(self): - current_user = self.env.user - result = {} - for meeting in self: - alreay_meeting_partners = meeting.attendee_ids.mapped('partner_id') - meeting_attendees = self.env['calendar.attendee'] - meeting_partners = self.env['res.partner'] - for partner in meeting.partner_ids.filtered(lambda partner: partner not in alreay_meeting_partners): - values = { - 'partner_id': partner.id, - 'email': partner.email, - 'event_id': meeting.id, - } - - # TODO move this in the correct module - if self._context.get('google_internal_event_id', False): - values['google_internal_event_id'] = self._context.get('google_internal_event_id') - - # current user don't have to accept his own meeting - if partner == self.env.user.partner_id: - values['state'] = 'accepted' - - attendee = self.env['calendar.attendee'].create(values) - - meeting_attendees |= attendee - meeting_partners |= partner - - if meeting_attendees and not self._context.get('detaching'): - to_notify = meeting_attendees.filtered(lambda a: a.email != current_user.email) - to_notify._send_mail_to_attendees('calendar.calendar_template_meeting_invitation') - - if meeting_attendees: - meeting.write({'attendee_ids': [(4, meeting_attendee.id) for meeting_attendee in meeting_attendees]}) - - if meeting_partners: - meeting.message_subscribe(partner_ids=meeting_partners.ids) - - # We remove old attendees who are not in partner_ids now. - all_partners = meeting.partner_ids - all_partner_attendees = meeting.attendee_ids.mapped('partner_id') - old_attendees = meeting.attendee_ids - partners_to_remove = all_partner_attendees + meeting_partners - all_partners - - attendees_to_remove = self.env["calendar.attendee"] - if partners_to_remove: - attendees_to_remove = self.env["calendar.attendee"].search([('partner_id', 'in', partners_to_remove.ids), ('event_id', '=', meeting.id)]) - attendees_to_remove.unlink() - - result[meeting.id] = { - 'new_attendees': meeting_attendees, - 'old_attendees': old_attendees, - 'removed_attendees': attendees_to_remove, - 'removed_partners': partners_to_remove - } - 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 def get_interval(self, interval, tz=None): """ Format and localize some dates to be used in email templates @@ -646,6 +622,11 @@ class Meeting(models.Model): break_recurrence = values.get('recurrency') is False self._sync_activities(values) + if 'partner_ids' in values: + values['attendee_ids'] = self._attendees_values(values['partner_ids']) + + 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: @@ -660,39 +641,24 @@ class Meeting(models.Model): else: super().write(values) + if recurrence_update_setting != 'self_only' and not break_recurrence: + detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update_setting == 'future_events') - for meeting in self: - - attendees_create = False - if values.get('partner_ids'): - attendees_create = self.with_context(dont_notify=True).create_attendees() # to prevent multiple _notify_next_alarm - - # Notify attendees if there is an alarm on the modified event, or if there was an alarm - # that has just been removed, as it might have changed their next event notification - if not self._context.get('dont_notify'): - if len(meeting.alarm_ids) > 0 or values.get('alarm_ids'): - partners_to_notify = meeting.partner_ids.ids - event_attendees_changes = attendees_create and meeting and attendees_create[meeting.id] - if event_attendees_changes: - partners_to_notify.extend(event_attendees_changes['removed_partners'].ids) - self.env['calendar.alarm_manager']._notify_next_alarm(partners_to_notify) - - if (values.get('start_date') or values.get('start_datetime') or - (values.get('start') and self.env.context.get('from_ui'))) and values.get('active', True): - for current_meeting in self: - if attendees_create: - attendees_create = attendees_create[current_meeting.id] - attendee_to_email = attendees_create['old_attendees'] - attendees_create['removed_attendees'] - else: - attendee_to_email = current_meeting.attendee_ids - - if attendee_to_email: - attendee_to_email._send_mail_to_attendees('calendar.calendar_template_meeting_changedate') - - if recurrence_update != 'self_only' and not break_recurrence: - detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update == 'future_events') (detached_events & self).active = False (detached_events - self).unlink() + + # 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: + (current_attendees & previous_attendees)._send_mail_to_attendees('calendar.calendar_template_meeting_changedate', ignore_recurrence=not update_recurrence) + return True @api.model # LUL TODO create multi @@ -719,6 +685,9 @@ class Meeting(models.Model): activity_vals['user_id'] = user_id values['activity_ids'] = [(0, 0, activity_vals)] + if 'partner_ids' in values: + values['attendee_ids'] = self._attendees_values(values['partner_ids']) + recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values} meeting = super(Meeting, self).create(values) @@ -726,11 +695,9 @@ class Meeting(models.Model): detached_events = meeting._apply_recurrence_values(recurrence_values) detached_events.active = False + meeting.attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation') meeting._sync_activities(values) - # `dont_notify=True` in context to prevent multiple _notify_next_alarm - meeting.with_context(dont_notify=True).create_attendees() - # Notify attendees if there is an alarm on the created event, as it might have changed their # next event notification if not self._context.get('dont_notify'): diff --git a/addons/calendar/models/calendar_recurrence.py b/addons/calendar/models/calendar_recurrence.py index 2553b43218d..acc5241ac42 100644 --- a/addons/calendar/models/calendar_recurrence.py +++ b/addons/calendar/models/calendar_recurrence.py @@ -90,8 +90,7 @@ class RecurrenceRule(models.Model): _name = 'calendar.recurrence' _description = 'Event Recurrence Rule' - # TODO exdate? - + name = fields.Char(compute='_compute_name', store=True) base_event_id = fields.Many2one('calendar.event', ondelete='set null', copy=False) calendar_event_ids = fields.One2many('calendar.event', 'recurrence_id') event_tz = fields.Selection(_tz_get, string='Timezone', default=lambda self: self.env.context.get('tz') or self.env.user.tz) @@ -118,6 +117,33 @@ class RecurrenceRule(models.Model): ('month_day', "CHECK (rrule_type != 'monthly' OR month_by != 'day' OR day >= 1 AND day <= 31)", "The day must be between 1 and 31"), ] + @api.depends('rrule') + def _compute_name(self): + for recurrence in self: + period = dict(RRULE_TYPE_SELECTION)[recurrence.rrule_type] + every = _("Every %s %s, ") % (recurrence.interval, 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() + fields = (self._fields[weekday_to_field(w)] for w in weekdays) + on = _("on %s,") % ", ".join([field.string for field in 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']) diff --git a/addons/calendar/models/ir_http.py b/addons/calendar/models/ir_http.py index f28088382a1..07441f15135 100644 --- a/addons/calendar/models/ir_http.py +++ b/addons/calendar/models/ir_http.py @@ -32,4 +32,4 @@ class IrHttp(models.AbstractModel): if error_message: raise BadRequest(error_message) - return True + cls._auth_method_public() diff --git a/addons/calendar/tests/__init__.py b/addons/calendar/tests/__init__.py index 137c25cc007..71cfed3531b 100644 --- a/addons/calendar/tests/__init__.py +++ b/addons/calendar/tests/__init__.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_attendees from . import test_calendar from . import test_calendar_recurrent_event_case2 from . import test_event_recurrence +from . import test_event_notifications diff --git a/addons/calendar/tests/test_attendees.py b/addons/calendar/tests/test_attendees.py new file mode 100644 index 00000000000..7b3389b717c --- /dev/null +++ b/addons/calendar/tests/test_attendees.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime + +from odoo.tests.common import SavepointCase, new_test_user + + +class TestEventNotifications(SavepointCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = cls.env['calendar.event'].create({ + 'name': "Doom's day", + 'start': datetime(2019, 10, 25, 8, 0), + 'stop': datetime(2019, 10, 27, 18, 0), + }).with_context(mail_notrack=True) + cls.user = new_test_user(cls.env, 'xav', email='em@il.com', notification_type='inbox') + cls.partner = cls.user.partner_id + + def test_attendee_added(self): + self.event.partner_ids = self.partner + self.assertTrue(self.event.attendee_ids, "It should have created an attendee") + self.assertEqual(self.event.attendee_ids.partner_id, self.partner, "It should be linked to the partner") + self.assertIn(self.partner, self.event.message_follower_ids.partner_id, "He should be follower of the event") + + def test_attendee_added_create(self): + event = self.env['calendar.event'].create({ + 'name': "Doom's day", + 'start': datetime(2019, 10, 25, 8, 0), + 'stop': datetime(2019, 10, 27, 18, 0), + 'partner_ids': [(4, self.partner.id)], + }) + self.assertTrue(event.attendee_ids, "It should have created an attendee") + self.assertEqual(event.attendee_ids.partner_id, self.partner, "It should be linked to the partner") + self.assertIn(self.partner, event.message_follower_ids.partner_id, "He should be follower of the event") + + def test_attendee_added_multi(self): + event = self.env['calendar.event'].create({ + 'name': "Doom's day", + 'start': datetime(2019, 10, 25, 8, 0), + 'stop': datetime(2019, 10, 27, 18, 0), + }) + events = self.event | event + events.partner_ids = self.partner + self.assertEqual(len(events.attendee_ids), 2, "It should have created one attendee per event") + + def test_existing_attendee_added(self): + self.event.partner_ids = self.partner + attendee = self.event.attendee_ids + self.event.write({'partner_ids': [(4, self.partner.id)]}) # Add existing partner + self.assertEqual(self.event.attendee_ids, attendee, "It should not have created an new attendee record") + + def test_attendee_add_self(self): + self.event.with_user(self.user).partner_ids = self.partner + self.assertTrue(self.event.attendee_ids, "It should have created an attendee") + self.assertEqual(self.event.attendee_ids.partner_id, self.partner, "It should be linked to the partner") + self.assertEqual(self.event.attendee_ids.state, 'accepted', "It should be accepted for the current user") + + def test_attendee_removed(self): + partner_bis = self.env['res.partner'].create({'name': "Xavier"}) + self.event.partner_ids = partner_bis + attendee = self.event.attendee_ids + self.event.partner_ids |= self.partner + self.event.partner_ids -= self.partner + self.assertEqual(attendee, self.event.attendee_ids, "It should not have re-created an attendee record") + self.assertNotIn(self.partner, self.event.attendee_ids.partner_id, "It should have removed the attendee") + self.assertNotIn(self.partner, self.event.message_follower_ids.partner_id, "It should have unsubscribed the partner") + self.assertIn(partner_bis, self.event.attendee_ids.partner_id, "It should have left the attendee") diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index 522a2738e94..8c8ca29d20d 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -273,11 +273,9 @@ class TestCalendar(SavepointCaseWithUserDemo): def _test_one_mail_per_attendee(self, m, partners): # check that every attendee receive a (single) mail for the event for partner in partners: - mail = self.env['mail.mail'].sudo().search([ - ('recipient_ids', 'in', partner.id), - ('subject', 'like', m.name), + mail = self.env['mail.message'].sudo().search([ + ('notified_partner_ids', 'in', partner.id), ]) - print(mail.mapped('subject')) self.assertEqual(len(mail), 1) partners = [ diff --git a/addons/calendar/tests/test_event_notifications.py b/addons/calendar/tests/test_event_notifications.py new file mode 100644 index 00000000000..798ee73da3d --- /dev/null +++ b/addons/calendar/tests/test_event_notifications.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +from datetime import datetime, date +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests.common import SavepointCase, new_test_user +from odoo.addons.mail.tests.common import MailCase + + +class TestEventNotifications(SavepointCase, MailCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = cls.env['calendar.event'].create({ + 'name': "Doom's day", + 'start': datetime(2019, 10, 25, 8, 0), + 'stop': datetime(2019, 10, 27, 18, 0), + }).with_context(mail_notrack=True) + cls.user = new_test_user(cls.env, 'xav', email='em@il.com', notification_type='inbox') + cls.partner = cls.user.partner_id + + def test_message_invite(self): + with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], { + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + }): + self.event.partner_ids = self.partner + + def test_message_invite_self(self): + with self.assertNoNotifications(): + self.event.with_user(self.user).partner_ids = self.partner + + def test_message_inactive_invite(self): + self.event.active = False + with self.assertNoNotifications(): + self.event.partner_ids = self.partner + + def test_message_set_inactive_invite(self): + self.event.active = False + with self.assertNoNotifications(): + self.event.write({ + 'partner_ids': [(4, self.partner.id)], + 'active': False, + }) + + def test_message_datetime_changed(self): + self.event.partner_ids = self.partner + "Invitation to Presentation of the new Calendar" + with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], { + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + }): + self.event.start += relativedelta(days=-1) + + def test_message_date_changed(self): + self.event.write({ + 'allday': True, + 'start_date': date(2019, 10, 15), + 'stop_date': date(2019, 10, 15), + }) + self.event.partner_ids = self.partner + with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], { + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + }): + self.event.start_date += relativedelta(days=-1) + + def test_message_set_inactive_date_changed(self): + self.event.write({ + 'allday': True, + 'start_date': date(2019, 10, 15), + 'stop_date': date(2019, 10, 15), + }) + self.event.partner_ids = self.partner + with self.assertNoNotifications(): + self.event.write({ + 'start_date': self.event.start_date - relativedelta(days=1), + 'active': False, + }) + + def test_message_inactive_date_changed(self): + self.event.write({ + 'allday': True, + 'start_date': date(2019, 10, 15), + 'stop_date': date(2019, 10, 15), + 'active': False, + }) + self.event.partner_ids = self.partner + with self.assertNoNotifications(): + self.event.start_date += relativedelta(days=-1) + + def test_message_add_and_date_changed(self): + self.event.partner_ids -= self.partner + with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], { + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + }): + self.event.write({ + 'start': self.event.start - relativedelta(days=1), + 'partner_ids': [(4, self.partner.id)], + }) + + def test_bus_notif(self): + alarm = self.env['calendar.alarm'].create({ + 'name': 'Alarm', + 'alarm_type': 'notification', + 'interval': 'minutes', + 'duration': 30, + }) + now = fields.Datetime.now() + with patch.object(fields.Datetime, 'now', lambda: now): + with self.assertBus([(self.env.cr.dbname, 'calendar.alarm', self.partner.id)]): + self.event.with_context(no_mail_to_attendees=True).write({ + 'start': now + relativedelta(minutes=50), + 'stop': now + relativedelta(minutes=55), + 'partner_ids': [(4, self.partner.id)], + 'alarm_ids': [(4, alarm.id)] + }) + bus_message = [{ + "alarm_id": alarm.id, + "event_id": self.event.id, + "title": "Doom's day", + "message": self.event.display_time, + "timer": 20*60, + "notify_at": fields.Datetime.to_string(now + relativedelta(minutes=20)), + }] + notif = self.env['calendar.alarm_manager'].with_user(self.user).get_next_notif() + self.assertEqual(notif, bus_message) + + def test_email_alarm(self): + alarm = self.env['calendar.alarm'].create({ + 'name': 'Alarm', + 'alarm_type': 'email', + 'interval': 'minutes', + 'duration': 20, + }) + now = fields.Datetime.now() + self.event.write({ + 'start': now + relativedelta(minutes=15), + 'stop': now + relativedelta(minutes=18), + 'partner_ids': [(4, self.partner.id)], + 'alarm_ids': [(4, alarm.id)], + }) + with patch.object(fields.Datetime, 'now', lambda: now): + with self.assertSinglePostNotifications([{'partner': self.partner, 'type': 'inbox'}], { + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + }): + self.env['calendar.alarm_manager'].with_context(lastcall=now - relativedelta(minutes=15))._get_partner_next_mail(self.partner) diff --git a/addons/calendar/tests/test_event_recurrence.py b/addons/calendar/tests/test_event_recurrence.py index e9b8ec5447e..e4519be71c0 100644 --- a/addons/calendar/tests/test_event_recurrence.py +++ b/addons/calendar/tests/test_event_recurrence.py @@ -584,7 +584,6 @@ class TestUpdateMonthlyByDay(TestRecurrentEvents): 'stop': event.stop - relativedelta(days=4), }) - # LUL TODO test shift future class TestUpdateMonthlyByDate(TestRecurrentEvents): |
