[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 <koz@odoo.com>
Co-Authored-By: Thibault Delavallee <tde@odoo.com>
This commit is contained in:
Krupal Oza
2021-03-24 15:16:42 +00:00
committed by Thibault Delavallée
co-authored by Thibault Delavallee
parent f742d0f72c
commit bc9bc2686c
7 changed files with 184 additions and 77 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
{
'name': 'Calendar',
'version': '1.0',
'version': '1.1',
'sequence': 165,
'depends': ['base', 'mail'],
'summary': "Schedule employees' meetings",
+105 -38
View File
@@ -11,26 +11,46 @@
<field name="lang">${object.partner_id.lang}</field>
<field name="body_html" type="html">
<div>
% 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
<p>
Hello ${object.common_name},<br/><br/>
${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 <strong>${object.event_id.appointment_type_id.name}</strong> with ${object.event_id.user_id.name} has been booked.
% elif is_online and target_responsible:
% if customer
${customer.name} scheduled the following appointment <strong>${object.event_id.appointment_type_id.name}</strong> with you.
% else
Your appointment <strong>${object.event_id.appointment_type_id.name}</strong> has been booked.
% endif
% elif not target_responsible:
${object.event_id.user_id.partner_id.name} invited you for the <strong>${object.event_id.name}</strong> meeting.
% else:
Your meeting <strong>${object.event_id.name}</strong> has been booked.
% endif
</p>
<div style="text-align: center; margin: 16px 0px 16px 0px;">
% set target = 'recurrence' if recurrent else 'meeting'
<a href="/calendar/${target}/accept?token=${object.access_token}&amp;id=${object.event_id.id}"
% if not is_online or object.state != 'accepted':
<a href="/calendar/meeting/accept?token=${object.access_token}&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/${target}/decline?token=${object.access_token}&amp;id=${object.event_id.id}"
<a href="/calendar/meeting/decline?token=${object.access_token}&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>
% endif
<a href="/calendar/meeting/view?token=${object.access_token}&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>
${'Reschedule' if is_online and target_customer else '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)}
@@ -46,24 +66,35 @@
</div>
</td>
<td width="20px;"/>
% endif
<td style="padding-top: 5px;">
<p><strong>Details of the event</strong></p>
<ul>
% if is_online:
<li>Appointment Type: ${object.event_id.appointment_type_id.name}</li>
% endif
% if object.event_id.location:
<li>Location: ${object.event_id.location}
(<a target="_blank" href="http://maps.google.com/maps?oi=map&amp;q=${object.event_id.location}">View Map</a>)
</li>
% endif
% 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
% if not is_online and object.event_id.description :
<li>Description: ${object.event_id.description}</li>
% elif is_online and object.event_id.description:
% set splitted_description = object.event_id.description_to_html_lines()
<li>Description:
<ul>
% for description_line in splitted_description:
<li>${description_line | safe}</li>
% endfor
</ul>
</li>
% endif
<li>Attendees
<ul>
% for attendee in object.event_id.attendee_ids:
@@ -105,18 +136,38 @@
<field name="lang">${object.partner_id.lang}</field>
<field name="body_html" type="html">
<div>
% 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
<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)}.
% if is_online and target_responsible:
% if customer:
The date of your appointment with ${customer.name} has been updated.
% else
Your appointment has been updated.
% endif
The appointment <strong>${object.event_id.appointment_type_id.name}</strong> is now scheduled for
${object.event_id.get_display_time_tz(tz=object.partner_id.tz)}.
% elif is_online and target_customer:
The date of your appointment with ${object.event_id.user_id.partner_id.name} has been updated.
The appointment <strong>${object.event_id.appointment_type_id.name}</strong> is now scheduled for
${object.event_id.get_display_time_tz(tz=object.partner_id.tz)}.
% else:
The date of the meeting has been updated.
The meeting <strong>${object.event_id.name}</strong> 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)}.
% endif
</p>
<div style="text-align: center; margin: 16px 0px 16px 0px;">
% set target = 'recurrence' if recurrent else 'meeting'
<a href="/calendar/${target}/accept?token=${object.access_token}&amp;id=${object.event_id.id}"
<a href="/calendar/meeting/accept?token=${object.access_token}&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/${target}/decline?token=${object.access_token}&amp;id=${object.event_id.id}"
<a href="/calendar/meeting/decline?token=${object.access_token}&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?token=${object.access_token}&amp;id=${object.event_id.id}"
@@ -124,7 +175,6 @@
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)}
@@ -140,7 +190,6 @@
</div>
</td>
<td width="20px;"/>
% endif
<td style="padding-top: 5px;">
<p><strong>Details of the event</strong></p>
<ul>
@@ -149,15 +198,24 @@
(<a target="_blank" href="http://maps.google.com/maps?oi=map&amp;q=${object.event_id.location}">View Map</a>)
</li>
% endif
% 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
% if not is_online and object.event_id.description :
<li>Description: ${object.event_id.description}</li>
% elif is_online and object.event_id.description:
% set splitted_description = object.event_id.description_to_html_lines()
<li>Description:
<ul>
% for description_line in splitted_description:
<li>${description_line | safe}</li>
% endfor
</ul>
</li>
% endif
<li>Attendees
<ul>
% for attendee in object.event_id.attendee_ids:
@@ -199,21 +257,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'}
<!--
In a recurring event case, the object.event_id is always the first event
This makes the event date (and a lot of other information) incorrect
-->
% set event_id = ctx.get('force_event_id') or object.event_id
% 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 recurrent = object.recurrence_id and not ctx.get('calendar_template_ignore_recurrence')
<p>
Hello ${object.common_name},<br/><br/>
This is a reminder for the below event :
</p>
<div style="text-align: center; margin: 16px 0px 16px 0px;">
<a href="/calendar/meeting/accept?token=${object.access_token}&amp;id=${object.event_id.id}"
<a href="/calendar/${'recurrence' if recurrent else 'meeting'}/accept?token=${object.access_token}&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?token=${object.access_token}&amp;id=${object.event_id.id}"
<a href="/calendar/${'recurrence' if recurrent else 'meeting'}/decline?token=${object.access_token}&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?token=${object.access_token}&amp;id=${object.event_id.id}"
@@ -223,16 +278,16 @@
<table border="0" cellpadding="0" cellspacing="0"><tr>
<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;">
${event_id.get_interval('dayname', tz=object.partner_id.tz if not event_id.allday else None)}
${object.event_id.get_interval('dayname', tz=object.partner_id.tz if not object.event_id.allday else None)}
</div>
<div style="font-size: 48px; min-height: auto; font-weight: bold; text-align: center; color: #5F5F5F; background-color: #F8F8F8; border: 1px solid #875A7B;">
${event_id.get_interval('day', tz=object.partner_id.tz if not event_id.allday else None)}
${object.event_id.get_interval('day', tz=object.partner_id.tz if not object.event_id.allday else None)}
</div>
<div style='font-size: 12px; text-align: center; font-weight: bold; color: #ffffff; background-color: #875A7B;'>
${event_id.get_interval('month', tz=object.partner_id.tz if not event_id.allday else None)}
${object.event_id.get_interval('month', tz=object.partner_id.tz if not object.event_id.allday else None)}
</div>
<div style="border-collapse: separate; color: #5F5F5F; text-align: center; font-size: 12px; border-bottom-right-radius: 3px; font-weight: bold; border: 1px solid #875A7B; border-bottom-left-radius: 3px;">
${not event_id.allday and event_id.get_interval('time', tz=object.partner_id.tz) or ''}
${not object.event_id.allday and object.event_id.get_interval('time', tz=object.partner_id.tz) or ''}
</div>
</td>
<td width="20px;"/>
@@ -244,11 +299,23 @@
(<a target="_blank" href="http://maps.google.com/maps?oi=map&amp;q=${object.event_id.location}">View Map</a>)
</li>
% endif
% if object.event_id.description :
<li>Description: ${object.event_id.description}</li>
% 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,(object.event_id.duration*60)%60))}</li>
<li>Duration: ${('%dH%02d' % (object.event_id.duration,round(object.event_id.duration*60)%60))}</li>
% endif
% if not is_online and object.event_id.description :
<li>Description: ${object.event_id.description}</li>
% elif is_online and object.event_id.description:
% set splitted_description = object.event_id.description_to_html_lines()
<li>Description:
<ul>
% for description_line in splitted_description:
<li>${description_line | safe}</li>
% endfor
</ul>
</li>
% endif
<li>Attendees
<ul>
@@ -167,10 +167,9 @@ class AlarmManager(models.AbstractModel):
# Executed via cron
events = self._get_events_to_notify('email')
attendees = events.attendee_ids.filtered(lambda a: a.state != 'declined')
attendees._send_mail_to_attendees(
attendees.with_context(calendar_template_ignore_recurrence=True)._send_mail_to_attendees(
'calendar.calendar_template_meeting_reminder',
force_send=True,
ignore_recurrence=True,
)
@api.model
+3 -21
View File
@@ -15,6 +15,7 @@ class Attendee(models.Model):
_name = 'calendar.attendee'
_rec_name = 'common_name'
_description = 'Calendar Attendee Information'
_order = 'create_date ASC'
def _default_access_token(self):
return uuid.uuid4().hex
@@ -80,18 +81,16 @@ class Attendee(models.Model):
partners = (event.attendee_ids & self).partner_id & event.message_partner_ids
event.message_unsubscribe(partner_ids=partners.ids)
def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False):
def _send_mail_to_attendees(self, template_xmlid, force_send=False):
""" Send mail for event invitation to event attendees.
:param template_xmlid: xml id of the email template to use to send the invitation
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
:param ignore_recurrence: ignore event recurrence
"""
res = False
if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self._context.get("no_mail_to_attendees"):
return res
calendar_view = self.env.ref('calendar.view_calendar_event_calendar')
invitation_template = self.env.ref(template_xmlid, raise_if_not_found=False)
if not invitation_template:
_logger.warning("Template %s could not be found. %s not notified." % (template_xmlid, self))
@@ -99,25 +98,8 @@ class Attendee(models.Model):
# get ics file for all meetings
ics_files = self.mapped('event_id')._get_ics_file()
# prepare rendering context for mail template
colors = {
'needsAction': 'grey',
'accepted': 'green',
'tentative': '#FFFF00',
'declined': 'red'
}
rendering_context = dict(self._context)
rendering_context.update({
'colors': colors,
'ignore_recurrence': ignore_recurrence,
'action_id': self.env['ir.actions.act_window'].sudo().search([('view_id', '=', calendar_view.id)], limit=1).id,
'dbname': self._cr.dbname,
'base_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url', default='http://localhost:8069'),
})
for attendee in self:
if attendee.email and attendee.partner_id != self.env.user.partner_id:
# FIXME: is ics_file text or bytes?
event_id = attendee.event_id.id
ics_file = ics_files.get(event_id)
@@ -128,7 +110,7 @@ class Attendee(models.Model):
'mimetype': 'text/calendar',
'datas': base64.b64encode(ics_file)})
]
body = invitation_template.with_context(rendering_context)._render_field(
body = invitation_template._render_field(
'body_html',
attendee.ids,
compute_lang=True,
+70 -11
View File
@@ -16,7 +16,7 @@ from odoo.addons.calendar.models.calendar_attendee import Attendee
from odoo.addons.calendar.models.calendar_recurrence import weekday_to_field, RRULE_TYPE_SELECTION, END_TYPE_SELECTION, MONTH_BY_SELECTION, WEEKDAY_SELECTION, BYDAY_SELECTION
from odoo.tools.translate import _
from odoo.tools.misc import get_lang
from odoo.tools import pycompat
from odoo.tools import pycompat, html_escape
from odoo.exceptions import UserError, ValidationError, AccessError
_logger = logging.getLogger(__name__)
@@ -287,7 +287,11 @@ class Meeting(models.Model):
def _compute_attendee(self):
for meeting in self:
attendee = meeting._find_my_attendee()
attendee = next(
(attendee for attendee in self.attendee_ids
if attendee.partner_id == self.env.user.partner_id),
self.env['calendar.attendee']
)
meeting.attendee_status = attendee.state if attendee else 'needsAction'
@api.constrains('start', 'stop', 'start_date', 'stop_date')
@@ -477,7 +481,11 @@ class Meeting(models.Model):
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)
(current_attendees & previous_attendees).with_context(
calendar_template_ignore_recurrence=not update_recurrence
)._send_mail_to_attendees(
'calendar.calendar_template_meeting_changedate'
)
return True
@@ -713,15 +721,13 @@ class Meeting(models.Model):
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.
"""
def find_partner_customer(self):
self.ensure_one()
for attendee in self.attendee_ids:
if self.env.user.partner_id == attendee.partner_id:
return attendee
return False
return next(
(attendee.partner_id for attendee in self.attendee_ids.sorted('create_date')
if attendee.partner_id != self.user_id.partner_id),
self.env['calendar.attendee']
)
# ------------------------------------------------------------
# TOOLS
@@ -920,3 +926,56 @@ class Meeting(models.Model):
'id', 'active', 'allday',
'duration', 'user_id', 'interval',
'count', 'rrule', 'recurrence_id', 'show_as', 'privacy'}
def description_to_html_lines(self):
""" Description could contain some structure content, depending on
its use. Notably appointment could add some structured data in it
in addition to free text. Purpose of this method is to generate a
simple html.
Input (self.description) example:
Some free text salespeople added
as multi line
* Mobile: +320475000000
* Email: my.email@test.example.com
* SingleLine: answer
* MultiLine:
Answer1
Answer2
Answer3
* Dropdown: answer
* Radio: answer
* Checkboxes: answer1, answer2
Some free text salespeople added
as multi line
Output: a list of items[
'Some free text salespeople added<br />as multi line',
'Mobile: +320475000000',
'Email: my.email@test.example.com',
'SingleLine: answer',
'MultiLine:<br />Answer1<br />Answer2<br />Answer3',
'Dropdown: answer',
'Radio: answer',
'Checkboxes: answer1, answer2',
'Some free text salespeople added<br .>as multi line']
Each item of returned list is escaped so that only our intended <br />
are html tags. It should therefore be safe.
"""
final_lines = []
parsed_lines = []
for line in self.description.split('\n'):
if not line.strip():
continue
# new line
if line.startswith(' *'):
if parsed_lines:
final_lines.append('<br />'.join(html_escape(line) for line in parsed_lines))
parsed_lines = [line.lstrip(' *')]
else:
parsed_lines.append(line)
if parsed_lines:
final_lines.append('<br />'.join(html_escape(line) for line in parsed_lines))
return final_lines
@@ -9,11 +9,11 @@ class Attendee(models.Model):
_name = 'calendar.attendee'
_inherit = 'calendar.attendee'
def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False):
def _send_mail_to_attendees(self, template_xmlid, force_send=False):
""" Override
If not synced with Google, let Odoo in charge of sending emails
Otherwise, nothing to do: Google will send them
"""
with google_calendar_token(self.env.user.sudo()) as token:
if not token:
super()._send_mail_to_attendees(template_xmlid, force_send, ignore_recurrence)
super()._send_mail_to_attendees(template_xmlid, force_send)
@@ -10,11 +10,11 @@ class Attendee(models.Model):
_name = 'calendar.attendee'
_inherit = 'calendar.attendee'
def _send_mail_to_attendees(self, template_xmlid, force_send=False, ignore_recurrence=False):
def _send_mail_to_attendees(self, template_xmlid, force_send=False):
""" Override the super method
If not synced with Microsoft Outlook, let Odoo in charge of sending emails
Otherwise, Microsoft Outlook will send them
"""
with microsoft_calendar_token(self.env.user.sudo()) as token:
if not token:
super()._send_mail_to_attendees(template_xmlid, force_send, ignore_recurrence)
super()._send_mail_to_attendees(template_xmlid, force_send)