[ADD] web_editor: add field html history
Create a new model mixins that allow other model to activate the history feature on html fields. This history automatically track the changes on the related html field and allow the user to revert to a previous version at any time. This also introduce a new component that can be used to : * List the recent history * See a previous version of the document * Compare a previous version to the current one * Revert the document to a previous version of it task-3039787 closes odoo/odoo#112957 Related: odoo/enterprise#37211 Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Test - html_field_history',
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'depends': ['web_editor'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import model_html_field_history_test
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ModelHtmlFieldHistoryTest(models.Model):
|
||||
_description = "Test html_field_history Model"
|
||||
_name = "html.field.history.test"
|
||||
_inherit = ["html.field.history.mixin"]
|
||||
|
||||
def _get_versioned_fields(self):
|
||||
return [
|
||||
ModelHtmlFieldHistoryTest.versioned_field_1.name,
|
||||
ModelHtmlFieldHistoryTest.versioned_field_2.name,
|
||||
]
|
||||
|
||||
versioned_field_1 = fields.Html(string="vf1")
|
||||
versioned_field_2 = fields.Html(string="vf2", sanitize=False)
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_resource_test_all,resource.test.all,model_html_field_history_test,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_model
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class TestModel(TransactionCase):
|
||||
def setUp(self):
|
||||
self.env["html.field.history.test"].search([]).unlink()
|
||||
super().setUp()
|
||||
|
||||
def test_html_field_history_write(self):
|
||||
rec1 = self.env["html.field.history.test"].create(
|
||||
{
|
||||
"versioned_field_1": "mock content",
|
||||
}
|
||||
)
|
||||
self.assertFalse(
|
||||
rec1.html_field_history,
|
||||
"Record creation should not generate revisions",
|
||||
)
|
||||
self.assertFalse(
|
||||
rec1.html_field_history_metadata,
|
||||
"We should never have metadata without revisions",
|
||||
)
|
||||
|
||||
rec1.write(
|
||||
{
|
||||
"versioned_field_1": "mock content 2",
|
||||
}
|
||||
)
|
||||
self.assertEqual(len(rec1.html_field_history["versioned_field_1"]), 1)
|
||||
self.assertEqual(len(rec1.html_field_history_metadata["versioned_field_1"]), 1)
|
||||
self.assertFalse(rec1.html_field_history["versioned_field_2"])
|
||||
self.assertFalse(rec1.html_field_history_metadata["versioned_field_2"])
|
||||
|
||||
rec1.write(
|
||||
{
|
||||
"versioned_field_1": "mock content 3",
|
||||
}
|
||||
)
|
||||
rec1.write(
|
||||
{
|
||||
"versioned_field_1": None,
|
||||
}
|
||||
)
|
||||
self.assertEqual(len(rec1.html_field_history["versioned_field_1"]), 3)
|
||||
rec1.unlink()
|
||||
|
||||
rec2 = self.env["html.field.history.test"].create(
|
||||
{
|
||||
"versioned_field_2": "mock content",
|
||||
}
|
||||
)
|
||||
self.assertFalse(
|
||||
rec2.html_field_history,
|
||||
"Record creation should not generate revisions",
|
||||
)
|
||||
self.assertFalse(
|
||||
rec2.html_field_history_metadata,
|
||||
"We should never have metadata without revisions",
|
||||
)
|
||||
|
||||
with self.assertRaises(
|
||||
ValidationError,
|
||||
msg="We should not be able to versioned a field that is not declared as sanitize=True",
|
||||
):
|
||||
rec2.write(
|
||||
{
|
||||
"versioned_field_2": "mock content 2",
|
||||
}
|
||||
)
|
||||
|
||||
rec2.unlink()
|
||||
|
||||
def test_html_field_history_revision_are_sanitized(self):
|
||||
rec1 = self.env["html.field.history.test"].create(
|
||||
{
|
||||
"versioned_field_1": "mock content",
|
||||
}
|
||||
)
|
||||
self.assertFalse(
|
||||
rec1.html_field_history,
|
||||
"Record creation should not generate revisions",
|
||||
)
|
||||
# Attempt to write unsecure HTML inside sanitized html field
|
||||
rec1.write({"versioned_field_1": 'scam <iframe src="http://not.secure.scam" />'})
|
||||
self.assertEqual(len(rec1.html_field_history["versioned_field_1"]), 1)
|
||||
self.assertEqual(rec1.versioned_field_1, "<p>scam </p>")
|
||||
self.assertNotIn("iframe", rec1.html_field_history["versioned_field_1"])
|
||||
self.assertNotIn("not.secure.scam", rec1.html_field_history["versioned_field_1"])
|
||||
|
||||
# Ensure the unsecure HTML was not stored in revision data
|
||||
rec1.write({"versioned_field_1": "not a scam"})
|
||||
self.assertEqual(len(rec1.html_field_history["versioned_field_1"]), 2)
|
||||
self.assertEqual(rec1.versioned_field_1, "<p>not a scam</p>")
|
||||
self.assertNotIn("iframe", rec1.html_field_history["versioned_field_1"])
|
||||
self.assertNotIn("not.secure.scam", rec1.html_field_history["versioned_field_1"])
|
||||
rec1.unlink()
|
||||
@@ -186,6 +186,7 @@ Odoo Web Editor widget.
|
||||
|
||||
'web_editor/static/src/js/backend/**/*',
|
||||
'web_editor/static/src/xml/backend.xml',
|
||||
'web_editor/static/src/components/history_dialog/**/*',
|
||||
],
|
||||
"web.assets_web_dark": [
|
||||
'web_editor/static/src/scss/odoo-editor/powerbox.dark.scss',
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import ir_ui_view
|
||||
from . import ir_http
|
||||
from . import ir_websocket
|
||||
from . import models
|
||||
from . import html_field_history_mixin
|
||||
|
||||
from . import assets
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 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",
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
# 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)
|
||||
|
||||
|
||||
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)>"
|
||||
|
||||
|
||||
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 ""
|
||||
# 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,
|
||||
]:
|
||||
comparison[index] = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX,
|
||||
DELETION_COMPARISON_REGEX,
|
||||
comparison[index],
|
||||
)
|
||||
|
||||
if operation_type == PATCH_OPERATION_ADD:
|
||||
for line in patch_content_line:
|
||||
comparison.insert(
|
||||
start_index + 1,
|
||||
re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX,
|
||||
ADDITION_COMPARISON_REGEX,
|
||||
line,
|
||||
),
|
||||
)
|
||||
|
||||
if operation_type == PATCH_OPERATION_REPLACE:
|
||||
for i, line in enumerate(patch_content_line):
|
||||
# We need to remove the first tag of a replace operation
|
||||
# to avoid having a duplicate opening tag in the middle of a
|
||||
# line.
|
||||
replace_regex = (
|
||||
ADDITION_1ST_REPLACE_COMPARISON_REGEX
|
||||
if i == len(patch_content_line) - 1
|
||||
else ADDITION_COMPARISON_REGEX
|
||||
)
|
||||
comparison.insert(
|
||||
start_index + 1,
|
||||
re.sub(HTML_TAG_ISOLATION_REGEX, replace_regex, line),
|
||||
)
|
||||
|
||||
if operation_type in [PATCH_OPERATION_REMOVE, PATCH_OPERATION_REPLACE]:
|
||||
comparison[start_index] = re.sub(
|
||||
HTML_TAG_ISOLATION_REGEX,
|
||||
DELETION_COMPARISON_REGEX,
|
||||
comparison[start_index],
|
||||
)
|
||||
|
||||
comparison = [re.sub(EMPTY_OPERATION_TAG, "", line) for line in comparison]
|
||||
return LINE_SEPARATOR.join(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 "{}{}".format(PATCH_OPERATION_LINE_AT, start)
|
||||
return "{}{},{}".format(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' {}="[^"]*"'.format(attribute), "", html_content
|
||||
)
|
||||
|
||||
return html_content
|
||||
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 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
|
||||
|
||||
|
||||
class HtmlFieldHistory(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):
|
||||
new_revisions = False
|
||||
db_contents = None
|
||||
versioned_fields = self._get_versioned_fields()
|
||||
vals_contain_versioned_fields = set(vals).intersection(versioned_fields)
|
||||
|
||||
if vals_contain_versioned_fields:
|
||||
self.ensure_one()
|
||||
db_contents = dict([(f, self[f]) for f in versioned_fields])
|
||||
fields_data = self.env[self._name]._fields
|
||||
|
||||
if any(f in vals and not fields_data[f].sanitize for f in versioned_fields):
|
||||
raise ValidationError(
|
||||
"Ensure all versioned fields ( %s ) in model %s are declared as sanitize=True"
|
||||
% (str(versioned_fields), self._name)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
history_revs = self.html_field_history or {}
|
||||
|
||||
for field in versioned_fields:
|
||||
new_content = self[field] or ""
|
||||
|
||||
if field not in history_revs:
|
||||
history_revs[field] = []
|
||||
|
||||
old_content = db_contents[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 = self._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().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]
|
||||
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(self[field_name], restored_content)
|
||||
@@ -0,0 +1,97 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Dialog } from '@web/core/dialog/dialog';
|
||||
import { formatDateTime } from '@web/core/l10n/dates';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { memoize } from '@web/core/utils/functions';
|
||||
import { Component, onMounted, useState, markup } from '@odoo/owl';
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
class HistoryDialog extends Component {
|
||||
static template = 'web_editor.HistoryDialog';
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
recordId: Number,
|
||||
recordModel: String,
|
||||
close: Function,
|
||||
restoreRequested: Function,
|
||||
historyMetadata: Array,
|
||||
versionedFieldName: String
|
||||
};
|
||||
|
||||
state = useState({
|
||||
revisionsData: [],
|
||||
revisionContent: null,
|
||||
revisionComparison: null,
|
||||
revisionId: null
|
||||
});
|
||||
|
||||
setup() {
|
||||
this.size = 'xl';
|
||||
this.title = _t('History');
|
||||
this.orm = useService('orm');
|
||||
|
||||
onMounted(() => this.init());
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.state.revisionsData = this.props.historyMetadata;
|
||||
await this.updateCurrentRevision(this.props.historyMetadata[0]['revision_id']);
|
||||
}
|
||||
|
||||
async updateCurrentRevision(revisionId) {
|
||||
if (this.state.revisionId === revisionId) {
|
||||
return;
|
||||
}
|
||||
this.env.services.ui.block();
|
||||
this.state.revisionId = revisionId;
|
||||
this.state.revisionContent = await this.getRevisionContent(revisionId);
|
||||
this.state.revisionComparison = await this.getRevisionComparison(
|
||||
revisionId
|
||||
);
|
||||
this.env.services.ui.unblock();
|
||||
}
|
||||
|
||||
getRevisionComparison = memoize(
|
||||
async function getRevisionComparison(revisionId) {
|
||||
const comparison = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
'html_field_history_get_comparison_at_revision',
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return markup(comparison);
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
getRevisionContent = memoize(
|
||||
async function getRevisionContent(revisionId) {
|
||||
const content = await this.orm.call(
|
||||
this.props.recordModel,
|
||||
'html_field_history_get_content_at_revision',
|
||||
[this.props.recordId, this.props.versionedFieldName, revisionId]
|
||||
);
|
||||
return markup(content);
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
async _onRestoreRevisionClick() {
|
||||
this.env.services.ui.block();
|
||||
const restoredContent = await this.getRevisionContent(
|
||||
this.state.revisionId
|
||||
);
|
||||
this.props.restoreRequested(restoredContent);
|
||||
this.env.services.ui.unblock();
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters
|
||||
**/
|
||||
getRevisionDate(revision) {
|
||||
return formatDateTime(DateTime.fromISO(revision['create_date']));
|
||||
}
|
||||
}
|
||||
|
||||
export default HistoryDialog;
|
||||
@@ -0,0 +1,51 @@
|
||||
.html-history-dialog {
|
||||
.history-container {
|
||||
margin-left: 240px;
|
||||
>div {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
}
|
||||
.nav {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
removed {
|
||||
display: inline;
|
||||
background-color: #f1afaf;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
added {
|
||||
display: inline;
|
||||
background-color: #c8f1af;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
}
|
||||
.revision-list {
|
||||
margin: 38px 0 0 8px;
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
width: 220px;
|
||||
float : left;
|
||||
|
||||
.btn {
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 220px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
&:before {
|
||||
content: '\f105';
|
||||
font-family: 'FontAwesome';
|
||||
position: absolute;
|
||||
right : 8px;
|
||||
top: 0;
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web_editor.HistoryDialog" owl="1">
|
||||
<Dialog size="size" title="title">
|
||||
<div class="dialog-container html-history-dialog">
|
||||
<div class="revision-list d-flex flex-column align-content-stretch">
|
||||
<t t-if="!state.revisionsData.length">
|
||||
<div class="text-center w-100 pb-2 pt-0 px-0 fw-bolder">No history</div>
|
||||
</t>
|
||||
|
||||
<t t-foreach="state.revisionsData" t-as="rev"
|
||||
t-key="rev.index">
|
||||
<a type="object" href="#" role="button"
|
||||
t-attf-class="btn btn-outline-primary #{state.revisionId === rev.revision_id ? 'active' : ''}"
|
||||
t-on-click="() => this.updateCurrentRevision(rev.revision_id )">
|
||||
<strong><t t-esc="this.getRevisionDate(rev)" /></strong>
|
||||
<br/>
|
||||
<small><t t-esc="rev.create_user_name" /></small>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<div class="history-container o_notebook">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="history-content" data-bs-toggle="tab"
|
||||
data-bs-target="#history-content-tab" type="button" role="tab"
|
||||
aria-controls="content" aria-selected="true">Content</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="history-comparison" data-bs-toggle="tab"
|
||||
data-bs-target="#history-comparison-tab" type="button" role="tab"
|
||||
aria-controls="comparison" aria-selected="false">Comparison</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="history-content-tab" role="tabpanel"
|
||||
aria-labelledby="history-content">
|
||||
<t t-out="state.revisionContent"/>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="history-comparison-tab" role="tabpanel"
|
||||
aria-labelledby="history-comparison">
|
||||
<t t-out="state.revisionComparison"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer" owl="1">
|
||||
<button class="btn btn-primary" t-on-click="_onRestoreRevisionClick">Restore history</button>
|
||||
<button class="btn btn-secondary" t-on-click="props.close">Cancel</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -3212,6 +3212,11 @@ export class Wysiwyg extends Component {
|
||||
}
|
||||
_attachHistoryIds(editable = this.odooEditor.editable) {
|
||||
if (this.options.collaborative) {
|
||||
// clean existig 'data-last-history-steps' attributes
|
||||
editable.querySelectorAll('[data-last-history-steps]').forEach(
|
||||
el => el.removeAttribute('data-last-history-steps')
|
||||
);
|
||||
|
||||
const historyIds = this.odooEditor.historyGetBranchIds().join(',');
|
||||
const firstChild = editable.children[0];
|
||||
if (firstChild) {
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
from . import test_controller
|
||||
from . import test_converter
|
||||
from . import test_odoo_editor
|
||||
from . import test_diff_utils
|
||||
from . import test_views
|
||||
from . import test_tools
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo.tests
|
||||
|
||||
from odoo.tests.common import BaseCase
|
||||
from odoo.addons.web_editor.models.diff_utils import (
|
||||
generate_patch,
|
||||
generate_comparison,
|
||||
apply_patch,
|
||||
)
|
||||
|
||||
|
||||
@odoo.tests.tagged("post_install", "-at_install", "html_history")
|
||||
class TestPatchUtils(BaseCase):
|
||||
def test_new_content_add_line(self):
|
||||
initial_content = "<p>foo</p><p>baz</p>"
|
||||
new_content = "<p>foo</p><p>bar</p><p>baz</p>"
|
||||
|
||||
patch = generate_patch(new_content, initial_content)
|
||||
# Even if we added content in the new_content, we expect a remove
|
||||
# operation, because the patch would be used to restore the initial
|
||||
# content from the new content.
|
||||
self.assertEqual(patch, "-@3,4")
|
||||
|
||||
restored_initial_content = apply_patch(new_content, patch)
|
||||
self.assertEqual(restored_initial_content, initial_content)
|
||||
|
||||
comparison = generate_comparison(new_content, initial_content)
|
||||
self.assertEqual(
|
||||
comparison, "<p>foo</p><p><removed>bar</removed></p><p>baz</p>"
|
||||
)
|
||||
|
||||
def test_new_content_remove_line(self):
|
||||
initial_content = "<p>foo</p><p>bar</p><p>baz</p>"
|
||||
new_content = "<p>foo</p><p>baz</p>"
|
||||
|
||||
patch = generate_patch(new_content, initial_content)
|
||||
self.assertEqual(patch, "+@2:<p>bar</p>")
|
||||
|
||||
restored_initial_content = apply_patch(new_content, patch)
|
||||
self.assertEqual(restored_initial_content, initial_content)
|
||||
|
||||
comparison = generate_comparison(new_content, initial_content)
|
||||
self.assertEqual(comparison, "<p>foo</p><p><added>bar</added></p><p>baz</p>")
|
||||
|
||||
def test_new_content_replace_line(self):
|
||||
initial_content = "<p>foo</p><p>bar</p><p>bor</p><p>bir</p><p>baz</p>"
|
||||
new_content = "<p>foo</p><p>buz</p><p>baz</p>"
|
||||
|
||||
patch = generate_patch(new_content, initial_content)
|
||||
self.assertEqual(patch, "R@3:<p>bar</p><p>bor</p><p>bir")
|
||||
|
||||
restored_initial_content = apply_patch(new_content, patch)
|
||||
self.assertEqual(restored_initial_content, initial_content)
|
||||
|
||||
comparison = generate_comparison(new_content, initial_content)
|
||||
self.assertEqual(
|
||||
comparison,
|
||||
"<p>foo</p>"
|
||||
"<p><removed>buz</removed><added>bar</added></p>"
|
||||
"<p><added>bor</added></p><p><added>bir</added></p>"
|
||||
"<p>baz</p>",
|
||||
)
|
||||
|
||||
def test_new_content_is_falsy(self):
|
||||
initial_content = "<p>foo</p><p>bar</p>"
|
||||
new_content = ""
|
||||
|
||||
patch = generate_patch(new_content, initial_content)
|
||||
self.assertEqual(patch, "+@0:<p>foo</p><p>bar</p>")
|
||||
|
||||
restored_initial_content = apply_patch(new_content, patch)
|
||||
self.assertEqual(restored_initial_content, initial_content)
|
||||
|
||||
comparison = generate_comparison(new_content, initial_content)
|
||||
self.assertEqual(
|
||||
comparison, "<p><added>foo</added></p><p><added>bar</added></p>"
|
||||
)
|
||||
|
||||
def test_new_content_multiple_operation(self):
|
||||
initial_content = "<p>foo</p><p>bar</p><p>baz</p><p>buz</p><p>boz</p>"
|
||||
new_content = (
|
||||
"<p>foo</p><div>new1<b>new2</b>new3</div>"
|
||||
"<p>bar</p><p>baz</p><p>boz</p><p>end</p>"
|
||||
)
|
||||
|
||||
patch = generate_patch(new_content, initial_content)
|
||||
self.assertEqual(
|
||||
patch,
|
||||
"""-@3,6
|
||||
+@10:<p>buz</p>
|
||||
-@13,14""",
|
||||
)
|
||||
|
||||
restored_initial_content = apply_patch(new_content, patch)
|
||||
self.assertEqual(restored_initial_content, initial_content)
|
||||
|
||||
comparison = generate_comparison(new_content, initial_content)
|
||||
self.assertEqual(
|
||||
comparison,
|
||||
"<p>foo</p>"
|
||||
"<div><removed>new1</removed>"
|
||||
"<b><removed>new2</removed></b>"
|
||||
"<removed>new3</removed></div>"
|
||||
"<p>bar</p><p>baz</p><p><added>buz</added></p>"
|
||||
"<p>boz</p><p><removed>end</removed></p>",
|
||||
)
|
||||
|
||||
def test_multiple_revision(self):
|
||||
contents = [
|
||||
"<p>foo</p><p>bar</p>",
|
||||
"<p>foo</p>",
|
||||
"<p>f<b>u</b>i</p><p>baz</p>",
|
||||
"<p>fi</p><p>boz</p>",
|
||||
"<div><h1>something</h1><p>completely different</p></div>",
|
||||
"<p>foo</p><p>boz</p><p>buz</p>",
|
||||
"<p>buz</p>",
|
||||
]
|
||||
patches = []
|
||||
for i in range(len(contents) - 1):
|
||||
patches.append(generate_patch(contents[i + 1], contents[i]))
|
||||
|
||||
patches.reverse()
|
||||
reconstruct_content = contents[-1]
|
||||
for patch in patches:
|
||||
reconstruct_content = apply_patch(reconstruct_content, patch)
|
||||
|
||||
self.assertEqual(reconstruct_content, contents[0])
|
||||
Reference in New Issue
Block a user