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:
Ernad Husremovic 2026-03-09 15:31:13 +01:00
parent 4b94f0abc5
commit f866779561
1513 changed files with 396049 additions and 358525 deletions

View file

@ -0,0 +1,5 @@
from . import test_controller
from . import test_converter
from . import test_tools
from . import test_views
from . import test_diff_utils

View file

@ -0,0 +1,284 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import binascii
import json
import odoo.tests
from odoo.tests.common import HttpCase, new_test_user
from odoo.tools.json import scriptsafe as json_safe
from unittest.mock import patch
from odoo.addons.mail.tools import link_preview
@odoo.tests.tagged('-at_install', 'post_install')
class TestController(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
portal_user = new_test_user(cls.env, login='portal_user', groups='base.group_portal')
cls.portal_user = portal_user
cls.portal = portal_user.login
admin_user = new_test_user(cls.env, login='admin_user', groups='base.group_user,base.group_system')
cls.admin = admin_user.login
cls.headers = {"Content-Type": "application/json"}
cls.pixel = 'R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs='
def test_01_upload_document(self):
self.authenticate('admin', 'admin')
# Upload document.
response = self.url_open(
'/html_editor/attachment/add_data',
headers={'Content-Type': 'application/json'},
data=json_safe.dumps({'params': {
'name': 'test.txt',
'data': 'SGVsbG8gd29ybGQ=', # base64 Hello world
'is_image': False,
}})
).json()
self.assertFalse('error' in response, 'Upload failed: %s' % response.get('error', {}).get('message'))
attachment_id = response['result']['id']
checksum = response['result']['checksum']
# Download document and check content.
response = self.url_open(
'/web/content/%s?unique=%s&download=true' % (attachment_id, checksum)
)
self.assertEqual(200, response.status_code, 'Expect response')
self.assertEqual(b'Hello world', response.content, 'Expect raw content')
def test_02_illustration_shape(self):
self.authenticate('admin', 'admin')
# SVG with all replaceable colors.
svg = b"""
<svg viewBox="0 0 400 400">
<rect width="300" height="300" style="fill:#3AADAA;" />
<rect x="20" y="20" width="300" height="300" style="fill:#7C6576;" />
<rect x="40" y="40" width="300" height="300" style="fill:#F6F6F6;" />
<rect x="60" y="60" width="300" height="300" style="fill:#FFFFFF;" />
<rect x="80" y="80" width="300" height="300" style="fill:#383E45;" />
</svg>
"""
attachment = self.env['ir.attachment'].create({
'name': 'test.svg',
'mimetype': 'image/svg+xml',
'datas': binascii.b2a_base64(svg, newline=False),
'public': True,
'res_model': 'ir.ui.view',
'res_id': 0,
})
# Shape illustration with slug.
slug = self.env['ir.http']._slug
url = '/html_editor/shape/illustration/%s' % slug(attachment)
palette = 'c1=%233AADAA&c2=%237C6576&&c3=%23F6F6F6&&c4=%23FFFFFF&&c5=%23383E45'
attachment['url'] = '%s?%s' % (url, palette)
response = self.url_open(url)
self.assertEqual(200, response.status_code, 'Expect response')
self.assertEqual(svg, response.content, 'Expect unchanged SVG')
response = self.url_open(url + '?c1=%23ABCDEF')
self.assertEqual(200, response.status_code, 'Expect response')
self.assertEqual(len(svg), len(response.content), 'Expect same length as original')
self.assertTrue('ABCDEF' in str(response.content), 'Expect patched c1')
self.assertTrue('3AADAA' not in str(response.content), 'Old c1 should not be there anymore')
# Shape illustration without slug.
url = '/html_editor/shape/illustration/noslug'
attachment['url'] = url
response = self.url_open(url)
self.assertEqual(200, response.status_code, 'Expect response')
self.assertEqual(svg, response.content, 'Expect unchanged SVG')
response = self.url_open(url + '?c1=%23ABCDEF')
self.assertEqual(200, response.status_code, 'Expect response')
self.assertEqual(len(svg), len(response.content), 'Expect same length as original')
self.assertTrue('ABCDEF' in str(response.content), 'Expect patched c1')
self.assertTrue('3AADAA' not in str(response.content), 'Old c1 should not be there anymore')
def test_03_get_image_info(self):
gif_base64 = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
self.authenticate('admin', 'admin')
# Upload document.
response = self.url_open(
'/html_editor/attachment/add_data',
headers={'Content-Type': 'application/json'},
data=json_safe.dumps({'params': {
'name': 'test.gif',
'data': gif_base64,
'is_image': True,
}})
)
response = response.json()
self.assertFalse('error' in response, 'Upload failed: %s' % response.get('error', {}).get('message'))
attachment_id = response['result']['id']
image_src = response['result']['image_src']
mimetype = response['result']['mimetype']
self.assertEqual('image/gif', mimetype, "Wrong mimetype")
# Ensure image info can be retrieved.
response = self.url_open('/html_editor/get_image_info',
headers={'Content-Type': 'application/json'},
data=json_safe.dumps({
"params": {
"src": image_src,
}
}),
)
response = response.json()
self.assertEqual(attachment_id, response['result']['original']['id'], "Wrong id")
self.assertEqual(image_src, response['result']['original']['image_src'], "Wrong image_src")
self.assertEqual(mimetype, response['result']['original']['mimetype'], "Wrong mimetype")
def test_04_admin_attachment(self):
self.authenticate(self.admin, self.admin)
payload = self.build_rpc_payload({"name": "pixel", "data": self.pixel, "is_image": True})
response = self.url_open('/html_editor/attachment/add_data', data=json.dumps(payload), headers=self.headers)
self.assertEqual(200, response.status_code)
attachment = self.env['ir.attachment'].search([('name', '=', 'pixel')])
self.assertTrue(attachment)
domain = [('name', '=', 'pixel')]
result = attachment.search(domain)
self.assertTrue(len(result), "No attachment fetched")
self.assertEqual(result, attachment)
def test_05_internal_link_preview(self):
self.authenticate(self.admin, self.admin)
def _get_full_url(pathname):
return f"{self.base_url()}{pathname}"
def _patched_get_link_preview_from_url(url):
if url == _get_full_url("/page-with-description"):
return {
'og_description': 'Mocked page description',
}
elif url == _get_full_url("/page-without-description") or url == _get_full_url("/shop/category/1"):
return {
'og_description': None,
}
else:
return False
# retrieve metadata of an record without customerized link_preview_name but with display_name
response_without_preview_name = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url(f"/odoo/users/{self.portal_user.id}"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_without_preview_name.status_code)
self.assertTrue('display_name' in response_without_preview_name.text)
# retrieve metadata of a url with wrong action name
response_wrong_action = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/odoo/actionInvalid/1"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_wrong_action.status_code)
self.assertTrue('error_msg' in response_wrong_action.text)
# retrieve metadata of a url with wrong record id
response_wrong_record = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/odoo/users/9999"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_wrong_record.status_code)
self.assertTrue('error_msg' in response_wrong_record.text)
# retrieve metadata of a url not directing to a record
with patch.object(link_preview, 'get_link_preview_from_url', side_effect=_patched_get_link_preview_from_url):
# Check metadata for a URL that points to a valid frontend page with
# a page description set
response_page_with_desc = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/page-with-description"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_page_with_desc.status_code)
self.assertTrue('"description": "Mocked page description"' in response_page_with_desc.text)
# Check metadata for a URL that points to a valid frontend page with
# no page description set
response_page_without_desc = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/page-without-description"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_page_without_desc.status_code)
self.assertTrue('"result": {}' in response_page_without_desc.text)
response_page_without_desc = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/shop/category/1"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_page_without_desc.status_code)
self.assertTrue('"result": {}' in response_page_without_desc.text)
self.assertFalse('error_msg' in response_page_without_desc.text)
# Check metadata for a URL that points to an invalid/unknown page
invalid_page = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/invalid-page"),
}
}),
headers=self.headers
)
self.assertEqual(200, invalid_page.status_code)
self.assertTrue('"result": {}' in invalid_page.text)
# Attempt to retrieve metadata for path format `odoo/<model>/<record_id>`
response_model_record = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url(f"/odoo/res.users/{self.portal_user.id}"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_model_record.status_code)
self.assertTrue('display_name' in response_model_record.text)
self.assertIn(self.portal_user.display_name, response_model_record.text)
# Attempt to retrieve metadata for an abstract model
response_abstract_model = self.url_open(
'/html_editor/link_preview_internal',
data=json_safe.dumps({
"params": {
"preview_url": _get_full_url("/odoo/mail.thread/1"),
}
}),
headers=self.headers
)
self.assertEqual(200, response_abstract_model.status_code)
self.assertTrue('error_msg' in response_abstract_model.text)

View file

@ -0,0 +1,223 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import textwrap
from lxml import etree, html
from lxml.builder import E
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.addons.html_editor.models.ir_qweb_fields import html_to_text
class TestHTMLToText(BaseCase):
def test_rawstring(self):
self.assertEqual(
"foobar",
html_to_text(E.div("foobar")))
def test_br(self):
self.assertEqual(
"foo\nbar",
html_to_text(E.div("foo", E.br(), "bar")))
self.assertEqual(
"foo\n\nbar\nbaz",
html_to_text(E.div(
"foo", E.br(), E.br(),
"bar", E.br(),
"baz")))
def test_p(self):
self.assertEqual(
"foo\n\nbar\n\nbaz",
html_to_text(E.div(
"foo",
E.p("bar"),
"baz")))
self.assertEqual(
"foo",
html_to_text(E.div(E.p("foo"))))
self.assertEqual(
"foo\n\nbar",
html_to_text(E.div("foo", E.p("bar"))))
self.assertEqual(
"foo\n\nbar",
html_to_text(E.div(E.p("foo"), "bar")))
self.assertEqual(
"foo\n\nbar\n\nbaz",
html_to_text(E.div(
E.p("foo"),
E.p("bar"),
E.p("baz"),
)))
def test_div(self):
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.div("bar"),
"baz"
)))
self.assertEqual(
"foo",
html_to_text(E.div(E.div("foo"))))
self.assertEqual(
"foo\nbar",
html_to_text(E.div("foo", E.div("bar"))))
self.assertEqual(
"foo\nbar",
html_to_text(E.div(E.div("foo"), "bar")))
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.div("bar"),
E.div("baz")
)))
def test_other_block(self):
self.assertEqual(
"foo\nbar\nbaz",
html_to_text(E.div(
"foo",
E.section("bar"),
"baz"
)))
def test_inline(self):
self.assertEqual(
"foobarbaz",
html_to_text(E.div("foo", E.span("bar"), "baz")))
def test_whitespace(self):
self.assertEqual(
"foo bar\nbaz",
html_to_text(E.div(
"foo\nbar",
E.br(),
"baz")
))
self.assertEqual(
"foo bar\nbaz",
html_to_text(E.div(
E.div(E.span("foo"), " bar"),
"baz")))
class TestConvertBack(common.TransactionCase):
def setUp(self):
super().setUp()
self.env = self.env(context={'inherit_branding': True})
def field_rountrip_result(self, field, value, expected):
model = 'html_editor.converter.test'
record = self.env[model].create({field: value})
t = etree.Element('t')
e = etree.Element('span')
t.append(e)
field_value = 'record.%s' % field
e.set('t-field', field_value)
rendered = self.env['ir.qweb']._render(t, {'record': record})
element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8'))
model = 'ir.qweb.field.' + element.get('data-oe-type', '')
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
value_back = converter.from_html(model, record._fields[field], element)
if isinstance(expected, bytes):
expected = expected.decode('utf-8')
self.assertEqual(value_back, expected)
def field_roundtrip(self, field, value):
self.field_rountrip_result(field, value, value)
def test_integer(self):
self.field_roundtrip('integer', 42)
self.field_roundtrip('integer', 42000)
def test_float(self):
self.field_roundtrip('float', 42.567890)
self.field_roundtrip('float', 324542.567890)
def test_numeric(self):
self.field_roundtrip('numeric', 42.77)
def test_char(self):
self.field_roundtrip('char', "foo bar")
self.field_roundtrip('char', "ⒸⓄⓇⒼⒺ")
def test_selection_str(self):
self.field_roundtrip('selection_str', 'B')
def test_text(self):
self.field_roundtrip('text', textwrap.dedent("""\
You must obey the dance commander
Givin' out the order for fun
You must obey the dance commander
You know that he's the only one
Who gives the orders here,
Alright
Who gives the orders here,
Alright
It would be awesome
If we could dance-a
It would be awesome, yeah
Let's take the chance-a
It would be awesome, yeah
Let's start the show
Because you never know
You never know
You never know until you go"""))
def test_m2o(self):
""" the M2O field conversion (from html) is markedly different from
others as it directly writes into the m2o and returns nothing at all.
"""
field = 'many2one'
subrec1 = self.env['html_editor.converter.test.sub'].create({'name': "Foo"})
subrec2 = self.env['html_editor.converter.test.sub'].create({'name': "Bar"})
record = self.env['html_editor.converter.test'].create({field: subrec1.id})
t = etree.Element('t')
e = etree.Element('span')
t.append(e)
field_value = 'record.%s' % field
e.set('t-field', field_value)
rendered = self.env['ir.qweb']._render(t, {'record': record})
element = html.fromstring(rendered, parser=html.HTMLParser(encoding='utf-8'))
# emulate edition
element.set('data-oe-many2one-id', str(subrec2.id))
element.text = "New content"
model = 'ir.qweb.field.' + element.get('data-oe-type')
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
value_back = converter.from_html('html_editor.converter.test', record._fields[field], element)
self.assertIsNone(
value_back, "the m2o converter should return None to avoid spurious"
" or useless writes on the parent record")
self.assertEqual(
subrec1.name,
"Foo",
"element edition can't change directly the m2o record"
)
self.assertEqual(
record.many2one.name,
"Bar",
"element edition should have been change the m2o id"
)

View file

@ -0,0 +1,355 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo.tests.common import BaseCase
from odoo.addons.html_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><added>bar</added></p>"
"<p><added>bor</added></p>"
"<p><added>bir</added><removed>buz</removed></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_is_equal(self):
initial_content = "<p>foo</p><p>bar</p>"
new_content = "<p>foo</p><p>bar</p>"
patch = generate_patch(new_content, initial_content)
self.assertEqual(patch, "")
restored_initial_content = apply_patch(new_content, patch)
self.assertEqual(restored_initial_content, initial_content)
initial_content = ""
new_content = ""
patch = generate_patch(new_content, initial_content)
self.assertEqual(patch, "")
restored_initial_content = apply_patch(new_content, patch)
self.assertEqual(restored_initial_content, initial_content)
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])
def test_replace_tag(self):
initial_content = "<blockquote>foo</blockquote>"
new_content = "<code>foo</code>"
comparison = generate_comparison(new_content, initial_content)
self.assertEqual(
comparison,
"<blockquote><added>foo</added></blockquote>"
"<code><removed>foo</removed></code>",
)
def test_replace_complex(self):
initial_content = (
"<blockquote>foo</blockquote>"
"<blockquote>bar</blockquote>"
"<blockquote>baz</blockquote>"
"<p>---<span>***</span>---</p>"
"<blockquote>only content change</blockquote>"
"<p>+++<span>~~~</span>+++</p>"
"<blockquote>content and tag change</blockquote>"
"<p>???<span>===</span>???</p>"
"<blockquote>111</blockquote>"
"<blockquote>222</blockquote>"
"<blockquote>333</blockquote>"
)
new_content = (
"<code>foo</code>"
"<code>bar</code>"
"<code>baz</code>"
"<p>---<span>***</span>---</p>"
"<blockquote>lorem ipsum</blockquote>"
"<p>+++<span>~~~</span>+++</p>"
"<code>dolor sit amet</code>"
"<p>???<span>===</span>???</p>"
"<blockquote>aaa</blockquote>"
"<blockquote>bbb</blockquote>"
"<blockquote>ccc</blockquote>"
)
comparison = generate_comparison(new_content, initial_content)
self.assertEqual(
comparison,
"<blockquote><added>foo</added></blockquote>"
"<blockquote><added>bar</added></blockquote>"
"<blockquote><added>baz</added></blockquote>"
"<code><removed>foo</removed></code>"
"<code><removed>bar</removed></code>"
"<code><removed>baz</removed></code>"
"<p>---<span>***</span>---</p>"
"<blockquote><added>only content change</added>"
"<removed>lorem ipsum</removed></blockquote>"
"<p>+++<span>~~~</span>+++</p>"
"<blockquote><added>content and tag change</added></blockquote>"
"<code><removed>dolor sit amet</removed></code>"
"<p>???<span>===</span>???</p>"
"<blockquote><added>111</added><removed>aaa</removed></blockquote>"
"<blockquote><added>222</added><removed>bbb</removed></blockquote>"
"<blockquote><added>333</added><removed>ccc</removed></blockquote>",
)
def test_replace_tag_multiline(self):
initial_content = (
"<blockquote>foo</blockquote>"
"<code>bar lorem ipsum dolor</code>"
"<blockquote>baz</blockquote>"
)
new_content = (
"<code>foo</code>"
"<blockquote>bar lorem ipsum dolor</blockquote>"
"<code>baz</code>"
)
comparison = generate_comparison(new_content, initial_content)
self.assertEqual(
comparison,
"<blockquote><added>foo</added></blockquote>"
"<code><added>bar lorem ipsum dolor</added>"
"<removed>foo</removed></code>"
"<blockquote><added>baz</added>"
"<removed>bar lorem ipsum dolor</removed></blockquote>"
"<code><removed>baz</removed></code>",
)
def test_replace_nested_divs(self):
initial_content = "<div class='A1'><div class='A2'><b>A</b></div></div>"
new_content = "<div class='B1'><div class='B2'><i>B</i></div></div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off because of the limitation of the current
# comparison system :
# We can't easily generate comparison when only the tag parameters
# changes, because the diff system will not contain the closing tags
# in this case.
#
# This is why we choose to have the comparison below instead of :
# <div class='A1'><div class='A2'>
# <b><removed>A</removed></b>
# </div></div>
# <div class='B1'><div class='B2'>
# <i><added>B</added></i>
# </div></div>
#
# If we need to improve this in the future, we would probably have to
# change drastically the comparison system to add a way to parse HTML.
self.assertEqual(
comparison,
"<div class='A1'><div class='A2'>"
"<b><added>A</added></b>"
"<i><removed>B</removed></i>"
"</div></div>",
)
def test_same_tag_replace_fixer(self):
initial_content = "<div><p><b>A</b><b>B</b></p></div>"
new_content = "<div>X<p><b>B</b></p></div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div><removed>X</removed>"
"<p><b><added>A</added></b>"
"<b>B</b></p></div>",
)
def test_simple_removal(self):
initial_content = "<div><p>A</p></div>"
new_content = "<div>X<p>A</p></div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div><removed>X</removed><p>A</p></div>",
)
def test_simple_addition(self):
initial_content = "<div>X<p>A</p></div>"
new_content = "<div><p>A</p></div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div><added>X</added><p>A</p></div>",
)
def test_replace_just_class(self):
initial_content = "<div class='A1'>A</div>"
new_content = "<div class='B1'>A</div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div class='A1'>A</div>",
)
def test_replace_twice_just_class(self):
initial_content = (
"<div class='A1'>A</div><p>abc</p><div class='D1'>D</div>"
)
new_content = "<div class='B1'>A</div><p>abc</p><div class='E1'>D</div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div class='A1'>A</div><p>abc</p><div class='D1'>D</div>",
)
def test_replace_with_just_class(self):
initial_content = "<p>abc</p><div class='A1'>A</div>"
new_content = "<p>def</p><div class='B1'>A</div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<p><added>abc</added><removed>def</removed></p>"
"<div class='A1'>A</div>",
)
def test_replace_class_and_content(self):
initial_content = "<div class='A1'>A</div>"
new_content = "<div class='B1'>B</div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div class='A1'><added>A</added><removed>B</removed></div>",
)
def test_replace_class_and_deep_content(self):
initial_content = "<div class='A1'><p><i>A</i></p></div>"
new_content = "<div class='B1'><p><i>B</i></p></div>"
comparison = generate_comparison(new_content, initial_content)
# This is a trade-off, see explanation in test_replace_nested_divs.
self.assertEqual(
comparison,
"<div class='A1'><p><i>"
"<added>A</added><removed>B</removed>"
"</i></p></div>",
)

View file

@ -0,0 +1,154 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo.tests import common, tagged
from odoo.addons.html_editor import tools
@tagged("post_install", "-at_install")
class TestVideoUtils(common.BaseCase):
urls = {
'youtube': 'https://www.youtube.com/watch?v=xCvFZrrQq7k',
'youtube_shorts_video': 'https://www.youtube.com/shorts/qAgW3oG7Zmc',
'youtube_live_stream': 'https://www.youtube.com/live/fmVNEoxr7iU?feature=shared',
'youtube_mobile': 'https://m.youtube.com/watch?v=xCvFZrrQq7k',
'vimeo': 'https://vimeo.com/395399735',
'vimeo_unlisted_video': 'https://vimeo.com/795669787/0763fdb816',
'vimeo_player': 'https://player.vimeo.com/video/395399735',
'vimeo_player_unlisted_video': 'https://player.vimeo.com/video/795669787?h=0763fdb816',
'dailymotion': 'https://www.dailymotion.com/video/x7svr6t',
'youku': 'https://v.youku.com/v_show/id_XMzY1MjY4.html?spm=a2hzp.8244740.0.0',
'instagram': 'https://www.instagram.com/p/B6dXGTxggTG/',
'dailymotion_hub_no_video': 'http://www.dailymotion.com/hub/x9q_Galatasaray',
'dailymotion_hub_#video': 'http://www.dailymotion.com/hub/x9q_Galatasaray#video=x2jvvep',
'dai.ly': 'https://dai.ly/x578has',
'dailymotion_embed': 'https://www.dailymotion.com/embed/video/x578has?autoplay=1',
'dailymotion_video_extra': 'https://www.dailymotion.com/video/x2jvvep_hakan-yukur-klip_sport',
'player_youku': 'https://player.youku.com/player.php/sid/XMTI5Mjg5NjE4MA==/v.swf',
'youku_embed': 'https://player.youku.com/embed/XNTIwMzE1MzUzNg',
"facebook_video": "http://www.facebook.com/watch?v=2206239373151307",
"facebook_reel": "https://www.facebook.com/reel/568986686120283",
}
def test_player_regexes(self):
# youtube
self.assertIsNotNone(re.search(tools.player_regexes['youtube'], TestVideoUtils.urls['youtube']))
self.assertIsNotNone(re.search(tools.player_regexes['youtube'], TestVideoUtils.urls['youtube_shorts_video']))
self.assertIsNotNone(re.search(tools.player_regexes['youtube'], TestVideoUtils.urls['youtube_live_stream']))
# vimeo
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo_unlisted_video']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo_player']))
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo_player_unlisted_video']))
# dailymotion
self.assertIsNotNone(re.search(tools.player_regexes['dailymotion'], TestVideoUtils.urls['dailymotion']))
# youku
self.assertIsNotNone(re.search(tools.player_regexes['youku'], TestVideoUtils.urls['youku']))
# instagram
self.assertIsNotNone(re.search(tools.player_regexes['instagram'], TestVideoUtils.urls['instagram']))
# facebook
self.assertIsNotNone(re.search(tools.player_regexes["facebook"], TestVideoUtils.urls["facebook_video"]))
self.assertIsNotNone(re.search(tools.player_regexes["facebook"], TestVideoUtils.urls["facebook_reel"]))
def test_get_video_source_data(self):
self.assertEqual(3, len(tools.get_video_source_data(TestVideoUtils.urls['youtube'])))
# youtube
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[0])
self.assertEqual('xCvFZrrQq7k', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[1])
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube_shorts_video'])[0])
self.assertEqual('qAgW3oG7Zmc', tools.get_video_source_data(TestVideoUtils.urls['youtube_shorts_video'])[1])
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube_live_stream'])[0])
self.assertEqual('fmVNEoxr7iU', tools.get_video_source_data(TestVideoUtils.urls['youtube_live_stream'])[1])
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube_mobile'])[0])
self.assertEqual('xCvFZrrQq7k', tools.get_video_source_data(TestVideoUtils.urls['youtube_mobile'])[1])
# vimeo
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[0])
self.assertEqual('395399735', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_unlisted_video'])[0])
self.assertEqual('795669787', tools.get_video_source_data(TestVideoUtils.urls['vimeo_unlisted_video'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player'])[0])
self.assertEqual('395399735', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player'])[1])
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player_unlisted_video'])[0])
self.assertEqual('795669787', tools.get_video_source_data(TestVideoUtils.urls['vimeo_player_unlisted_video'])[1])
# dailymotion
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[0])
self.assertEqual('x7svr6t', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[1])
self.assertEqual(None, tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_no_video']))
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_#video'])[0])
self.assertEqual('x2jvvep', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_hub_#video'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dai.ly'])[0])
self.assertEqual('x578has', tools.get_video_source_data(TestVideoUtils.urls['dai.ly'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_embed'])[0])
self.assertEqual('x578has', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_embed'])[1])
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_video_extra'])[0])
self.assertEqual('x2jvvep', tools.get_video_source_data(TestVideoUtils.urls['dailymotion_video_extra'])[1])
# youku
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['youku'])[0])
self.assertEqual('XMzY1MjY4', tools.get_video_source_data(TestVideoUtils.urls['youku'])[1])
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['player_youku'])[0])
self.assertEqual('XMTI5Mjg5NjE4MA', tools.get_video_source_data(TestVideoUtils.urls['player_youku'])[1])
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['youku_embed'])[0])
self.assertEqual('XNTIwMzE1MzUzNg', tools.get_video_source_data(TestVideoUtils.urls['youku_embed'])[1])
# instagram
self.assertEqual('instagram', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[0])
self.assertEqual('B6dXGTxggTG', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[1])
# facebook
self.assertEqual("facebook", tools.get_video_source_data(TestVideoUtils.urls["facebook_video"])[0])
self.assertEqual("2206239373151307", tools.get_video_source_data(TestVideoUtils.urls["facebook_video"])[1])
self.assertEqual("facebook", tools.get_video_source_data(TestVideoUtils.urls["facebook_reel"])[0])
self.assertEqual("568986686120283", tools.get_video_source_data(TestVideoUtils.urls["facebook_reel"])[1])
def test_get_video_url_data(self):
self.assertEqual(4, len(tools.get_video_url_data(TestVideoUtils.urls['youtube'])))
# youtube
for key in ['youtube', 'youtube_shorts_video', 'youtube_live_stream']:
self.assertEqual('youtube', tools.get_video_url_data(TestVideoUtils.urls[key])['platform'])
# vimeo
for key in ['vimeo', 'vimeo_player']:
self.assertEqual(tools.get_video_url_data(TestVideoUtils.urls[key]), {
'platform': 'vimeo',
'embed_url': '//player.vimeo.com/video/395399735?autoplay=0&dnt=1',
'video_id': '395399735',
'params': {
'autoplay': 0,
'dnt': 1,
}
})
for key in ['vimeo_unlisted_video', 'vimeo_player_unlisted_video']:
self.assertEqual(tools.get_video_url_data(TestVideoUtils.urls[key]), {
'platform': 'vimeo',
'embed_url': '//player.vimeo.com/video/795669787?autoplay=0&dnt=1&h=0763fdb816',
'video_id': '795669787',
'params': {
'autoplay': 0,
'dnt': 1,
'h': '0763fdb816',
}
})
# dailymotion
self.assertEqual('dailymotion', tools.get_video_url_data(TestVideoUtils.urls['dailymotion'])['platform'])
# youku
self.assertEqual('youku', tools.get_video_url_data(TestVideoUtils.urls['youku'])['platform'])
# instagram
self.assertEqual('instagram', tools.get_video_url_data(TestVideoUtils.urls['instagram'])['platform'])
def test_valid_video_url(self):
self.assertIsNotNone(re.search(tools.valid_url_regex, TestVideoUtils.urls['youtube']))
@tagged('-standard', 'external')
class TestVideoUtilsExternal(common.BaseCase):
def test_get_video_thumbnail(self):
# youtube
for key in ['youtube', 'youtube_shorts_video', 'youtube_live_stream']:
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls[key]), bytes)
# vimeo
for key in ['vimeo', 'vimeo_unlisted_video', 'vimeo_player', 'vimeo_player_unlisted_video']:
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls[key]), bytes)
# dailymotion
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['dailymotion']), bytes)
# instagram
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['instagram']), bytes)
# default
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['youku']), bytes)

View file

@ -0,0 +1,76 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import TransactionCase
class TestViews(TransactionCase):
def setUp(self):
super().setUp()
View = self.env['ir.ui.view']
self.first_view = View.create({
'name': 'Test View 1',
'type': 'qweb',
'arch': '<div>Hello World</div>',
'key': 'html_editor.test_first_view',
})
self.second_view = View.create({
'name': 'Test View 2',
'type': 'qweb',
'arch': '<div><t t-call="html_editor.test_first_view"/></div>',
'key': 'html_editor.test_second_view',
})
def test_infinite_inherit_loop(self):
# Creates an infinite loop: A t-call B and A inherit from B
View = self.env['ir.ui.view']
self.second_view.write({
'inherit_id': self.first_view.id,
})
# Test for RecursionError: maximum recursion depth exceeded in this function
View._views_get(self.first_view)
def test_oe_structure_as_inherited_view(self):
View = self.env['ir.ui.view']
base = View.create({
'name': 'Test View oe_structure',
'type': 'qweb',
'arch': """<xpath expr='//t[@t-call="html_editor.test_first_view"]' position='after'>
<div class="oe_structure" id='oe_structure_test_view_oe_structure'/>
</xpath>""",
'key': 'html_editor.oe_structure_view',
'inherit_id': self.second_view.id
})
# check view mode
self.assertEqual(base.mode, 'extension')
# update content of the oe_structure
value = '''<div class="oe_structure" id="oe_structure_test_view_oe_structure" data-oe-id="%s"
data-oe-xpath="/div" data-oe-model="ir.ui.view" data-oe-field="arch">
<p>Hello World!</p>
</div>''' % base.id
base.save(value=value, xpath='/xpath/div')
self.assertEqual(len(base.inherit_children_ids), 1)
self.assertEqual(base.inherit_children_ids.mode, 'extension')
self.assertIn(
'<p>Hello World!</p>',
base.inherit_children_ids.get_combined_arch(),
)
def test_find_available_name(self):
View = self.env['ir.ui.view']
used_names = ['Unrelated name']
initial_name = "Test name"
name = View._find_available_name(initial_name, used_names)
self.assertEqual(initial_name, name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (2)', name)
used_names.append(name)
name = View._find_available_name(initial_name, used_names)
self.assertEqual('Test name (3)', name)