diff --git a/addons/test_html_field_history/__init__.py b/addons/test_html_field_history/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/test_html_field_history/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/test_html_field_history/__manifest__.py b/addons/test_html_field_history/__manifest__.py new file mode 100644 index 00000000000..c87e7f03eb0 --- /dev/null +++ b/addons/test_html_field_history/__manifest__.py @@ -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', +} diff --git a/addons/test_html_field_history/models/__init__.py b/addons/test_html_field_history/models/__init__.py new file mode 100644 index 00000000000..a3b6f86312f --- /dev/null +++ b/addons/test_html_field_history/models/__init__.py @@ -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 diff --git a/addons/test_html_field_history/models/model_html_field_history_test.py b/addons/test_html_field_history/models/model_html_field_history_test.py new file mode 100644 index 00000000000..266347fab4f --- /dev/null +++ b/addons/test_html_field_history/models/model_html_field_history_test.py @@ -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) diff --git a/addons/test_html_field_history/security/ir.model.access.csv b/addons/test_html_field_history/security/ir.model.access.csv new file mode 100644 index 00000000000..e45ce3bc479 --- /dev/null +++ b/addons/test_html_field_history/security/ir.model.access.csv @@ -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 diff --git a/addons/test_html_field_history/tests/__init__.py b/addons/test_html_field_history/tests/__init__.py new file mode 100644 index 00000000000..e32480285d2 --- /dev/null +++ b/addons/test_html_field_history/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_model diff --git a/addons/test_html_field_history/tests/test_model.py b/addons/test_html_field_history/tests/test_model.py new file mode 100644 index 00000000000..7228fbe4c72 --- /dev/null +++ b/addons/test_html_field_history/tests/test_model.py @@ -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 '}) + self.assertEqual(len(rec1.html_field_history["versioned_field_1"]), 1) + self.assertEqual(rec1.versioned_field_1, "
scam
") + 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, "not a scam
") + self.assertNotIn("iframe", rec1.html_field_history["versioned_field_1"]) + self.assertNotIn("not.secure.scam", rec1.html_field_history["versioned_field_1"]) + rec1.unlink() diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index fb89fb3b92a..32a5e67b3cb 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -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', diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py index 87d483d5169..cd971d28eda 100644 --- a/addons/web_editor/models/__init__.py +++ b/addons/web_editor/models/__init__.py @@ -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 diff --git a/addons/web_editor/models/diff_utils.py b/addons/web_editor/models/diff_utils.py new file mode 100644 index 00000000000..78f4cf6e508 --- /dev/null +++ b/addons/web_editor/models/diff_utils.py @@ -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: +ab
cd
+ +@4,15:ef
gh
+ -@32 + -@125,129 + R@523:sdf + + :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"\1ab
cd
+ +@4,15:ef
gh
+ -@32 + -@125,129 + R@523:sdf + + :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 diff --git a/addons/web_editor/models/html_field_history_mixin.py b/addons/web_editor/models/html_field_history_mixin.py new file mode 100644 index 00000000000..8beab0d4cae --- /dev/null +++ b/addons/web_editor/models/html_field_history_mixin.py @@ -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) diff --git a/addons/web_editor/static/src/components/history_dialog/history_dialog.js b/addons/web_editor/static/src/components/history_dialog/history_dialog.js new file mode 100644 index 00000000000..84161c66b8e --- /dev/null +++ b/addons/web_editor/static/src/components/history_dialog/history_dialog.js @@ -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; diff --git a/addons/web_editor/static/src/components/history_dialog/history_dialog.scss b/addons/web_editor/static/src/components/history_dialog/history_dialog.scss new file mode 100644 index 00000000000..212d4a68eb5 --- /dev/null +++ b/addons/web_editor/static/src/components/history_dialog/history_dialog.scss @@ -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; + } + } + } +} diff --git a/addons/web_editor/static/src/components/history_dialog/history_dialog.xml b/addons/web_editor/static/src/components/history_dialog/history_dialog.xml new file mode 100644 index 00000000000..4ccf123cbe5 --- /dev/null +++ b/addons/web_editor/static/src/components/history_dialog/history_dialog.xml @@ -0,0 +1,53 @@ + +foo
baz
" + new_content = "foo
bar
baz
" + + 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, "foo
baz
" + ) + + def test_new_content_remove_line(self): + initial_content = "foo
bar
baz
" + new_content = "foo
baz
" + + patch = generate_patch(new_content, initial_content) + self.assertEqual(patch, "+@2:bar
") + + 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, "foo
baz
") + + def test_new_content_replace_line(self): + initial_content = "foo
bar
bor
bir
baz
" + new_content = "foo
buz
baz
" + + patch = generate_patch(new_content, initial_content) + self.assertEqual(patch, "R@3:bar
bor
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, + "
foo
" + "baz
", + ) + + def test_new_content_is_falsy(self): + initial_content = "foo
bar
" + new_content = "" + + patch = generate_patch(new_content, initial_content) + self.assertEqual(patch, "+@0:foo
bar
") + + 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, "foo
bar
baz
buz
boz
" + new_content = ( + "foo
bar
baz
boz
end
" + ) + + patch = generate_patch(new_content, initial_content) + self.assertEqual( + patch, + """-@3,6 ++@10:buz
+-@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, + "foo
" + "bar
baz
boz
foo
bar
", + "foo
", + "fui
baz
", + "fi
boz
", + "completely different
foo
boz
buz
", + "buz
", + ] + 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])