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,95 @@
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { evaluateExpr } from "@web/core/py_js/py";
import { floatField, FloatField } from "@web/views/fields/float/float_field";
import { monetaryField, MonetaryField } from "@web/views/fields/monetary/monetary_field";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const fieldRegistry = registry.category("fields");
class StockActionField extends Component {
static props = {
...FloatField.props,
...MonetaryField.props,
actionName: { type: String, optional: false },
actionContext: { type: String, optional: true },
disabled: { type: String, optional: true },
};
static components = {
FloatField,
MonetaryField,
}
static template = "stock.actionField";
setup() {
super.setup();
this.actionService = useService("action");
this.orm = useService("orm");
this.fieldType = this.props.record.fields[this.props.name].type;
}
extractProps () {
const keysToRemove = ["actionName", "actionContext", "disabled"];
return Object.fromEntries(
Object.entries(this.props).filter(([prop]) => !keysToRemove.includes(prop))
);
}
get disabled() {
return this.props.disabled ? evaluateExpr(this.props.disabled, this.props.record.evalContext) : false;
}
_onClick(ev) {
ev.stopPropagation();
ev.preventDefault();
// Get the action name from props.options
const actionName = this.props.actionName;
const actionContext = evaluateExpr(this.props.actionContext, this.props.record.evalContext);
// const action = this.orm.call(this.props.record.resModel, actionName, this.props.record.resId);
// Use the action service to perform the action
this.actionService.doAction(actionName, {
additionalContext: { ...actionContext, ...this.props.record.context },
});
}
}
const stockActionField = {
...floatField,
...monetaryField,
component: StockActionField,
supportedOptions: [
Object.values(
Object.fromEntries(
[...floatField.supportedOptions, ...monetaryField.supportedOptions].map(
(option) => [option.name, option]
)
)
),
{
label: _t("Action Name"),
name: "action_name",
type: "string",
},
],
extractProps: (...args) => {
const [{ context, fieldType, options }] = args;
const action_props = {
actionName: options.action_name,
disabled: options.disabled,
actionContext: context,
}
let props = {...action_props}
if (fieldType === "monetary") {
props = { ...action_props, ...monetaryField.extractProps(...args) };
} else if (fieldType === "float") {
props = { ...action_props, ...floatField.extractProps(...args) };
};
return props;
},
};
fieldRegistry.add("stock_action_field", stockActionField);

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="stock.actionField" owl="1">
<a t-if="! disabled" href="#" t-on-click="_onClick" class="btn-link">
<t t-call="stock.actionFieldContent"/>
</a>
<t t-else="">
<t t-call="stock.actionFieldContent"/>
</t>
</t>
<t t-name="stock.actionFieldContent" owl="1">
<t t-if="fieldType === 'monetary'">
<MonetaryField t-props="extractProps()"/>
</t>
<t t-else="">
<FloatField t-props="extractProps()"/>
</t>
</t>
</templates>

View File

@@ -0,0 +1,190 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { useSelectCreate, useOpenMany2XRecord} from "@web/views/fields/relational_utils";
import { useService } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
export class SMLX2ManyField extends X2ManyField {
setup() {
super.setup();
this.orm = useService("orm");
this.dirtyQuantsData = new Map();
const selectCreate = useSelectCreate({
resModel: "stock.quant",
activeActions: this.activeActions,
onSelected: (resIds) => this.selectRecord(resIds),
onCreateEdit: () => this.createOpenRecord(),
});
this.selectCreate = (params) => {
return selectCreate(params);
};
this.openQuantRecord = useOpenMany2XRecord({
resModel: "stock.quant",
activeActions: this.activeActions,
onRecordSaved: (record) => this.selectRecord([record.resId]),
fieldString: this.props.string,
is2Many: true,
});
}
get quantListViewShowOnHandOnly(){
return true; // To override in mrp_subcontracting
}
async onAdd({ context, editable } = {}) {
if (!this.props.record.data.show_quant) {
return super.onAdd(...arguments);
}
// Compute the quant offset from move lines quantity changes that were not saved yet.
// Hence, did not yet affect quant's quantity in DB.
await this.updateDirtyQuantsData();
context = {
...context,
single_product: true,
list_view_ref: "stock.view_stock_quant_tree_simple",
};
const productName = this.props.record.data.product_id.display_name;
const title = _t("Add line: %s", productName);
let domain = [
["product_id", "=", this.props.record.data.product_id.id],
["location_id", "child_of", this.props.context.default_location_id],
["quantity", ">", 0.0],
];
if (this.quantListViewShowOnHandOnly) {
domain.push(["on_hand", "=", true]);
}
if (this.dirtyQuantsData.size) {
const notFullyUsed = [];
const fullyUsed = [];
for (const [quantId, quantData] of this.dirtyQuantsData.entries()) {
if (quantData.available_quantity > 0) {
notFullyUsed.push(quantId);
} else {
fullyUsed.push(quantId);
}
}
if (fullyUsed.length) {
domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList();
}
if (notFullyUsed.length) {
domain = Domain.or([domain, [["id", "in", notFullyUsed]]]).toList();
}
}
return this.selectCreate({ domain, context, title });
}
async updateDirtyQuantsData() {
// Since changes of move line quantities will not affect the available quantity of the quant before
// the record has been saved, it is necessary to determine the offset of the DB quant data.
this.dirtyQuantsData.clear();
const dirtyQuantityMoveLines = this._move_line_ids.filter(
(ml) => !ml.data.quant_id && ml._values.quantity - ml._changes.quantity
);
const dirtyQuantMoveLines = this._move_line_ids.filter(
(ml) => ml.data.quant_id.id
);
const dirtyMoveLines = [...dirtyQuantityMoveLines, ...dirtyQuantMoveLines];
if (!dirtyMoveLines.length) {
return;
}
const match = await this.orm.call(
"stock.move.line",
"get_move_line_quant_match",
[
this._move_line_ids
.filter((rec) => rec.resId)
.map((rec) => rec.resId),
this.props.record.resId,
dirtyMoveLines.filter((rec) => rec.resId).map((rec) => rec.resId),
dirtyQuantMoveLines.map((ml) => ml.data.quant_id.id),
],
{}
);
const quants = match[0];
if (!quants.length) {
return;
}
const dbMoveLinesData = new Map();
for (const data of match[1]) {
dbMoveLinesData.set(data[0], { quantity: data[1].quantity, quantId: data[1].quant_id });
}
const offsetByQuant = new Map();
for (const ml of dirtyQuantMoveLines) {
const quantId = ml.data.quant_id.id;
offsetByQuant.set(quantId, (offsetByQuant.get(quantId) || 0) - ml.data.quantity);
const dbQuantId = dbMoveLinesData.get(ml.resId)?.quantId;
if (dbQuantId && quantId != dbQuantId) {
offsetByQuant.set(
dbQuantId,
(offsetByQuant.get(dbQuantId) || 0) + dbMoveLinesData.get(ml.resId).quantity
);
}
}
const offsetByQuantity = new Map();
for (const ml of dirtyQuantityMoveLines) {
offsetByQuantity.set(ml.resId, ml._values.quantity - ml._changes.quantity);
}
for (const quant of quants) {
const quantityOffest = quant[1].move_line_ids
.map((ml) => offsetByQuantity.get(ml) || 0)
.reduce((val, sum) => val + sum, 0);
const quantOffest = offsetByQuant.get(quant[0]) || 0;
this.dirtyQuantsData.set(quant[0], {
available_quantity: quant[1].available_quantity + quantityOffest + quantOffest,
});
}
}
async selectRecord(res_ids) {
const demand =
this.props.record.data.product_uom_qty -
this._move_line_ids
.map((ml) => ml.data.quantity)
.reduce((val, sum) => val + sum, 0);
const params = {
context: { default_quant_id: res_ids[0] },
};
if (demand <= 0) {
params.context.default_quantity = 0;
} else if (this.dirtyQuantsData.has(res_ids[0])) {
params.context.default_quantity = Math.min(
this.dirtyQuantsData.get(res_ids[0]).available_quantity,
demand
);
}
this.list.addNewRecord(params).then((record) => {
// Make it dirty to force the save of the record. addNewRecord make
// the new record dirty === False by default to remove them at unfocus event
record.dirty = true;
});
}
createOpenRecord() {
const activeElement = document.activeElement;
this.openQuantRecord({
context: {
...this.props.context,
form_view_ref: "stock.view_stock_quant_form",
},
immediate: true,
onClose: () => {
if (activeElement) {
activeElement.focus();
}
},
});
}
get _move_line_ids() {
return this.props.record.data.move_line_ids.records;
}
}
export const smlX2ManyField = {
...x2ManyField,
component: SMLX2ManyField,
};
registry.category("fields").add("sml_x2_many", smlX2ManyField);