[IMP] calendar: improves field type from text to html

Replace text fields to html fields as we have our own 'OdooEditor'.
Indeed, it gives more options to users in the way they format their
content without weighting too much on the UI
(tools appear on demand and not by default).

Adding a new method convert_online_event_desc_to_text
Because online events have fixed format for the description,
this method removes some specific html tags, and converts it into
readable plaintext (to be used in external calendars).

Models -> Fields

1) calendar.event -> description

Task Id: 2499504

X-original-commit: 0d5ca6a6fe8821b4ec19dd603bde116518721792
This commit is contained in:
dht-odoo
2021-06-07 05:24:08 +00:00
committed by Thibault Delavallée
parent 366aa2907e
commit dbf1ab157d
7 changed files with 50 additions and 102 deletions
+12 -36
View File
@@ -92,18 +92,6 @@
% 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:
@@ -121,6 +109,10 @@
<li>Join Video Call: <a href="${object.event_id.videocall_location}" target="_blank">${object.event_id.videocall_location}</a></li>
% endif
</ul>
% if not is_html_empty(object.event_id.description):
<p><strong>Description of the event</strong></p>
${object.event_id.description | safe}
% endif
</td>
</tr></table>
<br/>
@@ -222,18 +214,6 @@
% 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:
@@ -251,6 +231,10 @@
<li>Join Video Call: <a href="${object.event_id.videocall_location}" target="_blank">${object.event_id.videocall_location}</a></li>
% endif
</ul>
% if not is_html_empty(object.event_id.description):
<p><strong>Description of the event</strong></p>
${object.event_id.description | safe}
% endif
</td>
</tr></table>
<br/>
@@ -332,18 +316,6 @@
% 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:
@@ -361,6 +333,10 @@
<li>Join Video Call: <a href="${object.event_id.videocall_location}" target="_blank">${object.event_id.videocall_location}</a></li>
% endif
</ul>
% if not is_html_empty(object.event_id.description):
<p><strong>Description of the event</strong></p>
${object.event_id.description | safe}
% endif
</td>
</tr></table>
<br/>
+26 -59
View File
@@ -9,13 +9,12 @@ import pytz
from werkzeug.urls import url_join
from odoo import api, fields, models
from odoo import tools
from odoo.addons.base.models.res_partner import _tz_get
from odoo.addons.calendar.models.calendar_attendee import Attendee
from odoo.addons.calendar.models.calendar_recurrence import weekday_to_field, RRULE_TYPE_SELECTION, END_TYPE_SELECTION, MONTH_BY_SELECTION, WEEKDAY_SELECTION, BYDAY_SELECTION
from odoo.tools.translate import _
from odoo.tools.misc import get_lang
from odoo.tools import pycompat, html_escape
from odoo.tools import pycompat, html2plaintext, is_html_empty
from odoo.exceptions import UserError, ValidationError, AccessError
_logger = logging.getLogger(__name__)
@@ -96,7 +95,7 @@ class Meeting(models.Model):
# description
name = fields.Char('Meeting Subject', required=True)
description = fields.Text('Description')
description = fields.Html('Description')
user_id = fields.Many2one('res.users', 'Organizer', default=lambda self: self.env.user)
partner_id = fields.Many2one(
'res.partner', string='Scheduled by', related='user_id.partner_id', readonly=True)
@@ -627,7 +626,7 @@ class Meeting(models.Model):
if 'name' in fields:
activity_values['summary'] = event.name
if 'description' in fields:
activity_values['note'] = tools.plaintext2html(event.description)
activity_values['note'] = event.description
if 'start' in fields:
# self.start is a datetime UTC *only when the event is not allday*
# activty.date_deadline is a date (No TZ, but should represent the day in which the user's TZ is)
@@ -837,8 +836,12 @@ class Meeting(models.Model):
event.add('dtstart').value = ics_datetime(meeting.start, meeting.allday)
event.add('dtend').value = ics_datetime(meeting.stop, meeting.allday)
event.add('summary').value = meeting.name
if meeting.description:
event.add('description').value = meeting.description
if not is_html_empty(meeting.description):
if 'appointment_type_id' in meeting._fields and self.appointment_type_id:
# convert_online_event_desc_to_text method for correct data formatting in external calendars
event.add('description').value = self.convert_online_event_desc_to_text(meeting.description)
else:
event.add('description').value = html2plaintext(meeting.description)
if meeting.location:
event.add('location').value = meeting.location
if meeting.rrule:
@@ -866,6 +869,23 @@ class Meeting(models.Model):
return result
def convert_online_event_desc_to_text(self, description):
"""
We can sync the calendar events with google calendar, iCal and Outlook, and we
also pass the event description along with other data. This description needs
to be in plaintext to be displayed properly in above platforms. Because online
events have fixed format for the description, this method removes some specific
html tags, and converts it into readable plaintext (to be used in external
calendars). Note that for regular (offline) events, we simply use the standard
`html2plaintext` method instead.
"""
desc_str = str(description)
tags_to_replace = ["<ul>", "</ul>", "<li>"]
for tag in tags_to_replace:
desc_str = desc_str.replace(tag, "")
desc_str = desc_str.replace("</li>", "<br/>")
return html2plaintext(desc_str)
@api.model
def _get_display_time(self, start, stop, zduration, zallday):
""" Return date and time (from to from) based on duration with timezone in string. Eg :
@@ -948,56 +968,3 @@ 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
+4 -1
View File
@@ -34,7 +34,10 @@ class MailActivity(models.Model):
if feedback:
for event in events:
description = event.description
description = '%s\n%s%s' % (description or '', _("Feedback: "), feedback)
description = '%s<br />%s' % (
description if not tools.is_html_empty(description) else '',
_('Feedback: %(feedback)s', tools.plaintext2html(feedback)) if feedback else '',
)
event.write({'description': description})
return messages, activities
+1 -1
View File
@@ -123,7 +123,7 @@ class TestCalendar(SavepointCaseWithUserDemo):
self.assertEqual(test_record.activity_ids.date_deadline, (now + timedelta(days=-2)).date())
# update event with a description that have a special character and a new line
test_description3 = 'Test & \n Description'
test_description3 = 'Test & <br> Description'
test_note3 = '<p>Test &amp; <br> Description</p>'
test_event.write({
'description': test_description3,
+2 -2
View File
@@ -82,7 +82,7 @@ class Meeting(models.Model):
name = google_event.summary or related_event and related_event.name or _("(No title)")
values = {
'name': name,
'description': google_event.description,
'description': tools.plaintext2html(google_event.description),
'location': google_event.location,
'user_id': google_event.owner(self.env).id,
'privacy': google_event.visibility or self.default_get(['privacy'])['privacy'],
@@ -214,7 +214,7 @@ class Meeting(models.Model):
'start': start,
'end': end,
'summary': self.name,
'description': self.description or '',
'description': tools.html2plaintext(self.description) if not tools.is_html_empty(self.description) else '',
'location': self.location or '',
'guestsCanModify': True,
'organizer': {'email': self.user_id.email, 'self': self.user_id == self.env.user},
@@ -8,6 +8,7 @@ from dateutil.relativedelta import relativedelta
from odoo.tests.common import new_test_user
from odoo.addons.google_calendar.tests.test_sync_common import TestSyncGoogle, patch_api
from odoo.addons.google_calendar.utils.google_calendar import GoogleEvent
from odoo.tools import html2plaintext
class TestSyncGoogle2Odoo(TestSyncGoogle):
@@ -50,7 +51,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
self.assertTrue(event, "It should have created an event")
self.assertEqual(event.name, values.get('summary'))
self.assertFalse(event.allday)
self.assertEqual(event.description, values.get('description'))
self.assertEqual(html2plaintext(event.description), values.get('description'))
self.assertEqual(event.start, datetime(2020, 1, 13, 15, 55))
self.assertEqual(event.stop, datetime(2020, 1, 13, 18, 55))
admin_attendee = event.attendee_ids.filtered(lambda e: e.email == 'admin@yourcompany.example.com')
+3 -2
View File
@@ -7,6 +7,7 @@ from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import html2plaintext, is_html_empty, plaintext2html
ATTENDEE_CONVERTER_O2M = {
'needsAction': 'notresponded',
@@ -87,7 +88,7 @@ class Meeting(models.Model):
values = {
**default_values,
'name': microsoft_event.subject or _("(No title)"),
'description': microsoft_event.bodyPreview,
'description': plaintext2html(microsoft_event.bodyPreview),
'location': microsoft_event.location and microsoft_event.location.get('displayName') or False,
'user_id': microsoft_event.owner(self.env).id,
'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']),
@@ -247,7 +248,7 @@ class Meeting(models.Model):
if 'description' in fields_to_sync:
values['body'] = {
'content': self.description or '',
'content': html2plaintext(self.description) if not is_html_empty(self.description) else '',
'contentType': "text",
}