Eliminate Python dependency: embed frontend assets in odoo-go

- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/
  directory (2925 files, 43MB) — no more runtime reads from Python Odoo
- Replace OdooAddonsPath config with FrontendDir pointing to local frontend/
- Rewire bundle.go, static.go, templates.go, webclient.go to read from
  frontend/ instead of external Python Odoo addons directory
- Auto-detect frontend/ and build/ dirs relative to binary in main.go
- Delete obsolete Python helper scripts (tools/*.py)

The Go server is now fully self-contained: single binary + frontend/ folder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

View File

@@ -0,0 +1,126 @@
import {
click,
insertText,
openFormView,
start,
startServer,
triggerHotkey
} from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { asyncStep, contains, defineModels, fields, onRpc, models, waitForSteps} from "@web/../tests/web_test_helpers";
import { defineAccountModels } from "./account_test_helpers";
defineAccountModels();
test("When I switch tabs, it saves", async () => {
const pyEnv = await startServer();
const accountMove = pyEnv["account.move"].create({ name: "move0" });
await start();
onRpc("account.move", "web_save", () => {
asyncStep("tab saved");
});
await openFormView("account.move", accountMove, {
arch: `<form js_class='account_move_form'>
<sheet>
<notebook>
<page id="invoice_tab" name="invoice_tab" string="Invoice Lines">
<field name="name"/>
</page>
<page id="aml_tab" string="Journal Items" name="aml_tab"></page>
</notebook>
</sheet>
</form>`,
});
await insertText("[name='name'] input", "somebody save me!");
triggerHotkey("Enter");
await click('a[name="aml_tab"]');
await waitForSteps(["tab saved"]);
});
test("Confirmation dialog on delete contains a warning", async () => {
const pyEnv = await startServer();
const accountMove = pyEnv["account.move"].create({ name: "move0" });
await start();
onRpc("account.move", "check_move_sequence_chain", () => {
return false;
});
await openFormView("account.move", accountMove, {
arch: `<form js_class='account_move_form'>
<sheet>
<notebook>
<page id="invoice_tab" name="invoice_tab" string="Invoice Lines">
<field name="name"/>
</page>
<page id="aml_tab" string="Journal Items" name="aml_tab"></page>
</notebook>
</sheet>
</form>`,
});
await contains(".o_cp_action_menus button").click();
await contains(".o_menu_item:contains(Delete)").click();
expect(".o_dialog div.text-danger").toHaveText("This operation will create a gap in the sequence.", {
message: "warning message has been added in the dialog"
});
});
class AccountMove extends models.Model {
line_ids = fields.One2many({
string: "Invoice Lines",
relation: "account.move.line",
})
_records = [{ id: 1, name: "account.move" }]
}
class AccountMoveLine extends models.Model {
name = fields.Char();
product_id = fields.Many2one({
string:"Product",
relation:"product",
});
move_id = fields.Many2one({
string: "Account Move",
relation: "account.move",
})
}
class Product extends models.Model {
name = fields.Char();
_records = [{ id: 1, name: "testProduct" }];
}
defineModels({ Product, AccountMoveLine, AccountMove });
test("Update description on product line", async() => {
const pyEnv = await startServer();
const productId = pyEnv["product"].browse([1]);
const accountMove = pyEnv["account.move"].browse([1]);
pyEnv["account.move"].write([accountMove[0].id], {
invoice_line_ids: [[0, 0, { name: productId[0].name, product_id: productId[0].id }]],
});
await start();
onRpc("account.move", "web_save", () => { asyncStep("save")});
await openFormView("account.move", accountMove[0].id, {
arch: `<form js_class="account_move_form">
<sheet>
<notebook>
<page id="invoice_tab" name="invoice_tab" string="Invoice Lines">
<field name="invoice_line_ids" mode="list" widget="product_label_section_and_note_field_o2m">
<list name="journal_items" editable="bottom" string="Journal Items">
<field name="product_id" widget="product_label_section_and_note_field" readonly="0"/>
<field name="name" widget="section_and_note_text" optional="show"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>`,
});
await click(".o_many2one");
await contains("#labelVisibilityButtonId").click()
await insertText("textarea[placeholder='Enter a description']", "testDescription");
await click(".o_form_button_save");
await waitForSteps(["save"]);
const line = pyEnv["account.move.line"].browse([1])[0];
expect(line.name).toBe("testProduct\ntestDescription");
});

View File

@@ -0,0 +1,13 @@
import { AccountMove } from "./mock_server/mock_models/account_move";
import { AccountMoveLine } from "./mock_server/mock_models/account_move_line";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
export const accountModels = {
AccountMove,
AccountMoveLine,
};
export function defineAccountModels() {
return defineModels({ ...mailModels, ...accountModels });
}

View File

@@ -0,0 +1,188 @@
import { describe, expect, test } from "@odoo/hoot";
import { setInputFiles } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fields,
mockService,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
class Partner extends models.Model {
name = fields.Char();
type = fields.Char();
_records = [
{
id: 7,
name: "first record",
type: "purchase",
},
];
_views = {
form: `
<form>
<widget name="account_file_uploader"/>
<field name="name" required="1"/>
</form>
`,
list: `
<list>
<field name="id"/>
<field name="name"/>
</list>
`,
search: `<search/>`,
};
}
class AccountPaymentTerm extends models.Model {
_name = "account_payment_term";
line_ids = fields.One2many({
string: "Payment Term Lines",
relation: "account_payment_term_line",
});
_records = [
{
id: 1,
line_ids: [1, 2],
},
];
}
class AccountPaymentTermLine extends models.Model {
_name = "account_payment_term_line";
value_amount = fields.Float({ string: "Due" });
_records = [
{
id: 1,
value_amount: 0,
},
{
id: 2,
value_amount: 50,
},
];
}
defineModels([AccountPaymentTerm, AccountPaymentTermLine, Partner]);
defineMailModels();
describe("AccountFileUploader", () => {
test("widget contains context based on the record despite field not in view", async () => {
onRpc("ir.attachment", "create", () => {
expect.step("create ir.attachment");
return [99];
});
onRpc("account.journal", "create_document_from_attachment", ({ kwargs }) => {
expect.step("create_document_from_attachment");
expect(kwargs.context.default_journal_id).toBe(7, {
message: "create documents in correct journal",
});
expect(kwargs.context.default_move_type).toBe("in_invoice", {
message: "create documents with correct move type",
});
return {
name: "Generated Documents",
domain: [],
res_model: "partner",
type: "ir.actions.act_window",
context: {},
views: [
[false, "list"],
[false, "form"],
],
view_mode: "list, form",
};
});
mockService("action", {
doAction(action) {
expect.step("doAction");
expect(action.type).toBe("ir.actions.act_window", {
message: "do action after documents created",
});
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 7,
});
expect(".o_widget_account_file_uploader").toHaveCount(1);
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await contains(".o_widget_account_file_uploader a").click();
await setInputFiles([file]);
await expect.waitForSteps([
"create ir.attachment",
"create_document_from_attachment",
"doAction",
]);
});
});
describe("AccountMoveUploadKanbanView", () => {
test.tags("desktop");
test("can render AccountMoveUploadKanbanView", async () => {
Partner._views.kanban = `
<kanban js_class="account_documents_kanban">
<templates>
<t t-name="card">
<field name="name"/>
</t>
</templates>
</kanban>
`;
onRpc("res.company", "search_read", () => [{ id: 1, country_code: "US" }]);
await mountView({
type: "kanban",
resModel: "partner",
});
expect(".o_control_panel .o_button_upload_bill:visible").toHaveCount(1);
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
});
});
describe("PaymentTermsLineWidget", () => {
test("records don't get abandoned after clicking globally or on an exisiting record", async () => {
await mountView({
type: "form",
resModel: "account_payment_term",
resId: 1,
arch: `
<form>
<field name="line_ids" widget="payment_term_line_ids">
<list string="Payment Terms" editable="top">
<field name="value_amount"/>
</list>
</field>
</form>
`,
});
expect(".o_data_row").toHaveCount(2);
// click the add button
await contains(".o_field_x2many_list_row_add > a").click();
// make sure the new record is added
expect(".o_data_row").toHaveCount(3);
// global click
await contains(".o_form_view").click();
// make sure the new record is still there
expect(".o_data_row").toHaveCount(3);
// click the add button again
await contains(".o_field_x2many_list_row_add > a").click();
// make sure the new record is added
expect(".o_data_row").toHaveCount(4);
// click on an existing record
await contains(".o_data_row .o_data_cell").click();
// make sure the new record is still there
expect(".o_data_row").toHaveCount(4);
});
});

View File

@@ -0,0 +1,78 @@
import { expect, test } from "@odoo/hoot";
import { queryFirst } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
} from "@web/../tests/web_test_helpers";
import { defineAccountModels } from "./account_test_helpers";
class Account extends models.Model {
_name = "account.account";
_inherit = [];
code = fields.Char({
string: "Code",
trim: true,
});
placeholder_code = fields.Char();
_records = [
{
id: 1,
placeholder_code: "Placeholder Code",
},
];
_views = {
list: /* xml */ `
<list editable="top" create="1" delete="1">
<field name="placeholder_code" column_invisible="1" />
<field name="code" widget="char_with_placeholder_field" options="{'placeholder_field': 'placeholder_code'}" />
</list>
`,
};
}
defineAccountModels();
defineModels([Account]);
test.tags("desktop");
test("List: placeholder_field shows as text/placeholder", async () => {
await mountView({
type: "list",
resModel: "account.account",
});
const firstCellSelector = "tbody td:not(.o_list_record_selector):first";
expect(`${firstCellSelector} span`).toHaveText("Placeholder Code", {
message: "placeholder_field should be the text value",
});
expect(`${firstCellSelector} span`).toHaveClass("text-muted", {
message: "placeholder_field should be greyed out",
});
await contains(firstCellSelector).click();
expect(queryFirst(firstCellSelector).parentElement).toHaveClass("o_selected_row", {
message: "should be set as edit mode",
});
expect(`${firstCellSelector} input`).toHaveValue("", {
message: "once in edit mode, should have no value in input",
});
expect(`${firstCellSelector} input`).toHaveAttribute("placeholder", "Placeholder Code", {
message: "once in edit mode, should have placeholder_field as placeholder",
});
await fieldInput("code").edit("100001", { confirm: false });
await contains(".o_list_button_save").click();
expect(firstCellSelector).toHaveText("100001", {
message: "entered value should be saved",
});
expect(firstCellSelector).not.toHaveClass("text-muted", {
message: "field should not be greyed out",
});
});

View File

@@ -0,0 +1,10 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountMove extends models.ServerModel {
_name = "account.move";
get_extra_print_items() {
return [];
}
}

View File

@@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountMoveLine extends models.ServerModel {
_name = "account.move.line";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { addSectionFromProductCatalog, showProductColumn } from "@account/js/tours/tour_utils";
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_use_product_catalog_on_invoice", {
steps: () => [
{
content: "Click Catalog Button",
trigger: "button[name=action_add_from_catalog]",
run: "click",
},
{
content: "Add a Product",
trigger: ".o_kanban_record:contains(Test Product)",
run: "click",
},
{
content: "Wait for it",
trigger: ".o_product_added",
},
{
content: "Back to Invoice",
trigger: ".o-kanban-button-back",
run: "click",
},
...showProductColumn(),
{
content: "Ensure product is added",
trigger: ".o_field_product_label_section_and_note_cell:contains(Test Product)",
},
],
});
registry.category("web_tour.tours").add('test_add_section_from_product_catalog_on_invoice', {
steps: () => addSectionFromProductCatalog()
});

View File

@@ -0,0 +1,28 @@
import { registry } from '@web/core/registry';
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add("deductible_amount_column", {
url: "/odoo/vendor-bills/new",
steps: () => [
{
content: "Add item",
trigger: "div[name='invoice_line_ids'] .o_field_x2many_list_row_add a:contains('Add a line')",
run: "click",
},
{
content: "Edit name",
trigger: ".o_field_widget[name='name'] .o_input",
run: "edit Laptop"
},
{
content: "Edit deductible amount",
trigger: ".o_field_widget[name='deductible_amount'] > .o_input",
run: "edit 80"
},
{
content: "Set Bill Date",
trigger: "input[data-field=invoice_date]",
run: "edit 2025-12-01",
},
...stepUtils.saveForm(),
]})

View File

@@ -0,0 +1,147 @@
import { accountTourSteps } from "@account/js/tours/account";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add('account_tax_group', {
url: "/odoo",
steps: () => [
...accountTourSteps.goToAccountMenu("Go to Invoicing"),
{
content: "Go to Vendors",
trigger: 'span:contains("Vendors")',
run: "click",
},
{
content: "Go to Bills",
trigger: 'a:contains("Bills")',
run: "click",
},
{
trigger: ".o_breadcrumb .text-truncate:contains(Bills)",
},
{
content: "Create new bill",
trigger: '.o_control_panel_main_buttons .o_list_button_add',
run: "click",
},
// Set a vendor
{
content: "Add vendor",
trigger: 'div.o_field_widget.o_field_res_partner_many2one[name="partner_id"] div input',
run: "edit Account Tax Group Partner",
},
{
content: "Valid vendor",
trigger: '.ui-menu-item a:contains("Account Tax Group Partner")',
run: "click",
},
// Show product column
{
content: "Open line fields list",
trigger: ".o_optional_columns_dropdown_toggle",
run: "click"
},
{
content: "Show product column",
trigger: '.o-dropdown-item input[name="product_id"]',
run: "click"
},
{
content: "Close line fields list",
trigger: ".o_optional_columns_dropdown_toggle",
run: "click"
},
// Add First product
{
content: "Add items",
trigger: 'div[name="invoice_line_ids"] .o_field_x2many_list_row_add a:contains("Add a line")',
run: "click",
},
{
content: "Select input",
trigger: 'div[name="invoice_line_ids"] .o_selected_row .o_list_many2one[name="product_id"] input',
run: "edit Account Tax Group Product",
},
{
content: "Valid item",
trigger: '.ui-menu-item-wrapper:contains("Account Tax Group Product")',
run: "click",
},
{
content: "Set Bill Date",
trigger: "input[data-field=invoice_date]",
run: "edit 2025-12-01",
},
// Save account.move
...stepUtils.saveForm(),
// Edit tax group amount
{
content: "Edit tax group amount",
trigger: '.o_tax_group_edit',
run: "click",
},
{
content: "Modify the input value",
trigger: '.o_tax_group_edit_input input',
run() {
this.anchor.value = 200;
this.anchor.select();
this.anchor.blur();
},
},
// Check new value for total (with modified tax_group_amount).
{
content: "Valid total amount",
trigger: 'span[name="amount_total"]:contains("800")',
run: "click",
},
// Modify the quantity of the object
{
content: "Select item quantity",
trigger: 'div[name="invoice_line_ids"] tbody tr.o_data_row .o_list_number[name="quantity"]',
run: "click",
},
{
content: "Change item quantity",
trigger: 'div[name="invoice_line_ids"] tbody tr.o_data_row .o_list_number[name="quantity"] input',
run: "edit 2",
},
{
content: "Valid the new value",
trigger: 'div[name="invoice_line_ids"] tbody tr.o_data_row .o_list_number[name="quantity"] input',
run: "press Enter",
},
// Check new tax group value
{
content: "Check new value of tax group",
trigger: '.o_tax_group_amount_value:contains("120")',
run: "click",
},
// Save form
...stepUtils.saveForm(),
// Check new tax group value
{
content: "Check new value of tax group",
trigger: '.o_tax_group_amount_value:contains("120")',
run: "click",
},
{
content: "Edit tax value",
trigger: '.o_tax_group_edit_input input',
run: "edit 2 && click body",
},
{
content: "Check new value of total",
trigger: '.oe_subtotal_footer_separator:contains("1,202")',
run: "click",
},
{
content: "Discard changes",
trigger: '.o_form_button_cancel',
run: "click",
},
{
content: "Check tax value is reset",
trigger: '.o_tax_group_amount_value:contains("120")',
},
]});

View File

@@ -0,0 +1,16 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('tests_shared_js_python', {
url: "/account/init_tests_shared_js_python",
steps: () => [
{
content: "Click",
trigger: 'button',
run: "click",
},
{
content: "Wait",
trigger: 'button.text-success',
timeout: 3000,
},
]});

View File

@@ -0,0 +1,138 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import {
contains,
defineModels,
fields,
mockService,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class AccountMove extends models.Model {
_name = "account.move";
name = fields.Char();
duplicated_ref_ids = fields.Many2many({
string: "Duplicated Bills",
relation: "account.move",
});
ref = fields.Char({ string: "Bill Reference" });
_records = [
// for the sake of mocking data, we don't care about the consistency of duplicated refs across records
{ id: 1, display_name: "Bill 1", duplicated_ref_ids: [2, 3], ref: "b1" },
{ id: 2, display_name: "Bill 2", duplicated_ref_ids: [1], ref: "b2" },
{ id: 3, display_name: "Bill 3", duplicated_ref_ids: [1], ref: "b3" },
{ id: 4, display_name: "Bill 4", duplicated_ref_ids: [1, 2, 3], ref: "b4" },
{ id: 5, display_name: "Bill 5", duplicated_ref_ids: [], ref: "b5" },
{ id: 6, display_name: "Bill 6", duplicated_ref_ids: [1, 2, 3, 4, 5], ref: "b6" },
];
_views = {
form: `
<form>
<field name="display_name"/>
<field name="ref"/>
<field name="duplicated_ref_ids" widget="x2many_buttons"/>
</form>
`,
};
}
defineModels([AccountMove]);
defineMailModels();
test("component rendering: less than 3 records on field", async () => {
expect.assertions(2);
await mountView({
resModel: "account.move",
resId: 1,
type: "form",
});
expect(".o_field_x2many_buttons").toHaveCount(1);
expect(".o_field_x2many_buttons button").toHaveCount(2);
});
test("component rendering: exactly 3 records on field", async () => {
expect.assertions(2);
await mountView({
resModel: "account.move",
resId: 4,
type: "form",
});
expect(".o_field_x2many_buttons").toHaveCount(1);
expect(".o_field_x2many_buttons button").toHaveCount(3);
});
test("component rendering: more than 3 records on field", async () => {
expect.assertions(3);
await mountView({
resModel: "account.move",
resId: 6,
type: "form",
});
expect(".o_field_x2many_buttons").toHaveCount(1);
expect(".o_field_x2many_buttons button").toHaveCount(4);
expect(".o_field_x2many_buttons button:eq(3)").toHaveText("... (View all)");
});
test("edit record and check if edits get discarded when click on one of the buttons and redirects to proper record", async () => {
onRpc("account.move", "action_open_business_doc", ({ args }) => {
expect.step("action_open_business_doc");
expect(args.length).toBe(1);
expect(args[0]).toBe(2);
return {
res_model: "account.move",
res_id: 2,
type: "ir.actions.act_window",
views: [[false, "form"]],
};
});
await mountView({
resModel: "account.move",
resId: 1,
type: "form",
});
await contains("[name='ref'] input").edit("new ref");
expect("[name='ref'] input").toHaveValue("new ref");
await contains(".o_field_x2many_buttons button").click();
expect("[name='ref'] input").toHaveValue("b1");
expect.verifySteps(["action_open_business_doc"]);
});
// test if clicking on last button redirects to records in list view
test("redirect to list view and discards edits when clicking on last button with more than 3 records on field", async () => {
expect.assertions(3);
mockService("action", {
doAction(action) {
expect(action).toEqual({
domain: [["id", "in", [1, 2, 3, 4, 5]]],
name: "Duplicated Bills",
res_model: "account.move",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
context: {
list_view_ref: "account.view_duplicated_moves_tree_js",
},
});
},
});
await mountView({
resModel: "account.move",
resId: 6,
type: "form",
});
await contains("[name='ref'] input").edit("new ref");
expect("[name='ref'] input").toHaveValue("new ref");
await contains(".o_field_x2many_buttons button:eq(3)").click();
expect("[name='ref'] input").toHaveValue("b6");
});