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
543 lines
21 KiB
Python
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}')
|