[IMP] spreadsheet_dashboard: use py test to validate dashboard data
use a python test to walk throuhg jsons instead of tours Tours: - you need to add one for each dashboard. It's a PITA and it doesn't scale - they only check visible formulas since formulas are loaded only if they are in the viewport (I'm not even sure what is "visible" in chrome headless) - they are slow - can't check fields defined in global filters, links to odoo menu/views `./odoo-bin --addons-path=../enterprise,./addons -d odoo-db -u --test-enable` before: `59,76s user 5,78s system 29% cpu 3:43,15 total` after: `15,12s user 1,20s system 86% cpu 18,912 total` The Expenses dashboard was broken by https://github.com/odoo/odoo/pull/98914 But I want to merge this test right now. Task 2980180 closes odoo/odoo#100757 X-original-commit: 17afe2987e4146b85187b31a34caf7146d5a7cf2 Related: odoo/enterprise#31598 Signed-off-by: Rémi Rahir (rar) <rar@odoo.com> Signed-off-by: Lucas Lefèvre (lul) <lul@odoo.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
import json
|
||||
import re
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
markdown_link_regex = r"^\[([^\[]+)\]\((.+)\)$"
|
||||
|
||||
xml_id_url_prefix = "odoo://ir_menu_xml_id/"
|
||||
|
||||
odoo_view_link_prefix = "odoo://view/"
|
||||
|
||||
|
||||
def odoo_charts(data):
|
||||
"""return all odoo chart definition in the spreadsheet"""
|
||||
figures = []
|
||||
for sheet in data["sheets"]:
|
||||
figures += [
|
||||
figure["data"]
|
||||
for figure in sheet["figures"]
|
||||
if figure["tag"] == "chart" and figure["data"]["type"].startswith("odoo_")
|
||||
]
|
||||
return figures
|
||||
|
||||
|
||||
def links_urls(data):
|
||||
"""return all markdown links in cells"""
|
||||
urls = []
|
||||
link_prefix = "odoo://view/"
|
||||
for sheet in data["sheets"]:
|
||||
for cell in sheet["cells"].values():
|
||||
content = cell.get("content", "")
|
||||
match = re.match(markdown_link_regex, content)
|
||||
if match and match.group(2).startswith(link_prefix):
|
||||
urls.append(match.group(2))
|
||||
return urls
|
||||
|
||||
|
||||
def odoo_view_links(data):
|
||||
"""return all view definitions embedded in link cells.
|
||||
urls looks like odoo://view/{... view data...}
|
||||
"""
|
||||
return [
|
||||
json.loads(url[len(odoo_view_link_prefix) :])
|
||||
for url in links_urls(data)
|
||||
if url.startswith(odoo_view_link_prefix)
|
||||
]
|
||||
|
||||
|
||||
def remove_group_operator(field_name):
|
||||
"""remove the group operator
|
||||
>>> remove_group_operator("amount:sum")
|
||||
>>> "amount"
|
||||
"""
|
||||
return field_name.split(":")[0]
|
||||
|
||||
|
||||
def domain_fields(domain):
|
||||
"""return all field names used in the domain"""
|
||||
fields = []
|
||||
for leaf in domain:
|
||||
if len(leaf) == 3:
|
||||
fields.append(leaf[0])
|
||||
return fields
|
||||
|
||||
|
||||
def pivot_measure_fields(pivot):
|
||||
return [
|
||||
measure["field"]
|
||||
for measure in pivot["measures"]
|
||||
if measure["field"] != "__count"
|
||||
]
|
||||
|
||||
|
||||
def pivot_fields(pivot):
|
||||
"""return all field names used in a pivot definition"""
|
||||
model = pivot["model"]
|
||||
fields = set(
|
||||
pivot["colGroupBys"]
|
||||
+ pivot["rowGroupBys"]
|
||||
+ pivot_measure_fields(pivot)
|
||||
+ domain_fields(pivot["domain"])
|
||||
)
|
||||
measure = pivot.get("sortedColumn") and pivot["sortedColumn"]["measure"]
|
||||
if measure and measure != "__count":
|
||||
fields.add(measure)
|
||||
return model, fields
|
||||
|
||||
|
||||
def list_order_fields(list_definition):
|
||||
return [order["name"] for order in list_definition["orderBy"]]
|
||||
|
||||
|
||||
def list_fields(list_definition):
|
||||
"""return all field names used in a list definitions"""
|
||||
model = list_definition["model"]
|
||||
fields = set(
|
||||
list_definition["columns"]
|
||||
+ list_order_fields(list_definition)
|
||||
+ domain_fields(list_definition["domain"])
|
||||
)
|
||||
return model, fields
|
||||
|
||||
|
||||
def chart_fields(chart):
|
||||
"""return all field names used in a chart definitions"""
|
||||
model = chart["metaData"]["resModel"]
|
||||
fields = set(
|
||||
chart["metaData"]["groupBy"]
|
||||
+ chart["searchParams"]["groupBy"]
|
||||
+ domain_fields(chart["searchParams"]["domain"])
|
||||
)
|
||||
measure = chart["metaData"]["measure"]
|
||||
if measure != "__count":
|
||||
fields.add(measure)
|
||||
return model, fields
|
||||
|
||||
|
||||
def filter_fields(data):
|
||||
"""return all field names used in global filter definitions"""
|
||||
fields_by_model = defaultdict(set)
|
||||
charts = odoo_charts(data)
|
||||
for filter_definition in data["globalFilters"]:
|
||||
for pivot_id, matching in filter_definition.get("pivotFields", dict()).items():
|
||||
model = data["pivots"][pivot_id]["model"]
|
||||
fields_by_model[model].add(matching["field"])
|
||||
for list_id, matching in filter_definition.get("listFields", dict()).items():
|
||||
model = data["lists"][list_id]["model"]
|
||||
fields_by_model[model].add(matching["field"])
|
||||
for chart_id, matching in filter_definition.get("graphFields", dict()).items():
|
||||
chart = next((chart for chart in charts if chart["id"] == chart_id), None)
|
||||
model = chart["metaData"]["resModel"]
|
||||
fields_by_model[model].add(matching["field"])
|
||||
return dict(fields_by_model)
|
||||
|
||||
|
||||
def odoo_view_fields(view):
|
||||
return view["action"]["modelName"], set(domain_fields(view["action"]["domain"]))
|
||||
|
||||
|
||||
def extract_fields(extract_fn, items):
|
||||
fields_by_model = defaultdict(set)
|
||||
for item in items:
|
||||
model, fields = extract_fn(item)
|
||||
fields_by_model[model] |= {remove_group_operator(field) for field in fields}
|
||||
return dict(fields_by_model)
|
||||
|
||||
|
||||
def fields_in_spreadsheet(data):
|
||||
"""return all fields, grouped by model, used in the spreadsheet"""
|
||||
all_fields = chain(
|
||||
extract_fields(list_fields, data.get("lists", dict()).values()).items(),
|
||||
extract_fields(pivot_fields, data.get("pivots", dict()).values()).items(),
|
||||
extract_fields(chart_fields, odoo_charts(data)).items(),
|
||||
extract_fields(odoo_view_fields, odoo_view_links(data)).items(),
|
||||
filter_fields(data).items(),
|
||||
)
|
||||
fields_by_model = defaultdict(set)
|
||||
for model, fields in all_fields:
|
||||
fields_by_model[model] |= fields
|
||||
return dict(fields_by_model)
|
||||
|
||||
|
||||
def xml_ids_in_spreadsheet(data):
|
||||
|
||||
return set(data.get("chartOdooMenusReferences", {}).values()) | {
|
||||
url[len(xml_id_url_prefix) :]
|
||||
for url in links_urls(data)
|
||||
if url.startswith(xml_id_url_prefix)
|
||||
}
|
||||
|
||||
|
||||
class ValidateSpreadsheetData(TransactionCase):
|
||||
def validate_spreadsheet_data(self, stringified_data, spreadsheet_name):
|
||||
data = json.loads(stringified_data)
|
||||
for model, fields in fields_in_spreadsheet(data).items():
|
||||
if model not in self.env:
|
||||
raise AssertionError(
|
||||
f"model '{model}' used in '{spreadsheet_name}' does not exist"
|
||||
)
|
||||
for field_chain in fields:
|
||||
field_model = model
|
||||
for fname in field_chain.split(
|
||||
"."
|
||||
): # field chain 'product_id.channel_ids'
|
||||
if fname not in self.env[field_model]._fields:
|
||||
raise AssertionError(
|
||||
f"field '{fname}' used in spreadsheet '{spreadsheet_name}' does not exist on model '{field_model}'"
|
||||
)
|
||||
field = self.env[field_model]._fields[fname]
|
||||
if field.relational:
|
||||
field_model = field.comodel_name
|
||||
|
||||
for xml_id in xml_ids_in_spreadsheet(data):
|
||||
record = self.env.ref(xml_id, raise_if_not_found=False)
|
||||
if not record:
|
||||
raise AssertionError(
|
||||
f"xml id '{xml_id}' used in spreadsheet '{spreadsheet_name}' does not exist"
|
||||
)
|
||||
@@ -37,8 +37,5 @@
|
||||
"spreadsheet_dashboard/static/tests/utils/**/*.js",
|
||||
("include", "spreadsheet.o_spreadsheet"),
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard/static/tests/utils/tour.js',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import tour from "web_tour.tour";
|
||||
|
||||
/**
|
||||
* @param {string} tourName
|
||||
* @param {string} dashboardDisplayName
|
||||
*/
|
||||
export function registerDashboardTour(tourName, dashboardDisplayName) {
|
||||
tour.register(
|
||||
tourName,
|
||||
{
|
||||
test: true,
|
||||
url: "/web",
|
||||
},
|
||||
[
|
||||
...tour.stepUtils.goToAppSteps(
|
||||
"spreadsheet_dashboard.spreadsheet_dashboard_menu_root",
|
||||
"Open dashboard app"
|
||||
),
|
||||
{
|
||||
trigger: `.o_search_panel li[data-name="${dashboardDisplayName}"]`,
|
||||
content: `click ${dashboardDisplayName} dashboard`,
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o-spreadsheet",
|
||||
content: "dashboard is displayed",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
from . import test_spreadsheet_dashboard
|
||||
from . import test_dashboard_data
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from odoo.addons.spreadsheet.tests.validate_spreadsheet_data import (
|
||||
ValidateSpreadsheetData,
|
||||
)
|
||||
from odoo.tests.common import tagged
|
||||
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class TestSpreadsheetDashboardData(ValidateSpreadsheetData):
|
||||
def test_validate_dashboard_data(self):
|
||||
"""validate fields and models used in dashboards"""
|
||||
dashboards = self.env["spreadsheet.dashboard"].search([])
|
||||
for dashboard in dashboards:
|
||||
# this dashboard is skipped because it's currently broken
|
||||
# but I still want to merge this test right now to avoid other broken dashboards
|
||||
if dashboard == self.env.ref(
|
||||
"spreadsheet_dashboard_hr_expense.spreadsheet_dashboard_expense",
|
||||
raise_if_not_found=False,
|
||||
):
|
||||
continue
|
||||
with self.subTest(dashboard.name):
|
||||
self.validate_spreadsheet_data(dashboard.raw, dashboard.name)
|
||||
@@ -14,12 +14,5 @@
|
||||
'installable': True,
|
||||
'auto_install': ['account'],
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet_dashboard.o_spreadsheet': [],
|
||||
'web.assets_backend': [],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard_account/static/tests/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': []
|
||||
}
|
||||
'assets': {}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registerDashboardTour } from "@spreadsheet_dashboard/../tests/utils/tour";
|
||||
|
||||
registerDashboardTour("spreadsheet_dashboard_invoicing", "Invoicing");
|
||||
@@ -1 +0,0 @@
|
||||
from . import test_accounting_dashboard_tour
|
||||
@@ -1,9 +0,0 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAccountingDashboard(HttpCase):
|
||||
|
||||
def test_invoicing_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_invoicing', login='admin')
|
||||
@@ -14,12 +14,5 @@
|
||||
'installable': True,
|
||||
'auto_install': ['event_sale'],
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet_dashboard.o_spreadsheet': [],
|
||||
'web.assets_backend': [],
|
||||
'web.qunit_suite_tests': [],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard_event_sale/static/tests/**/*',
|
||||
],
|
||||
}
|
||||
'assets': {}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registerDashboardTour } from "@spreadsheet_dashboard/../tests/utils/tour";
|
||||
|
||||
registerDashboardTour("spreadsheet_dashboard_events", "Events");
|
||||
@@ -1 +0,0 @@
|
||||
from . import test_event_dashboard_tour
|
||||
@@ -1,9 +0,0 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestEventsDashboard(HttpCase):
|
||||
|
||||
def test_events_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_events', login='admin')
|
||||
@@ -14,12 +14,5 @@
|
||||
'installable': True,
|
||||
'auto_install': ['hr_expense'],
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet_dashboard.o_spreadsheet': [],
|
||||
'web.assets_backend': [],
|
||||
'web.qunit_suite_tests': [],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard_hr_expense/static/tests/**/*',
|
||||
],
|
||||
}
|
||||
'assets': {}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registerDashboardTour } from "@spreadsheet_dashboard/../tests/utils/tour";
|
||||
|
||||
registerDashboardTour("spreadsheet_dashboard_expense", "Expenses");
|
||||
@@ -1 +0,0 @@
|
||||
from . import test_expense_dashboard_tour
|
||||
@@ -1,9 +0,0 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestExpenseDashboard(HttpCase):
|
||||
|
||||
def test_expense_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_expense', login='admin')
|
||||
@@ -14,12 +14,5 @@
|
||||
'installable': True,
|
||||
'auto_install': ['pos_hr'],
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet_dashboard.o_spreadsheet': [],
|
||||
'web.assets_backend': [],
|
||||
'web.qunit_suite_tests': [],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard_pos_hr/static/tests/**/*',
|
||||
],
|
||||
}
|
||||
'assets': {}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registerDashboardTour } from "@spreadsheet_dashboard/../tests/utils/tour";
|
||||
|
||||
registerDashboardTour("spreadsheet_dashboard_pos", "Point of Sale");
|
||||
@@ -1 +0,0 @@
|
||||
from . import test_pos_dashboard_tour
|
||||
@@ -1,9 +0,0 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPosDashboard(HttpCase):
|
||||
|
||||
def test_pos_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_pos', login='admin')
|
||||
@@ -14,12 +14,5 @@
|
||||
'installable': True,
|
||||
'auto_install': ['sale'],
|
||||
'license': 'LGPL-3',
|
||||
'assets': {
|
||||
'spreadsheet_dashboard.o_spreadsheet': [],
|
||||
'web.assets_backend': [],
|
||||
'web.qunit_suite_tests': [],
|
||||
'web.assets_tests': [
|
||||
'spreadsheet_dashboard_sale/static/tests/**/*',
|
||||
],
|
||||
}
|
||||
'assets': {}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registerDashboardTour } from "@spreadsheet_dashboard/../tests/utils/tour";
|
||||
|
||||
registerDashboardTour("spreadsheet_dashboard_sales", "Sales");
|
||||
registerDashboardTour("spreadsheet_dashboard_product", "Product");
|
||||
@@ -1 +0,0 @@
|
||||
from . import test_sales_dashboard_tour
|
||||
@@ -1,12 +0,0 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSalesDashboard(HttpCase):
|
||||
|
||||
def test_sales_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_sales', login='admin')
|
||||
|
||||
def test_product_dashboard(self):
|
||||
self.start_tour('/web', 'spreadsheet_dashboard_product', login='admin')
|
||||
Reference in New Issue
Block a user