[REF] calendar: Refactor calendar attendees management

This commit refactors the event attendees and notifications according
to the recurring event refactoring.

Some (very) basic reminder tests are also added.

Task 2126717
PR #42031
PR Enterprise odoo/enterprise#8006
This commit is contained in:
Lucas Lefèvre
2020-04-09 10:56:37 +02:00
parent a27afdb543
commit 7159f657a0
12 changed files with 429 additions and 151 deletions
+26
View File
@@ -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)
+21 -7
View File
@@ -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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 '' }&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;id=${object.event_id.id}"
<a href="/calendar/${target}/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;id=${object.event_id.id}"
<a href="/calendar/meeting/view?db=${'dbname' in ctx and ctx['dbname'] or ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 '' }&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;id=${object.event_id.id}"
<a href="/calendar/${target}/decline?db=${'dbname' in ctx and ctx['dbname'] or '' }&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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 ''}&amp;token=${object.access_token}&amp;action=${'action_id' in ctx and ctx['action_id'] or ''}&amp;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
@@ -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 {
+60 -41
View File
@@ -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'})
+52 -85
View File
@@ -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,
}
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 = []
# 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')
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
# current user don't have to accept his own meeting
if partner == self.env.user.partner_id:
values['state'] = 'accepted'
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 = 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
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'):
+28 -2
View File
@@ -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'])
+1 -1
View File
@@ -32,4 +32,4 @@ class IrHttp(models.AbstractModel):
if error_message:
raise BadRequest(error_message)
return True
cls._auth_method_public()
+2
View File
@@ -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
+70
View File
@@ -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")
+2 -4
View File
@@ -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 = [
@@ -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)
@@ -584,7 +584,6 @@ class TestUpdateMonthlyByDay(TestRecurrentEvents):
'stop': event.stop - relativedelta(days=4),
})
# LUL TODO test shift future
class TestUpdateMonthlyByDate(TestRecurrentEvents):