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,29 @@
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { useComponent } from "@odoo/owl";
export function useArchiveEmployee() {
const component = useComponent();
const action = useService("action");
return (ids) => {
action.doAction(
{
type: "ir.actions.act_window",
name: _t("Employee Termination"),
res_model: "hr.departure.wizard",
views: [[false, "form"]],
view_mode: "form",
target: "new",
context: {
active_ids: ids,
employee_termination: true,
},
},
{
onClose: async () => {
await component.model.load();
},
}
);
};
}

View File

@@ -0,0 +1,44 @@
import { AvatarCardEmployeePopover } from "@hr/components/avatar_card_employee/avatar_card_employee_popover";
import { onWillStart } from "@odoo/owl";
import { usePopover } from "@web/core/popover/popover_hook";
import { user } from "@web/core/user";
/**
* Mixin that handles public/private access of employee records in many2X fields
* @param { Class } fieldClass
* @returns Class
*/
export function EmployeeFieldRelationMixin(fieldClass) {
return class extends fieldClass {
static props = {
...fieldClass.props,
relation: { type: String, optional: true },
};
setup() {
super.setup();
onWillStart(async () => {
this.isHrUser = await user.hasGroup("hr.group_hr_user");
});
this.avatarCard = usePopover(AvatarCardEmployeePopover, { closeOnClickAway: true });
}
get relation() {
if (this.props.relation) {
return this.props.relation;
}
return this.isHrUser ? "hr.employee" : "hr.employee.public";
}
getAvatarCardProps(record) {
const originalProps = super.getAvatarCardProps(record);
if (["hr.employee", "hr.employee.public"].includes(this.relation)) {
return {
...originalProps,
recordModel: this.relation,
};
}
return originalProps;
}
};
}

View File

@@ -0,0 +1,89 @@
import { registry } from "@web/core/registry";
import {
Many2ManyTagsAvatarUserField,
KanbanMany2ManyTagsAvatarUserField,
ListMany2ManyTagsAvatarUserField,
many2ManyTagsAvatarUserField,
kanbanMany2ManyTagsAvatarUserField,
listMany2ManyTagsAvatarUserField,
} from "@mail/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field";
import { EmployeeFieldRelationMixin } from "@hr/views/fields/employee_field_relation_mixin";
export class Many2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
Many2ManyTagsAvatarUserField
) {
displayAvatarCard(record) {
return (
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
super.displayAvatarCard(record)
);
}
}
export const many2ManyTagsAvatarEmployeeField = {
...many2ManyTagsAvatarUserField,
component: Many2ManyTagsAvatarEmployeeField,
additionalClasses: [
...many2ManyTagsAvatarUserField.additionalClasses,
"o_field_many2many_avatar_user",
],
extractProps: (fieldInfo, dynamicInfo) => ({
...many2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
canQuickCreate: false,
relation: fieldInfo.options?.relation,
}),
};
registry.category("fields").add("many2many_avatar_employee", many2ManyTagsAvatarEmployeeField);
export class KanbanMany2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
KanbanMany2ManyTagsAvatarUserField
) {
displayAvatarCard(record) {
return (
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
super.displayAvatarCard(record)
);
}
}
export const kanbanMany2ManyTagsAvatarEmployeeField = {
...kanbanMany2ManyTagsAvatarUserField,
component: KanbanMany2ManyTagsAvatarEmployeeField,
additionalClasses: [
...kanbanMany2ManyTagsAvatarUserField.additionalClasses,
"o_field_many2many_avatar_user",
],
extractProps: (fieldInfo, dynamicInfo) => ({
...kanbanMany2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
relation: fieldInfo.options?.relation,
}),
};
registry
.category("fields")
.add("kanban.many2many_avatar_employee", kanbanMany2ManyTagsAvatarEmployeeField)
.add("activity.many2many_avatar_employee", kanbanMany2ManyTagsAvatarEmployeeField);
export class ListMany2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
ListMany2ManyTagsAvatarUserField
) {
displayAvatarCard(record) {
return (
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
super.displayAvatarCard(record)
);
}
}
export const listMany2ManyTagsAvatarEmployeeField = {
...listMany2ManyTagsAvatarUserField,
component: ListMany2ManyTagsAvatarEmployeeField,
additionalClasses: [
...listMany2ManyTagsAvatarUserField.additionalClasses,
"o_field_many2many_avatar_user",
],
};
registry
.category("fields")
.add("list.many2many_avatar_employee", listMany2ManyTagsAvatarEmployeeField);

View File

@@ -0,0 +1,73 @@
import { AvatarEmployee } from "@hr/components/avatar_employee/avatar_employee";
import { Component, onWillStart } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { computeM2OProps, KanbanMany2One } from "@web/views/fields/many2one/many2one";
import {
buildM2OFieldDescription,
extractM2OFieldProps,
m2oSupportedOptions,
Many2OneField,
} from "@web/views/fields/many2one/many2one_field";
export class KanbanMany2OneAvatarEmployeeField extends Component {
static template = "hr.KanbanMany2OneAvatarEmployeeField";
static components = { AvatarEmployee, KanbanMany2One };
static props = {
...Many2OneField.props,
displayAvatarName: { type: Boolean, optional: true },
relation: { type: String, optional: true },
};
setup() {
onWillStart(async () => {
this.isHrUser = await user.hasGroup("hr.group_hr_user");
});
}
get displayName() {
return this.props.displayAvatarName && this.value ? this.value.display_name : "";
}
get m2oProps() {
return {
...computeM2OProps(this.props),
canQuickCreate: false,
relation: this.relation,
};
}
get relation() {
return this.props.relation ?? (this.isHrUser ? "hr.employee" : "hr.employee.public");
}
get value() {
return this.props.record.data[this.props.name];
}
}
/** @type {import("registries").FieldsRegistryItemShape} */
const fieldDescr = {
...buildM2OFieldDescription(KanbanMany2OneAvatarEmployeeField),
additionalClasses: ["o_field_many2one_avatar_kanban", "o_field_many2one_avatar_user"],
extractProps(staticInfo, dynamicInfo) {
return {
...extractM2OFieldProps(staticInfo, dynamicInfo),
displayAvatarName: staticInfo.options.display_avatar_name || false,
readonly: dynamicInfo.readonly,
relation: staticInfo.options.relation,
};
},
supportedOptions: [
...m2oSupportedOptions,
{
label: _t("Display avatar name"),
name: "display_avatar_name",
type: "boolean",
},
],
};
registry.category("fields").add("activity.many2one_avatar_employee", fieldDescr);
registry.category("fields").add("kanban.many2one_avatar_employee", fieldDescr);

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr.KanbanMany2OneAvatarEmployeeField">
<KanbanMany2One t-props="m2oProps">
<t t-set-slot="avatar">
<AvatarEmployee cssClass="'o_m2o_avatar'" resModel="relation" resId="value.id" noSpacing="false" displayName="displayName"/>
</t>
<t t-set-slot="autoCompleteItem" t-slot-scope="autoCompleteItemScope">
<span class="o_avatar_many2x_autocomplete o_avatar d-flex align-items-center">
<img class="rounded me-1" t-attf-src="/web/image/{{relation}}/{{autoCompleteItemScope.record.id}}/avatar_128?unique={{autoCompleteItemScope.record.data.write_date.ts}}"/>
<span t-out="autoCompleteItemScope.label"/>
</span>
</t>
</KanbanMany2One>
</t>
</templates>

View File

@@ -0,0 +1,55 @@
import { AvatarEmployee } from "@hr/components/avatar_employee/avatar_employee";
import { Component, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
import {
buildM2OFieldDescription,
extractM2OFieldProps,
Many2OneField,
} from "@web/views/fields/many2one/many2one_field";
export class Many2OneAvatarEmployeeField extends Component {
static template = "hr.Many2OneAvatarEmployeeField";
static components = { AvatarEmployee, Many2One };
static props = {
...Many2OneField.props,
relation: { type: String, optional: true },
};
setup() {
onWillStart(async () => {
this.isHrUser = await user.hasGroup("hr.group_hr_user");
});
}
get m2oProps() {
return {
...computeM2OProps(this.props),
canQuickCreate: false,
relation: this.relation,
};
}
get relation() {
return this.props.relation ?? (this.isHrUser ? "hr.employee" : "hr.employee.public");
}
}
registry.category("fields").add("many2one_avatar_employee", {
...buildM2OFieldDescription(Many2OneAvatarEmployeeField),
additionalClasses: [
"o_field_many2one_avatar",
"o_field_many2one_avatar_kanban",
"o_field_many2one_avatar_user",
],
extractProps(staticInfo, dynamicInfo) {
return {
...extractM2OFieldProps(staticInfo, dynamicInfo),
relation: staticInfo.options.relation,
canOpen: "no_open" in staticInfo.options
? !staticInfo.options.no_open
: staticInfo.viewType === "form",
};
},
});

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr.Many2OneAvatarEmployeeField">
<t t-set="value" t-value="props.record.data[props.name]"/>
<div class="d-flex align-items-center gap-1">
<t t-if="value !== false">
<AvatarEmployee cssClass="'o_m2o_avatar'" resModel="relation" resId="value.id" noSpacing="true"/>
</t>
<Many2One t-props="m2oProps" cssClass="'w-100'">
<t t-set-slot="autoCompleteItem" t-slot-scope="autoCompleteItemScope">
<div class="o_avatar_many2x_autocomplete d-flex align-items-center">
<AvatarEmployee resModel="relation" resId="autoCompleteItemScope.record.id" canOpenPopover="false"/>
<span t-out="autoCompleteItemScope.label"/>
</div>
</t>
</Many2One>
</div>
</t>
</templates>

View File

@@ -0,0 +1,24 @@
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { FormController } from "@web/views/form/form_controller";
import { useArchiveEmployee } from "@hr/views/archive_employee_hook";
export class EmployeeFormController extends FormController {
setup() {
super.setup();
this.archiveEmployee = useArchiveEmployee();
}
getStaticActionMenuItems() {
const menuItems = super.getStaticActionMenuItems();
menuItems.archive.callback = this.archiveEmployee.bind(this, [this.model.root.resId]);
return menuItems;
}
}
registry.category("views").add("hr_employee_form", {
...formView,
Controller: EmployeeFormController,
});

View File

@@ -0,0 +1,29 @@
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { useArchiveEmployee } from "@hr/views/archive_employee_hook";
export class EmployeeKanbanController extends KanbanController {
setup() {
super.setup();
this.archiveEmployee = useArchiveEmployee();
}
getStaticActionMenuItems() {
const menuItems = super.getStaticActionMenuItems();
const selectedRecords = this.model.root.selection;
menuItems.archive.callback = this.archiveEmployee.bind(
this,
selectedRecords.map(({ resId }) => resId)
);
return menuItems;
}
}
registry.category("views").add("hr_employee_kanban", {
...kanbanView,
Controller: EmployeeKanbanController,
});

View File

@@ -0,0 +1,33 @@
import { registry } from '@web/core/registry';
import { listView } from '@web/views/list/list_view';
import { ListController } from '@web/views/list/list_controller';
import { useArchiveEmployee } from '@hr/views/archive_employee_hook';
export class EmployeeListController extends ListController {
setup() {
super.setup();
this.archiveEmployee = useArchiveEmployee();
}
getStaticActionMenuItems() {
const menuItems = super.getStaticActionMenuItems();
const selectedRecords = this.model.root.selection;
menuItems.archive.callback = this.archiveEmployee.bind(
this,
selectedRecords.map(({resId}) => resId),
)
return menuItems;
}
async createRecord() {
await this.props.createRecord();
}
}
registry.category('views').add('hr_employee_list', {
...listView,
Controller: EmployeeListController,
});

View File

@@ -0,0 +1,16 @@
import { helpers } from "@mail/core/web/open_chat_hook";
import { patch } from "@web/core/utils/patch";
patch(helpers, {
SUPPORTED_M2X_AVATAR_MODELS: [
...helpers.SUPPORTED_M2X_AVATAR_MODELS,
"hr.employee",
"hr.employee.public",
],
buildOpenChatParams(resModel, id) {
if (["hr.employee", "hr.employee.public"].includes(resModel)) {
return { employeeId: id };
}
return super.buildOpenChatParams(...arguments);
}
});

View File

@@ -0,0 +1,28 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { formView } from "@web/views/form/form_view";
export class HrUserPreferencesController extends formView.Controller {
setup() {
super.setup();
this.action = useService("action");
this.mustReload = false;
}
onWillSaveRecord(record, changes) {
this.mustReload = "lang" in changes;
}
async onRecordSaved(record) {
await super.onRecordSaved(...arguments);
if (this.mustReload) {
this.mustReload = false;
return this.action.doAction("reload_context");
}
}
}
registry.category("views").add("hr_user_preferences_form", {
...formView,
Controller: HrUserPreferencesController,
});