[FW][MERGE] various: Html fields everywhere !

PURPOSE

Replace text fields by html ones now that our own html editor has been
merged. Indeed it gives more options to users in the way they format their
content without weighting too much on the UI as tools appear on demand
and not by default.

SPECIFICATIONS

Convert a lot of text fields to Html fields as we have our own OdooEditor.
In some modules code adaptation will be necessary to handle html /
plaintext conversion when required.

See individual commits per main application scope for more details.

LINKS

Task ID-2499504

closes odoo/odoo#71776

Forward-port-of: odoo/odoo#70663
Related: odoo/enterprise#18778
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-06-07 10:30:14 +02:00
committed by GitHub
80 changed files with 243 additions and 224 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
from odoo import api, fields, models, _
from odoo.exceptions import RedirectWarning, UserError, ValidationError, AccessError
from odoo.tools import float_compare, date_utils, email_split, email_re, html_escape
from odoo.tools import float_compare, date_utils, email_split, email_re, html_escape, is_html_empty
from odoo.tools.misc import formatLang, format_date, get_lang
from datetime import date, timedelta
@@ -144,7 +144,7 @@ class AccountMove(models.Model):
default=fields.Date.context_today
)
ref = fields.Char(string='Reference', copy=False, tracking=True)
narration = fields.Text(string='Terms and Conditions')
narration = fields.Html(string='Terms and Conditions')
state = fields.Selection(selection=[
('draft', 'Draft'),
('posted', 'Posted'),
@@ -493,7 +493,7 @@ class AccountMove(models.Model):
''' Onchange made to filter the partners depending of the type. '''
if self.is_sale_document(include_receipts=True):
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'):
self.narration = self.company_id.invoice_terms or self.env.company.invoice_terms
self.narration = self.company_id.invoice_terms if not is_html_empty(self.company_id.invoice_terms) else self.env.company.invoice_terms
@api.onchange('invoice_line_ids')
def _onchange_invoice_line_ids(self):
@@ -16,7 +16,7 @@ class AccountPaymentTerm(models.Model):
name = fields.Char(string='Payment Terms', translate=True, required=True)
active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
note = fields.Text(string='Description on the Invoice', translate=True)
note = fields.Html(string='Description on the Invoice', translate=True)
line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
company_id = fields.Many2one('res.company', string='Company')
sequence = fields.Integer(required=True, default=10)
+1 -1
View File
@@ -102,7 +102,7 @@ class ResCompany(models.Model):
# account dashboard onboarding
account_invoice_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account invoice onboarding panel", default='not_done')
account_dashboard_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account dashboard onboarding panel", default='not_done')
invoice_terms = fields.Text(string='Default Terms and Conditions', translate=True)
invoice_terms = fields.Html(string='Default Terms and Conditions', translate=True)
terms_type = fields.Selection([('plain', 'Terms as Notes'), ('html', 'Terms as Web Page')],
string='Terms & Conditions format', default='plain')
invoice_terms_html = fields.Html(string='Default Terms and Conditions as a Web page', translate=True,
+1 -1
View File
@@ -28,7 +28,7 @@ class AccountFiscalPosition(models.Model):
default=lambda self: self.env.company)
account_ids = fields.One2many('account.fiscal.position.account', 'position_id', string='Account Mapping', copy=True)
tax_ids = fields.One2many('account.fiscal.position.tax', 'position_id', string='Tax Mapping', copy=True)
note = fields.Text('Notes', translate=True, help="Legal mentions that have to be printed on the invoices.")
note = fields.Html('Notes', translate=True, help="Legal mentions that have to be printed on the invoices.")
auto_apply = fields.Boolean(string='Detect Automatically', help="Apply automatically this fiscal position.")
vat_required = fields.Boolean(string='VAT required', help="Apply only if partner has a VAT number.")
company_country_id = fields.Many2one(string="Company Country", related='company_id.country_id')
+1 -1
View File
@@ -124,7 +124,7 @@ class ResConfigSettings(models.TransientModel):
invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False)
invoice_is_email = fields.Boolean(string='Send Email', related='company_id.invoice_is_email', readonly=False)
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', related='company_id.incoterm_id', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.', readonly=False)
invoice_terms = fields.Text(related='company_id.invoice_terms', string="Terms & Conditions", readonly=False)
invoice_terms = fields.Html(related='company_id.invoice_terms', string="Terms & Conditions", readonly=False)
invoice_terms_html = fields.Html(related='company_id.invoice_terms_html', string="Terms & Conditions as a Web page",
readonly=False)
terms_type = fields.Selection(
+3 -2
View File
@@ -1022,12 +1022,13 @@
<field name="invoice_payments_widget" colspan="2" nolabel="1" widget="payment"/>
<field name="amount_residual" class="oe_subtotal_footer_separator" attrs="{'invisible': [('state', '=', 'draft')]}"/>
</group>
<field name="narration" placeholder="Terms and Conditions" class="oe_inline" nolabel="1"/>
<field name="invoice_outstanding_credits_debits_widget"
class="oe_invoice_outstanding_credits_debits"
colspan="2" nolabel="1" widget="payment"
attrs="{'invisible': ['|', ('state', '!=', 'posted'), ('move_type', 'in', ('out_receipt', 'in_receipt'))]}"/>
<group>
<field name="narration" placeholder="Terms and Conditions" class="oe_inline" nolabel="1"/>
</group>
</page>
<page id="aml_tab" string="Journal Items" groups="account.group_account_readonly">
<div class="alert alert-info text-center mb-0" role="alert" attrs="{'invisible': ['|', ('payment_state', '!=', 'invoicing_legacy'), ('move_type', '=', 'entry')]}">
@@ -118,7 +118,7 @@
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
<div><strong class="o_kanban_record_title"><t t-esc="record.name.value"/></strong></div>
<div t-if="record.note.value"><t t-esc="record.note.value"/></div>
<div t-if="!widget.isHtmlEmpty(record.note.raw_value)"><t t-raw="record.note.raw_value"/></div>
</div>
</t>
</templates>
+2 -2
View File
@@ -191,10 +191,10 @@
<p t-if="o.invoice_payment_term_id" name="payment_term">
<span t-field="o.invoice_payment_term_id.note"/>
</p>
<p t-if="o.narration" name="comment">
<p t-if="not is_html_empty(o.narration)" name="comment">
<span t-field="o.narration"/>
</p>
<p t-if="o.fiscal_position_id.note" name="note">
<p t-if="not is_html_empty(o.fiscal_position_id.note)" name="note">
<span t-field="o.fiscal_position_id.note"/>
</p>
<p t-if="o.invoice_incoterm_id" name="incoterm">
+1 -1
View File
@@ -103,7 +103,7 @@
<span class="d-block font-weight-bold" t-if="line.partner_id" t-field="line.partner_id"/>
<span class="d-block" t-if="line.partner_bank_id" t-field="line.partner_bank_id"/>
<span class="d-block" t-if="line.payment_ref" t-field="line.payment_ref"/>
<span class="d-block" t-if="line.narration" t-field="line.narration"/>
<span class="d-block" t-if=" not is_html_empty(line.narration)" t-field="line.narration"/>
</td>
<td class="text-right p-0">
<span class="d-block font-weight-bold" t-field="line.amount"/>
@@ -116,7 +116,7 @@
<ram:IssueDateTime>
<udt:DateTimeString format="102" t-esc="format_date(record.invoice_date)"/>
</ram:IssueDateTime>
<ram:IncludedNote t-if="record.narration">
<ram:IncludedNote t-if="not is_html_empty(record.narration)">
<ram:Content t-esc="record.narration"/>
</ram:IncludedNote>
</rsm:ExchangedDocument>
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import api, models, fields, tools, _
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, float_repr
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, float_repr, is_html_empty
from odoo.tests.common import Form
from odoo.exceptions import UserError
@@ -61,6 +61,7 @@ class AccountEdiFormat(models.Model):
**invoice._prepare_edi_vals_to_export(),
'format_date': format_date,
'format_monetary': format_monetary,
'is_html_empty': is_html_empty,
}
xml_content = b"<?xml version='1.0' encoding='UTF-8'?>"
@@ -38,7 +38,7 @@ class ResConfigSettings(models.TransientModel):
module_partner_autocomplete = fields.Boolean("Partner Autocomplete")
module_base_geolocalize = fields.Boolean("GeoLocalize")
module_google_recaptcha = fields.Boolean("reCAPTCHA")
report_footer = fields.Text(related="company_id.report_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.", readonly=False)
report_footer = fields.Html(related="company_id.report_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.", readonly=False)
group_multi_currency = fields.Boolean(string='Multi-Currencies',
implied_group='base.group_multi_currency',
help="Allows to work in a multi currency environment")
+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
@@ -121,7 +121,7 @@ class Lead(models.Model):
'res.company', string='Company', index=True,
compute='_compute_company_id', readonly=False, store=True)
referred = fields.Char('Referred By')
description = fields.Text('Notes')
description = fields.Html('Notes')
active = fields.Boolean('Active', default=True, tracking=True)
type = fields.Selection([
('lead', 'Lead'), ('opportunity', 'Opportunity')],
@@ -1376,7 +1376,7 @@ class Lead(models.Model):
def _merge_get_fields_specific(self):
return {
'description': lambda fname, leads: '\n\n'.join(desc for desc in leads.mapped('description') if desc),
'description': lambda fname, leads: '<br/><br/>'.join(desc for desc in leads.mapped('description') if not is_html_empty(desc)),
'type': lambda fname, leads: 'opportunity' if any(lead.type == 'opportunity' for lead in leads) else 'lead',
'priority': lambda fname, leads: max(leads.mapped('priority')) if leads else False,
}
+1 -1
View File
@@ -399,7 +399,7 @@ class TestCrmCommon(TestSalesCommon, MailCase):
def _get_description():
values = [_find_value(lead, 'description') for lead in leads]
return '\n\n'.join(value for value in values if value)
return '<br><br>'.join(value for value in values if value)
def _get_priority():
values = [_find_value(lead, 'priority') for lead in leads]
+1 -1
View File
@@ -88,7 +88,7 @@ class TestLeadMerge(TestLeadMergeCommon):
# and exclude inactive leads, but that's not written anywhere ... intended ??
self.assertEqual(merge.opportunity_ids, self.leads - self.lead_w_partner_company - self.lead_w_email_lost)
ordered_merge = self.lead_w_contact + self.lead_w_email + self.lead_1 + self.lead_w_partner
ordered_merge_description = '\n\n'.join(l.description for l in ordered_merge)
ordered_merge_description = '<br><br>'.join(l.description for l in ordered_merge)
# merged opportunity: in this test, all input are leads. Confidence is based on stage
# sequence -> lead_w_contact has a stage sequence of 30
+3 -3
View File
@@ -28,9 +28,9 @@ class MailChannel(models.Model):
:param key: operator input in chat ('/lead Lead about Product')
"""
description = ''.join(
'%s: %s\n' % (message.author_id.name or self.anonymous_name, message.body)
'%s: %s<br/>' % (message.author_id.name or self.anonymous_name, html2plaintext(message.body))
for message in self.message_ids.sorted('id')
)
) # converting message body back to plaintext for correct data formating in description
# if public user is part of the chat: consider lead to be linked to an
# anonymous user whatever the participants. Otherwise keep only share
# partners (no user or portal user) to link to the lead.
@@ -48,7 +48,7 @@ class MailChannel(models.Model):
'partner_id': customers[0].id if customers else False,
'user_id': False,
'team_id': False,
'description': html2plaintext(description),
'description': description,
'referred': partner.name,
'source_id': utm_source and utm_source.id,
})
+1 -1
View File
@@ -106,7 +106,7 @@ class EventEvent(models.Model):
return self.env['ir.ui.view']._render_template('event.event_default_descripton')
name = fields.Char(string='Event', translate=True, required=True)
note = fields.Text(string='Note')
note = fields.Html(string='Note')
description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False, sanitize_form=False, default=_default_description)
active = fields.Boolean(default=True)
user_id = fields.Many2one(
+1 -1
View File
@@ -177,7 +177,7 @@ class EventLeadRule(models.Model):
additionnal_description = group_registrations._get_lead_description(_("New registrations"), line_counter=True)
for lead in toupdate_leads:
lead.write({
'description': "%s\n%s" % (lead.description, additionnal_description),
'description': "%s<br/>%s" % (lead.description, additionnal_description),
'registration_ids': [(4, reg.id) for reg in group_registrations],
})
elif group_registrations:
@@ -132,7 +132,7 @@ class EventRegistration(models.Model):
upd_description_fields = [field for field in self._get_lead_description_fields() if field in new_vals.keys()]
if any(new_vals[field] != old_vals[field] for field in upd_description_fields):
for lead in leads_attendee:
lead_values['description'] = "%s\n%s" % (
lead_values['description'] = "%s<br/>%s" % (
lead.description,
registration._get_lead_description(_("Updated registrations"), line_counter=True)
)
@@ -148,7 +148,7 @@ class EventRegistration(models.Model):
if not lead.partner_id:
lead_values['description'] = lead.registration_ids._get_lead_description(_("Participants"), line_counter=True)
elif new_vals['partner_id'] != lead.partner_id.id:
lead_values['description'] = lead.description + "\n" + lead.registration_ids._get_lead_description(_("Updated registrations"), line_counter=True, line_suffix=_("(updated)"))
lead_values['description'] = lead.description + "<br/>" + lead.registration_ids._get_lead_description(_("Updated registrations"), line_counter=True, line_suffix=_("(updated)"))
if lead_values:
lead.write(lead_values)
@@ -232,17 +232,16 @@ class EventRegistration(models.Model):
"""
reg_lines = [
registration._get_lead_description_registration(
prefix="%s. " % (index + 1) if line_counter else "",
line_suffix=line_suffix
) for index, registration in enumerate(self)
) for registration in self
]
return ("%s\n" % prefix if prefix else "") + ("\n".join(reg_lines))
return ("%s<br/>" % prefix if prefix else "") + (
"<ol>" if line_counter else "<ul>") + ("".join(reg_lines)) + ("</ol>" if line_counter else "</ul>")
def _get_lead_description_registration(self, prefix='', line_suffix=''):
def _get_lead_description_registration(self, line_suffix=''):
""" Build the description line specific to a given registration. """
self.ensure_one()
return "%s%s (%s)%s" % (
prefix or "",
return "<li>%s (%s)%s</li>" % (
self.name or self.partner_id.name or self.email,
" - ".join(self[field] for field in ('email', 'phone') if self[field]),
" %s" % line_suffix if line_suffix else "",
+1 -1
View File
@@ -18,7 +18,7 @@ class FleetVehicle(models.Model):
return state if state and state.id else False
name = fields.Char(compute="_compute_vehicle_name", store=True)
description = fields.Text("Vehicle Description")
description = fields.Html("Vehicle Description")
active = fields.Boolean('Active', default=True, tracking=True)
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
@@ -46,7 +46,7 @@ class FleetVehicleLogContract(models.Model):
help='Choose whether the contract is still valid or not',
tracking=True,
copy=False)
notes = fields.Text('Terms and Conditions', help='Write here all supplementary information relative to this contract', copy=False)
notes = fields.Html('Terms and Conditions', help='Write here all supplementary information relative to this contract', copy=False)
cost_generated = fields.Monetary('Recurring Cost')
cost_frequency = fields.Selection([
('no', 'No'),
@@ -36,7 +36,7 @@
<div>
Congratulations ${object.user_id.name} !<br/>
You just received badge <strong>${object.badge_id.name}</strong> !<br/>
% if object.badge_id.description
% if not is_html_empty(object.badge_id.description)
<table cellspacing="0" cellpadding="0" border="0" style="width: 560px; margin-top: 5px;">
<tbody><tr>
<td valign="center">
+1 -1
View File
@@ -74,7 +74,7 @@ class GamificationBadge(models.Model):
name = fields.Char('Badge', required=True, translate=True)
active = fields.Boolean('Active', default=True)
description = fields.Text('Description', translate=True)
description = fields.Html('Description', translate=True)
level = fields.Selection([
('bronze', 'Bronze'), ('silver', 'Silver'), ('gold', 'Gold')],
string='Forum Badge Level', default='bronze')
+2 -2
View File
@@ -144,8 +144,8 @@
<strong><t t-esc="record.granted_count.raw_value"/></strong> granted,
<strong><t t-esc="record.stat_this_month.raw_value"/></strong> this month
</div>
<div t-if="record.description.value" class="o_kanban_badge_description">
<em><field name="description"/></em>
<div t-if="!widget.isHtmlEmpty(record.description.value)" class="o_kanban_badge_description">
<em t-raw="record.description.raw_value"/>
<div>
<t t-foreach="record.unique_owner_ids.raw_value.slice(0,11)" t-as="owner">
<img class="oe_kanban_avatar o_image_24_cover" t-att-src="kanban_image('res.users', 'avatar_128', owner)" t-att-data-member_id="owner" alt="Owner"/>
+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')
+1 -1
View File
@@ -20,7 +20,7 @@ class Job(models.Model):
no_of_hired_employee = fields.Integer(string='Hired Employees', copy=False,
help='Number of hired employees for this job position during recruitment phase.')
employee_ids = fields.One2many('hr.employee', 'job_id', string='Employees', groups='base.group_user')
description = fields.Text(string='Job Description')
description = fields.Html(string='Job Description')
requirements = fields.Text('Requirements')
department_id = fields.Many2one('hr.department', string='Department', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
+1 -1
View File
@@ -33,7 +33,7 @@ class Contract(models.Model):
default=lambda self: self.env.company.resource_calendar_id.id, copy=False, index=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
wage = fields.Monetary('Wage', required=True, tracking=True, help="Employee's monthly gross wage.")
notes = fields.Text('Notes')
notes = fields.Html('Notes')
state = fields.Selection([
('draft', 'New'),
('open', 'Running'),
@@ -66,7 +66,7 @@ class HolidaysAllocation(models.Model):
'hr.employee', compute='_compute_from_holiday_type', store=True, string='Employee', index=True, readonly=False, ondelete="restrict", tracking=True,
states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]})
manager_id = fields.Many2one('hr.employee', compute='_compute_from_employee_id', store=True, string='Manager')
notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]})
notes = fields.Html('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]})
# duration
number_of_days = fields.Float(
'Number of Days', compute='_compute_from_holiday_status_id', store=True, readonly=False, tracking=True, default=1,
@@ -99,7 +99,7 @@ class Applicant(models.Model):
name = fields.Char("Subject / Application Name", required=True, help="Email subject for applications sent via email")
active = fields.Boolean("Active", default=True, help="If the active field is set to false, it will allow you to hide the case without removing it.")
description = fields.Text("Description")
description = fields.Html("Description")
email_from = fields.Char("Email", size=128, help="Applicant email", compute='_compute_partner_phone_email',
inverse='_inverse_partner_email', store=True)
probability = fields.Float("Probability")
@@ -225,7 +225,7 @@ class AccountEdiFormat(models.Model):
# Comment. <2.1.1.11>
elements = body_tree.xpath('.//DatiGeneraliDocumento//Causale')
for element in elements:
invoice_form.narration = '%s%s\n' % (invoice_form.narration or '', element.text)
invoice_form.narration = '%s%s<br/>' % (invoice_form.narration or '', element.text)
# Informations relative to the purchase order, the contract, the agreement,
# the reception phase or invoices previously transmitted
+1 -1
View File
@@ -58,7 +58,7 @@ class AccountFiscalPosition(models.Model):
_inherit = 'account.fiscal.position'
name = fields.Char(translate=True)
note = fields.Text(translate=True)
note = fields.Html(translate=True)
class AccountFiscalPositionTemplate(models.Model):
+1 -1
View File
@@ -41,7 +41,7 @@ class LunchOrder(models.Model):
display_toppings = fields.Text('Extras', compute='_compute_display_toppings', store=True)
product_description = fields.Text('Description', related='product_id.description')
product_description = fields.Html('Description', related='product_id.description')
topping_label_1 = fields.Char(related='product_id.category_id.topping_label_1')
topping_label_2 = fields.Char(related='product_id.category_id.topping_label_2')
topping_label_3 = fields.Char(related='product_id.category_id.topping_label_3')
+1 -1
View File
@@ -111,7 +111,7 @@ class LunchProduct(models.Model):
name = fields.Char('Product Name', required=True, translate=True)
category_id = fields.Many2one('lunch.product.category', 'Product Category', check_company=True, required=True)
description = fields.Text('Description', translate=True)
description = fields.Html('Description', translate=True)
price = fields.Float('Price', digits='Account', required=True)
supplier_id = fields.Many2one('lunch.supplier', 'Vendor', check_company=True, required=True)
active = fields.Boolean(default=True)
+1 -1
View File
@@ -15,7 +15,7 @@ class LunchProductReport(models.Model):
product_id = fields.Many2one('lunch.product', 'Product')
name = fields.Char('Product Name', related='product_id.name')
category_id = fields.Many2one('lunch.product.category', 'Product Category')
description = fields.Text('Description', related='product_id.description')
description = fields.Html('Description', related='product_id.description')
price = fields.Float('Price')
supplier_id = fields.Many2one('lunch.supplier', 'Vendor')
company_id = fields.Many2one('res.company')
+4 -3
View File
@@ -84,9 +84,10 @@
</group>
<group>
<field name="new_until"/>
<field name='description'/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<label for="description"/>
<field name='description'/>
</group>
</sheet>
</form>
@@ -148,7 +149,7 @@
</div>
<div class="o_kanban_record_bottom">
<ul>
<li t-esc="record.description.value" class="text-muted"/>
<li t-raw="record.description.raw_value" class="text-muted"/>
</ul>
</div>
</div>
@@ -192,7 +193,7 @@
</div>
<div class="o_kanban_record_bottom">
<ul>
<li t-esc="record.description.value" class="text-muted"/>
<li t-raw="record.description.raw_value" class="text-muted"/>
</ul>
</div>
</div>
+2 -1
View File
@@ -14,7 +14,7 @@ from werkzeug import urls
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError
from odoo.tools import safe_eval
from odoo.tools import is_html_empty, safe_eval
_logger = logging.getLogger(__name__)
@@ -279,6 +279,7 @@ class MailRenderMixin(models.AbstractModel):
'format_duration': lambda value: tools.format_duration(value),
'user': self.env.user,
'ctx': self._context,
'is_html_empty': is_html_empty,
}
@api.model
+3 -3
View File
@@ -40,7 +40,7 @@ class MaintenanceEquipmentCategory(models.Model):
default=lambda self: self.env.company)
technician_user_id = fields.Many2one('res.users', 'Responsible', tracking=True, default=lambda self: self.env.uid)
color = fields.Integer('Color Index')
note = fields.Text('Comments', translate=True)
note = fields.Html('Comments', translate=True)
equipment_ids = fields.One2many('maintenance.equipment', 'category_id', string='Equipments', copy=False)
equipment_count = fields.Integer(string="Equipment", compute='_compute_equipment_count')
maintenance_ids = fields.One2many('maintenance.request', 'category_id', copy=False)
@@ -125,7 +125,7 @@ class MaintenanceEquipment(models.Model):
assign_date = fields.Date('Assigned Date', tracking=True)
effective_date = fields.Date('Effective Date', default=fields.Date.context_today, required=True, help="Date at which the equipment became effective. This date will be used to compute the Mean Time Between Failure.")
cost = fields.Float('Cost')
note = fields.Text('Note')
note = fields.Html('Note')
warranty_date = fields.Date('Warranty Expiration Date')
color = fields.Integer('Color Index')
scrap_date = fields.Date('Scrap Date')
@@ -280,7 +280,7 @@ class MaintenanceRequest(models.Model):
name = fields.Char('Subjects', required=True)
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company)
description = fields.Text('Description')
description = fields.Html('Description')
request_date = fields.Date('Request Date', tracking=True, default=fields.Date.context_today,
help="Date requested for the maintenance to happen")
owner_user_id = fields.Many2one('res.users', string='Created by User', default=lambda s: s.env.uid)
+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",
}
+1 -1
View File
@@ -26,7 +26,7 @@ class MrpRoutingWorkcenter(models.Model):
string="Work Sheet", default="text",
help="Defines if you want to use a PDF or a Google Slide as work sheet."
)
note = fields.Text('Description', help="Text worksheet description")
note = fields.Html('Description', help="Text worksheet description")
worksheet = fields.Binary('PDF')
worksheet_google_slide = fields.Char('Google Slide', help="Paste the url of your Google Slide. Make sure the access to the document is public.")
time_mode = fields.Selection([
+1 -1
View File
@@ -26,7 +26,7 @@ class MrpWorkcenter(models.Model):
active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False)
code = fields.Char('Code', copy=False)
note = fields.Text(
note = fields.Html(
'Description',
help="Description of the Work Center.")
capacity = fields.Float(
+1 -1
View File
@@ -112,7 +112,7 @@ class MrpWorkorder(models.Model):
string='Worksheet Type', related='operation_id.worksheet_type', readonly=True)
worksheet_google_slide = fields.Char(
'Worksheet URL', related='operation_id.worksheet_google_slide', readonly=True)
operation_note = fields.Text("Description", related='operation_id.note', readonly=True)
operation_note = fields.Html("Description", related='operation_id.note', readonly=True)
move_raw_ids = fields.One2many(
'stock.move', 'workorder_id', 'Raw Moves',
domain=[('raw_material_production_id', '!=', False), ('production_id', '=', False)])
+2
View File
@@ -4,6 +4,7 @@
from odoo import api, models, fields
from odoo.http import request
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.tools import is_html_empty
class View(models.Model):
@@ -24,5 +25,6 @@ class View(models.Model):
self._context.copy(),
languages=[lang for lang in Lang.get_available() if lang[0] in portal_lang_code],
url_for=url_for,
is_html_empty=is_html_empty,
))
return qcontext
+1 -1
View File
@@ -36,7 +36,7 @@ class ProductTemplate(models.Model):
name = fields.Char('Name', index=True, required=True, translate=True)
sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list')
description = fields.Text(
description = fields.Html(
'Description', translate=True)
description_purchase = fields.Text(
'Purchase Description', translate=True)
+1 -1
View File
@@ -103,7 +103,7 @@ class PurchaseOrder(models.Model):
('cancel', 'Cancelled')
], string='Status', readonly=True, index=True, copy=False, default='draft', tracking=True)
order_line = fields.One2many('purchase.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True)
notes = fields.Text('Terms and Conditions')
notes = fields.Html('Terms and Conditions')
invoice_count = fields.Integer(compute="_compute_invoice", string='Bill Count', copy=False, default=0, store=True)
invoice_ids = fields.Many2many('account.move', compute="_compute_invoice", string='Bills', copy=False, store=True)
@@ -57,7 +57,7 @@ class PurchaseRequisition(models.Model):
user_id = fields.Many2one(
'res.users', string='Purchase Representative',
default=lambda self: self.env.user, check_company=True)
description = fields.Text()
description = fields.Html()
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
purchase_ids = fields.One2many('purchase.order', 'requisition_id', string='Purchase Orders', states={'done': [('readonly', True)]})
line_ids = fields.One2many('purchase.requisition.line', 'requisition_id', string='Products to Purchase', states={'done': [('readonly', True)]}, copy=True)
+3 -3
View File
@@ -100,8 +100,8 @@ class Repair(models.Model):
fees_lines = fields.One2many(
'repair.fee', 'repair_id', 'Operations',
copy=True, readonly=False)
internal_notes = fields.Text('Internal Notes')
quotation_notes = fields.Text('Quotation Notes')
internal_notes = fields.Html('Internal Notes')
quotation_notes = fields.Html('Quotation Notes')
user_id = fields.Many2one('res.users', string="Responsible", default=lambda self: self.env.user, check_company=True)
company_id = fields.Many2one(
'res.company', 'Company',
@@ -360,7 +360,7 @@ class Repair(models.Model):
if not invoice_vals['narration']:
invoice_vals['narration'] = narration
else:
invoice_vals['narration'] += '\n' + narration
invoice_vals['narration'] += '<br/>' + narration
# Create invoice lines from operations.
for operation in repair.operations.filtered(lambda op: op.type == 'add'):
+7 -5
View File
@@ -9,7 +9,7 @@ from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tools.misc import formatLang
from odoo.osv import expression
from odoo.tools import float_is_zero
from odoo.tools import float_is_zero, html_keep_url, is_html_empty
class SaleOrder(models.Model):
@@ -111,7 +111,8 @@ class SaleOrder(models.Model):
def _default_note(self):
use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms')
if use_invoice_terms and self.env.company.terms_type == "html":
return _('Terms & Conditions: %s/terms', self._default_note_url())
baseurl = html_keep_url(self._default_note_url() + '/terms')
return _('Terms & Conditions: %s', baseurl)
return use_invoice_terms and self.env.company.invoice_terms or ''
@api.model
@@ -225,7 +226,7 @@ class SaleOrder(models.Model):
('no', 'Nothing to Invoice')
], string='Invoice Status', compute='_get_invoice_status', store=True, readonly=True)
note = fields.Text('Terms and conditions', default=_default_note)
note = fields.Html('Terms and conditions', default=_default_note)
terms_type = fields.Selection(related='company_id.terms_type')
amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=5)
@@ -409,8 +410,9 @@ class SaleOrder(models.Model):
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'):
if self.terms_type == 'html' and self.env.company.invoice_terms_html:
values['note'] = _('Terms & Conditions: %s/terms', self.get_base_url())
elif self.env.company.invoice_terms:
baseurl = html_keep_url(self.get_base_url() + '/terms')
values['note'] = _('Terms & Conditions: %s', baseurl)
elif not is_html_empty(self.env.company.invoice_terms):
values['note'] = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms
if not self.env.context.get('not_self_saleperson') or not self.team_id:
values['team_id'] = self.env['crm.team'].with_context(
+2 -2
View File
@@ -198,10 +198,10 @@
<div class="oe_structure"/>
<p t-field="doc.note" />
<p t-if="doc.payment_term_id.note">
<p t-if="not is_html_empty(doc.payment_term_id.note)">
<span t-field="doc.payment_term_id.note"/>
</p>
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.sudo().note">
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and not is_html_empty(doc.fiscal_position_id.sudo().note)">
<strong>Fiscal Position Remark:</strong>
<span t-field="doc.fiscal_position_id.sudo().note"/>
</p>
+2 -2
View File
@@ -611,12 +611,12 @@ class TestSaleOrder(TestSaleCommon):
self.env.company.terms_type = 'plain'
self.env.company.invoice_terms = "Coin coin"
sale_order = self._create_sale_order()
self.assertEqual(sale_order.note, "Coin coin")
self.assertEqual(sale_order.note, "<p>Coin coin</p>")
# Html invoice terms (/terms page)
self.env.company.terms_type = 'html'
sale_order = self._create_sale_order()
self.assertTrue(sale_order.note.startswith("Terms & Conditions: "))
self.assertTrue(sale_order.note.startswith("<p>Terms &amp; Conditions: "))
def test_validity_days(self):
self.env['ir.config_parameter'].sudo().set_param('sale.use_quotation_validity_days', True)
+1 -1
View File
@@ -526,7 +526,7 @@
</div>
</section>
<section id="terms" class="mt-5" t-if="sale_order.note">
<section id="terms" class="mt-5" t-if="not is_html_empty(sale_order.note)">
<h3 class="">Terms &amp; Conditions</h3>
<hr class="mt-0 mb-1"/>
<t t-if="sale_order.terms_type == 'html'">
+3 -2
View File
@@ -5,6 +5,7 @@ from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import is_html_empty
class SaleOrder(models.Model):
@@ -53,7 +54,7 @@ class SaleOrder(models.Model):
def onchange_partner_id(self):
super(SaleOrder, self).onchange_partner_id()
template = self.sale_order_template_id.with_context(lang=self.partner_id.lang)
self.note = template.note or self.note
self.note = template.note if not is_html_empty(template.note) else self.note
def _compute_line_data_for_template_change(self, line):
return {
@@ -145,7 +146,7 @@ class SaleOrder(models.Model):
self.require_signature = template.require_signature
self.require_payment = template.require_payment
if template.note:
if not is_html_empty(template.note):
self.note = template.note
def action_confirm(self):
@@ -17,7 +17,7 @@ class SaleOrderTemplate(models.Model):
name = fields.Char('Quotation Template', required=True)
sale_order_template_line_ids = fields.One2many('sale.order.template.line', 'sale_order_template_id', 'Lines', copy=True)
note = fields.Text('Terms and conditions', translate=True)
note = fields.Html('Terms and conditions', translate=True)
sale_order_template_option_ids = fields.One2many('sale.order.template.option', 'sale_order_template_id', 'Optional Products', copy=True)
number_of_days = fields.Integer('Quotation Duration',
help='Number of days for the validity date computation of the quotation')
@@ -71,7 +71,7 @@
<div t-field="option_line.website_description" class="oe_no_empty"/>
</t>
</t>
<section id="terms" class="container" t-if="template.note">
<section id="terms" class="container" t-if="not is_html_empty(template.note)">
<h1 t-ignore="True">Terms &amp; Conditions</h1>
<p t-field="template.note"/>
</section>
+2 -2
View File
@@ -626,8 +626,8 @@ class ProductTemplate(models.Model):
('lot', 'By Lots'),
('none', 'No Tracking')], string="Tracking", help="Ensure the traceability of a storable product in your warehouse.", default='none', required=True)
description_picking = fields.Text('Description on Picking', translate=True)
description_pickingout = fields.Text('Description on Delivery Orders', translate=True)
description_pickingin = fields.Text('Description on Receptions', translate=True)
description_pickingout = fields.Html('Description on Delivery Orders', translate=True)
description_pickingin = fields.Html('Description on Receptions', translate=True)
qty_available = fields.Float(
'Quantity On Hand', compute='_compute_quantities', search='_search_qty_available',
compute_sudo=False, digits='Product Unit of Measure')
+1 -1
View File
@@ -51,7 +51,7 @@ class Location(models.Model):
'stock.location', 'Parent Location', index=True, ondelete='cascade', check_company=True,
help="The parent location that includes this location. Example : The 'Dispatch Zone' is the 'Gate 1' parent location.")
child_ids = fields.One2many('stock.location', 'location_id', 'Contains')
comment = fields.Text('Additional Information')
comment = fields.Html('Additional Information')
posx = fields.Integer('Corridor (X)', default=0, help="Optional localization details, for information purpose only")
posy = fields.Integer('Shelves (Y)', default=0, help="Optional localization details, for information purpose only")
posz = fields.Integer('Height (Z)', default=0, help="Optional localization details, for information purpose only")
+1 -1
View File
@@ -255,7 +255,7 @@ class Picking(models.Model):
'Source Document', index=True,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
help="Reference of the document")
note = fields.Text('Notes')
note = fields.Html('Notes')
backorder_id = fields.Many2one(
'stock.picking', 'Back Order of',
copy=False, index=True, readonly=True,
+2 -2
View File
@@ -29,8 +29,8 @@ class BaseDocumentLayout(models.TransientModel):
logo = fields.Binary(related='company_id.logo', readonly=False)
preview_logo = fields.Binary(related='logo', string="Preview logo")
report_header = fields.Text(related='company_id.report_header', readonly=False)
report_footer = fields.Text(related='company_id.report_footer', readonly=False)
report_header = fields.Html(related='company_id.report_header', readonly=False)
report_footer = fields.Html(related='company_id.report_footer', readonly=False)
# The paper format changes won't be reflected in the preview.
paperformat_id = fields.Many2one(related='company_id.paperformat_id', readonly=False)
@@ -34,15 +34,11 @@ function _renderImageOnCanvas( data, formattype, rerendercallable ) {
img.src = 'data:' + formattype + ',' + data;
}
// Override
// Supported image types
const imageTypes = [
[
'image',
'image/png;base64',
'image/jpeg;base64',
'image/jpg;base64',
];
// Override
for (const imageType of imageTypes) {
$.fn.jSignature("addPlugin", "import", imageType, _renderImageOnCanvas);
}
].forEach(imageType => $.fn.jSignature("addPlugin", "import", imageType, _renderImageOnCanvas));
@@ -87,6 +87,22 @@ var KanbanRecord = Widget.extend(WidgetAdapterMixin, {
// Public
//--------------------------------------------------------------------------
/**
* Check if a html content is empty. If there are only formatting tags with
* style attributes or a void content. Famous use case is
* '<p style="..." class=".."><br></p>' added by some web editor(s).
* Note that because the use of this method is limited, we ignore the cases
* like there's one <img> tag in the content. In such case, even if it's the
* actual content, we consider it empty.
*
* @param {string} htmlContent
* @returns {boolean} true if no content found or if containing only formatting tags
*/
isHtmlEmpty: function (htmlContent) {
let div = document.createElement('div');
div.innerHTML = htmlContent || "";
return div.innerText.trim() === "";
},
/**
* Re-renders the record with a new state
*
+11
View File
@@ -92,6 +92,17 @@ table {
}
}
// TODO: investigate further for proper fix
// By default there is 40px padding on the lists (both <ol> and <ul>) and it is
// also the case with HTML reports, but when PDFs are rendered with Wkhtmltopdf,
// the padding is not applied on <ol>, it is strangely applied on <ul> only.
// So we simply remove the left padding, and apply left margin instead which
// seems to do the job while not breaking layout in both html/pdf.
ol {
margin-left: 40px;
padding-left: 0;
}
// Wkhtmltopdf doesn't handle flexbox properly, both the content
// of columns and columns themselves does not wrap over new lines
// when needed: the font of the pdf will reduce to make the content
@@ -7528,6 +7528,43 @@ QUnit.module('Views', {
kanban.destroy();
});
QUnit.test('kanban with isHtmlEmpty method', async function (assert) {
assert.expect(3);
this.data.product.fields.description = {string: 'Description', type: 'html'};
this.data.product.records.push(
{id: 11, display_name: "product 11", description: "<span class='text-info'>hello</hello>"},
{id: 12, display_name: "product 12", description: "<p class='a'><span style='color:red;'/><br/></p>"},
);
var kanban = await createView({
View: KanbanView,
model: 'product',
data: this.data,
arch: `<kanban>
<field name="description"/>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="display_name"/>
<div class="test" t-if="!widget.isHtmlEmpty(record.description.raw_value)">
<t t-raw="record.description.raw_value"/>
</div>
</div>
</t></templates>
</kanban>`,
domain: [['id', 'in', [11, 12]]],
});
assert.containsOnce(kanban, '.o_kanban_record:first div.test',
"the container is displayed if description have actual content");
assert.strictEqual(kanban.$('.o_kanban_record:first div.test span.text-info').html().trim(), 'hello',
"the inner html content is rendered properly");
assert.containsNone(kanban, '.o_kanban_record:last div.test',
"the container is not displayed if description just have formatting tags and no actual content");
kanban.destroy();
});
});
});
@@ -24,7 +24,7 @@
</button>
</div>
<field name="font" widget="font"/>
<field name="report_header" widget="char" placeholder="e.g. Global Business Solutions" />
<field name="report_header" placeholder="e.g. Global Business Solutions" />
<field name="report_footer" string="Footer" placeholder="e.g. Opening hours, bank accounts (one per line)" />
<field name="paperformat_id" required="1" />
</group>
@@ -7,18 +7,18 @@ from odoo import models, _
class EventRegistration(models.Model):
_inherit = 'event.registration'
def _get_lead_description_registration(self, prefix='', line_suffix=''):
def _get_lead_description_registration(self, line_suffix=''):
"""Add the questions and answers linked to the registrations into the description of the lead."""
reg_description = super(EventRegistration, self)._get_lead_description_registration(prefix=prefix, line_suffix=line_suffix)
reg_description = super(EventRegistration, self)._get_lead_description_registration(line_suffix=line_suffix)
if not self.registration_answer_ids:
return reg_description
answer_descriptions = []
for answer in self.registration_answer_ids:
answer_value = answer.value_answer_id.name if answer.question_type == "simple_choice" else answer.value_text_box
answer_value = "\n".join([" %s" % line for line in answer_value.split('\n')])
answer_descriptions.append(" - %s\n%s" % (answer.question_id.title, answer_value))
return "%s\n%s\n%s" % (reg_description, _("Questions"), '\n'.join(answer_descriptions))
answer_value = "<br/>".join([" %s" % line for line in answer_value.split('\n')])
answer_descriptions.append(" - %s<br/>%s" % (answer.question_id.title, answer_value))
return "%s%s<br/>%s" % (reg_description, _("Questions"), '<br/>'.join(answer_descriptions))
def _get_lead_description_fields(self):
res = super(EventRegistration, self)._get_lead_description_fields()
+5 -5
View File
@@ -3,16 +3,13 @@
import base64
import json
import pytz
from datetime import datetime
from psycopg2 import IntegrityError
from werkzeug.exceptions import BadRequest
from odoo import http, SUPERUSER_ID, _
from odoo.http import request
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools.translate import _
from odoo.tools import plaintext2html
from odoo.exceptions import ValidationError, UserError
from odoo.addons.base.models.ir_qweb_fields import nl2br
@@ -100,6 +97,9 @@ class WebsiteForm(http.Controller):
def floating(self, field_label, field_input):
return float(field_input)
def html(self, field_label, field_input):
return plaintext2html(field_input)
def boolean(self, field_label, field_input):
return bool(field_input)
@@ -115,7 +115,7 @@ class WebsiteForm(http.Controller):
_input_filters = {
'char': identity,
'text': identity,
'html': identity,
'html': html,
'date': identity,
'datetime': identity,
'many2one': integer,
@@ -52,10 +52,10 @@
<h5 t-if="job.no_of_recruitment &gt; 1">
<t t-esc="job.no_of_recruitment"/> open positions
</h5>
<p t-if="editable"
<div t-if="editable"
t-field="job.description"
class="mt16 mb0 css_non_editable_mode_hidden"/>
<p t-esc="len((job.description or '').split(' ')) > 35 and '%s ...' % ' '.join(job.description.split(' ')[:35]) or job.description"
<div t-esc="len((job.description or '').split(' ')) > 35 and '%s ...' % ' '.join(job.description.split(' ')[:35]) or job.description"
class="mt16 mb0 css_editable_mode_hidden"
/>
<div class="o_job_infos mt16">
@@ -17,7 +17,7 @@
<div class="media-body px-3">
<t t-set="description" t-value="widget.displayDescription and product['description_sale']"/>
<h6 t-attf-class="font-weight-bold #{description ? '' : 'mb-0'}" t-esc="product['name']"/>
<p t-if="description" class="mb-0" t-esc="description"/>
<p t-if="description" class="mb-0" t-raw="description"/>
</div>
<div t-if="widget.displayPrice" class="flex-shrink-0">
<t t-if="product['has_discounted_price']">
@@ -11,6 +11,7 @@ from odoo import api, fields, models, tools, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.exceptions import AccessError
from odoo.osv import expression
from odoo.tools import is_html_empty
_logger = logging.getLogger(__name__)
@@ -148,8 +149,8 @@ class Channel(models.Model):
# description
name = fields.Char('Name', translate=True, required=True)
active = fields.Boolean(default=True, tracking=100)
description = fields.Text('Description', translate=True, help="The description that is displayed on top of the course page, just below the title")
description_short = fields.Text('Short Description', translate=True, help="The description that is displayed on the course card")
description = fields.Html('Description', translate=True, help="The description that is displayed on top of the course page, just below the title")
description_short = fields.Html('Short Description', translate=True, help="The description that is displayed on the course card")
description_html = fields.Html('Detailed Description', translate=tools.html_translate, sanitize_attributes=False, sanitize_form=False)
channel_type = fields.Selection([
('training', 'Training'), ('documentation', 'Documentation')],
@@ -455,7 +456,7 @@ class Channel(models.Model):
vals['channel_partner_ids'] = [(0, 0, {
'partner_id': self.env.user.partner_id.id
})]
if vals.get('description') and not vals.get('description_short'):
if not is_html_empty(vals.get('description')) and is_html_empty(vals.get('description_short')):
vals['description_short'] = vals['description']
channel = super(Channel, self.with_context(mail_create_nosubscribe=True)).create(vals)
@@ -468,7 +469,7 @@ class Channel(models.Model):
def write(self, vals):
# If description_short wasn't manually modified, there is an implicit link between this field and description.
if vals.get('description') and not vals.get('description_short') and self.description == self.description_short:
if not is_html_empty(vals.get('description')) and is_html_empty(vals.get('description_short')) and self.description == self.description_short:
vals['description_short'] = vals.get('description')
res = super(Channel, self).write(vals)
+4 -4
View File
@@ -18,7 +18,7 @@ from odoo.addons.http_routing.models.ir_http import slug
from odoo.exceptions import Warning, UserError, AccessError
from odoo.http import request
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.tools import sql
from odoo.tools import html2plaintext, sql
class SlidePartnerRelation(models.Model):
@@ -132,7 +132,7 @@ class Slide(models.Model):
active = fields.Boolean(default=True, tracking=100)
sequence = fields.Integer('Sequence', default=0)
user_id = fields.Many2one('res.users', string='Uploaded by', default=lambda self: self.env.uid)
description = fields.Text('Description', translate=True)
description = fields.Html('Description', translate=True)
channel_id = fields.Many2one('slide.channel', string="Course", required=True)
tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags')
is_preview = fields.Boolean('Allow Preview', default=False, help="The course is accessible by anyone : the users don't need to join the channel to access the content of the course.")
@@ -876,9 +876,9 @@ class Slide(models.Model):
def _default_website_meta(self):
res = super(Slide, self)._default_website_meta()
res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description
res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = html2plaintext(self.description)
res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
res['default_meta_description'] = self.description
res['default_meta_description'] = html2plaintext(self.description)
return res
# ---------------------------------------------------------
@@ -130,7 +130,7 @@
<div class="col-12 col-md-8 col-lg-9 d-flex flex-column">
<div class="d-flex flex-column">
<h1 t-field="channel.name"/>
<p class="mb-0 mb-xl-3" t-field="channel.description"/>
<div class="mb-0 mb-xl-3" t-field="channel.description"/>
<div t-if="channel.channel_type == 'documentation'" class="d-flex mb-md-5">
<button role="button" class="btn text-white pl-0" title="Share Channel"
+2 -1
View File
@@ -6,7 +6,7 @@ from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError
from odoo.tools.safe_eval import safe_eval, time
from odoo.tools.misc import find_in_path
from odoo.tools import config
from odoo.tools import config, is_html_empty
from odoo.sql_db import TestCursor
from odoo.http import request
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN
@@ -886,6 +886,7 @@ class IrActionsReport(models.Model):
'doc_model': self_sudo.model,
'docs': docs,
})
data['is_html_empty'] = is_html_empty
return data
def _render(self, res_ids, data=None):
+2 -2
View File
@@ -61,8 +61,8 @@ class Company(models.Model):
parent_id = fields.Many2one('res.company', string='Parent Company', index=True)
child_ids = fields.One2many('res.company', 'parent_id', string='Child Companies')
partner_id = fields.Many2one('res.partner', string='Partner', required=True)
report_header = fields.Text(string='Company Tagline', help="Appears by default on the top right corner of your printed documents (report header).")
report_footer = fields.Text(string='Report Footer', translate=True, help="Footer text displayed at the bottom of all reports.")
report_header = fields.Html(string='Company Tagline', help="Appears by default on the top right corner of your printed documents (report header).")
report_footer = fields.Html(string='Report Footer', translate=True, help="Footer text displayed at the bottom of all reports.")
logo = fields.Binary(related='partner_id.image_1920', default=_get_logo, string="Company Logo", readonly=False)
# logo_web: do not store in attachments, since the image is retrieved in SQL for
# performance reasons (see addons/web/controllers/main.py, Binary.company_logo)
+1 -1
View File
@@ -173,7 +173,7 @@ class Partner(models.Model):
same_vat_partner_id = fields.Many2one('res.partner', string='Partner with same Tax ID', compute='_compute_same_vat_partner_id', store=False)
bank_ids = fields.One2many('res.partner.bank', 'partner_id', string='Banks')
website = fields.Char('Website Link')
comment = fields.Text(string='Notes')
comment = fields.Html(string='Notes')
category_id = fields.Many2many('res.partner.category', column1='partner_id',
column2='category_id', string='Tags', default=_default_category)
+3 -2
View File
@@ -35,6 +35,7 @@ class TestServerActionsBase(common.TransactionCase):
# Model data
Model = self.env['ir.model']
Fields = self.env['ir.model.fields']
self.comment_html = '<p>MyComment</p>'
self.res_partner_model = Model.search([('model', '=', 'res.partner')])
self.res_partner_name_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'name')])
self.res_partner_city_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'city')])
@@ -54,7 +55,7 @@ class TestServerActionsBase(common.TransactionCase):
'model_id': self.res_partner_model.id,
'model_name': 'res.partner',
'state': 'code',
'code': 'record.write({"comment": "MyComment"})',
'code': 'record.write({"comment": "%s"})' % self.comment_html,
})
@@ -62,7 +63,7 @@ class TestServerActions(TestServerActionsBase):
def test_00_action(self):
self.action.with_context(self.context).run()
self.assertEqual(self.test_partner.comment, 'MyComment', 'ir_actions_server: invalid condition check')
self.assertEqual(self.test_partner.comment, self.comment_html, 'ir_actions_server: invalid condition check')
self.test_partner.write({'comment': False})
# Do: create contextual action
+1 -1
View File
@@ -329,7 +329,7 @@ class TestHtmlTools(BaseCase):
self.assertTrue(is_html_empty(content))
void_html_samples = ['<p><br></p>', '<p><br> </p>', '<p><br /></p >', '<p style="margin: 4px"></p>',
'<div style="margin: 4px"></div>']
'<div style="margin: 4px"></div>', '<p class="oe_testing"><br></p>']
for content in void_html_samples:
self.assertTrue(is_html_empty(content), 'Failed with %s' % content)
+1 -1
View File
@@ -279,7 +279,7 @@ def is_html_empty(html_content):
"""
if not html_content:
return True
tag_re = re.compile(r'\<\s*\/?(?:p|div|span|br|b|i)\s*(?:style=\".+\")?/?\s*\>')
tag_re = re.compile(r'\<\s*\/?(?:p|div|span|br|b|i)(?:(?=\s+\w*)[^/>]*|\s*)/?\s*\>')
return not bool(re.sub(tag_re, '', html_content).strip())