[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:
Sébastien Geelen (sge)
2023-10-18 13:39:06 +00:00
parent f050ef335e
commit 10b855ea87
17 changed files with 898 additions and 0 deletions
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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()
+1
View File
@@ -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',
+1
View File
@@ -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
+273
View File
@@ -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) {
+1
View File
@@ -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
+129
View File
@@ -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])