mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-19 09:12:04 +02:00
replace stale web_editor with html_editor and html_builder for 19.0
web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.
🤖 assisted by claude
This commit is contained in:
parent
4b94f0abc5
commit
f866779561
1513 changed files with 396049 additions and 358525 deletions
|
|
@ -0,0 +1,10 @@
|
|||
from . import ir_attachment
|
||||
from . import ir_http
|
||||
from . import ir_qweb_fields
|
||||
from . import ir_ui_view
|
||||
from . import ir_websocket
|
||||
|
||||
from . import models
|
||||
from . import test_models
|
||||
|
||||
from . import html_field_history_mixin
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from difflib import SequenceMatcher, unified_diff
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Patch and comparison functions
|
||||
# ------------------------------------------------------------
|
||||
|
||||
|
||||
OPERATION_SEPARATOR = "\n"
|
||||
LINE_SEPARATOR = "<"
|
||||
|
||||
PATCH_OPERATION_LINE_AT = "@"
|
||||
PATCH_OPERATION_CONTENT = ":"
|
||||
|
||||
PATCH_OPERATION_ADD = "+"
|
||||
PATCH_OPERATION_REMOVE = "-"
|
||||
PATCH_OPERATION_REPLACE = "R"
|
||||
|
||||
PATCH_OPERATIONS = dict(
|
||||
insert=PATCH_OPERATION_ADD,
|
||||
delete=PATCH_OPERATION_REMOVE,
|
||||
replace=PATCH_OPERATION_REPLACE,
|
||||
)
|
||||
|
||||
HTML_ATTRIBUTES_TO_REMOVE = ["data-last-history-steps"]
|
||||
HTML_TAG_ISOLATION_REGEX = r"^([^>]*>)(.*)$"
|
||||
ADDITION_COMPARISON_REGEX = r"\1<added>\2</added>"
|
||||
ADDITION_1ST_REPLACE_COMPARISON_REGEX = r"added>\2</added>"
|
||||
DELETION_COMPARISON_REGEX = r"\1<removed>\2</removed>"
|
||||
EMPTY_OPERATION_TAG = r"<(added|removed)><\/(added|removed)>"
|
||||
SAME_TAG_REPLACE_FIXER = r"<\/added><(?:[^\/>]|(?:><))+><removed>"
|
||||
UNNECESSARY_REPLACE_FIXER = (
|
||||
r"<added>([^<](?!<\/added>)*)<\/added>"
|
||||
r"<removed>([^<](?!<\/removed>)*)<\/removed>"
|
||||
)
|
||||
|
||||
|
||||
def apply_patch(initial_content, patch):
|
||||
"""Apply a patch (multiple operations) on a content.
|
||||
Each operation is a string with the following format:
|
||||
<operation_type>@<start_index>[,<end_index>][:<patch_text>*]
|
||||
patch format example:
|
||||
+@4:<p>ab</p><p>cd</p>
|
||||
+@4,15:<p>ef</p><p>gh</p>
|
||||
-@32
|
||||
-@125,129
|
||||
R@523:<b>sdf</b>
|
||||
|
||||
:param string initial_content: the initial content to patch
|
||||
:param string patch: the patch to apply
|
||||
|
||||
:return: string: the patched content
|
||||
"""
|
||||
if not patch:
|
||||
return initial_content
|
||||
|
||||
# Replace break line in initial content to ensure they don't interfere with
|
||||
# operations
|
||||
initial_content = initial_content.replace("\n", "")
|
||||
initial_content = _remove_html_attribute(
|
||||
initial_content, HTML_ATTRIBUTES_TO_REMOVE
|
||||
)
|
||||
|
||||
content = initial_content.split(LINE_SEPARATOR)
|
||||
patch_operations = patch.split(OPERATION_SEPARATOR)
|
||||
# Apply operations in reverse order to preserve the indexes integrity.
|
||||
patch_operations.reverse()
|
||||
|
||||
for operation in patch_operations:
|
||||
metadata, *patch_content_line = operation.split(LINE_SEPARATOR)
|
||||
|
||||
metadata_split = metadata.split(PATCH_OPERATION_LINE_AT)
|
||||
operation_type = metadata_split[0]
|
||||
lines_index_range = metadata_split[1] if len(metadata_split) > 1 else ""
|
||||
# We need to remove PATCH_OPERATION_CONTENT char from lines_index_range.
|
||||
lines_index_range = lines_index_range.split(PATCH_OPERATION_CONTENT)[0]
|
||||
indexes = lines_index_range.split(",")
|
||||
start_index = int(indexes[0])
|
||||
end_index = int(indexes[1]) if len(indexes) > 1 else start_index
|
||||
|
||||
# We need to insert lines from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_content_line.reverse()
|
||||
|
||||
if end_index > start_index:
|
||||
for index in range(end_index, start_index, -1):
|
||||
if operation_type in [
|
||||
PATCH_OPERATION_REMOVE,
|
||||
PATCH_OPERATION_REPLACE,
|
||||
]:
|
||||
del content[index]
|
||||
|
||||
if operation_type in [PATCH_OPERATION_ADD, PATCH_OPERATION_REPLACE]:
|
||||
for line in patch_content_line:
|
||||
content.insert(start_index + 1, line)
|
||||
if operation_type in [PATCH_OPERATION_REMOVE, PATCH_OPERATION_REPLACE]:
|
||||
del content[start_index]
|
||||
|
||||
return LINE_SEPARATOR.join(content)
|
||||
|
||||
|
||||
def generate_comparison(new_content, old_content):
|
||||
"""Compare a content to an older content
|
||||
and generate a comparison html between both content.
|
||||
|
||||
:param string new_content: the current content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the comparison content
|
||||
"""
|
||||
new_content = _remove_html_attribute(new_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
old_content = _remove_html_attribute(old_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
|
||||
if new_content == old_content:
|
||||
return new_content
|
||||
|
||||
patch = generate_patch(new_content, old_content)
|
||||
comparison = new_content.split(LINE_SEPARATOR)
|
||||
patch_operations = patch.split(OPERATION_SEPARATOR)
|
||||
# We need to apply operation from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_operations.reverse()
|
||||
|
||||
for operation in patch_operations:
|
||||
metadata, *patch_content_line = operation.split(LINE_SEPARATOR)
|
||||
|
||||
metadata_split = metadata.split(PATCH_OPERATION_LINE_AT)
|
||||
operation_type = metadata_split[0]
|
||||
lines_index_range = metadata_split[1] if len(metadata_split) > 1 else ""
|
||||
lines_index_range = lines_index_range.split(PATCH_OPERATION_CONTENT)[0]
|
||||
indexes = lines_index_range.split(",")
|
||||
start_index = int(indexes[0])
|
||||
end_index = int(indexes[1]) if len(indexes) > 1 else start_index
|
||||
|
||||
# If the operation is a replace, we need to flag the changes that
|
||||
# will generate ghost opening tags if we don't ignore
|
||||
# them.
|
||||
# this can append when:
|
||||
# * A change concerning only html parameters.
|
||||
# <p class="x">a</p> => <p class="y">a</p>
|
||||
# * An addition in a previously empty element opening tag
|
||||
# <p></p> => <p>a</p>
|
||||
if operation_type == PATCH_OPERATION_REPLACE:
|
||||
for i, line in enumerate(patch_content_line):
|
||||
current_index = start_index + i
|
||||
if current_index > end_index:
|
||||
break
|
||||
|
||||
current_line = comparison[current_index]
|
||||
current_line_tag = current_line.split(">")[0]
|
||||
line_tag = line.split(">")[0]
|
||||
if current_line[-1] == ">" and (
|
||||
current_line_tag == line_tag
|
||||
or current_line_tag.split(" ")[0] == line_tag.split(" ")[0]
|
||||
):
|
||||
comparison[start_index + i] = "delete_me>"
|
||||
|
||||
# We need to insert lines from last to the first
|
||||
# to preserve the indexes integrity.
|
||||
patch_content_line.reverse()
|
||||
|
||||
for index in range(end_index, start_index - 1, -1):
|
||||
if operation_type in [
|
||||
PATCH_OPERATION_REMOVE,
|
||||
PATCH_OPERATION_REPLACE,
|
||||
]:
|
||||
deletion_flagged_comparison = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX,
|
||||
DELETION_COMPARISON_REGEX,
|
||||
comparison[index],
|
||||
)
|
||||
# Only use this line if it doesn't generate an empty
|
||||
# <removed> tag
|
||||
if not re.search(
|
||||
EMPTY_OPERATION_TAG, deletion_flagged_comparison
|
||||
):
|
||||
comparison[index] = deletion_flagged_comparison
|
||||
|
||||
if operation_type == PATCH_OPERATION_ADD:
|
||||
for line in patch_content_line:
|
||||
addition_flagged_line = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX, ADDITION_COMPARISON_REGEX, line
|
||||
)
|
||||
|
||||
if not re.search(EMPTY_OPERATION_TAG, addition_flagged_line):
|
||||
comparison.insert(start_index + 1, addition_flagged_line)
|
||||
else:
|
||||
comparison.insert(start_index + 1, line)
|
||||
|
||||
if operation_type == PATCH_OPERATION_REPLACE:
|
||||
for line in patch_content_line:
|
||||
addition_flagged_line = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX, ADDITION_COMPARISON_REGEX, line
|
||||
)
|
||||
if not re.search(EMPTY_OPERATION_TAG, addition_flagged_line):
|
||||
comparison.insert(start_index, addition_flagged_line)
|
||||
elif (
|
||||
line.split(">")[0] != comparison[start_index].split(">")[0]
|
||||
):
|
||||
comparison.insert(start_index, line)
|
||||
|
||||
final_comparison = LINE_SEPARATOR.join(comparison)
|
||||
# We can remove all the opening tags which are located between the end of an
|
||||
# added tag and the start of a removed tag, because this should never happen
|
||||
# as the added and removed tags should always be near each other.
|
||||
# This can happen when the new container tag had a parameter change.
|
||||
final_comparison = re.sub(
|
||||
SAME_TAG_REPLACE_FIXER, "</added><removed>", final_comparison
|
||||
)
|
||||
|
||||
# Remove al the <delete_me> tags
|
||||
final_comparison = final_comparison.replace(r"<delete_me>", "")
|
||||
|
||||
# This fix the issue of unnecessary replace tags.
|
||||
# ex: <added>abc</added><removed>abc</removed> -> abc
|
||||
# This can occur when the new content is the same as the old content and
|
||||
# their container tags are the same but the tags parameters are different
|
||||
for match in re.finditer(UNNECESSARY_REPLACE_FIXER, final_comparison):
|
||||
if match.group(1) == match.group(2):
|
||||
final_comparison = final_comparison.replace(
|
||||
match.group(0), match.group(1)
|
||||
)
|
||||
|
||||
return final_comparison
|
||||
|
||||
|
||||
def _format_line_index(start, end):
|
||||
"""Format the line index to be used in a patch operation.
|
||||
|
||||
:param start: the start index
|
||||
:param end: the end index
|
||||
:return: string
|
||||
"""
|
||||
length = end - start
|
||||
if not length:
|
||||
start -= 1
|
||||
if length <= 1:
|
||||
return "%s%s" % (PATCH_OPERATION_LINE_AT, start)
|
||||
return "%s%s,%s" % (PATCH_OPERATION_LINE_AT, start, start + length - 1)
|
||||
|
||||
|
||||
def _patch_generator(new_content, old_content):
|
||||
"""Generate a patch (multiple operations) between two contents.
|
||||
Each operation is a string with the following format:
|
||||
<operation_type>@<start_index>[,<end_index>][:<patch_text>*]
|
||||
patch format example:
|
||||
+@4:<p>ab</p><p>cd</p>
|
||||
+@4,15:<p>ef</p><p>gh</p>
|
||||
-@32
|
||||
-@125,129
|
||||
R@523:<b>sdf</b>
|
||||
|
||||
:param string new_content: the new content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the patch containing all the operations to reverse
|
||||
the new content to the old content
|
||||
"""
|
||||
# remove break line in contents to ensure they don't interfere with
|
||||
# operations
|
||||
new_content = new_content.replace("\n", "")
|
||||
old_content = old_content.replace("\n", "")
|
||||
|
||||
new_content_lines = new_content.split(LINE_SEPARATOR)
|
||||
old_content_lines = old_content.split(LINE_SEPARATOR)
|
||||
|
||||
for group in SequenceMatcher(
|
||||
None, new_content_lines, old_content_lines, False
|
||||
).get_grouped_opcodes(0):
|
||||
patch_content_line = []
|
||||
first, last = group[0], group[-1]
|
||||
patch_operation = _format_line_index(first[1], last[2])
|
||||
|
||||
if any(tag in {"replace", "delete"} for tag, _, _, _, _ in group):
|
||||
for tag, _, _, _, _ in group:
|
||||
if tag not in {"insert", "equal", "replace"}:
|
||||
patch_operation = PATCH_OPERATIONS[tag] + patch_operation
|
||||
|
||||
if any(tag in {"replace", "insert"} for tag, _, _, _, _ in group):
|
||||
for tag, _, _, j1, j2 in group:
|
||||
if tag not in {"delete", "equal"}:
|
||||
patch_operation = PATCH_OPERATIONS[tag] + patch_operation
|
||||
for line in old_content_lines[j1:j2]:
|
||||
patch_content_line.append(line)
|
||||
|
||||
if patch_content_line:
|
||||
patch_content = LINE_SEPARATOR + LINE_SEPARATOR.join(
|
||||
patch_content_line
|
||||
)
|
||||
yield str(patch_operation) + PATCH_OPERATION_CONTENT + patch_content
|
||||
else:
|
||||
yield str(patch_operation)
|
||||
|
||||
|
||||
def generate_patch(new_content, old_content):
|
||||
new_content = _remove_html_attribute(new_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
old_content = _remove_html_attribute(old_content, HTML_ATTRIBUTES_TO_REMOVE)
|
||||
|
||||
return OPERATION_SEPARATOR.join(
|
||||
list(_patch_generator(new_content, old_content))
|
||||
)
|
||||
|
||||
|
||||
def _remove_html_attribute(html_content, attributes_to_remove):
|
||||
for attribute in attributes_to_remove:
|
||||
html_content = re.sub(
|
||||
r' %s="[^"]*"' % attribute, "", html_content
|
||||
)
|
||||
|
||||
return html_content
|
||||
|
||||
|
||||
def _indent(content):
|
||||
"""Indent the content using BeautifulSoup.
|
||||
|
||||
:param string content: the content to indent
|
||||
|
||||
:return: string: the indented content
|
||||
"""
|
||||
content = "<document>" + _remove_html_attribute(content, HTML_ATTRIBUTES_TO_REMOVE) + "</document>"
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
return soup.prettify()
|
||||
|
||||
|
||||
def generate_unified_diff(new_content, old_content):
|
||||
"""Generate a unified diff between two contents.
|
||||
|
||||
:param string new_content: the current content
|
||||
:param string old_content: the old content
|
||||
|
||||
:return: string: the unified diff content
|
||||
"""
|
||||
new_content = _indent(new_content)
|
||||
old_content = _indent(old_content)
|
||||
|
||||
return OPERATION_SEPARATOR.join(
|
||||
list(unified_diff(
|
||||
old_content.split(OPERATION_SEPARATOR),
|
||||
new_content.split(OPERATION_SEPARATOR),
|
||||
fromfile='old',
|
||||
tofile='new'
|
||||
))
|
||||
)
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .diff_utils import apply_patch, generate_comparison, generate_patch, generate_unified_diff
|
||||
|
||||
|
||||
class HtmlFieldHistoryMixin(models.AbstractModel):
|
||||
_name = 'html.field.history.mixin'
|
||||
_description = "Field html History"
|
||||
_html_field_history_size_limit = 300
|
||||
|
||||
html_field_history = fields.Json("History data", prefetch=False)
|
||||
|
||||
html_field_history_metadata = fields.Json(
|
||||
"History metadata", compute="_compute_metadata"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_versioned_fields(self):
|
||||
"""This method should be overriden
|
||||
|
||||
:return: List[string]: A list of name of the fields to be versioned
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.depends("html_field_history")
|
||||
def _compute_metadata(self):
|
||||
for rec in self:
|
||||
history_metadata = None
|
||||
if rec.html_field_history:
|
||||
history_metadata = {}
|
||||
for field_name in rec.html_field_history:
|
||||
history_metadata[field_name] = []
|
||||
for revision in rec.html_field_history[field_name]:
|
||||
metadata = revision.copy()
|
||||
metadata.pop("patch")
|
||||
history_metadata[field_name].append(metadata)
|
||||
rec.html_field_history_metadata = history_metadata
|
||||
|
||||
def write(self, vals):
|
||||
rec_db_contents = {}
|
||||
versioned_fields = self._get_versioned_fields()
|
||||
vals_contain_versioned_fields = set(vals).intersection(versioned_fields)
|
||||
|
||||
if vals_contain_versioned_fields:
|
||||
for rec in self:
|
||||
rec_db_contents[rec.id] = {f: rec[f] for f in versioned_fields}
|
||||
|
||||
# Call super().write before generating the patch to be sure we perform
|
||||
# the diff on sanitized data
|
||||
write_result = super().write(vals)
|
||||
|
||||
if not vals_contain_versioned_fields:
|
||||
return write_result
|
||||
|
||||
# allow mutlti record write
|
||||
for rec in self:
|
||||
new_revisions = False
|
||||
fields_data = self.env[rec._name]._fields
|
||||
|
||||
if any(f in vals and not fields_data[f].sanitize for f in versioned_fields):
|
||||
raise ValidationError( # pylint: disable=missing-gettext
|
||||
"Ensure all versioned fields ( %s ) in model %s are declared as sanitize=True"
|
||||
% (str(versioned_fields), rec._name)
|
||||
)
|
||||
|
||||
history_revs = rec.html_field_history or {}
|
||||
|
||||
for field in versioned_fields:
|
||||
new_content = rec[field] or ""
|
||||
|
||||
if field not in history_revs:
|
||||
history_revs[field] = []
|
||||
|
||||
old_content = rec_db_contents[rec.id][field] or ""
|
||||
if new_content != old_content:
|
||||
new_revisions = True
|
||||
patch = generate_patch(new_content, old_content)
|
||||
revision_id = (
|
||||
(history_revs[field][0]["revision_id"] + 1)
|
||||
if history_revs[field]
|
||||
else 1
|
||||
)
|
||||
|
||||
history_revs[field].insert(
|
||||
0,
|
||||
{
|
||||
"patch": patch,
|
||||
"revision_id": revision_id,
|
||||
"create_date": self.env.cr.now().isoformat(),
|
||||
"create_uid": self.env.uid,
|
||||
"create_user_name": self.env.user.name,
|
||||
},
|
||||
)
|
||||
limit = rec._html_field_history_size_limit
|
||||
history_revs[field] = history_revs[field][:limit]
|
||||
# Call super().write again to include the new revision
|
||||
if new_revisions:
|
||||
extra_vals = {"html_field_history": history_revs}
|
||||
write_result = super(HtmlFieldHistoryMixin, rec).write(extra_vals) and write_result
|
||||
|
||||
return write_result
|
||||
|
||||
def html_field_history_get_content_at_revision(self, field_name, revision_id):
|
||||
"""Get the requested field content restored at the revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to restore
|
||||
|
||||
:return: string: the restored content
|
||||
"""
|
||||
self.ensure_one()
|
||||
revisions = [
|
||||
i
|
||||
for i in self.html_field_history[field_name]
|
||||
if i["revision_id"] >= revision_id
|
||||
]
|
||||
|
||||
content = self[field_name] or ""
|
||||
for revision in revisions:
|
||||
content = apply_patch(content, revision["patch"])
|
||||
|
||||
return content
|
||||
|
||||
def html_field_history_get_comparison_at_revision(self, field_name, revision_id):
|
||||
"""For the requested field,
|
||||
Get a comparison between the current content of the field and the
|
||||
content restored at the requested revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to compare
|
||||
|
||||
:return: string: the comparison
|
||||
"""
|
||||
self.ensure_one()
|
||||
restored_content = self.html_field_history_get_content_at_revision(
|
||||
field_name, revision_id
|
||||
)
|
||||
|
||||
return generate_comparison(restored_content, self[field_name] or "")
|
||||
|
||||
def html_field_history_get_unified_diff_at_revision(self, field_name, revision_id):
|
||||
"""For the requested field,
|
||||
Get a unified diff between the current content of the field and the
|
||||
content restored at the requested revision_id.
|
||||
|
||||
:param str field_name: the name of the field
|
||||
:param int revision_id: id of the last revision to compare
|
||||
|
||||
:return: string: the unified diff
|
||||
"""
|
||||
self.ensure_one()
|
||||
restored_content = self.html_field_history_get_content_at_revision(
|
||||
field_name, revision_id
|
||||
)
|
||||
|
||||
return generate_unified_diff(self[field_name] or "", restored_content)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo.tools.image import base64_to_image
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
SUPPORTED_IMAGE_MIMETYPES = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpe': '.jpe',
|
||||
'image/jpeg': '.jpeg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
local_url = fields.Char("Attachment URL", compute='_compute_local_url')
|
||||
image_src = fields.Char(compute='_compute_image_src')
|
||||
image_width = fields.Integer(compute='_compute_image_size')
|
||||
image_height = fields.Integer(compute='_compute_image_size')
|
||||
original_id = fields.Many2one('ir.attachment', string="Original (unoptimized, unresized) attachment", index='btree_not_null')
|
||||
|
||||
def _compute_local_url(self):
|
||||
for attachment in self:
|
||||
if attachment.url:
|
||||
attachment.local_url = attachment.url
|
||||
else:
|
||||
attachment.local_url = '/web/image/%s?unique=%s' % (attachment.id, attachment.checksum)
|
||||
|
||||
@api.depends('mimetype', 'url', 'name')
|
||||
def _compute_image_src(self):
|
||||
for attachment in self:
|
||||
# Only add a src for supported images
|
||||
if not attachment.mimetype or attachment.mimetype.split(';')[0] not in SUPPORTED_IMAGE_MIMETYPES:
|
||||
attachment.image_src = False
|
||||
continue
|
||||
|
||||
if attachment.type == 'url':
|
||||
if attachment.url.startswith('/'):
|
||||
# Local URL
|
||||
attachment.image_src = attachment.url
|
||||
else:
|
||||
name = quote(attachment.name)
|
||||
attachment.image_src = '/web/image/%s-redirect/%s' % (attachment.id, name)
|
||||
else:
|
||||
# Adding unique in URLs for cache-control
|
||||
unique = attachment.checksum[:8]
|
||||
if attachment.url:
|
||||
# For attachments-by-url, unique is used as a cachebuster. They
|
||||
# currently do not leverage max-age headers.
|
||||
separator = '&' if '?' in attachment.url else '?'
|
||||
attachment.image_src = '%s%sunique=%s' % (attachment.url, separator, unique)
|
||||
else:
|
||||
name = quote(attachment.name)
|
||||
attachment.image_src = '/web/image/%s-%s/%s' % (attachment.id, unique, name)
|
||||
|
||||
@api.depends('datas')
|
||||
def _compute_image_size(self):
|
||||
for attachment in self:
|
||||
try:
|
||||
image = base64_to_image(attachment.datas)
|
||||
attachment.image_width = image.width
|
||||
attachment.image_height = image.height
|
||||
except UserError:
|
||||
attachment.image_width = 0
|
||||
attachment.image_height = 0
|
||||
|
||||
def _get_media_info(self):
|
||||
"""Return a dict with the values that we need on the media dialog."""
|
||||
self.ensure_one()
|
||||
return self._read_format(['id', 'name', 'description', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'])[0]
|
||||
|
||||
def _can_bypass_rights_on_media_dialog(self, **attachment_data):
|
||||
""" This method is meant to be overridden, for instance to allow to
|
||||
create image attachment despite the user not allowed to create
|
||||
attachment, eg:
|
||||
- Portal user uploading an image on the forum (bypass acl)
|
||||
- Non admin user uploading an unsplash image (bypass binary/url check)
|
||||
"""
|
||||
return False
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
CONTEXT_KEYS = ['editable', 'edit_translations', 'translatable']
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = "ir.http"
|
||||
|
||||
@classmethod
|
||||
def _get_editor_context(cls):
|
||||
""" Check for ?editable and stuff in the query-string """
|
||||
return {
|
||||
key: True
|
||||
for key in CONTEXT_KEYS
|
||||
if key in request.httprequest.args and key not in request.env.context
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, args):
|
||||
super()._pre_dispatch(rule, args)
|
||||
ctx = cls._get_editor_context()
|
||||
request.update_context(**ctx)
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
return ["html_editor", *super()._get_translation_frontend_modules_name()]
|
||||
|
|
@ -0,0 +1,710 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
"""
|
||||
Web_editor-context rendering needs to add some metadata to rendered and allow to edit fields,
|
||||
as well as render a few fields differently.
|
||||
|
||||
Also, adds methods to convert values back to Odoo models.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import babel
|
||||
import pytz
|
||||
import requests
|
||||
from lxml import etree, html
|
||||
from markupsafe import Markup, escape_silent
|
||||
from PIL import Image as I
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, models, fields
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import posix_to_ldml
|
||||
from odoo.tools.json import scriptsafe as json_safe
|
||||
from odoo.tools.misc import file_open, get_lang, babel_locale_parse
|
||||
|
||||
REMOTE_CONNECTION_TIMEOUT = 2.5
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrQweb(models.AbstractModel):
|
||||
""" IrQweb object for rendering editor stuff
|
||||
"""
|
||||
_inherit = 'ir.qweb'
|
||||
|
||||
def _compile_node(self, el, compile_context, level):
|
||||
snippet_key = compile_context.get('snippet-key')
|
||||
|
||||
template = compile_context['ref_name']
|
||||
sub_call_key = compile_context.get('snippet-sub-call-key')
|
||||
|
||||
# We only add the 'data-snippet' & 'data-name' attrib once when
|
||||
# compiling the root node of the template.
|
||||
if not template or template not in {snippet_key, sub_call_key} or el.getparent() is not None:
|
||||
return super()._compile_node(el, compile_context, level)
|
||||
|
||||
snippet_base_node = el
|
||||
if el.tag == 't':
|
||||
el_children = [child for child in list(el) if isinstance(child.tag, str) and child.tag != 't']
|
||||
if len(el_children) == 1:
|
||||
snippet_base_node = el_children[0]
|
||||
elif not el_children:
|
||||
# If there's not a valid base node we check if the base node is
|
||||
# a t-call to another template. If so the called template's base
|
||||
# node must take the current snippet key.
|
||||
el_children = [child for child in list(el) if isinstance(child.tag, str)]
|
||||
if len(el_children) == 1:
|
||||
sub_call = el_children[0].get('t-call')
|
||||
if sub_call:
|
||||
el_children[0].set('t-options', f"{{'snippet-key': '{snippet_key}', 'snippet-sub-call-key': '{sub_call}'}}")
|
||||
# If it already has a data-snippet it is a saved or an
|
||||
# inherited snippet. Do not override it.
|
||||
if 'data-snippet' not in snippet_base_node.attrib:
|
||||
snippet_base_node.attrib['data-snippet'] = \
|
||||
snippet_key.split('.', 1)[-1]
|
||||
# If it already has a data-name it is a saved or an
|
||||
# inherited snippet. Do not override it.
|
||||
snippet_name = compile_context.get('snippet-name')
|
||||
if snippet_name and 'data-name' not in snippet_base_node.attrib:
|
||||
snippet_base_node.attrib['data-name'] = snippet_name
|
||||
return super()._compile_node(el, compile_context, level)
|
||||
|
||||
def _get_preload_attribute_xmlids(self):
|
||||
return super()._get_preload_attribute_xmlids() + ['t-snippet', 't-snippet-call']
|
||||
|
||||
# compile directives
|
||||
|
||||
def _compile_directive_snippet(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-snippet')
|
||||
el.set('t-call', key)
|
||||
snippet_lang = self.env.context.get('snippet_lang')
|
||||
if snippet_lang:
|
||||
el.set('t-lang', f"'{snippet_lang}'")
|
||||
|
||||
el.set('t-options', f"{{'snippet-key': {key!r}}}")
|
||||
view = self.env['ir.ui.view']._get_template_view(key)
|
||||
name = el.attrib.pop('string', view.name)
|
||||
thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail")
|
||||
image_preview = el.attrib.pop('t-image-preview', None)
|
||||
# Forbid sanitize contains the specific reason:
|
||||
# - "true": always forbid
|
||||
# - "form": forbid if forms are sanitized
|
||||
forbid_sanitize = el.attrib.pop('t-forbid-sanitize', None)
|
||||
grid_column_span = el.attrib.pop('t-grid-column-span', None)
|
||||
snippet_group = el.attrib.pop('snippet-group', None)
|
||||
group = el.attrib.pop('group', None)
|
||||
label = el.attrib.pop('label', None)
|
||||
div = Markup('<div name="%s" data-oe-type="snippet" data-o-image-preview="%s" data-oe-thumbnail="%s" data-oe-snippet-id="%s" data-oe-snippet-key="%s" data-oe-keywords="%s" %s %s %s %s %s>') % (
|
||||
name,
|
||||
escape_silent(image_preview),
|
||||
thumbnail,
|
||||
view.id,
|
||||
key.split('.')[-1],
|
||||
escape_silent(el.findtext('keywords')),
|
||||
Markup('data-oe-forbid-sanitize="%s"') % forbid_sanitize if forbid_sanitize else '',
|
||||
Markup('data-o-grid-column-span="%s"') % grid_column_span if grid_column_span else '',
|
||||
Markup('data-o-snippet-group="%s"') % snippet_group if snippet_group else '',
|
||||
Markup('data-o-group="%s"') % group if group else '',
|
||||
Markup('data-o-label="%s"') % label if label else '',
|
||||
)
|
||||
self._append_text(div, compile_context)
|
||||
code = self._compile_node(el, compile_context, indent)
|
||||
self._append_text('</div>', compile_context)
|
||||
return code
|
||||
|
||||
def _compile_directive_snippet_call(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-snippet-call')
|
||||
snippet_name = el.attrib.pop('string', None)
|
||||
el.set('t-call', key)
|
||||
el.set('t-options', f"{{'snippet-key': {key!r}, 'snippet-name': {snippet_name!r}}}")
|
||||
return self._compile_node(el, compile_context, indent)
|
||||
|
||||
def _compile_directive_install(self, el, compile_context, indent):
|
||||
key = el.attrib.pop('t-install')
|
||||
thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
|
||||
image_preview = el.attrib.pop('t-image-preview', None)
|
||||
group = el.attrib.pop('group', None)
|
||||
label = el.attrib.pop('label', None)
|
||||
if self.env.user.has_group('base.group_system'):
|
||||
module = self.env['ir.module.module'].search([('name', '=', key)])
|
||||
if not module or module.state == 'installed':
|
||||
return []
|
||||
name = el.attrib.get('string') or 'Snippet'
|
||||
div = Markup('<div name="%s" data-oe-type="snippet" data-module-id="%s" data-module-display-name="%s" data-o-image-preview="%s" data-oe-thumbnail="%s" %s %s><section/></div>') % (
|
||||
name,
|
||||
module.id,
|
||||
module.display_name,
|
||||
escape_silent(image_preview),
|
||||
thumbnail,
|
||||
Markup('data-o-group="%s"') % group if group else '',
|
||||
Markup('data-o-label="%s"') % label if label else '',
|
||||
)
|
||||
self._append_text(div, compile_context)
|
||||
return []
|
||||
|
||||
def _compile_directive_placeholder(self, el, compile_context, indent):
|
||||
el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
|
||||
return []
|
||||
|
||||
# order and ignore
|
||||
|
||||
def _directives_eval_order(self):
|
||||
directives = super()._directives_eval_order()
|
||||
# Insert before "att" as those may rely on static attributes like
|
||||
# "string" and "att" clears all of those
|
||||
index = directives.index('att') - 1
|
||||
directives.insert(index, 'placeholder')
|
||||
directives.insert(index, 'snippet')
|
||||
directives.insert(index, 'snippet-call')
|
||||
directives.insert(index, 'install')
|
||||
return directives
|
||||
|
||||
def _get_template_cache_keys(self):
|
||||
return super()._get_template_cache_keys() + ['snippet_lang']
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# QWeb fields
|
||||
# ------------------------------------------------------
|
||||
|
||||
|
||||
class IrQwebField(models.AbstractModel):
|
||||
_name = 'ir.qweb.field'
|
||||
_description = 'Qweb Field'
|
||||
_inherit = ['ir.qweb.field']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
field = record._fields[field_name]
|
||||
|
||||
placeholder = options.get('placeholder') or getattr(field, 'placeholder', None)
|
||||
if placeholder:
|
||||
attrs['placeholder'] = placeholder
|
||||
|
||||
if options['translate'] and field.type in ('char', 'text'):
|
||||
lang = record.env.lang or 'en_US'
|
||||
base_lang = record._get_base_lang()
|
||||
if lang == base_lang:
|
||||
attrs['data-oe-translation-state'] = 'translated'
|
||||
else:
|
||||
base_value = record.with_context(lang=base_lang)[field_name]
|
||||
value = record[field_name]
|
||||
attrs['data-oe-translation-state'] = 'translated' if base_value != value else 'to_translate'
|
||||
|
||||
return attrs
|
||||
|
||||
def value_from_string(self, value):
|
||||
return value
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
return self.value_from_string(element.text_content().strip()) or False
|
||||
|
||||
|
||||
class IrQwebFieldInteger(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.integer'
|
||||
_description = 'Qweb Field Integer'
|
||||
_inherit = ['ir.qweb.field.integer']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
value = element.text_content().strip()
|
||||
return int(value.replace(lang.thousands_sep or '', ''))
|
||||
|
||||
|
||||
class IrQwebFieldFloat(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.float'
|
||||
_description = 'Qweb Field Float'
|
||||
_inherit = ['ir.qweb.field.float']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
value = element.text_content().strip()
|
||||
return float(value.replace(lang.thousands_sep or '', '')
|
||||
.replace(lang.decimal_point, '.'))
|
||||
|
||||
|
||||
class IrQwebFieldMany2one(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.many2one'
|
||||
_description = 'Qweb Field Many to One'
|
||||
_inherit = ['ir.qweb.field.many2one']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
field = record._fields[field_name]
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
many2one = record[field_name]
|
||||
if many2one:
|
||||
attrs['data-oe-many2one-id'] = many2one.id
|
||||
attrs['data-oe-many2one-model'] = many2one._name
|
||||
if options.get('null_text'):
|
||||
attrs['data-oe-many2one-allowreset'] = 1
|
||||
if not many2one:
|
||||
attrs['data-oe-many2one-model'] = record._fields[field_name].comodel_name
|
||||
attrs['data-oe-many2one-domain'] = json_safe.dumps(field._description_domain(self.env))
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
Model = self.env[element.get('data-oe-model')]
|
||||
id = int(element.get('data-oe-id'))
|
||||
M2O = self.env[field.comodel_name]
|
||||
field_name = element.get('data-oe-field')
|
||||
many2one_id = int(element.get('data-oe-many2one-id'))
|
||||
|
||||
allow_reset = element.get('data-oe-many2one-allowreset')
|
||||
if allow_reset and not many2one_id:
|
||||
# Reset the id of the many2one
|
||||
Model.browse(id).write({field_name: False})
|
||||
return None
|
||||
|
||||
record = many2one_id and M2O.browse(many2one_id)
|
||||
if record and record.exists():
|
||||
# save the new id of the many2one
|
||||
Model.browse(id).write({field_name: many2one_id})
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class IrQwebFieldContact(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.contact'
|
||||
_description = 'Qweb Field Contact'
|
||||
_inherit = ['ir.qweb.field.contact']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-contact-options'] = json.dumps(options)
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def get_record_to_html(self, contact_ids, options=None):
|
||||
""" Helper to call the rendering of contact field. """
|
||||
return self.value_to_html(self.env['res.partner'].search([('id', '=', contact_ids[0])]), options=options)
|
||||
|
||||
|
||||
class IrQwebFieldDate(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.date'
|
||||
_description = 'Qweb Field Date'
|
||||
_inherit = ['ir.qweb.field.date']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-original'] = record[field_name]
|
||||
|
||||
if record._fields[field_name].type == 'datetime':
|
||||
attrs = self.env['ir.qweb.field.datetime'].attributes(record, field_name, options, values)
|
||||
attrs['data-oe-type'] = 'datetime'
|
||||
return attrs
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
locale = babel_locale_parse(lg.code)
|
||||
babel_format = value_format = posix_to_ldml(lg.date_format, locale=locale)
|
||||
|
||||
if record[field_name]:
|
||||
date = fields.Date.from_string(record[field_name])
|
||||
value_format = babel.dates.format_date(date, format=babel_format, locale=locale)
|
||||
|
||||
attrs['data-oe-original-with-format'] = value_format
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
date = datetime.strptime(value, lg.date_format)
|
||||
return fields.Date.to_string(date)
|
||||
|
||||
|
||||
class IrQwebFieldDatetime(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.datetime'
|
||||
_description = 'Qweb Field Datetime'
|
||||
_inherit = ['ir.qweb.field.datetime']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
|
||||
if options.get('inherit_branding'):
|
||||
value = record[field_name]
|
||||
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
locale = babel_locale_parse(lg.code)
|
||||
babel_format = value_format = posix_to_ldml('%s %s' % (lg.date_format, lg.time_format), locale=locale)
|
||||
tz = record.env.context.get('tz') or self.env.user.tz
|
||||
|
||||
if isinstance(value, str):
|
||||
value = fields.Datetime.from_string(value)
|
||||
|
||||
if value:
|
||||
# convert from UTC (server timezone) to user timezone
|
||||
value = fields.Datetime.context_timestamp(self.with_context(tz=tz), timestamp=value)
|
||||
value_format = babel.dates.format_datetime(value, format=babel_format, locale=locale)
|
||||
value = fields.Datetime.to_string(value)
|
||||
|
||||
attrs['data-oe-original'] = value
|
||||
attrs['data-oe-original-with-format'] = value_format
|
||||
attrs['data-oe-original-tz'] = tz
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
if not value:
|
||||
return False
|
||||
|
||||
# parse from string to datetime
|
||||
lg = get_lang(self.env, self.env.user.lang)
|
||||
try:
|
||||
datetime_format = f'{lg.date_format} {lg.time_format}'
|
||||
dt = datetime.strptime(value, datetime_format)
|
||||
except ValueError:
|
||||
raise ValidationError(_("The datetime %(value)s does not match the format %(format)s", value=value, format=datetime_format))
|
||||
|
||||
# convert back from user's timezone to UTC
|
||||
tz_name = element.attrib.get('data-oe-original-tz') or self.env.context.get('tz') or self.env.user.tz
|
||||
if tz_name:
|
||||
try:
|
||||
user_tz = pytz.timezone(tz_name)
|
||||
utc = pytz.utc
|
||||
|
||||
dt = user_tz.localize(dt).astimezone(utc)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to convert the value for a field of the model"
|
||||
" %s back from the user's timezone (%s) to UTC",
|
||||
model, tz_name,
|
||||
exc_info=True)
|
||||
|
||||
# format back to string
|
||||
return fields.Datetime.to_string(dt)
|
||||
|
||||
|
||||
class IrQwebFieldText(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.text'
|
||||
_description = 'Qweb Field Text'
|
||||
_inherit = ['ir.qweb.field.text']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
return html_to_text(element)
|
||||
|
||||
|
||||
class IrQwebFieldSelection(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.selection'
|
||||
_description = 'Qweb Field Selection'
|
||||
_inherit = ['ir.qweb.field.selection']
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
selection = field.get_description(self.env)['selection']
|
||||
for k, v in selection:
|
||||
if value == v:
|
||||
return k
|
||||
|
||||
raise ValueError("No value found for label %s in selection %s" % (
|
||||
value, selection))
|
||||
|
||||
|
||||
class IrQwebFieldHtml(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.html'
|
||||
_description = 'Qweb Field HTML'
|
||||
_inherit = ['ir.qweb.field.html']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
field = record._fields[field_name]
|
||||
if field.sanitize:
|
||||
if field.sanitize_overridable:
|
||||
if record.env.user.has_group('base.group_sanitize_override'):
|
||||
# Don't mark the field as 'sanitize' if the sanitize
|
||||
# is defined as overridable and the user has the right
|
||||
# to do so
|
||||
return attrs
|
||||
else:
|
||||
try:
|
||||
field.convert_to_column_insert(record[field_name], record)
|
||||
except UserError:
|
||||
# The field contains element(s) that would be
|
||||
# removed if sanitized. It means that someone who
|
||||
# was part of a group allowing to bypass the
|
||||
# sanitation saved that field previously. Mark the
|
||||
# field as not editable.
|
||||
attrs['data-oe-sanitize-prevent-edition'] = 1
|
||||
return attrs
|
||||
# The field edition is not fully prevented and the sanitation cannot be bypassed
|
||||
attrs['data-oe-sanitize'] = 'no_block' if field.sanitize_attributes else 1 if field.sanitize_form else 'allow_form'
|
||||
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
content = []
|
||||
if element.text:
|
||||
content.append(element.text)
|
||||
content.extend(html.tostring(child, encoding='unicode')
|
||||
for child in element.iterchildren(tag=etree.Element))
|
||||
return '\n'.join(content)
|
||||
|
||||
|
||||
class IrQwebFieldImage(models.AbstractModel):
|
||||
"""
|
||||
Widget options:
|
||||
|
||||
``class``
|
||||
set as attribute on the generated <img> tag
|
||||
"""
|
||||
_name = 'ir.qweb.field.image'
|
||||
_description = 'Qweb Field Image'
|
||||
_inherit = ['ir.qweb.field.image']
|
||||
|
||||
local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
|
||||
redirect_url_re = re.compile(r'\/web\/image\/\d+-redirect\/')
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
if element.find('img') is None:
|
||||
return False
|
||||
url = element.find('img').get('src')
|
||||
|
||||
url_object = urls.url_parse(url)
|
||||
if url_object.path.startswith('/web/image'):
|
||||
fragments = url_object.path.split('/')
|
||||
query = url_object.decode_query()
|
||||
url_id = fragments[3].split('-')[0]
|
||||
# ir.attachment image urls: /web/image/<id>[-<checksum>][/...]
|
||||
if url_id.isdigit():
|
||||
model = 'ir.attachment'
|
||||
oid = url_id
|
||||
field = 'datas'
|
||||
# url of binary field on model: /web/image/<model>/<id>/<field>[/...]
|
||||
else:
|
||||
model = query.get('model', fragments[3])
|
||||
oid = query.get('id', fragments[4])
|
||||
field = query.get('field', fragments[5])
|
||||
item = self.env[model].browse(int(oid))
|
||||
if self.redirect_url_re.match(url_object.path):
|
||||
return self.load_remote_url(item.url)
|
||||
return item[field]
|
||||
|
||||
if self.local_url_re.match(url_object.path):
|
||||
return self.load_local_url(url)
|
||||
|
||||
return self.load_remote_url(url)
|
||||
|
||||
def load_local_url(self, url):
|
||||
match = self.local_url_re.match(urls.url_parse(url).path)
|
||||
rest = match.group('rest')
|
||||
|
||||
path = os.path.join(
|
||||
match.group('module'), 'static', rest)
|
||||
|
||||
try:
|
||||
with file_open(path, 'rb') as f:
|
||||
# force complete image load to ensure it's valid image data
|
||||
image = I.open(f)
|
||||
image.load()
|
||||
f.seek(0)
|
||||
return base64.b64encode(f.read())
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to load local image %r", url)
|
||||
return None
|
||||
|
||||
def load_remote_url(self, url):
|
||||
try:
|
||||
# should probably remove remote URLs entirely:
|
||||
# * in fields, downloading them without blowing up the server is a
|
||||
# challenge
|
||||
# * in views, may trigger mixed content warnings if HTTPS CMS
|
||||
# linking to HTTP images
|
||||
# implement drag & drop image upload to mitigate?
|
||||
|
||||
req = requests.get(url, timeout=REMOTE_CONNECTION_TIMEOUT)
|
||||
# PIL needs a seekable file-like image so wrap result in IO buffer
|
||||
image = I.open(io.BytesIO(req.content))
|
||||
# force a complete load of the image data to validate it
|
||||
image.load()
|
||||
# We're catching all exceptions because Pillow's exceptions are
|
||||
# directly inheriting from Exception.
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Failed to load remote image %r", url, exc_info=True)
|
||||
return None
|
||||
|
||||
# don't use original data in case weird stuff was smuggled in, with
|
||||
# luck PIL will remove some of it?
|
||||
out = io.BytesIO()
|
||||
image.save(out, image.format)
|
||||
return base64.b64encode(out.getvalue())
|
||||
|
||||
|
||||
class IrQwebFieldMonetary(models.AbstractModel):
|
||||
_inherit = 'ir.qweb.field.monetary'
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
lang = self.user_lang()
|
||||
|
||||
value = element.find('span').text_content().strip()
|
||||
|
||||
return float(value.replace(lang.thousands_sep or '', '')
|
||||
.replace(lang.decimal_point, '.'))
|
||||
|
||||
|
||||
class IrQwebFieldDuration(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.duration'
|
||||
_description = 'Qweb Field Duration'
|
||||
_inherit = ['ir.qweb.field.duration']
|
||||
|
||||
@api.model
|
||||
def attributes(self, record, field_name, options, values=None):
|
||||
attrs = super().attributes(record, field_name, options, values)
|
||||
if options.get('inherit_branding'):
|
||||
attrs['data-oe-original'] = record[field_name]
|
||||
return attrs
|
||||
|
||||
@api.model
|
||||
def from_html(self, model, field, element):
|
||||
value = element.text_content().strip()
|
||||
|
||||
# non-localized value
|
||||
return float(value)
|
||||
|
||||
|
||||
class IrQwebFieldRelative(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.relative'
|
||||
_description = 'Qweb Field Relative'
|
||||
_inherit = ['ir.qweb.field.relative']
|
||||
|
||||
# get formatting from ir.qweb.field.relative but edition/save from datetime
|
||||
|
||||
|
||||
class IrQwebFieldQweb(models.AbstractModel):
|
||||
_name = 'ir.qweb.field.qweb'
|
||||
_description = 'Qweb Field qweb'
|
||||
_inherit = ['ir.qweb.field.qweb']
|
||||
|
||||
|
||||
def html_to_text(element):
|
||||
""" Converts HTML content with HTML-specified line breaks (br, p, div, ...)
|
||||
in roughly equivalent textual content.
|
||||
|
||||
Used to replace and fixup the roundtripping of text and m2o: when using
|
||||
libxml 2.8.0 (but not 2.9.1) and parsing IrQwebFieldHtml with lxml.html.fromstring
|
||||
whitespace text nodes (text nodes composed *solely* of whitespace) are
|
||||
stripped out with no recourse, and fundamentally relying on newlines
|
||||
being in the text (e.g. inserted during user edition) is probably poor form
|
||||
anyway.
|
||||
|
||||
-> this utility function collapses whitespace sequences and replaces
|
||||
nodes by roughly corresponding linebreaks
|
||||
* p are pre-and post-fixed by 2 newlines
|
||||
* br are replaced by a single newline
|
||||
* block-level elements not already mentioned are pre- and post-fixed by
|
||||
a single newline
|
||||
|
||||
ought be somewhat similar (but much less high-tech) to aaronsw's html2text.
|
||||
the latter produces full-blown markdown, our text -> html converter only
|
||||
replaces newlines by <br> elements at this point so we're reverting that,
|
||||
and a few more newline-ish elements in case the user tried to add
|
||||
newlines/paragraphs into the text field
|
||||
|
||||
:param element: lxml.html content
|
||||
:returns: corresponding pure-text output
|
||||
"""
|
||||
|
||||
# output is a list of str | int. Integers are padding requests (in minimum
|
||||
# number of newlines). When multiple padding requests, fold them into the
|
||||
# biggest one
|
||||
output = []
|
||||
_wrap(element, output)
|
||||
|
||||
# remove any leading or tailing whitespace, replace sequences of
|
||||
# (whitespace)\n(whitespace) by a single newline, where (whitespace) is a
|
||||
# non-newline whitespace in this case
|
||||
return re.sub(
|
||||
r'[ \t\r\f]*\n[ \t\r\f]*',
|
||||
'\n',
|
||||
''.join(_realize_padding(output)).strip())
|
||||
|
||||
|
||||
_PADDED_BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6"}
|
||||
# https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
|
||||
_MISC_BLOCK = {"address", "article", "aside", "audio", "blockquote", "canvas",
|
||||
"dd", "dl", "div", "figcaption", "figure", "footer", "form",
|
||||
"header", "hgroup", "hr", "ol", "output", "pre", "section", "tfoot",
|
||||
"ul", "video"}
|
||||
|
||||
|
||||
def _collapse_whitespace(text):
|
||||
""" Collapses sequences of whitespace characters in ``text`` to a single
|
||||
space
|
||||
"""
|
||||
return re.sub(r'\s+', ' ', text)
|
||||
|
||||
|
||||
def _realize_padding(it):
|
||||
""" Fold and convert padding requests: integers in the output sequence are
|
||||
requests for at least n newlines of padding. Runs thereof can be collapsed
|
||||
into the largest requests and converted to newlines.
|
||||
"""
|
||||
padding = 0
|
||||
for item in it:
|
||||
if isinstance(item, int):
|
||||
padding = max(padding, item)
|
||||
continue
|
||||
|
||||
if padding:
|
||||
yield '\n' * padding
|
||||
padding = 0
|
||||
|
||||
yield item
|
||||
# leftover padding irrelevant as the output will be stripped
|
||||
|
||||
|
||||
def _wrap(element, output, wrapper=''):
|
||||
""" Recursively extracts text from ``element`` (via _element_to_text), and
|
||||
wraps it all in ``wrapper``. Extracted text is added to ``output``
|
||||
|
||||
:type wrapper: basestring | int
|
||||
"""
|
||||
output.append(wrapper)
|
||||
if element.text:
|
||||
output.append(_collapse_whitespace(element.text))
|
||||
for child in element:
|
||||
_element_to_text(child, output)
|
||||
output.append(wrapper)
|
||||
|
||||
|
||||
def _element_to_text(e, output):
|
||||
if e.tag == 'br':
|
||||
output.append('\n')
|
||||
elif e.tag in _PADDED_BLOCK:
|
||||
_wrap(e, output, 2)
|
||||
elif e.tag in _MISC_BLOCK:
|
||||
_wrap(e, output, 1)
|
||||
else:
|
||||
# inline
|
||||
_wrap(e, output)
|
||||
|
||||
if e.tail:
|
||||
output.append(_collapse_whitespace(e.tail))
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import uuid
|
||||
from lxml import etree, html
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import ValidationError, MissingError
|
||||
from odoo.fields import Domain
|
||||
from odoo.addons.base.models.ir_ui_view import MOVABLE_BRANDING
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
EDITING_ATTRIBUTES = MOVABLE_BRANDING + [
|
||||
'data-oe-type',
|
||||
'data-oe-expression',
|
||||
'data-oe-translation-id',
|
||||
'data-note-id'
|
||||
]
|
||||
|
||||
|
||||
class IrUiView(models.Model):
|
||||
_inherit = 'ir.ui.view'
|
||||
|
||||
def _get_cleaned_non_editing_attributes(self, attributes):
|
||||
"""
|
||||
Returns a new mapping of attributes -> value without the parts that are
|
||||
not meant to be saved (branding, editing classes, ...). Note that
|
||||
classes are meant to be cleaned on the client side before saving as
|
||||
mostly linked to the related options (so we are not supposed to know
|
||||
which to remove here).
|
||||
|
||||
:param attributes: a mapping of attributes -> value
|
||||
:return: a new mapping of attributes -> value
|
||||
"""
|
||||
attributes = {k: v for k, v in attributes if k not in EDITING_ATTRIBUTES}
|
||||
if 'class' in attributes:
|
||||
classes = attributes['class'].split()
|
||||
attributes['class'] = ' '.join([c for c in classes if c != 'o_editable'])
|
||||
if attributes.get('contenteditable') == 'true':
|
||||
del attributes['contenteditable']
|
||||
return attributes
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Save from html
|
||||
# ------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def extract_embedded_fields(self, arch):
|
||||
return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
|
||||
|
||||
@api.model
|
||||
def extract_oe_structures(self, arch):
|
||||
return arch.xpath('//*[hasclass("oe_structure")][contains(@id, "oe_structure")]')
|
||||
|
||||
@api.model
|
||||
def get_default_lang_code(self):
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def save_embedded_field(self, el):
|
||||
Model = self.env[el.get('data-oe-model')]
|
||||
field = el.get('data-oe-field')
|
||||
|
||||
model = 'ir.qweb.field.' + el.get('data-oe-type')
|
||||
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
|
||||
|
||||
try:
|
||||
value = converter.from_html(Model, Model._fields[field], el)
|
||||
if value is not None:
|
||||
# TODO: batch writes?
|
||||
record = Model.browse(int(el.get('data-oe-id')))
|
||||
if not self.env.context.get('lang') and self.get_default_lang_code():
|
||||
record.with_context(lang=self.get_default_lang_code()).write({field: value})
|
||||
else:
|
||||
record.write({field: value})
|
||||
|
||||
if callable(Model._fields[field].translate):
|
||||
self._copy_custom_snippet_translations(record, field)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_(
|
||||
"Invalid field value for %(field_name)s: %(value)s",
|
||||
field_name=Model._fields[field].string,
|
||||
value=el.text_content().strip(),
|
||||
))
|
||||
|
||||
def save_oe_structure(self, el):
|
||||
self.ensure_one()
|
||||
|
||||
if el.get('id') in self.key:
|
||||
# Do not inherit if the oe_structure already has its own inheriting view
|
||||
return False
|
||||
|
||||
arch = etree.Element('data')
|
||||
xpath = etree.Element('xpath', expr="//*[hasclass('oe_structure')][@id='{}']".format(el.get('id')), position="replace")
|
||||
arch.append(xpath)
|
||||
attributes = self._get_cleaned_non_editing_attributes(el.attrib.items())
|
||||
structure = etree.Element(el.tag, attrib=attributes)
|
||||
structure.text = el.text
|
||||
xpath.append(structure)
|
||||
for child in el.iterchildren(tag=etree.Element):
|
||||
structure.append(copy.deepcopy(child))
|
||||
|
||||
vals = {
|
||||
'inherit_id': self.id,
|
||||
'name': '%s (%s)' % (self.name, el.get('id')),
|
||||
'arch': etree.tostring(arch, encoding='unicode'),
|
||||
'key': '%s_%s' % (self.key, el.get('id')),
|
||||
'type': 'qweb',
|
||||
'mode': 'extension',
|
||||
}
|
||||
vals.update(self._save_oe_structure_hook())
|
||||
oe_structure_view = self.env['ir.ui.view'].create(vals)
|
||||
self._copy_custom_snippet_translations(oe_structure_view, 'arch_db')
|
||||
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _copy_custom_snippet_translations(self, record, html_field):
|
||||
""" Given a ``record`` and its HTML ``field``, detect any
|
||||
usage of a custom snippet and copy its translations.
|
||||
"""
|
||||
lang_value = record[html_field]
|
||||
if not lang_value:
|
||||
return
|
||||
|
||||
try:
|
||||
tree = html.fromstring(lang_value)
|
||||
except etree.ParserError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
for custom_snippet_el in tree.xpath('//*[hasclass("s_custom_snippet")]'):
|
||||
custom_snippet_name = custom_snippet_el.get('data-name')
|
||||
custom_snippet_view = self.search([('name', '=', custom_snippet_name)], limit=1)
|
||||
if custom_snippet_view:
|
||||
self._copy_field_terms_translations(custom_snippet_view, 'arch_db', record, html_field)
|
||||
|
||||
@api.model
|
||||
def _copy_field_terms_translations(self, records_from, name_field_from, record_to, name_field_to):
|
||||
""" Copy model terms translations from ``records_from.name_field_from``
|
||||
to ``record_to.name_field_to`` for all activated languages if the term
|
||||
in ``record_to.name_field_to`` is untranslated (the term matches the
|
||||
one in the current language).
|
||||
|
||||
For instance, copy the translations of a
|
||||
``product.template.html_description`` field to a ``ir.ui.view.arch_db``
|
||||
field.
|
||||
|
||||
The method takes care of read and write access of both records/fields.
|
||||
"""
|
||||
record_to.check_access('write')
|
||||
field_from = records_from._fields[name_field_from]
|
||||
field_to = record_to._fields[name_field_to]
|
||||
record_to._check_field_access(field_to, 'write')
|
||||
|
||||
error_callable_msg = "'translate' property of field %r is not callable"
|
||||
if not callable(field_from.translate):
|
||||
raise TypeError(error_callable_msg % field_from)
|
||||
if not callable(field_to.translate):
|
||||
raise TypeError(error_callable_msg % field_to)
|
||||
if not field_to.store:
|
||||
raise ValueError("Field %r is not stored" % field_to)
|
||||
|
||||
# This will also implicitly check for `read` access rights
|
||||
if not record_to[name_field_to] or not any(records_from.mapped(name_field_from)):
|
||||
return
|
||||
|
||||
lang_env = self.env.lang or 'en_US'
|
||||
langs = {lang for lang, _ in self.env['res.lang'].get_installed()}
|
||||
|
||||
# 1. Get translations
|
||||
records_from.flush_model([name_field_from])
|
||||
existing_translation_dictionary = field_to.get_translation_dictionary(
|
||||
record_to[name_field_to],
|
||||
{lang: record_to.with_context(prefetch_langs=True, lang=lang)[name_field_to] for lang in langs if lang != lang_env}
|
||||
)
|
||||
extra_translation_dictionary = {}
|
||||
for record_from in records_from:
|
||||
extra_translation_dictionary.update(field_from.get_translation_dictionary(
|
||||
record_from[name_field_from],
|
||||
{lang: record_from.with_context(prefetch_langs=True, lang=lang)[name_field_from] for lang in langs if lang != lang_env}
|
||||
))
|
||||
for term, extra_translation_values in extra_translation_dictionary.items():
|
||||
existing_translation_values = existing_translation_dictionary.setdefault(term, {})
|
||||
# Update only default translation values that aren't customized by the user.
|
||||
for lang, extra_translation in extra_translation_values.items():
|
||||
if existing_translation_values.get(lang, term) == term:
|
||||
existing_translation_values[lang] = extra_translation
|
||||
translation_dictionary = existing_translation_dictionary
|
||||
|
||||
# The `en_US` jsonb value should always be set, even if english is not
|
||||
# installed. If we don't do this, the custom snippet `arch_db` will only
|
||||
# have a `fr_BE` key but no `en_US` key.
|
||||
langs.add('en_US')
|
||||
|
||||
# 2. Set translations
|
||||
new_value = {
|
||||
lang: field_to.translate(lambda term: translation_dictionary.get(term, {}).get(lang), record_to[name_field_to])
|
||||
for lang in langs
|
||||
}
|
||||
record_to.env.cache.update_raw(record_to, field_to, [new_value], dirty=True)
|
||||
# Call `write` to trigger compute etc (`modified()`)
|
||||
record_to[name_field_to] = new_value[lang_env]
|
||||
|
||||
@api.model
|
||||
def _save_oe_structure_hook(self):
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def _are_archs_equal(self, arch1, arch2):
|
||||
# Note that comparing the strings would not be ok as attributes order
|
||||
# must not be relevant
|
||||
if arch1.tag != arch2.tag:
|
||||
return False
|
||||
if arch1.text != arch2.text:
|
||||
return False
|
||||
if arch1.tail != arch2.tail:
|
||||
return False
|
||||
if arch1.attrib != arch2.attrib:
|
||||
return False
|
||||
if len(arch1) != len(arch2):
|
||||
return False
|
||||
return all(self._are_archs_equal(arch1, arch2) for arch1, arch2 in zip(arch1, arch2))
|
||||
|
||||
@api.model
|
||||
def _get_allowed_root_attrs(self):
|
||||
return ['style', 'class', 'target', 'href']
|
||||
|
||||
def replace_arch_section(self, section_xpath, replacement, replace_tail=False):
|
||||
# the root of the arch section shouldn't actually be replaced as it's
|
||||
# not really editable itself, only the content truly is editable.
|
||||
self.ensure_one()
|
||||
arch = etree.fromstring(self.arch.encode('utf-8'))
|
||||
# => get the replacement root
|
||||
if not section_xpath:
|
||||
root = arch
|
||||
else:
|
||||
# ensure there's only one match
|
||||
[root] = arch.xpath(section_xpath)
|
||||
|
||||
root.text = replacement.text
|
||||
|
||||
# We need to replace some attrib for styles changes on the root element
|
||||
for attribute in self._get_allowed_root_attrs():
|
||||
if attribute in replacement.attrib:
|
||||
root.attrib[attribute] = replacement.attrib[attribute]
|
||||
elif attribute in root.attrib:
|
||||
del root.attrib[attribute]
|
||||
|
||||
# Note: after a standard edition, the tail *must not* be replaced
|
||||
if replace_tail:
|
||||
root.tail = replacement.tail
|
||||
# replace all children
|
||||
del root[:]
|
||||
for child in replacement:
|
||||
root.append(copy.deepcopy(child))
|
||||
|
||||
return arch
|
||||
|
||||
@api.model
|
||||
def to_field_ref(self, el):
|
||||
# filter out meta-information inserted in the document
|
||||
attributes = {k: v for k, v in el.attrib.items()
|
||||
if not k.startswith('data-oe-')}
|
||||
attributes['t-field'] = el.get('data-oe-expression')
|
||||
|
||||
out = html.html_parser.makeelement(el.tag, attrib=attributes)
|
||||
out.tail = el.tail
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def to_empty_oe_structure(self, el):
|
||||
out = html.html_parser.makeelement(el.tag, attrib=el.attrib)
|
||||
out.tail = el.tail
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def _set_noupdate(self):
|
||||
self.sudo().mapped('model_data_id').write({'noupdate': True})
|
||||
|
||||
def save(self, value, xpath=None):
|
||||
""" Update a view section. The view section may embed fields to write
|
||||
|
||||
Note that `self` record might not exist when saving an embed field
|
||||
|
||||
:param str xpath: valid xpath to the tag to replace
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
arch_section = html.fromstring(
|
||||
value, parser=html.HTMLParser(encoding='utf-8'))
|
||||
|
||||
if xpath is None:
|
||||
# value is an embedded field on its own, not a view section
|
||||
self.save_embedded_field(arch_section)
|
||||
return
|
||||
|
||||
for el in self.extract_embedded_fields(arch_section):
|
||||
self.save_embedded_field(el)
|
||||
|
||||
# transform embedded field back to t-field
|
||||
el.getparent().replace(el, self.to_field_ref(el))
|
||||
|
||||
for el in self.extract_oe_structures(arch_section):
|
||||
if self.save_oe_structure(el):
|
||||
# empty oe_structure in parent view
|
||||
empty = self.to_empty_oe_structure(el)
|
||||
if el == arch_section:
|
||||
arch_section = empty
|
||||
else:
|
||||
el.getparent().replace(el, empty)
|
||||
|
||||
new_arch = self.replace_arch_section(xpath, arch_section)
|
||||
old_arch = etree.fromstring(self.arch.encode('utf-8'))
|
||||
if not self._are_archs_equal(old_arch, new_arch):
|
||||
self._set_noupdate()
|
||||
self.write({'arch': etree.tostring(new_arch, encoding='unicode')})
|
||||
self._copy_custom_snippet_translations(self, 'arch_db')
|
||||
|
||||
@api.model
|
||||
def _view_get_inherited_children(self, view):
|
||||
if self.env.context.get('no_primary_children', False):
|
||||
original_hierarchy = self.env.context.get('__views_get_original_hierarchy', [])
|
||||
return view.inherit_children_ids.filtered(lambda extension: extension.mode != 'primary' or extension.id in original_hierarchy)
|
||||
return view.inherit_children_ids
|
||||
|
||||
# Returns all views (called and inherited) related to a view
|
||||
# Used by translation mechanism, SEO and optional templates
|
||||
|
||||
@api.model
|
||||
def _views_get(self, view_id, get_children=True, bundles=False, root=True, visited=None):
|
||||
""" For a given view ``view_id``, should return:
|
||||
* the view itself (starting from its top most parent)
|
||||
* all views inheriting from it, enabled or not
|
||||
- but not the optional children of a non-enabled child
|
||||
* all views called from it (via t-call)
|
||||
|
||||
:returns: recordset of ir.ui.view
|
||||
"""
|
||||
try:
|
||||
if isinstance(view_id, models.BaseModel):
|
||||
view = view_id
|
||||
else:
|
||||
view = self._get_template_view(view_id)
|
||||
except MissingError:
|
||||
_logger.warning("Could not find view object with view_id '%s'", view_id)
|
||||
return self.env['ir.ui.view']
|
||||
|
||||
if visited is None:
|
||||
visited = []
|
||||
original_hierarchy = self.env.context.get('__views_get_original_hierarchy', [])
|
||||
while root and view.inherit_id:
|
||||
original_hierarchy.append(view.id)
|
||||
view = view.inherit_id
|
||||
|
||||
views_to_return = view
|
||||
|
||||
node = etree.fromstring(view.arch)
|
||||
xpath = "//t[@t-call]"
|
||||
if bundles:
|
||||
xpath += "| //t[@t-call-assets]"
|
||||
for child in node.xpath(xpath):
|
||||
try:
|
||||
called_view = self._get_template_view(child.get('t-call', child.get('t-call-assets')))
|
||||
except MissingError:
|
||||
continue
|
||||
if called_view and called_view not in views_to_return and called_view.id not in visited:
|
||||
views_to_return += self._views_get(called_view, get_children=get_children, bundles=bundles, visited=visited + views_to_return.ids)
|
||||
|
||||
if not get_children:
|
||||
return views_to_return
|
||||
|
||||
extensions = self._view_get_inherited_children(view)
|
||||
|
||||
# Keep children in a deterministic order regardless of their applicability
|
||||
for extension in extensions.sorted(key=lambda v: v.id):
|
||||
# only return optional grandchildren if this child is enabled
|
||||
if extension.id not in visited:
|
||||
for ext_view in self._views_get(extension, get_children=extension.active, root=False, visited=visited + views_to_return.ids):
|
||||
if ext_view not in views_to_return:
|
||||
views_to_return += ext_view
|
||||
return views_to_return
|
||||
|
||||
@api.model
|
||||
def get_related_views(self, key, bundles=False):
|
||||
""" Get inherit view's informations of the template ``key``.
|
||||
returns templates info (which can be active or not)
|
||||
``bundles=True`` returns also the asset bundles
|
||||
"""
|
||||
user_groups = set(self.env.user.group_ids)
|
||||
new_context = {
|
||||
**self.env.context,
|
||||
'active_test': False,
|
||||
}
|
||||
new_context.pop('lang', None)
|
||||
View = self.with_context(new_context)
|
||||
views = View._views_get(key, bundles=bundles)
|
||||
return views.filtered(lambda v: not v.group_ids or len(user_groups.intersection(v.group_ids)))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Snippet saving
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _get_snippet_addition_view_key(self, template_key, key):
|
||||
return '%s.%s' % (template_key, key)
|
||||
|
||||
@api.model
|
||||
def _snippet_save_view_values_hook(self):
|
||||
return {}
|
||||
|
||||
def _find_available_name(self, name, used_names):
|
||||
attempt = 1
|
||||
candidate_name = name
|
||||
while candidate_name in used_names:
|
||||
attempt += 1
|
||||
candidate_name = f"{name} ({attempt})"
|
||||
return candidate_name
|
||||
|
||||
@api.model
|
||||
def save_snippet(self, name, arch, template_key, snippet_key, thumbnail_url):
|
||||
"""
|
||||
Saves a new snippet arch so that it appears with the given name when
|
||||
using the given snippets template.
|
||||
|
||||
:param name: the name of the snippet to save
|
||||
:param arch: the html structure of the snippet to save
|
||||
:param template_key: the key of the view regrouping all snippets in
|
||||
which the snippet to save is meant to appear
|
||||
:param snippet_key: the key (without module part) to identify
|
||||
the snippet from which the snippet to save originates
|
||||
:param thumbnail_url: the url of the thumbnail to use when displaying
|
||||
the snippet to save
|
||||
"""
|
||||
app_name = template_key.split('.')[0]
|
||||
snippet_key = '%s_%s' % (snippet_key, uuid.uuid4().hex)
|
||||
full_snippet_key = '%s.%s' % (app_name, snippet_key)
|
||||
|
||||
# find available name
|
||||
current_website = self.env['website'].browse(self.env.context.get('website_id'))
|
||||
website_domain = Domain(current_website.website_domain())
|
||||
used_names = self.search(Domain('name', '=like', '%s%%' % name) & website_domain).mapped('name')
|
||||
name = self._find_available_name(name, used_names)
|
||||
|
||||
# html to xml to add '/' at the end of self closing tags like br, ...
|
||||
arch_tree = html.fromstring(arch)
|
||||
attributes = self._get_cleaned_non_editing_attributes(arch_tree.attrib.items())
|
||||
for attr in arch_tree.attrib:
|
||||
if attr in attributes:
|
||||
arch_tree.attrib[attr] = attributes[attr]
|
||||
else:
|
||||
del arch_tree.attrib[attr]
|
||||
xml_arch = etree.tostring(arch_tree, encoding='utf-8')
|
||||
new_snippet_view_values = {
|
||||
'name': name,
|
||||
'key': full_snippet_key,
|
||||
'type': 'qweb',
|
||||
'arch': xml_arch,
|
||||
}
|
||||
new_snippet_view_values.update(self._snippet_save_view_values_hook())
|
||||
custom_snippet_view = self.create(new_snippet_view_values)
|
||||
model = self.env.context.get('model')
|
||||
field = self.env.context.get('field')
|
||||
if field == 'arch':
|
||||
# Special case for `arch` which is a kind of related (through a
|
||||
# compute) to `arch_db` but which is hosting XML/HTML content while
|
||||
# being a char field.. Which is then messing around with the
|
||||
# `get_translation_dictionary` call, returning XML instead of
|
||||
# strings
|
||||
field = 'arch_db'
|
||||
res_id = self.env.context.get('resId')
|
||||
if model and field and res_id:
|
||||
self._copy_field_terms_translations(
|
||||
self.env[model].browse(int(res_id)),
|
||||
field,
|
||||
custom_snippet_view,
|
||||
'arch_db',
|
||||
)
|
||||
|
||||
custom_section = self.search([('key', '=', template_key)])
|
||||
snippet_addition_view_values = {
|
||||
'name': name + ' Block',
|
||||
'key': self._get_snippet_addition_view_key(template_key, snippet_key),
|
||||
'inherit_id': custom_section.id,
|
||||
'type': 'qweb',
|
||||
'arch': """
|
||||
<data inherit_id="%s">
|
||||
<xpath expr="//snippets[@id='snippet_custom']" position="inside">
|
||||
<t t-snippet="%s" t-thumbnail="%s"/>
|
||||
</xpath>
|
||||
</data>
|
||||
""" % (template_key, full_snippet_key, thumbnail_url),
|
||||
}
|
||||
snippet_addition_view_values.update(self._snippet_save_view_values_hook())
|
||||
self.create(snippet_addition_view_values)
|
||||
return name
|
||||
|
||||
@api.model
|
||||
def rename_snippet(self, name, view_id, template_key):
|
||||
snippet_view = self.browse(view_id)
|
||||
key = snippet_view.key.split('.')[1]
|
||||
custom_key = self._get_snippet_addition_view_key(template_key, key)
|
||||
snippet_addition_view = self.search([('key', '=', custom_key)])
|
||||
if snippet_addition_view:
|
||||
snippet_addition_view.name = name + ' Block'
|
||||
snippet_view.name = name
|
||||
|
||||
@api.model
|
||||
def delete_snippet(self, view_id, template_key):
|
||||
snippet_view = self.browse(view_id)
|
||||
key = snippet_view.key.split('.')[1]
|
||||
custom_key = self._get_snippet_addition_view_key(template_key, key)
|
||||
snippet_addition_view = self.search([('key', '=', custom_key)])
|
||||
(snippet_addition_view | snippet_view).unlink()
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from odoo import models
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
|
||||
class IrWebsocket(models.AbstractModel):
|
||||
_inherit = 'ir.websocket'
|
||||
|
||||
def _build_bus_channel_list(self, channels):
|
||||
if self.env.uid:
|
||||
# Do not alter original list.
|
||||
channels = list(channels)
|
||||
for channel in channels:
|
||||
if isinstance(channel, str):
|
||||
match = re.match(r'editor_collaboration:(\w+(?:\.\w+)*):(\w+):(\d+)', channel)
|
||||
if match:
|
||||
model_name = match[1]
|
||||
field_name = match[2]
|
||||
res_id = int(match[3])
|
||||
|
||||
# Verify access to the edition channel.
|
||||
if self.env.user._is_public():
|
||||
raise AccessDenied()
|
||||
|
||||
document = self.env[model_name].browse([res_id])
|
||||
if not document.exists():
|
||||
continue
|
||||
|
||||
document.check_access('read')
|
||||
document.check_access('write')
|
||||
if field := document._fields.get(field_name):
|
||||
document._check_field_access(field, 'read')
|
||||
document._check_field_access(field, 'write')
|
||||
|
||||
channels.append((self.env.registry.db_name, 'editor_collaboration', model_name, field_name, res_id))
|
||||
return super()._build_bus_channel_list(channels)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def _get_view_field_attributes(self):
|
||||
keys = super()._get_view_field_attributes()
|
||||
keys.append('sanitize')
|
||||
keys.append('sanitize_tags')
|
||||
return keys
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class Html_EditorConverterTest(models.Model):
|
||||
_name = 'html_editor.converter.test'
|
||||
_description = 'Html Editor Converter Test'
|
||||
|
||||
# disable translation export for those brilliant field labels and values
|
||||
_translate = False
|
||||
|
||||
char = fields.Char()
|
||||
integer = fields.Integer()
|
||||
float = fields.Float()
|
||||
numeric = fields.Float(digits=(16, 2))
|
||||
many2one = fields.Many2one('html_editor.converter.test.sub')
|
||||
binary = fields.Binary(attachment=False)
|
||||
date = fields.Date()
|
||||
datetime = fields.Datetime()
|
||||
selection_str = fields.Selection([
|
||||
('A', "Qu'il n'est pas arrivé à Toronto"),
|
||||
('B', "Qu'il était supposé arriver à Toronto"),
|
||||
('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"),
|
||||
('D', "La réponse D"),
|
||||
], string="Lorsqu'un pancake prend l'avion à destination de Toronto et "
|
||||
"qu'il fait une escale technique à St Claude, on dit:")
|
||||
html = fields.Html()
|
||||
text = fields.Text()
|
||||
|
||||
|
||||
class Html_EditorConverterTestSub(models.Model):
|
||||
_name = 'html_editor.converter.test.sub'
|
||||
_description = 'Html Editor Converter Subtest'
|
||||
|
||||
name = fields.Char()
|
||||
Loading…
Add table
Add a link
Reference in a new issue