Files
full_encoach_platform/odoo/addons/website/tests/test_qweb.py
Yamen Ahmad 3e83d8d7d5 feat: add complete EnCoach backend — Odoo 19 + all addons
Includes:
- Odoo 19 framework (odoo/)
- 27 custom EnCoach addons (new_project/custom_addons/)
  - encoach_core, encoach_api, encoach_lms_api, encoach_adaptive_api
  - encoach_exam, encoach_taxonomy, encoach_adaptive, encoach_assignment
  - encoach_ai, encoach_ai_grading, encoach_ai_generation, encoach_ai_media
  - encoach_courseware, encoach_communication, encoach_subscription
  - encoach_notification, encoach_approval, encoach_branding
  - encoach_classroom, encoach_registration, encoach_stats
  - encoach_faq, encoach_ticket, encoach_training, encoach_resources
  - encoach_adaptive_ai, encoach_sis
- 21 OpenEduCat Enterprise modules (new_project/enterprise-19/)
- 14 OpenEduCat Community modules (new_project/openeducat_erp-19.0/)
- Configuration: odoo.conf, requirements.txt, scripts
- 200+ REST API endpoints with JWT authentication
- SRS and test documentation

Made-with: Cursor
2026-04-01 17:10:04 +04:00

543 lines
21 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from contextlib import contextmanager
from odoo import http
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
from odoo.addons.http_routing.tests.common import MockRequest
from odoo.tests.common import TransactionCase, tagged
class TestQweb(TransactionCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_demo.group_ids = cls.env.ref('base.group_user')
def test_qweb_post_processing_att(self):
website = self.env.ref('website.default_website')
t = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
'arch_db': '''<t t-name="attr-escaping">
<img src="http://test.external.img/img.png"/>
<img t-att-src="url"/>
</t>'''
})
result = """
<img src="http://test.external.img/img.png" loading="lazy"/>
<img src="http://test.external.img/img2.png" loading="lazy"/>
"""
rendered = self.env['ir.qweb']._render(t.id, {'url': 'http://test.external.img/img2.png'}, website_id=website.id)
self.assertEqual(rendered.strip(), result.strip())
def test_render_context_website(self):
self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
'key': 'website.dummy',
'arch_db': '<t t-name="dummy"><span>Stuff</span></t>'
})
template = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
'key': 'root',
'arch_db': '''<t t-name="root"><div><t t-call="website.dummy"/></div></t>'''
})
result = """<div><span>Stuff</span></div>"""
rendered = self.env['ir.qweb']._render(template.id)
self.assertEqual(rendered.strip(), result.strip(), 'First rendering (without website_id)')
rendered = self.env['ir.qweb'].with_context(website_id=1)._render(template.id)
self.assertEqual(rendered.strip(), result.strip(), 'Second rendering (with website_id=1)')
rendered = self.env['ir.qweb'].with_context(website_id=None)._render(template.id)
self.assertEqual(rendered.strip(), result.strip(), 'Third rendering (with website_id=None)')
rendered = self.env['ir.qweb'].with_context(website_id=1)._render(template.id)
self.assertEqual(rendered.strip(), result.strip(), 'Fourth rendering (with website_id=1)')
def test_render_query_count(self):
"""
see also test_call_query_count test in base/tests/test_queb.py
"""
IrUiView = self.env['ir.ui.view']
IrUiView.create({
'name': 'test',
'type': 'qweb',
'key': 'base.testing_unused',
'arch_db': '''<span>unused</span>''',
})
header_0 = IrUiView.create({
'name': 'test',
'type': 'qweb',
'key': 'base.testing_header_0',
'arch_db': '''<span>0</span>''',
})
IrUiView.create([{ # website_id=1
'name': 'test',
'type': 'qweb',
'website_id': 1,
'key': 'base.testing_header_1',
'arch_db': '''<span>WITH WEBSITE</span>''',
}, { # same key but website_id=False
'name': 'test',
'type': 'qweb',
'website_id': False,
'key': 'base.testing_header_1',
'arch_db': '''<span>NO WEBSITE</span>''',
}, {
'name': 'test',
'type': 'qweb',
'key': 'base.testing_header',
'arch_db': f'''<t t-name="base.testing_header">
<t t-call="{header_0.id}"/>
<header>header</header>
<t t-call="base.testing_header_1"/>
</t>''',
}, {
'name': 'test',
'type': 'qweb',
'key': 'base.testing_footer_0',
'arch_db': '''<span>0</span>''',
}, {
'name': 'test',
'type': 'qweb',
'key': 'base.testing_footer_1',
'arch_db': '''<span>1</span>''',
}, { # website_id=False
'name': 'test',
'type': 'qweb',
'key': 'base.testing_footer',
'arch_db': '''<t t-name="base.testing_footer">
<t t-call="base.testing_footer_0"/>
<footer>footer</footer>
<t t-call="base.testing_footer_1"/>
</t>''',
}, { # website_id=1
'name': 'test',
'type': 'qweb',
'website_id': 1,
'key': 'base.testing_footer',
'arch_db': '''<t t-name="base.testing_footer">
<t t-call="base.testing_footer_0"/>
<footer>footer WITH WEBSITE</footer>
<t t-call="base.testing_footer_1"/>
</t>''',
}, {
'name': 'test',
'type': 'qweb',
'key': 'base.testing_layout',
'arch_db': '''<t t-name="base.testing_layout">
<section>
<div id="header"><t t-call="base.testing_header"/></div>
<article><t t-out="0"/></article>
<div id="footer"><t t-call="base.testing_footer"/></div>
</section>
</t>''',
}])
view = IrUiView.create({
'name': 'test',
'type': 'qweb',
'key': 'base.testing_content',
'arch_db': '''<t t-call="base.testing_layout"><div><t t-call="base.testing_header_0"/><t t-out="doc"/></div></t>''',
})
website = self.env['website'].browse(1)
other_website = self.env['website'].create({'name': 'testing'})
expected = """
<section>
<div id="header"><span>0</span>
<header>header</header><span>NO WEBSITE</span></div>
<article><div><span>0</span>%s</div></article>
<div id="footer"><span>0</span>
<footer>footer</footer><span>1</span></div>
</section>"""
expected_website = """
<section>
<div id="header"><span>0</span>
<header>header</header><span>WITH WEBSITE</span></div>
<article><div><span>0</span>%s</div></article>
<div id="footer"><span>0</span>
<footer>footer WITH WEBSITE</footer><span>1</span></div>
</section>"""
env = self.env(user=self.user_demo, context={
'lang': 'en_US',
'website_id': other_website.id,
'minimal_qcontext': True,
'cookies_allowed': True,
})
# add some website information in cache (default website, lang...)
env['ir.qweb']._render('base.testing_unused')
with MockRequest(env, website=website) as request:
# SELECT res_lang
# SELECT ir_attachment from res.lang
# SELECT website.id from domain
# SELECT website.id ORDER BY sequence (without WHERE)
request.env['ir.qweb']._render('base.testing_unused')
# do not count those fetching queries
env.user.fetch(['name'])
website.with_env(env).fetch(['name'])
other_website.with_env(env).fetch(['name'])
def invalidate(*args):
if 'templates' in args:
env.registry.clear_cache('templates')
if 'view' in args:
IrUiView.invalidate_model()
def check(template, name, queries):
init = env.cr.sql_log_count
value = str(env['ir.qweb']._render(template, {'doc': name}))
self.assertEqual(value, expected % name)
self.assertEqual(env.cr.sql_log_count - init, queries, f'Maximum queries: {queries}')
def check_website(template, name, queries):
queries += 1
init = env.cr.sql_log_count
with MockRequest(env, website=website) as request:
value = str(request.env['ir.qweb']._render(template, {'doc': name}))
self.assertEqual(value, expected_website % name)
self.assertEqual(env.cr.sql_log_count - init, queries, f'Maximum queries: {queries}')
# SELECT visibility (from _render) + fields from in cache
# 'base.testing_content'
# SELECT RECURSIVE arch combine
# 'base.testing_layout', 'base.testing_header_0'
# SELECT id + fields from (xmlid + website_id)
# SELECT RECURSIVE arch combine
# 'base.testing_header', 'base.testing_footer'
# SELECT id + fields from (xmlid + website_id)
# SELECT RECURSIVE arch combine
# 'base.testing_header_1', 'base.testing_footer_0', 'base.testing_footer_1'
# SELECT id + fields from (xmlid + website_id)
# SELECT RECURSIVE arch combine
FIRST_SEARCH_FETCH = 1 # instead of the first SELECT visibility
OTHER_SEARCH_FETCH = 3 # "SELECT id + fields from xmlid"
ARCH_COMBINE = 4 # SELECT RECURSIVE arch combine
invalidate('templates', 'view')
check('base.testing_content', 'test-cold-0',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
invalidate('templates', 'view')
check_website('base.testing_content', 'test-cold-0',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
check('base.testing_content', 'test-cold-0',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
check('base.testing_content', 'test-hot-0', 0)
check_website('base.testing_content', 'test-hot-0', 0)
check('base.testing_content', 'test-hot-1', 0)
check_website('base.testing_content', 'test-hot-1', 0)
invalidate('view')
check('base.testing_content', 'test-hot-2', 0)
invalidate('view')
check_website('base.testing_content', 'test-hot-2', 0)
check(view.id, 'test-hot-id', 0)
check_website(view.id, 'test-hot-id', 0)
# like 'test-cold-0'
invalidate('templates')
check(view.id, 'test-cold-id-1',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
invalidate('templates')
check(view.id, 'test-cold-id-1',
0 + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 7
invalidate('templates')
check_website(view.id, 'test-cold-id-1',
0 + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 7
# like 'test-cold-0' the first search query is replaced by a fetching
invalidate('templates', 'view')
check_website(view.id, 'test-cold-id-2',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
invalidate('templates', 'view')
check(view.id, 'test-cold-id-2',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
env = self.env(user=self.user_demo, context={
'lang': 'en_US',
'minimal_qcontext': True,
'cookies_allowed': True,
})
# like 'test-cold-0'
invalidate('templates')
check('base.testing_content', 'test-cold-1',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
invalidate('templates')
check_website('base.testing_content', 'test-cold-1',
FIRST_SEARCH_FETCH + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 8
# like 'test-cold-0'
invalidate('templates')
check_website(view.id, 'test-cold-id-3',
0 + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 7
invalidate('templates')
check(view.id, 'test-cold-id-3',
0 + OTHER_SEARCH_FETCH + ARCH_COMBINE) # 7
class TestQwebProcessAtt(TransactionCase):
def setUp(self):
super(TestQwebProcessAtt, self).setUp()
self.website = self.env.ref('website.default_website')
self.env['res.lang']._activate_lang('fr_FR')
self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr')
self.website.default_lang_id = self.env.ref('base.lang_en')
self.website.cdn_activated = True
self.website.cdn_url = "http://test.cdn"
self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^(/[a-z]{2})?/a$", "^/b$"])
def _test_att(self, url, expect, tag='a', attribute='href'):
self.assertEqual(
self.env['ir.qweb']._post_processing_att(tag, {attribute: url}),
expect
)
def test_process_att_no_request(self):
# no request so no URL rewriting
self._test_att('/', {'href': '/'})
self._test_att('/en', {'href': '/en'})
self._test_att('/fr', {'href': '/fr'})
# no URL rewritting for CDN
self._test_att('/a', {'href': '/a'})
def test_process_att_no_website(self):
with MockRequest(self.env):
# no website so URL rewriting
self._test_att('/', {'href': '/'})
self._test_att('/en', {'href': '/en'})
self._test_att('/fr', {'href': '/fr'})
# no URL rewritting for CDN
self._test_att('/a', {'href': '/a'})
def test_process_att_monolang_route(self):
with MockRequest(self.env, website=self.website, multilang=False):
# lang not changed in URL but CDN enabled
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en/a', {'href': 'http://test.cdn/en/a'})
self._test_att('/b', {'href': 'http://test.cdn/b'})
self._test_att('/en/b', {'href': '/en/b'})
def test_process_att_no_request_lang(self):
with MockRequest(self.env, website=self.website):
self._test_att('/', {'href': '/'})
self._test_att('/en/', {'href': '/'})
self._test_att('/fr/', {'href': '/fr/'})
self._test_att('/fr', {'href': '/fr'})
def test_process_att_with_request_lang(self):
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}):
self._test_att('/', {'href': '/fr'})
self._test_att('/en/', {'href': '/'})
self._test_att('/fr/', {'href': '/fr/'})
self._test_att('/fr', {'href': '/fr'})
def test_process_att_matching_cdn_and_lang(self):
with MockRequest(self.env, website=self.website):
# lang prefix is added before CDN
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en/a', {'href': 'http://test.cdn/a'})
self._test_att('/fr/a', {'href': 'http://test.cdn/fr/a'})
self._test_att('/b', {'href': 'http://test.cdn/b'})
self._test_att('/en/b', {'href': 'http://test.cdn/b'})
self._test_att('/fr/b', {'href': '/fr/b'})
def test_process_att_no_route(self):
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False):
# default on multilang=True if route is not /{module}/static/
self._test_att('/web/static/hi', {'href': '/web/static/hi'})
self._test_att('/my-page', {'href': '/fr/my-page'})
def test_process_att_url_crap(self):
with MockRequest(self.env, website=self.website):
match = http.root.get_db_router.return_value.bind.return_value.match
# #{fragment} is stripped from URL when testing route
self._test_att('/x#y?z', {'href': '/x#y?z'})
match.assert_called_with('/x', method='POST', query_args=None)
match.reset_calls()
self._test_att('/x?y#z', {'href': '/x?y#z'})
match.assert_called_with('/x', method='POST', query_args='y')
@tagged('-at_install', 'post_install')
class TestQwebDataSnippet(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['ir.ui.view'].create({
'name': 'some_html',
'type': 'qweb',
'key': 'website.some_html',
'arch': '''
<t t-name="some_html">
<article>
<span>Hello</span>
<t t-out="0"/>
</article>
</t>
'''
})
cls.env['ir.ui.view'].create({
'name': 's_a',
'type': 'qweb',
'key': 'website.s_a',
'arch': '''
<t t-name="s_a">
<section class="hello">
<t t-call="website.some_html"/>
<t t-out="0"/>
</section>
</t>
'''
})
cls.env['ir.ui.view'].create({
'name': 's_b',
'type': 'qweb',
'key': 'website.s_b',
'arch': '''
<t t-name="s_b">
<section class="foo">
<t t-snippet-call="website.s_a"/>
</section>
</t>
'''
})
cls.env['ir.ui.view'].create({
'name': 's_c',
'type': 'qweb',
'key': 'website.s_c',
'arch': '''
<t t-name="s_c">
<t t-call="website.some_html">
<p>World!</p>
</t>
</t>
'''
})
cls.env['ir.ui.view'].create({
'name': 's_d',
'type': 'qweb',
'key': 'website.s_d',
'arch_db': '''
<t t-name="s_d">
<t t-snippet-call="website.s_a">
<p>World!</p>
</t>
</t>
'''
})
def _normalize_xml(self, html):
return "\n".join(
line.strip() for line in html.strip().splitlines() if line.strip()
)
def _render_snippet(self, snippet):
render_template = self.env['ir.ui.view'].create({
'name': f't-snippet-call_{snippet}',
'type': 'qweb',
'arch': f'''
<t t-snippet-call="{snippet}"/>
'''
})
return self.env['ir.qweb']._render(render_template.id)
def test_t_call_inside_snippet(self):
expected_output = '''
<section class="hello" data-snippet="s_a">
<article>
<span>Hello</span>
</article>
</section>
'''
rendered = self._render_snippet('website.s_a')
self.assertEqual(self._normalize_xml(rendered), self._normalize_xml(expected_output))
def test_t_snippet_call_inside_snippet(self):
expected_output = '''
<section class="foo" data-snippet="s_b">
<section class="hello" data-snippet="s_a">
<article>
<span>Hello</span>
</article>
</section>
</section>
'''
rendered = self._render_snippet('website.s_b')
self.assertEqual(self._normalize_xml(rendered), self._normalize_xml(expected_output))
def test_t_call_as_snippet_root(self):
expected_output = '''
<article data-snippet="s_c">
<span>Hello</span>
<p>World!</p>
</article>
'''
rendered = self._render_snippet('website.s_c')
self.assertEqual(self._normalize_xml(rendered), self._normalize_xml(expected_output))
def test_t_snippet_call_as_snippet_root(self):
expected_output = '''
<section class="hello" data-snippet="s_a">
<article>
<span>Hello</span>
</article>
<p>World!</p>
</section>
'''
rendered = self._render_snippet('website.s_d')
self.assertEqual(self._normalize_xml(rendered), self._normalize_xml(expected_output))
def test_call_query_count_snippets_template(self):
actual_queries = []
with contextmanager(lambda: self._patchExecute(actual_queries))():
with MockRequest(self.env, website=self.env['website'].browse(1)):
render = self.env['ir.ui.view'].render_public_asset('website.snippets')
self.assertTrue('name="Blockquote"' in render)
re_sql = re.compile(r'\bir_ui_view\b', re.IGNORECASE)
ir_ui_view_queries = [q for q in actual_queries if re_sql.search(q)]
# nb_snippets = 156
first_search = 1 # for key & website
t_call_snippets = 2 # number of nested t-calls (t-call > view > t-call > other views...)
fetch_snippets = 0 # number of fetches (normally performed with the previous search)
get_root_view = 1 # determine the root views
combine_views = 3 # Queries performed to execute the read combine
all_ir_ui_view_queries = first_search + t_call_snippets + fetch_snippets + get_root_view + combine_views # 9
self.assertEqual(len(ir_ui_view_queries), all_ir_ui_view_queries, f'ir_ui_view queries: {all_ir_ui_view_queries}')
re_sql = re.compile(r'\bwebsite\b', re.IGNORECASE)
website_queries = [q for q in actual_queries if re_sql.search(q)]
str_queries = "\n".join(website_queries)
self.assertLessEqual(len(website_queries), 20, f'Maximum queries: {20}\n{str_queries}')