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,249 @@
import { RPCError } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { Deferred, Race } from "@web/core/utils/concurrency";
import { useService } from "@web/core/utils/hooks";
import { useSetupAction } from "@web/search/action_hook";
import { SEARCH_KEYS } from "@web/search/with_search/with_search";
import { buildSampleORM } from "./sample_server";
import {
EventBus,
onWillStart,
onWillUnmount,
onWillUpdateProps,
status,
useComponent,
} from "@odoo/owl";
/**
* @typedef {import("@web/env").OdooEnv} OdooEnv
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
* @typedef {import("services").ServiceFactories} Services
*/
export class Model {
static services = [];
/**
* @param {OdooEnv} env
* @param {SearchParams} params
* @param {Services} services
*/
constructor(env, params, services) {
this.env = env;
this.orm = services.orm;
this.bus = new EventBus();
this.isReady = false;
this.whenReady = new Deferred();
this.whenReady.then(() => {
this.isReady = true;
this.notify();
});
this.setup(params, services);
}
/**
* @param {SearchParams} params
* @param {Services} services
*/
setup(/* params, services */) {}
/**
* @param {Partial<SearchParams>} _params
*/
async load(_params) {}
/**
* This function is meant to be overriden by models that want to implement
* the sample data feature. It should return true iff the last loaded state
* actually contains data. If not, another load will be done (if the sample
* feature is enabled) with the orm service substituted by another using the
* SampleServer, to have sample data to display instead of an empty screen.
*
* @returns {boolean}
*/
hasData() {
return true;
}
/**
* This function is meant to be overriden by models that want to combine
* sample data with real groups that exist on the server.
*
* @returns {boolean}
*/
getGroups() {
return null;
}
notify() {
this.bus.trigger("update");
}
}
/**
* @param {Record<string, unknown>} props
* @returns {SearchParams}
*/
function getSearchParams(props) {
const params = {};
for (const key of SEARCH_KEYS) {
params[key] = props[key];
}
return params;
}
/**
* @template {typeof Model} T
* @param {T} ModelClass
* @param {Object} params
* @param {Object} [options]
* @param {Function} [options.beforeFirstLoad]
* @returns {InstanceType<T>}
*/
export function useModel(ModelClass, params, options = {}) {
const component = useComponent();
const services = {};
for (const key of ModelClass.services) {
services[key] = useService(key);
}
services.orm = services.orm || useService("orm");
const model = new ModelClass(component.env, params, services);
onWillStart(async () => {
await options.beforeFirstLoad?.();
await model.load(getSearchParams(component.props));
model.whenReady.resolve();
});
onWillUpdateProps((nextProps) => model.load(getSearchParams(nextProps)));
return model;
}
/**
* @template {typeof Model} T
* @param {T} ModelClass
* @param {Object} params
* @param {Object} [options]
* @param {Function} [options.lazy=false]
* @returns {InstanceType<T>}
*/
export function useModelWithSampleData(ModelClass, params, options = {}) {
const component = useComponent();
if (!(ModelClass.prototype instanceof Model)) {
throw new Error(`the model class should extend Model`);
}
const services = {};
for (const key of ModelClass.services) {
services[key] = useService(key);
}
services.orm = services.orm || useService("orm");
if (!("isAlive" in params)) {
params.isAlive = () => status(component) !== "destroyed";
}
const model = new ModelClass(component.env, params, services);
const onUpdate = () => component.render(true);
model.bus.addEventListener("update", onUpdate);
onWillUnmount(() => model.bus.removeEventListener("update", onUpdate));
const globalState = component.props.globalState || {};
const localState = component.props.state || {};
let useSampleModel =
component.props.useSampleModel &&
(!("useSampleModel" in globalState) || globalState.useSampleModel);
model.useSampleModel = false;
const orm = model.orm;
let sampleORM = localState.sampleORM;
/**
* @param {Record<string, unknown>} props
*/
async function _load(props) {
const searchParams = getSearchParams(props);
await model.load(searchParams);
if (useSampleModel && !model.hasData()) {
sampleORM =
sampleORM || buildSampleORM(component.props.resModel, component.props.fields, user);
// Load data with sampleORM then restore real ORM.
model.orm = sampleORM;
await model.load(searchParams);
model.orm = orm;
model.useSampleModel = true;
} else {
useSampleModel = false;
model.useSampleModel = useSampleModel;
}
model.whenReady.resolve(); // resolve after the first successful load
if (status(component) === "mounted") {
model.notify();
}
}
const race = new Race();
const load = (props) => race.add(_load(props));
onWillStart(() => {
const prom = load(component.props);
if (options.lazy) {
// in-house error handling as we're out of willStart
prom.catch((e) => {
if (e instanceof RPCError) {
component.env.config.historyBack();
}
throw e;
});
} else {
return prom;
}
});
onWillUpdateProps((nextProps) => {
useSampleModel = false;
load(nextProps);
});
useSetupAction({
getGlobalState() {
if (component.props.useSampleModel) {
return { useSampleModel };
}
},
getLocalState: () => ({ sampleORM }),
});
return model;
}
export function _makeFieldFromPropertyDefinition(name, definition, relatedPropertyField) {
return {
...definition,
name,
propertyName: definition.name,
relation: definition.comodel,
relatedPropertyField,
};
}
export async function addPropertyFieldDefs(orm, resModel, context, fields, groupBy) {
const proms = [];
for (const gb of groupBy) {
if (gb in fields) {
continue;
}
const [fieldName] = gb.split(".");
const field = fields[fieldName];
if (field?.type === "properties") {
proms.push(
orm
.call(resModel, "get_property_definition", [gb], {
context,
})
.then((definition) => {
fields[gb] = _makeFieldFromPropertyDefinition(gb, definition, field);
})
.catch(() => {
fields[gb] = _makeFieldFromPropertyDefinition(gb, {}, field);
})
);
}
}
return Promise.all(proms);
}

View File

@@ -0,0 +1,204 @@
import { useService } from "@web/core/utils/hooks";
import { isObject, pick } from "@web/core/utils/objects";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { getFieldsSpec } from "@web/model/relational_model/utils";
import { Component, xml, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
const defaultActiveField = { attrs: {}, options: {}, domain: "[]", string: "" };
class StandaloneRelationalModel extends RelationalModel {
load(params = {}) {
if (params.values) {
const data = params.values;
const config = this._getNextConfig(this.config, params);
this.root = this._createRoot(config, data);
this.config = config;
this.hooks.onRootLoaded(this.root);
return Promise.resolve();
}
return super.load(params);
}
}
class _Record extends Component {
static template = xml`<t t-slot="default" record="model.root"/>`;
static props = ["slots", "info", "fields", "values?"];
setup() {
this.orm = useService("orm");
const resModel = this.props.info.resModel;
const activeFields = this.getActiveFields();
const modelParams = {
config: {
resModel,
fields: this.props.fields,
isMonoRecord: true,
activeFields,
resId: this.props.info.resId,
mode: this.props.info.mode,
context: this.props.info.context,
},
hooks: this.props.info.hooks,
};
const modelServices = Object.fromEntries(
StandaloneRelationalModel.services.map((servName) => [servName, useService(servName)])
);
modelServices.orm = this.orm;
this.model = useState(new StandaloneRelationalModel(this.env, modelParams, modelServices));
const prepareLoadWithValues = async (values) => {
values = pick(values, ...Object.keys(modelParams.config.activeFields));
const proms = [];
for (const fieldName in values) {
if (["one2many", "many2many"].includes(this.props.fields[fieldName].type)) {
if (values[fieldName].length && typeof values[fieldName][0] === "number") {
const resModel = this.props.fields[fieldName].relation;
const resIds = values[fieldName];
const activeField = modelParams.config.activeFields[fieldName];
if (activeField.related) {
const { activeFields, fields } = activeField.related;
const fieldSpec = getFieldsSpec(activeFields, fields, {});
const kwargs = {
context: activeField.context || {},
specification: fieldSpec,
};
proms.push(
this.orm.webRead(resModel, resIds, kwargs).then((records) => {
values[fieldName] = records;
})
);
}
}
}
if (this.props.fields[fieldName].type === "many2one") {
const loadDisplayName = async (resId) => {
const resModel = this.props.fields[fieldName].relation;
const activeField = modelParams.config.activeFields[fieldName];
const kwargs = {
context: activeField.context || {},
specification: { display_name: {} },
};
const records = await this.orm.webRead(resModel, [resId], kwargs);
return records[0].display_name;
};
if (typeof values[fieldName] === "number") {
const prom = loadDisplayName(values[fieldName]);
prom.then((displayName) => {
values[fieldName] = {
id: values[fieldName],
display_name: displayName,
};
});
proms.push(prom);
} else if (Array.isArray(values[fieldName])) {
if (values[fieldName][1] === undefined) {
const prom = loadDisplayName(values[fieldName][0]);
prom.then((displayName) => {
values[fieldName] = {
id: values[fieldName][0],
display_name: displayName,
};
});
proms.push(prom);
}
values[fieldName] = {
id: values[fieldName][0],
display_name: values[fieldName][1],
};
} else if (isObject(values[fieldName])) {
if (values[fieldName].display_name === undefined) {
const prom = loadDisplayName(values[fieldName].id);
prom.then((displayName) => {
values[fieldName] = {
id: values[fieldName].id,
display_name: displayName,
};
});
proms.push(prom);
}
values[fieldName] = {
id: values[fieldName].id,
display_name: values[fieldName].display_name,
};
}
}
await Promise.all(proms);
}
return values;
};
onWillStart(async () => {
if (this.props.values) {
const values = await prepareLoadWithValues(this.props.values);
await this.model.load({ values });
} else {
await this.model.load();
}
this.model.whenReady.resolve();
});
onWillUpdateProps(async (nextProps) => {
const params = {};
if (nextProps.info.resId !== this.model.root.resId) {
params.resId = nextProps.info.resId;
}
if (nextProps.values) {
params.values = await prepareLoadWithValues(nextProps.values);
}
if (Object.keys(params).length) {
return this.model.load(params);
}
});
}
getActiveFields() {
if (this.props.info.activeFields) {
const activeFields = {};
for (const [fName, fInfo] of Object.entries(this.props.info.activeFields)) {
activeFields[fName] = { ...defaultActiveField, ...fInfo };
}
return activeFields;
}
return Object.fromEntries(
this.props.info.fieldNames.map((f) => [f, { ...defaultActiveField }])
);
}
}
export class Record extends Component {
static template = xml`<_Record fields="fields" slots="props.slots" values="props.values" info="props" />`;
static components = { _Record };
static props = [
"slots",
"resModel?",
"fieldNames?",
"activeFields?",
"fields?",
"resId?",
"mode?",
"values?",
"context?",
"hooks?",
];
static defaultProps = {
context: {},
};
setup() {
const { activeFields, fieldNames, fields, resModel } = this.props;
if (!activeFields && !fieldNames) {
throw Error(
`Record props should have either a "activeFields" key or a "fieldNames" key`
);
}
if (!fields && (!fieldNames || !resModel)) {
throw Error(
`Record props should have either a "fields" key or a "fieldNames" and a "resModel" key`
);
}
if (fields) {
this.fields = fields;
} else {
const fieldService = useService("field");
onWillStart(async () => {
this.fields = await fieldService.loadFields(resModel, { fieldNames });
});
}
}
}

View File

@@ -0,0 +1,64 @@
import { markRaw } from "@odoo/owl";
import { Reactive } from "@web/core/utils/reactive";
import { getId } from "./utils";
/**
* @typedef {import("@web/search/search_model").Field} Field
* @typedef {import("@web/search/search_model").FieldInfo} FieldInfo
* @typedef {import("./relational_model").RelationalModel} RelationalModel
* @typedef {import("./relational_model").RelationalModelConfig} RelationalModelConfig
*/
export class DataPoint extends Reactive {
/**
* @param {RelationalModel} model
* @param {RelationalModelConfig} config
* @param {Record<string, unknown>} data
* @param {unknown} [options]
*/
constructor(model, config, data, options) {
super(...arguments);
this.id = getId("datapoint");
this.model = model;
markRaw(config.activeFields);
markRaw(config.fields);
/** @type {RelationalModelConfig} */
this._config = config;
this.setup(config, data, options);
}
/**
* @abstract
* @template [O={}]
* @param {RelationalModelConfig} _config
* @param {Record<string, unknown>} _data
* @param {O | undefined} _options
*/
setup(_config, _data, _options) {}
get activeFields() {
return this.config.activeFields;
}
get fields() {
return this.config.fields;
}
get fieldNames() {
return Object.keys(this.activeFields).filter(
(fieldName) => !this.fields[fieldName].relatedPropertyField
);
}
get resModel() {
return this.config.resModel;
}
get config() {
return this._config;
}
get context() {
return this.config.context;
}
}

View File

@@ -0,0 +1,385 @@
//@ts-check
import { Domain } from "@web/core/domain";
import { DynamicList } from "./dynamic_list";
import { getGroupServerValue } from "./utils";
export const MOVABLE_RECORD_TYPES = ["char", "boolean", "integer", "selection", "many2one"];
/**
* @typedef {import("./record").Record} RelationalRecord
*/
export class DynamicGroupList extends DynamicList {
static type = "DynamicGroupList";
/**
* @type {DynamicList["setup"]}
*/
setup(_config, data) {
super.setup(...arguments);
this.isGrouped = true;
this._nbRecordsMatchingDomain = null;
this._setData(data);
}
/**
* @param {Record<string, unknown>} data
*/
_setData(data) {
/** @type {import("./group").Group[]} */
this.groups = data.groups.map((g) => this._createGroupDatapoint(g));
this.count = data.length;
this._selectDomain(this.isDomainSelected);
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get groupBy() {
return this.config.groupBy;
}
get groupByField() {
return this.fields[this.groupBy[0].split(":")[0]];
}
get hasData() {
return this.groups.some((group) => group.hasData);
}
get isRecordCountTrustable() {
return this.count <= this.limit || this._nbRecordsMatchingDomain !== null;
}
/**
* List of loaded records inside groups.
* @returns {RelationalRecord[]}
*/
get records() {
return this.groups
.filter((group) => !group.isFolded)
.map((group) => group.records)
.flat();
}
/**
* @returns {number}
*/
get recordCount() {
if (this._nbRecordsMatchingDomain !== null) {
return this._nbRecordsMatchingDomain;
}
return this.groups.reduce((acc, group) => acc + group.count, 0);
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* @param {string} groupName
* @param {string} [foldField] if given, will write true on this field to
* make the group folded by default
*/
async createGroup(groupName, foldField) {
if (!this.groupByField || this.groupByField.type !== "many2one") {
throw new Error("Cannot create a group on a non many2one group field");
}
await this.model.mutex.exec(() => this._createGroup(groupName, foldField));
}
async deleteGroups(groups) {
await this.model.mutex.exec(() => this._deleteGroups(groups));
}
/**
* @param {string} dataRecordId
* @param {string} dataGroupId
* @param {string} refId
* @param {string} targetGroupId
*/
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
if (dataGroupId === targetGroupId) {
// move a record inside the same group
await targetGroup.list._resequence(
targetGroup.list.records,
this.resModel,
dataRecordId,
refId
);
return;
}
// move record from a group to another group
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
const recordIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
const record = sourceGroup.list.records[recordIndex];
// step 1: move record to correct position
const refIndex = targetGroup.list.records.findIndex((r) => r.id === refId);
const oldIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
const sourceList = sourceGroup.list;
// if the source contains more records than what's loaded, reload it after moving the record
const mustReloadSourceList = sourceList.count > sourceList.offset + sourceList.limit;
sourceGroup._removeRecords([record.id]);
targetGroup._addRecord(record, refIndex + 1);
// step 2: update record value
let value = targetGroup.value;
if (targetGroup.groupByField.type === "many2one") {
value = value ? { id: value, display_name: targetGroup.displayName } : false;
}
const revert = () => {
targetGroup._removeRecords([record.id]);
sourceGroup._addRecord(record, oldIndex);
};
try {
const changes = { [targetGroup.groupByField.name]: value };
const res = await record.update(changes, { save: true });
if (!res) {
return revert();
}
} catch (e) {
// revert changes
revert();
throw e;
}
const proms = [];
if (mustReloadSourceList) {
const { offset, limit, orderBy, domain } = sourceGroup.list;
proms.push(sourceGroup.list._load(offset, limit, orderBy, domain));
}
if (!targetGroup.isFolded) {
const targetList = targetGroup.list;
const records = targetList.records;
proms.push(targetList._resequence(records, this.resModel, dataRecordId, refId));
}
return Promise.all(proms);
}
async resequence(movedGroupId, targetGroupId) {
if (!this.groupByField || this.groupByField.type !== "many2one") {
throw new Error("Cannot resequence a group on a non many2one group field");
}
return this.model.mutex.exec(async () => {
await this._resequence(
this.groups,
this.groupByField.relation,
movedGroupId,
targetGroupId
);
});
}
async selectDomain(value) {
return this.model.mutex.exec(async () => {
await this._ensureCorrectRecordCount();
this._selectDomain(value);
});
}
async sortBy(fieldName) {
if (!this.groups.length) {
return;
}
if (this.groups.every((group) => group.isFolded)) {
// all groups are folded
if (this.groupByField.name !== fieldName) {
// grouped by another field than fieldName
if (!(fieldName in this.groups[0].aggregates)) {
// fieldName has no aggregate values
return;
}
}
}
return super.sortBy(fieldName);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _createGroup(groupName, foldField = false) {
const [id] = await this.model.orm.call(
this.groupByField.relation,
"name_create",
[groupName],
{ context: this.context }
);
if (foldField) {
await this.model.orm.write(
this.groupByField.relation,
[id],
{ [foldField]: true },
{ context: this.context }
);
}
const lastGroup = this.groups.at(-1);
// This is almost a copy/past of the code in relational_model.js
// Maybe we can create an addGroup method in relational_model.js
// and call it from here and from relational_model.js
const commonConfig = {
resModel: this.config.resModel,
fields: this.config.fields,
activeFields: this.config.activeFields,
fieldsToAggregate: this.config.fieldsToAggregate,
};
const context = {
...this.context,
[`default_${this.groupByField.name}`]: id,
};
const nextConfigGroups = { ...this.config.groups };
const domain = Domain.and([this.domain, [[this.groupByField.name, "=", id]]]).toList();
const groupBy = this.groupBy.slice(1);
nextConfigGroups[id] = {
...commonConfig,
context,
groupByFieldName: this.groupByField.name,
isFolded: Boolean(foldField),
value: id,
extraDomain: false,
initialDomain: domain,
list: {
...commonConfig,
context,
domain: domain,
groupBy,
orderBy: this.orderBy,
limit: this.model.initialLimit,
offset: 0,
},
};
this.model._updateConfig(this.config, { groups: nextConfigGroups }, { reload: false });
const data = {
aggregates: {},
count: 0,
length: 0,
__domain: domain,
[this.groupByField.name]: [id, groupName],
value: id,
serverValue: getGroupServerValue(this.groupByField, id),
displayName: groupName,
rawValue: [id, groupName],
};
if (groupBy.length) {
data.groups = [];
} else {
data.records = [];
}
const group = this._createGroupDatapoint(data);
if (lastGroup) {
const groups = [...this.groups, group];
await this._resequence(groups, this.groupByField.relation, group.id, lastGroup.id);
this.groups = groups;
} else {
this.groups.push(group);
}
}
_createGroupDatapoint(data) {
return new this.model.constructor.Group(this.model, this.config.groups[data.value], data);
}
async _deleteGroups(groups) {
const shouldReload = groups.some((g) => g.count > 0);
await this._unlinkGroups(groups);
const configGroups = { ...this.config.groups };
for (const group of groups) {
delete configGroups[group.value];
}
if (shouldReload) {
await this.model._updateConfig(
this.config,
{ groups: configGroups },
{ commit: this._setData.bind(this) }
);
} else {
for (const group of groups) {
this._removeGroup(group);
}
this.model._updateConfig(this.config, { groups: configGroups }, { reload: false });
}
}
async _ensureCorrectRecordCount() {
if (!this.isRecordCountTrustable) {
this._nbRecordsMatchingDomain = await this.model.orm.searchCount(
this.resModel,
this.domain,
{ limit: this.model.initialCountLimit }
);
}
}
_getDPresId(group) {
return group.value;
}
_getDPFieldValue(group, handleField) {
return group[handleField];
}
async _load(offset, limit, orderBy, domain) {
await this.model._updateConfig(
this.config,
{ offset, limit, orderBy, domain },
{ commit: this._setData.bind(this) }
);
if (this.isDomainSelected) {
await this._ensureCorrectRecordCount();
}
}
_removeGroup(group) {
const index = this.groups.findIndex((g) => g.id === group.id);
this.groups.splice(index, 1);
this.count--;
}
_removeRecords(recordIds) {
const proms = [];
for (const group of this.groups) {
proms.push(group._removeRecords(recordIds));
}
return Promise.all(proms);
}
_selectDomain(value) {
for (const group of this.groups) {
group.list._selectDomain(value);
}
super._selectDomain(value);
}
async _toggleSelection() {
if (!this.records.length) {
// all groups are folded, so there's no visible records => select all domain
if (!this.isDomainSelected) {
await this._ensureCorrectRecordCount();
this._selectDomain(true);
} else {
this._selectDomain(false);
}
} else {
super._toggleSelection();
}
}
_unlinkGroups(groups) {
const groupResIds = groups.map((g) => g.value);
return this.model.orm.unlink(this.groupByField.relation, groupResIds, {
context: this.context,
});
}
}

View File

@@ -0,0 +1,512 @@
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { x2ManyCommands } from "@web/core/orm_service";
import { unique } from "@web/core/utils/arrays";
import { DataPoint } from "./datapoint";
import { Operation } from "./operation";
import { Record as RelationalRecord } from "./record";
import { getFieldsSpec, resequence } from "./utils";
/**
* @typedef {import("./record").Record} RelationalRecord
*/
const DEFAULT_HANDLE_FIELD = "sequence";
/**
* @abstract
*/
export class DynamicList extends DataPoint {
/**
* @type {DataPoint["setup"]}
*/
setup() {
super.setup(...arguments);
this.handleField = Object.keys(this.activeFields).find(
(fieldName) => this.activeFields[fieldName].isHandle
);
if (!this.handleField && DEFAULT_HANDLE_FIELD in this.fields) {
this.handleField = DEFAULT_HANDLE_FIELD;
}
this.isDomainSelected = false;
this.evalContext = this.context;
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get groupBy() {
return [];
}
get orderBy() {
return this.config.orderBy;
}
get domain() {
return this.config.domain;
}
/**
* Be careful that this getter is costly, as it iterates over the whole list
* of records. This property should not be accessed in a loop.
*/
get editedRecord() {
return this.records.find((record) => record.isInEdition);
}
get isRecordCountTrustable() {
return true;
}
get limit() {
return this.config.limit;
}
get offset() {
return this.config.offset;
}
/**
* Be careful that this getter is costly, as it iterates over the whole list
* of records. This property should not be accessed in a loop.
*/
get selection() {
return this.records.filter((record) => record.selected);
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
archive(isSelected) {
return this.model.mutex.exec(() => this._toggleArchive(isSelected, true));
}
canResequence() {
return !!this.handleField;
}
deleteRecords(records = []) {
return this.model.mutex.exec(() => this._deleteRecords(records));
}
duplicateRecords(records = []) {
return this.model.mutex.exec(() => this._duplicateRecords(records));
}
async enterEditMode(record) {
if (this.editedRecord === record) {
return true;
}
const canProceed = await this.leaveEditMode();
if (canProceed) {
record._checkValidity();
this.model._updateConfig(record.config, { mode: "edit" }, { reload: false });
}
return canProceed;
}
/**
* @param {boolean} [isSelected]
* @returns {Promise<number[]>}
*/
async getResIds(isSelected) {
let resIds;
if (isSelected) {
if (this.isDomainSelected) {
resIds = await this.model.orm.search(this.resModel, this.domain, {
limit: this.model.activeIdsLimit,
context: this.context,
});
} else {
resIds = this.selection.map((r) => r.resId);
}
} else {
resIds = this.records.map((r) => r.resId);
}
return unique(resIds);
}
async leaveEditMode({ discard } = {}) {
let editedRecord = this.editedRecord;
if (editedRecord) {
let canProceed = true;
if (discard) {
this._recordToDiscard = editedRecord;
await editedRecord.discard();
this._recordToDiscard = null;
editedRecord = this.editedRecord;
if (editedRecord && editedRecord.isNew) {
this._removeRecords([editedRecord.id]);
}
} else {
let isValid = true;
if (!this.model._urgentSave) {
isValid = await editedRecord.checkValidity();
editedRecord = this.editedRecord;
if (!editedRecord) {
return true;
}
}
if (editedRecord.isNew && !editedRecord.dirty) {
this._removeRecords([editedRecord.id]);
} else if (isValid || editedRecord.dirty) {
canProceed = await editedRecord.save();
}
}
editedRecord = this.editedRecord;
if (canProceed && editedRecord) {
this.model._updateConfig(
editedRecord.config,
{ mode: "readonly" },
{ reload: false }
);
} else {
return canProceed;
}
}
return true;
}
load(params = {}) {
const limit = params.limit === undefined ? this.limit : params.limit;
const offset = params.offset === undefined ? this.offset : params.offset;
const orderBy = params.orderBy === undefined ? this.orderBy : params.orderBy;
const domain = params.domain === undefined ? this.domain : params.domain;
return this.model.mutex.exec(() => this._load(offset, limit, orderBy, domain));
}
async multiSave(record, changes) {
return this.model.mutex.exec(() => this._multiSave(record, changes));
}
selectDomain(value) {
return this.model.mutex.exec(() => this._selectDomain(value));
}
sortBy(fieldName) {
return this.model.mutex.exec(() => {
let orderBy = [...this.orderBy];
if (orderBy.length && orderBy[0].name === fieldName) {
if (orderBy[0].asc) {
orderBy[0] = { name: orderBy[0].name, asc: false };
} else {
orderBy = [];
}
} else {
orderBy = orderBy.filter((o) => o.name !== fieldName);
orderBy.unshift({
name: fieldName,
asc: true,
});
}
return this._load(this.offset, this.limit, orderBy, this.domain);
});
}
toggleSelection() {
return this.model.mutex.exec(() => this._toggleSelection());
}
unarchive(isSelected) {
return this.model.mutex.exec(() => this._toggleArchive(isSelected, false));
}
toggleArchiveWithConfirmation(archive, dialogProps = {}) {
const isSelected = this.isDomainSelected || this.selection.length > 0;
if (archive) {
const defaultProps = {
body: _t("Are you sure that you want to archive all the selected records?"),
cancel: () => {},
confirm: () => this.archive(isSelected),
confirmLabel: _t("Archive"),
};
this.model.dialog.add(ConfirmationDialog, { ...defaultProps, ...dialogProps });
} else {
this.unarchive(isSelected);
}
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _duplicateRecords(records) {
let resIds;
if (records.length) {
resIds = unique(records.map((r) => r.resId));
} else {
resIds = await this.getResIds(true);
}
const copy = async (resIds) => {
const copiedRecords = await this.model.orm.call(this.resModel, "copy", [resIds], {
context: this.context,
});
if (resIds.length > copiedRecords.length) {
this.model.notification.add(_t("Some records could not be duplicated"));
}
return this.model.load();
};
if (resIds.length > 1) {
this.model.dialog.add(ConfirmationDialog, {
body: _t("Are you sure that you want to duplicate all the selected records?"),
confirm: () => copy(resIds),
cancel: () => {},
confirmLabel: _t("Confirm"),
});
} else {
await copy(resIds);
}
}
async _deleteRecords(records) {
let resIds;
if (records.length) {
resIds = unique(records.map((r) => r.resId));
} else {
resIds = await this.getResIds(true);
records = this.records.filter((r) => resIds.includes(r.resId));
}
const unlinked = await this.model.orm.unlink(this.resModel, resIds, {
context: this.context,
});
if (!unlinked) {
return false;
}
if (
this.isDomainSelected &&
resIds.length === this.model.activeIdsLimit &&
resIds.length < this.count
) {
const msg = _t(
"Only the first %(count)s records have been deleted (out of %(total)s selected)",
{ count: resIds.length, total: this.count }
);
this.model.notification.add(msg);
}
await this.model.load();
return unlinked;
}
async _leaveSampleMode() {
if (this.model.useSampleModel) {
await this._load(this.offset, this.limit, this.orderBy, this.domain);
this.model.useSampleModel = false;
}
}
async _multiSave(editedRecord, changes) {
if (!Object.keys(changes).length || editedRecord === this._recordToDiscard) {
return;
}
let canProceed = await this.model.hooks.onWillSaveMulti(editedRecord, changes);
if (canProceed === false) {
return false;
}
const selectedRecords = this.selection; // costly getter => compute it once
// special treatment for x2manys: apply commands on all selected record's static lists
const proms = [];
for (const fieldName in changes) {
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
const list = editedRecord.data[fieldName];
const commands = list._getCommands();
if ("display_name" in list.activeFields) {
// add display_name to LINK commands to prevent a web_read by selected record
for (const command of commands) {
if (command[0] === x2ManyCommands.LINK) {
const relRecord = list._cache[command[1]];
command[2] = { display_name: relRecord.data.display_name };
}
}
}
for (const record of selectedRecords) {
if (record !== editedRecord) {
proms.push(record.data[fieldName]._applyCommands(commands));
}
}
}
}
await Promise.all(proms);
// apply changes on all selected records (for x2manys, the change is the static list itself)
selectedRecords.forEach((record) => {
const _changes = Object.assign({}, changes);
for (const fieldName in _changes) {
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
_changes[fieldName] = record.data[fieldName];
}
}
record._applyChanges(_changes);
});
// determine valid and invalid records
const validRecords = [];
const invalidRecords = [];
for (const record of selectedRecords) {
const isEditedRecord = record === editedRecord;
if (
Object.keys(changes).every((fieldName) => !record._isReadonly(fieldName)) &&
record._checkValidity({ silent: !isEditedRecord })
) {
validRecords.push(record);
} else {
invalidRecords.push(record);
}
}
const discardInvalidRecords = () => invalidRecords.forEach((record) => record._discard());
if (validRecords.length === 0) {
editedRecord._displayInvalidFieldNotification();
discardInvalidRecords();
return false;
}
// generate the save callback with the values to save (must be done before discarding
// invalid records, in case the editedRecord is itself invalid)
const resIds = unique(validRecords.map((r) => r.resId));
const kwargs = {
context: this.context,
specification: getFieldsSpec(editedRecord.activeFields, editedRecord.fields),
};
let save;
if (Object.values(changes).some((v) => v instanceof Operation)) {
// "changes" contains a Field Operation => we must call the web_save_multi method to
// save each record individually
const changesById = {};
for (const record of validRecords) {
changesById[record.resId] = changesById[record.resId] || record._getChanges();
}
const valsList = resIds.map((resId) => changesById[resId]);
save = () => this.model.orm.webSaveMulti(this.resModel, resIds, valsList, kwargs);
} else {
const vals = editedRecord._getChanges();
save = () => this.model.orm.webSave(this.resModel, resIds, vals, kwargs);
}
const _changes = Object.assign(changes);
for (const fieldName in changes) {
if (this.fields[fieldName].type === "many2many") {
const list = changes[fieldName];
_changes[fieldName] = {
add: list._commands
.filter((command) => command[0] === x2ManyCommands.LINK)
.map((command) => list._cache[command[1]]),
remove: list._commands
.filter((command) => command[0] === x2ManyCommands.UNLINK)
.map((command) => list._cache[command[1]]),
};
}
}
discardInvalidRecords();
// ask confirmation
canProceed = await this.model.hooks.onAskMultiSaveConfirmation(_changes, validRecords);
if (canProceed === false) {
selectedRecords.forEach((record) => record._discard());
this.leaveEditMode({ discard: true });
return false;
}
// save changes
let records = [];
try {
records = await save();
} catch (e) {
selectedRecords.forEach((record) => record._discard());
this.model._updateConfig(editedRecord.config, { mode: "readonly" }, { reload: false });
throw e;
}
const serverValuesById = Object.fromEntries(records.map((record) => [record.id, record]));
for (const record of validRecords) {
const serverValues = serverValuesById[record.resId];
record._setData(serverValues);
this.model._updateSimilarRecords(record, serverValues);
}
this.model._updateConfig(editedRecord.config, { mode: "readonly" }, { reload: false });
this.model.hooks.onSavedMulti(validRecords);
return true;
}
async _resequence(originalList, resModel, movedId, targetId) {
if (this.resModel === resModel && !this.canResequence()) {
return;
}
const handleField = this.resModel === resModel ? this.handleField : DEFAULT_HANDLE_FIELD;
const order = this.orderBy.find((o) => o.name === handleField);
const getSequence = (dp) => dp && this._getDPFieldValue(dp, handleField);
const getResId = (dp) => this._getDPresId(dp);
const resequencedRecords = await resequence({
records: originalList,
resModel,
movedId,
targetId,
fieldName: handleField,
asc: order?.asc,
context: this.context,
orm: this.model.orm,
getSequence,
getResId,
});
for (const dpData of resequencedRecords) {
const dp = originalList.find((d) => getResId(d) === dpData.id);
if (dp instanceof RelationalRecord) {
dp._applyValues(dpData);
} else {
dp[handleField] = dpData[handleField];
}
}
}
_selectDomain(value) {
this.isDomainSelected = value;
}
async _toggleArchive(isSelected, state) {
const method = state ? "action_archive" : "action_unarchive";
const context = this.context;
const resIds = await this.getResIds(isSelected);
const action = await this.model.orm.call(this.resModel, method, [resIds], { context });
if (
this.isDomainSelected &&
resIds.length === this.model.activeIdsLimit &&
resIds.length < this.count
) {
const msg = _t(
"Of the %(selectedRecord)s selected records, only the first %(firstRecords)s have been archived/unarchived.",
{
selectedRecords: resIds.length,
firstRecords: this.count,
}
);
this.model.notification.add(msg);
}
const reload = () => this.model.load();
if (action && Object.keys(action).length) {
this.model.action.doAction(action, {
onClose: reload,
});
} else {
return reload();
}
}
async _toggleSelection() {
if (this.selection.length === this.records.length) {
this.records.forEach((record) => {
record._toggleSelection(false);
});
this._selectDomain(false);
} else {
this.records.forEach((record) => {
record._toggleSelection(true);
});
}
}
}

View File

@@ -0,0 +1,181 @@
import { DynamicList } from "./dynamic_list";
/**
* @typedef {import("./record").Record} RelationalRecord
*/
export class DynamicRecordList extends DynamicList {
static type = "DynamicRecordList";
/**
* @param {import("./relational_model").Config} config
* @param {Object} data
*/
setup(config, data) {
super.setup(config);
this._setData(data);
}
_setData(data) {
/** @type {RelationalRecord[]} */
this.records = data.records.map((r) => this._createRecordDatapoint(r));
this._updateCount(data);
this._selectDomain(this.isDomainSelected);
}
// -------------------------------------------------------------------------
// Getter
// -------------------------------------------------------------------------
get hasData() {
return this.count > 0;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* @param {number} resId
* @param {boolean} [atFirstPosition]
* @returns {Promise<Record>} the newly created record
*/
addExistingRecord(resId, atFirstPosition) {
return this.model.mutex.exec(async () => {
const record = this._createRecordDatapoint({});
await record._load({ resId });
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
return record;
});
}
/**
* @param {boolean} [atFirstPosition=false]
* @returns {Promise<Record>}
*/
addNewRecord(atFirstPosition = false) {
return this.model.mutex.exec(async () => {
await this._leaveSampleMode();
return this._addNewRecord(atFirstPosition);
});
}
/**
* Performs a search_count with the current domain to set the count. This is
* useful as web_search_read limits the count for performance reasons, so it
* might sometimes be less than the real number of records matching the domain.
**/
async fetchCount() {
this.count = await this.model._updateCount(this.config);
this.hasLimitedCount = false;
return this.count;
}
moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) {
return this.resequence(dataRecordId, refId);
}
removeRecord(record) {
if (!record.isNew) {
throw new Error("removeRecord can't be called on an existing record");
}
const index = this.records.findIndex((r) => r === record);
if (index < 0) {
return;
}
this.records.splice(index, 1);
this.count--;
return record;
}
async resequence(movedRecordId, targetRecordId) {
return this.model.mutex.exec(
async () =>
await this._resequence(this.records, this.resModel, movedRecordId, targetRecordId)
);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
async _addNewRecord(atFirstPosition) {
const values = await this.model._loadNewRecord({
resModel: this.resModel,
activeFields: this.activeFields,
fields: this.fields,
context: this.context,
});
const record = this._createRecordDatapoint(values, "edit");
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
return record;
}
_addRecord(record, index) {
this.records.splice(Number.isInteger(index) ? index : this.records.length, 0, record);
this.count++;
}
_createRecordDatapoint(data, mode = "readonly") {
return new this.model.constructor.Record(
this.model,
{
context: this.context,
activeFields: this.activeFields,
resModel: this.resModel,
fields: this.fields,
resId: data.id || false,
resIds: data.id ? [data.id] : [],
isMonoRecord: true,
mode,
},
data,
{ manuallyAdded: !data.id }
);
}
_getDPresId(record) {
return record.resId;
}
_getDPFieldValue(record, handleField) {
return record.data[handleField];
}
async _load(offset, limit, orderBy, domain) {
await this.model._updateConfig(
this.config,
{ offset, limit, orderBy, domain },
{ commit: this._setData.bind(this) }
);
}
_removeRecords(recordIds) {
const keptRecords = this.records.filter((r) => !recordIds.includes(r.id));
this.count -= this.records.length - keptRecords.length;
this.records = keptRecords;
if (this.offset && !this.records.length) {
// we weren't on the first page, and we removed all records of the current page
const offset = Math.max(this.offset - this.limit, 0);
this.model._updateConfig(this.config, { offset }, { reload: false });
}
}
_selectDomain(value) {
if (value) {
this.records.forEach((r) => (r.selected = true));
}
super._selectDomain(value);
}
_updateCount(data) {
const length = data.length;
if (length >= this.config.countLimit + 1) {
this.hasLimitedCount = true;
this.count = this.config.countLimit;
} else {
this.hasLimitedCount = false;
this.count = length;
}
}
}

View File

@@ -0,0 +1,22 @@
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
export class FetchRecordError extends Error {
constructor(resIds) {
super(
_t(
"It seems the records with IDs %s cannot be found. They might have been deleted.",
resIds
)
);
this.resIds = resIds;
}
}
function fetchRecordErrorHandler(env, error, originalError) {
if (originalError instanceof FetchRecordError) {
env.services.notification.add(originalError.message, { sticky: true, type: "danger" });
return true;
}
}
const errorHandlerRegistry = registry.category("error_handlers");
errorHandlerRegistry.add("fetchRecordErrorHandler", fetchRecordErrorHandler);

View File

@@ -0,0 +1,136 @@
import { Domain } from "@web/core/domain";
import { DataPoint } from "./datapoint";
/**
* @typedef Params
* @property {string[]} groupBy
*/
export class Group extends DataPoint {
static type = "Group";
/**
* @param {import("./relational_model").Config} config
*/
setup(config, data) {
super.setup(...arguments);
this.groupByField = this.fields[config.groupByFieldName];
this.range = data.range;
this._rawValue = data.rawValue;
/** @type {number} */
this.count = data.count;
this.value = data.value;
this.serverValue = data.serverValue;
this.displayName = data.displayName;
this.aggregates = data.aggregates;
let List;
if (config.list.groupBy.length) {
List = this.model.constructor.DynamicGroupList;
} else {
List = this.model.constructor.DynamicRecordList;
}
/** @type {import("./dynamic_group_list").DynamicGroupList | import("./dynamic_record_list").DynamicRecordList} */
this.list = new List(this.model, config.list, data);
this._useGroupCountForList();
if (config.record) {
config.record.context = { ...config.record.context, ...config.context };
this.record = new this.model.constructor.Record(this.model, config.record, data.values);
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
get groupDomain() {
return this.config.initialDomain;
}
get hasData() {
return this.count > 0;
}
get isFolded() {
return this.config.isFolded;
}
get records() {
return this.list.records;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
async addExistingRecord(resId, atFirstPosition = false) {
const record = await this.list.addExistingRecord(resId, atFirstPosition);
this.count++;
return record;
}
async addNewRecord(_unused, atFirstPosition = false) {
const canProceed = await this.model.root.leaveEditMode();
if (canProceed) {
const record = await this.list.addNewRecord(atFirstPosition);
if (record) {
this.count++;
}
}
}
async applyFilter(filter) {
if (filter) {
await this.list.load({
domain: Domain.and([this.groupDomain, filter]).toList(),
});
} else {
await this.list.load({ domain: this.groupDomain });
this.count = this.list.isGrouped ? this.list.recordCount : this.list.count;
}
this.model._updateConfig(this.config, { extraDomain: filter }, { reload: false });
}
deleteRecords(records) {
return this.model.mutex.exec(() => this._deleteRecords(records));
}
async toggle() {
if (this.config.isFolded) {
await this.list.load();
}
this._useGroupCountForList();
this.model._updateConfig(
this.config,
{ isFolded: !this.config.isFolded },
{ reload: false }
);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
_addRecord(record, index) {
this.list._addRecord(record, index);
this.count++;
}
async _deleteRecords(records) {
await this.list._deleteRecords(records);
this.count -= records.length;
}
/**
* The count returned by web_search_read is limited (see DEFAULT_COUNT_LIMIT). However, the one
* returned by formatted_read_group, for each group, isn't. So in the grouped case, it might happen
* that the group count is more accurate than the list one. It that case, we use it on the list.
*/
_useGroupCountForList() {
if (!this.list.isGrouped && this.list.count === this.list.config.countLimit) {
this.list.count = this.count;
}
}
async _removeRecords(recordIds) {
const idsToRemove = recordIds.filter((id) => this.list.records.some((r) => r.id === id));
this.list._removeRecords(idsToRemove);
this.count -= idsToRemove.length;
}
}

View File

@@ -0,0 +1,21 @@
export class Operation {
constructor(operator, operand) {
this.operator = operator;
this.operand = operand;
}
compute(value) {
switch (this.operator) {
case "+":
return value + this.operand;
case "-":
return value - this.operand;
case "*":
return value * this.operand;
case "/":
return value / this.operand;
default:
throw new Error(`Unsupported operator: ${this.operator}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,870 @@
// @ts-check
import { EventBus, markRaw, toRaw } from "@odoo/owl";
import { makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import { WarningDialog } from "@web/core/errors/error_dialogs";
import { rpcBus } from "@web/core/network/rpc";
import { shallowEqual } from "@web/core/utils/arrays";
import { deepCopy, pick } from "@web/core/utils/objects";
import { Deferred, KeepLast, Mutex } from "@web/core/utils/concurrency";
import { orderByToString } from "@web/search/utils/order_by";
import { Model } from "../model";
import { DynamicGroupList } from "./dynamic_group_list";
import { DynamicRecordList } from "./dynamic_record_list";
import { Group } from "./group";
import { Record as RelationalRecord } from "./record";
import { StaticList } from "./static_list";
import {
extractInfoFromGroupData,
getAggregateSpecifications,
getBasicEvalContext,
getFieldsSpec,
getGroupServerValue,
getId,
makeActiveField,
} from "./utils";
import { FetchRecordError } from "./errors";
/**
* @typedef {import("@web/core/context").Context} Context
* @typedef {import("./datapoint").DataPoint} DataPoint
* @typedef {import("@web/core/domain").DomainListRepr} DomainListRepr
* @typedef {import("@web/search/search_model").Field} Field
* @typedef {import("@web/search/search_model").FieldInfo} FieldInfo
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
* @typedef {import("services").ServiceFactories} Services
*
* @typedef {{
* changes?: Record<string, unknown>;
* fieldNames?: string[];
* evalContext?: Context;
* onError?: (error: unknown) => unknown;
* cache?: Object;
* }} OnChangeParams
*
* @typedef {SearchParams & {
* fields: Record<string, Field>;
* activeFields: Record<string, FieldInfo>;
* fieldsToAggregate: string[];
* isMonoRecord: boolean;
* isRoot: boolean;
* resIds?: number[];
* mode?: "edit" | "readonly";
* loadId?: string;
* limit?: number;
* offset?: number;
* countLimit?: number;
* groupsLimit?: number;
* groups?: Record<string, unknown>;
* currentGroups?: Record<string, unknown>; // FIXME: could be cleaned: Object
* openGroupsByDefault?: boolean;
* }} RelationalModelConfig
*
* @typedef {{
* config: RelationalModelConfig;
* state?: RelationalModelState;
* hooks?: Partial<typeof DEFAULT_HOOKS>;
* limit?: number;
* countLimit?: number;
* groupsLimit?: number;
* defaultOrderBy?: string[];
* maxGroupByDepth?: number;
* multiEdit?: boolean;
* groupByInfo?: Record<string, unknown>;
* activeIdsLimit?: number;
* useSendBeaconToSaveUrgently?: boolean;
* }} RelationalModelParams
*
* @typedef {{
* config: RelationalModelConfig;
* specialDataCaches: Record<string, unknown>;
* }} RelationalModelState
*/
const DEFAULT_HOOKS = {
/** @type {(config: RelationalModelConfig) => any} */
onWillLoadRoot: () => {},
/** @type {(root: DataPoint) => any} */
onRootLoaded: () => {},
/** @type {(record: RelationalRecord) => any} */
onWillSaveRecord: () => {},
/** @type {(record: RelationalRecord) => any} */
onRecordSaved: () => {},
/** @type {(record: RelationalRecord, changes: Object) => any} */
onWillSaveMulti: () => {},
/** @type {(records: RelationalRecord[]) => any} */
onSavedMulti: () => {},
/** @type {(record: RelationalRecord, fieldName: string) => any} */
onWillSetInvalidField: () => {},
/** @type {(record: RelationalRecord) => any} */
onRecordChanged: () => {},
/** @type {(warning: Object) => any} */
onWillDisplayOnchangeWarning: () => {},
/** @type {(changes: Object, validRecords: RelationalRecord[]) => any} */
onAskMultiSaveConfirmation: () => true,
};
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
if (ev.detail.data.params?.method === "unlink") {
rpcBus.trigger("CLEAR-CACHES", ["web_read", "web_search_read", "web_read_group"]);
}
});
export class RelationalModel extends Model {
static services = ["action", "dialog", "notification", "orm"];
static Record = RelationalRecord;
static Group = Group;
static DynamicRecordList = DynamicRecordList;
static DynamicGroupList = DynamicGroupList;
static StaticList = StaticList;
static DEFAULT_LIMIT = 80;
static DEFAULT_COUNT_LIMIT = 10000;
static DEFAULT_GROUP_LIMIT = 80;
static DEFAULT_OPEN_GROUP_LIMIT = 10; // TODO: remove ?
static withCache = true;
/**
* @param {RelationalModelParams} params
* @param {Services} services
*/
setup(params, { action, dialog, notification }) {
this.action = action;
this.dialog = dialog;
this.notification = notification;
this.bus = new EventBus();
this.keepLast = markRaw(new KeepLast());
this.mutex = markRaw(new Mutex());
/** @type {RelationalModelConfig} */
this.config = {
isMonoRecord: false,
context: {},
fieldsToAggregate: Object.keys(params.config.activeFields), // active fields by default
...params.config,
isRoot: true,
};
this.hooks = Object.assign({}, DEFAULT_HOOKS, params.hooks);
this.initialLimit = params.limit || this.constructor.DEFAULT_LIMIT;
this.initialGroupsLimit = params.groupsLimit;
this.initialCountLimit = params.countLimit || this.constructor.DEFAULT_COUNT_LIMIT;
this.defaultOrderBy = params.defaultOrderBy;
this.maxGroupByDepth = params.maxGroupByDepth;
this.groupByInfo = params.groupByInfo || {};
this.multiEdit = params.multiEdit;
this.activeIdsLimit = params.activeIdsLimit || Number.MAX_SAFE_INTEGER;
this.specialDataCaches = markRaw(params.state?.specialDataCaches || {});
this.useSendBeaconToSaveUrgently = params.useSendBeaconToSaveUrgently || false;
this.withCache = this.constructor.withCache && this.env.config?.cache;
this.initialSampleGroups = undefined; // real groups to populate with sample records
this._urgentSave = false;
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
exportState() {
const config = { ...toRaw(this.config) };
delete config.currentGroups;
return {
config,
specialDataCaches: this.specialDataCaches,
};
}
/**
* @override
* @type {Model["hasData"]}
*/
hasData() {
return this.root.hasData;
}
/**
* @override
* @type {Model["load"]}
*/
async load(params = {}) {
if (this.orm.isSample && this.initialSampleGroups?.length) {
this.orm.setGroups(this.initialSampleGroups);
}
const config = this._getNextConfig(this.config, params);
if (!this.isReady) {
// We want the control panel to be displayed directly, without waiting for data to be
// loaded, for instance to be able to interact with the search view. For that reason, we
// create an empty root, without data, s.t. controllers can make the assumption that the
// root is set when they are rendered. The root is replaced later on by the real root,
// when data are loaded.
this.root = this._createEmptyRoot(config);
this.config = config;
}
this.hooks.onWillLoadRoot(config);
const rootLoadDef = new Deferred();
const cache = this._getCacheParams(config, rootLoadDef);
const data = await this.keepLast.add(this._loadData(config, cache));
this.root = this._createRoot(config, data);
rootLoadDef.resolve({ root: this.root, loadId: config.loadId });
this.config = config;
await this.hooks.onRootLoaded(this.root);
}
// -------------------------------------------------------------------------
// Protected
// -------------------------------------------------------------------------
/**
* If we group by default based on a property, the property might not be loaded in `fields`.
*
* @param {RelationalModelConfig} config
* @param {string} propertyFullName
*/
async _getPropertyDefinition(config, propertyFullName) {
// dynamically load the property and add the definition in the fields attribute
const result = await this.orm.call(
config.resModel,
"get_property_definition",
[propertyFullName],
{ context: config.context }
);
if (!result) {
// the property might have been removed
config.groupBy = null;
} else {
result.propertyName = result.name;
result.name = propertyFullName; // "xxxxx" -> "property.xxxxx"
// needed for _applyChanges
result.relatedPropertyField = { fieldName: propertyFullName.split(".")[0] };
result.relation = result.comodel; // match name on field
config.fields[propertyFullName] = result;
}
}
async _askChanges() {
const proms = [];
this.bus.trigger("NEED_LOCAL_CHANGES", { proms });
await Promise.all([...proms, this.mutex.getUnlockedDef()]);
}
/**
* Creates a root datapoint without data. Supported root types are DynamicRecordList and
* DynamicGroupList.
*
* @param {RelationalModelConfig} config
* @returns {DataPoint | undefined}
*/
_createEmptyRoot(config) {
if (!config.isMonoRecord) {
if (config.groupBy.length) {
return this._createRoot(config, { groups: [], length: 0 });
}
return this._createRoot(config, { records: [], length: 0 });
}
}
/**
* @param {RelationalModelConfig} config
* @param {Record<string, unknown>} data
* @returns {DataPoint}
*/
_createRoot(config, data) {
if (config.isMonoRecord) {
return new this.constructor.Record(this, config, data);
}
if (config.groupBy.length) {
return new this.constructor.DynamicGroupList(this, config, data);
}
return new this.constructor.DynamicRecordList(this, config, data);
}
_getCacheParams(config, rootLoadDef) {
if (!this.withCache) {
return;
}
if (
!this.isReady || // first load of the model
// monorecord, loading a different id, or creating a new record (onchange)
(config.isMonoRecord && (this.root.config.resId !== config.resId || !config.resId))
) {
return {
type: "disk",
update: "always",
callback: async (result, hasChanged) => {
if (!hasChanged) {
return;
}
const { root, loadId } = await rootLoadDef;
if (root.id !== this.root.id) {
// The root id might have changed, either because:
// 1) the user already changed the domain and a second load has been done
// 2) there was no data, so we reloaded directly with the sample orm
// In the first case, there's nothing to do, we can ignore this update. We
// have to deal with the second case:
if (this.useSampleModel) {
// We displayed sample data from the cache, but the rpc returned records
// or groups => leave sample mode, forget previous groups and update
this.useSampleModel = false;
if (this.root.config.groupBy.length) {
delete this.root.config.currentGroups;
result = await this._postprocessReadGroup(this.root.config, result);
}
this.root._setData(result);
}
return;
}
if (loadId !== this.root.config.loadId) {
// Avoid updating if another load was already done (e.g. a sort in a list)
return;
}
if (root.config.isMonoRecord) {
if (!root.config.resId) {
// result is the response of the onchange rpc
return root._setData(result.value, { keepChanges: true });
}
// result is the response of a web_read rpc
if (!result.length) {
// we read a record that no longer exists
throw new FetchRecordError([root.config.resId]);
}
return root._setData(result[0], { keepChanges: true });
}
// multi record case: either grouped or ungrouped
if (root.config.groupBy.length) {
// result is the response of a web_read_group rpc
// in case there're less groups, we don't want to keep displaying groups
// that are no longer there => forget previous groups
delete this.root.config.currentGroups;
result = await this._postprocessReadGroup(root.config, result);
}
root._setData(result);
},
};
}
}
/**
* @param {RelationalModelConfig} currentConfig
* @param {Partial<SearchParams>} params
* @returns {RelationalModelConfig}
*/
_getNextConfig(currentConfig, params) {
const currentGroupBy = currentConfig.groupBy;
const config = Object.assign({}, currentConfig);
config.context = "context" in params ? params.context : config.context;
config.context = { ...config.context };
if (currentConfig.isMonoRecord) {
config.resId = "resId" in params ? params.resId : config.resId;
config.resIds = "resIds" in params ? params.resIds : config.resIds;
if (!config.resIds) {
config.resIds = config.resId ? [config.resId] : [];
}
if (!config.resId && config.mode !== "edit") {
config.mode = "edit";
}
} else {
config.domain = "domain" in params ? params.domain : config.domain;
// groupBy
config.groupBy = "groupBy" in params ? params.groupBy : config.groupBy;
// restrict the number of groupbys if requested
if (this.maxGroupByDepth) {
config.groupBy = config.groupBy.slice(0, this.maxGroupByDepth);
}
// apply month granularity if none explicitly given
// TODO: accept only explicit granularity
config.groupBy = config.groupBy.map((g) => {
if (g in config.fields && ["date", "datetime"].includes(config.fields[g].type)) {
return `${g}:month`;
}
return g;
});
// orderBy
config.orderBy = "orderBy" in params ? params.orderBy : config.orderBy;
// re-apply previous orderBy if not given (or no order)
if (!config.orderBy.length) {
config.orderBy = currentConfig.orderBy || [];
}
// apply default order if no order
if (this.defaultOrderBy && !config.orderBy.length) {
config.orderBy = this.defaultOrderBy;
}
// keep current root config if any, if the groupBy parameter is the same
if (!shallowEqual(config.groupBy || [], currentGroupBy || [])) {
delete config.groups;
}
if (!config.groupBy.length) {
config.orderBy = config.orderBy.filter((order) => order.name !== "__count");
}
}
if (!config.isMonoRecord && params.domain) {
// always reset the offset to 0 when reloading from above with a domain
const resetOffset = (config) => {
config.offset = 0;
for (const group of Object.values(config.groups || {})) {
resetOffset(group.list);
}
};
if (this.root) {
resetOffset(config);
}
if (!!config.groupBy.length !== !!currentGroupBy?.length) {
// from grouped to ungrouped or the other way around -> force the limit to be reset
delete config.limit;
}
}
return config;
}
/**
*
* @param {RelationalModelConfig} config
* @param {Object} [cache]
*/
async _loadData(config, cache) {
config.loadId = getId("load");
if (config.isMonoRecord) {
const evalContext = getBasicEvalContext(config);
if (!config.resId) {
return this._loadNewRecord(config, { evalContext, cache });
}
const records = await this._loadRecords(config, evalContext, cache);
return records[0];
}
if (config.resIds) {
// static list
const resIds = config.resIds.slice(config.offset, config.offset + config.limit);
return this._loadRecords({ ...config, resIds });
}
if (config.groupBy.length) {
return this._loadGroupedList(config, cache);
}
Object.assign(config, {
limit: config.limit || this.initialLimit,
countLimit: "countLimit" in config ? config.countLimit : this.initialCountLimit,
offset: config.offset || 0,
});
if (config.countLimit !== Number.MAX_SAFE_INTEGER) {
config.countLimit = Math.max(config.countLimit, config.offset + config.limit);
}
const { records, length } = await this._loadUngroupedList(config, cache);
if (config.offset && !records.length) {
config.offset = 0;
return this._loadData(config, cache);
}
return { records, length };
}
/**
* @param {RelationalModelConfig} config
* @param {Object} [cache]
*/
async _loadGroupedList(config, cache) {
config.offset = config.offset || 0;
config.limit = config.limit || this.initialGroupsLimit;
if (!config.limit) {
config.limit = config.openGroupsByDefault
? this.constructor.DEFAULT_OPEN_GROUP_LIMIT
: this.constructor.DEFAULT_GROUP_LIMIT;
}
config.groups = config.groups || {};
const response = await this._webReadGroup(config, cache);
return this._postprocessReadGroup(config, response);
}
async _postprocessReadGroup(config, { groups, length }) {
const commonConfig = {
resModel: config.resModel,
fields: config.fields,
activeFields: config.activeFields,
fieldsToAggregate: config.fieldsToAggregate,
offset: 0,
};
const extractGroups = async (currentConfig, groupsData) => {
const groupByFieldName = currentConfig.groupBy[0].split(":")[0];
if (groupByFieldName.includes(".")) {
if (!config.fields[groupByFieldName]) {
await this._getPropertyDefinition(config, groupByFieldName);
}
const propertiesFieldName = groupByFieldName.split(".")[0];
if (!config.activeFields[propertiesFieldName]) {
// add the properties field so we load its data when reading the records
// so when we drag and drop we don't need to fetch the value of the record
config.activeFields[propertiesFieldName] = makeActiveField();
}
}
const nextLevelGroupBy = currentConfig.groupBy.slice(1);
const groups = [];
let groupRecordConfig;
if (this.groupByInfo[groupByFieldName]) {
groupRecordConfig = {
...this.groupByInfo[groupByFieldName],
resModel: currentConfig.fields[groupByFieldName].relation,
context: {},
};
}
for (const groupData of groupsData) {
const group = extractInfoFromGroupData(
groupData,
currentConfig.groupBy,
currentConfig.fields,
currentConfig.domain
);
if (!currentConfig.groups[group.value]) {
currentConfig.groups[group.value] = {
...commonConfig,
groupByFieldName,
extraDomain: false,
value: group.value,
list: {
...commonConfig,
groupBy: nextLevelGroupBy,
groups: {},
limit:
nextLevelGroupBy.length === 0
? this.initialLimit
: this.initialGroupsLimit ||
this.constructor.DEFAULT_GROUP_LIMIT,
},
};
}
const groupConfig = currentConfig.groups[group.value];
groupConfig.list.orderBy = currentConfig.orderBy;
groupConfig.initialDomain = group.domain;
if (groupConfig.extraDomain) {
groupConfig.list.domain = Domain.and([
group.domain,
groupConfig.extraDomain,
]).toList();
} else {
groupConfig.list.domain = group.domain;
}
const context = {
...currentConfig.context,
[`default_${groupByFieldName}`]: group.serverValue,
};
groupConfig.list.context = context;
groupConfig.context = context;
if (nextLevelGroupBy.length) {
groupConfig.isFolded = !("__groups" in groupData);
if (!groupConfig.isFolded) {
const { groups, length } = groupData.__groups;
group.groups = await extractGroups(groupConfig.list, groups);
group.length = length;
} else {
group.groups = [];
}
} else {
groupConfig.isFolded = !("__records" in groupData);
if (!groupConfig.isFolded) {
group.records = groupData.__records;
group.length = groupData.__count;
} else {
group.records = [];
}
}
if (Object.hasOwn(groupData, "__offset")) {
groupConfig.list.offset = groupData.__offset;
}
if (groupRecordConfig) {
groupConfig.record = {
...groupRecordConfig,
resId: group.value ?? false,
};
}
groups.push(group);
}
return groups;
};
groups = await extractGroups(config, groups);
const params = JSON.stringify([
config.domain,
config.groupBy,
config.offset,
config.limit,
config.orderBy,
]);
if (config.currentGroups && config.currentGroups.params === params) {
const currentGroups = config.currentGroups.groups;
currentGroups.forEach((group, index) => {
if (
config.groups[group.value] &&
!groups.some((g) => JSON.stringify(g.value) === JSON.stringify(group.value))
) {
const aggregates = Object.assign({}, group.aggregates);
for (const key in aggregates) {
// the `array_agg_distinct` aggregator's value is an array
aggregates[key] = Array.isArray(aggregates[key]) ? [] : 0;
}
groups.splice(
index,
0,
Object.assign({}, group, { count: 0, length: 0, records: [], aggregates })
);
}
});
}
config.currentGroups = { params, groups };
return { groups, length };
}
/**
* @param {RelationalModelConfig} config
* @param {Partial<RelationalModelParams>} [params={}]
* @returns {Promise<Record<string, unknown>>}
*/
async _loadNewRecord(config, params = {}) {
return this._onchange(config, params);
}
/**
* @param {RelationalModelConfig} config
* @param {Context} evalContext
* @param {Object} [cache]
*/
async _loadRecords(config, evalContext = config.context, cache) {
const { resModel, activeFields, fields, context } = config;
const resIds = config.resId ? [config.resId] : config.resIds;
if (!resIds.length) {
return [];
}
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext);
if (Object.keys(fieldSpec).length > 0) {
const kwargs = {
context: { bin_size: true, ...context },
specification: fieldSpec,
};
const orm = cache ? this.orm.cache(cache) : this.orm;
const records = await orm.webRead(resModel, resIds, kwargs);
if (!records.length) {
throw new FetchRecordError(resIds);
}
return records;
} else {
return resIds.map((resId) => ({ id: resId }));
}
}
/**
* Load records from the server for an ungrouped list. Return the result
* of unity read RPC.
*
* @param {RelationalModelConfig} config
* @param {Object} [cache]
*/
async _loadUngroupedList(config, cache) {
const orderBy = config.orderBy.filter((o) => o.name !== "__count");
const kwargs = {
specification: getFieldsSpec(config.activeFields, config.fields, config.context),
offset: config.offset,
order: orderByToString(orderBy),
limit: config.limit,
context: { bin_size: true, ...config.context },
count_limit:
config.countLimit !== Number.MAX_SAFE_INTEGER ? config.countLimit + 1 : undefined,
};
const orm = cache ? this.orm.cache(cache) : this.orm;
return orm.webSearchRead(config.resModel, config.domain, kwargs);
}
/**
* @param {RelationalModelConfig} config
* @param {OnChangeParams} params
* @returns {Promise<Record<string, unknown>>}
*/
async _onchange(
config,
{ changes = {}, fieldNames = [], evalContext = config.context, onError, cache }
) {
const { fields, activeFields, resModel, resId } = config;
let context = config.context;
if (fieldNames.length === 1) {
const fieldContext = config.activeFields[fieldNames[0]].context;
context = makeContext([context, fieldContext], evalContext);
}
const spec = getFieldsSpec(activeFields, fields, evalContext, { withInvisible: true });
const args = [resId ? [resId] : [], changes, fieldNames, spec];
let response;
try {
const orm = cache ? this.orm.cache(cache) : this.orm;
response = await orm.call(resModel, "onchange", args, { context });
} catch (e) {
if (onError) {
return void onError(e);
}
throw e;
}
if (response.warning) {
Promise.resolve(this.hooks.onWillDisplayOnchangeWarning(response.warning)).then(() => {
const { type, title, message, className, sticky } = response.warning;
if (type === "dialog") {
this.dialog.add(WarningDialog, { title, message });
} else {
this.notification.add(message, {
className,
sticky,
title,
type: "warning",
});
}
});
}
return response.value;
}
/**
* @param {RelationalModelConfig} config
* @param {Partial<RelationalModelConfig>} patch
* @param {{
* commit?: (data: Record<string, unknown>) => unknown;
* reload?: boolean;
* }} [options]
*/
async _updateConfig(config, patch, { reload = true, commit } = {}) {
const tmpConfig = { ...config, ...patch };
markRaw(tmpConfig.activeFields);
markRaw(tmpConfig.fields);
let data;
if (reload) {
if (tmpConfig.isRoot) {
this.hooks.onWillLoadRoot(tmpConfig);
}
data = await this._loadData(tmpConfig);
}
Object.assign(config, tmpConfig);
if (data && commit) {
commit(data);
}
if (reload && config.isRoot) {
await this.hooks.onRootLoaded(this.root);
}
}
/**
*
* @param {RelationalModelConfig} config
* @returns {Promise<number>}
*/
async _updateCount(config) {
const count = await this.keepLast.add(
this.orm.searchCount(config.resModel, config.domain, { context: config.context })
);
config.countLimit = Number.MAX_SAFE_INTEGER;
return count;
}
/**
* When grouped by a many2many field, the same record may be displayed in
* several groups. When one of these records is edited, we want all other
* occurrences to be updated. The purpose of this function is to find and
* update all occurrences of a record that has been reloaded, in a grouped
* list view.
*
* @param {RelationalRecord} reloadedRecord
* @param {Record<string, unknown>} serverValues
*/
_updateSimilarRecords(reloadedRecord, serverValues) {
if (this.config.isMonoRecord || !this.config.groupBy.length) {
return;
}
for (const record of this.root.records) {
if (record === reloadedRecord) {
continue;
}
if (record.resId === reloadedRecord.resId) {
record._applyValues(serverValues);
}
}
}
/**
* @param {RelationalModelConfig} config
* @param {Object} cache
*/
async _webReadGroup(config, cache) {
function getGroupInfo(groups) {
return Object.values(groups).map((group) => {
const field = group.fields[group.groupByFieldName];
const value =
field.type !== "many2many"
? getGroupServerValue(field, group.value)
: group.value;
if (group.isFolded) {
return { value, folded: group.isFolded };
} else {
return {
value,
folded: group.isFolded,
limit: group.list.limit,
offset: group.list.offset,
progressbar_domain: group.extraDomain,
groups: group.list.groups && getGroupInfo(group.list.groups),
};
}
});
}
const aggregates = getAggregateSpecifications(
pick(config.fields, ...config.fieldsToAggregate)
);
const currentGroupInfos = getGroupInfo(config.groups);
const { activeFields, fields } = config;
const evalContext = getBasicEvalContext(config);
const unfoldReadSpecification = getFieldsSpec(activeFields, fields, evalContext);
const groupByReadSpecification = {};
for (const groupBy of config.groupBy) {
const groupInfo = this.groupByInfo[groupBy];
if (groupInfo) {
const { activeFields, fields } = this.groupByInfo[groupBy];
groupByReadSpecification[groupBy] = getFieldsSpec(
activeFields,
fields,
evalContext
);
}
}
const params = {
limit: config.limit !== Number.MAX_SAFE_INTEGER ? config.limit : undefined,
offset: config.offset,
order: orderByToString(config.orderBy),
auto_unfold: config.openGroupsByDefault,
opening_info: currentGroupInfos,
unfold_read_specification: unfoldReadSpecification,
unfold_read_default_limit: this.initialLimit,
groupby_read_specification: groupByReadSpecification,
context: { read_group_expand: true, ...config.context },
};
const orm = cache ? this.orm.cache(cache) : this.orm;
const result = await orm.webReadGroup(
config.resModel,
config.domain,
config.groupBy,
aggregates,
params
);
if (!this.initialSampleGroups) {
this.initialSampleGroups = deepCopy(result.groups);
}
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,905 @@
import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl";
import { evalPartialContext, makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { x2ManyCommands } from "@web/core/orm_service";
import { evaluateExpr } from "@web/core/py_js/py";
import { Deferred } from "@web/core/utils/concurrency";
import { omit } from "@web/core/utils/objects";
import { effect } from "@web/core/utils/reactive";
import { batched } from "@web/core/utils/timing";
import { orderByToString } from "@web/search/utils/order_by";
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { uniqueId } from "@web/core/utils/functions";
import { unique } from "@web/core/utils/arrays";
const granularityToInterval = {
hour: { hours: 1 },
day: { days: 1 },
week: { days: 7 },
month: { month: 1 },
quarter: { month: 4 },
year: { year: 1 },
};
/**
* @param {boolean || string} value boolean or string encoding a python expression
* @returns {string} string encoding a python expression
*/
function convertBoolToPyExpr(value) {
if (value === true || value === false) {
return value ? "True" : "False";
}
return value;
}
export function makeActiveField({
context,
invisible,
readonly,
required,
onChange,
forceSave,
isHandle,
} = {}) {
return {
context: context || "{}",
invisible: convertBoolToPyExpr(invisible || false),
readonly: convertBoolToPyExpr(readonly || false),
required: convertBoolToPyExpr(required || false),
onChange: onChange || false,
forceSave: forceSave || false,
isHandle: isHandle || false,
};
}
export const AGGREGATABLE_FIELD_TYPES = ["float", "integer", "monetary"]; // types that can be aggregated in grouped views
export function addFieldDependencies(activeFields, fields, fieldDependencies = []) {
for (const field of fieldDependencies) {
if (!("readonly" in field)) {
field.readonly = true;
}
if (field.name in activeFields) {
patchActiveFields(activeFields[field.name], makeActiveField(field));
} else {
activeFields[field.name] = makeActiveField(field);
if (["one2many", "many2many"].includes(field.type)) {
activeFields[field.name].related = { activeFields: {}, fields: {} };
}
}
if (!fields[field.name]) {
const newField = omit(field, [
"context",
"invisible",
"required",
"readonly",
"onChange",
]);
fields[field.name] = newField;
if (newField.type === "selection" && !Array.isArray(newField.selection)) {
newField.selection = [];
}
}
}
}
function completeActiveField(activeField, extra) {
if (extra.related) {
for (const fieldName in extra.related.activeFields) {
if (fieldName in activeField.related.activeFields) {
completeActiveField(
activeField.related.activeFields[fieldName],
extra.related.activeFields[fieldName]
);
} else {
activeField.related.activeFields[fieldName] = {
...extra.related.activeFields[fieldName],
};
}
}
Object.assign(activeField.related.fields, extra.related.fields);
}
}
export function completeActiveFields(activeFields, extraActiveFields) {
for (const fieldName in extraActiveFields) {
const extraActiveField = {
...extraActiveFields[fieldName],
invisible: "True",
};
if (fieldName in activeFields) {
completeActiveField(activeFields[fieldName], extraActiveField);
} else {
activeFields[fieldName] = extraActiveField;
}
}
}
export function createPropertyActiveField(property) {
const { type } = property;
const activeField = makeActiveField();
if (type === "one2many" || type === "many2many") {
activeField.related = {
fields: {
id: { name: "id", type: "integer" },
display_name: { name: "display_name", type: "char" },
},
activeFields: {
id: makeActiveField({ readonly: true }),
display_name: makeActiveField(),
},
};
}
return activeField;
}
export function combineModifiers(mod1, mod2, operator) {
if (operator === "AND") {
if (!mod1 || mod1 === "False" || !mod2 || mod2 === "False") {
return "False";
}
if (mod1 === "True") {
return mod2;
}
if (mod2 === "True") {
return mod1;
}
return "(" + mod1 + ") and (" + mod2 + ")";
} else if (operator === "OR") {
if (mod1 === "True" || mod2 === "True") {
return "True";
}
if (!mod1 || mod1 === "False") {
return mod2;
}
if (!mod2 || mod2 === "False") {
return mod1;
}
return "(" + mod1 + ") or (" + mod2 + ")";
}
throw new Error(
`Operator provided to "combineModifiers" must be "AND" or "OR", received ${operator}`
);
}
export function patchActiveFields(activeField, patch) {
activeField.invisible = combineModifiers(activeField.invisible, patch.invisible, "AND");
activeField.readonly = combineModifiers(activeField.readonly, patch.readonly, "AND");
activeField.required = combineModifiers(activeField.required, patch.required, "OR");
activeField.onChange = activeField.onChange || patch.onChange;
activeField.forceSave = activeField.forceSave || patch.forceSave;
activeField.isHandle = activeField.isHandle || patch.isHandle;
// x2manys
if (patch.related) {
const related = activeField.related;
for (const fieldName in patch.related.activeFields) {
if (fieldName in related.activeFields) {
patchActiveFields(
related.activeFields[fieldName],
patch.related.activeFields[fieldName]
);
} else {
related.activeFields[fieldName] = { ...patch.related.activeFields[fieldName] };
}
}
Object.assign(related.fields, patch.related.fields);
}
if ("limit" in patch) {
activeField.limit = patch.limit;
}
if (patch.defaultOrderBy) {
activeField.defaultOrderBy = patch.defaultOrderBy;
}
}
export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) {
const activeFields = {};
for (const fieldNode of Object.values(fieldNodes)) {
const fieldName = fieldNode.name;
const activeField = makeActiveField({
context: fieldNode.context,
invisible: combineModifiers(fieldNode.invisible, fieldNode.column_invisible, "OR"),
readonly: fieldNode.readonly,
required: fieldNode.required,
onChange: fieldNode.onChange,
forceSave: fieldNode.forceSave,
isHandle: fieldNode.isHandle,
});
if (["one2many", "many2many"].includes(fields[fieldName].type)) {
activeField.related = {
activeFields: {},
fields: {},
};
if (fieldNode.views) {
const viewDescr = fieldNode.views[fieldNode.viewMode];
if (viewDescr) {
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
activeField.limit = viewDescr.limit;
activeField.defaultOrderBy = viewDescr.defaultOrder;
if (fieldNode.views.form) {
// we already know the form view (it is inline), so add its fields (in invisible)
// s.t. they will be sent in the spec for onchange, and create commands returned
// by the onchange could return values for those fields (that would be displayed
// later if the user opens the form view)
const formArchInfo = extractFieldsFromArchInfo(
fieldNode.views.form,
fieldNode.views.form.fields
);
completeActiveFields(
activeField.related.activeFields,
formArchInfo.activeFields
);
Object.assign(activeField.related.fields, formArchInfo.fields);
}
if (fieldNode.viewMode !== "default" && fieldNode.views.default) {
const defaultArchInfo = extractFieldsFromArchInfo(
fieldNode.views.default,
fieldNode.views.default.fields
);
for (const fieldName in defaultArchInfo.activeFields) {
if (fieldName in activeField.related.activeFields) {
patchActiveFields(
activeField.related.activeFields[fieldName],
defaultArchInfo.activeFields[fieldName]
);
} else {
activeField.related.activeFields[fieldName] = {
...defaultArchInfo.activeFields[fieldName],
};
}
}
activeField.related.fields = Object.assign(
{},
defaultArchInfo.fields,
activeField.related.fields
);
}
}
}
if (fieldNode.field?.useSubView) {
activeField.required = "False";
}
}
if (
["many2one", "many2one_reference"].includes(fields[fieldName].type) &&
fieldNode.views
) {
const viewDescr = fieldNode.views.default;
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
}
if (fieldName in activeFields) {
patchActiveFields(activeFields[fieldName], activeField);
} else {
activeFields[fieldName] = activeField;
}
if (fieldNode.field) {
let fieldDependencies = fieldNode.field.fieldDependencies;
if (typeof fieldDependencies === "function") {
fieldDependencies = fieldDependencies(fieldNode);
}
addFieldDependencies(activeFields, fields, fieldDependencies);
}
}
for (const widgetInfo of Object.values(widgetNodes || {})) {
let fieldDependencies = widgetInfo.widget.fieldDependencies;
if (typeof fieldDependencies === "function") {
fieldDependencies = fieldDependencies(widgetInfo);
}
addFieldDependencies(activeFields, fields, fieldDependencies);
}
return { activeFields, fields };
}
export function getFieldContext(
record,
fieldName,
rawContext = record.activeFields[fieldName].context
) {
const context = {};
for (const key in record.context) {
if (
!key.startsWith("default_") &&
!key.startsWith("search_default_") &&
!key.endsWith("_view_ref")
) {
context[key] = record.context[key];
}
}
return {
...context,
...record.fields[fieldName].context,
...makeContext([rawContext], record.evalContext),
};
}
export function getFieldDomain(record, fieldName, domain) {
if (typeof domain === "function") {
domain = domain();
domain = typeof domain === "function" ? domain() : domain;
}
if (domain) {
return domain;
}
// Fallback to the domain defined in the field definition in python
domain = record.fields[fieldName].domain;
return typeof domain === "string"
? new Domain(evaluateExpr(domain, record.evalContext)).toList()
: domain || [];
}
export function getBasicEvalContext(config) {
const { uid, allowed_company_ids } = config.context;
return {
context: config.context,
uid,
allowed_company_ids,
current_company_id: user.activeCompany?.id,
};
}
function getFieldContextForSpec(activeFields, fields, fieldName, evalContext) {
let context = activeFields[fieldName].context;
if (!context || context === "{}") {
context = fields[fieldName].context || {};
} else {
context = evalPartialContext(context, evalContext);
}
if (Object.keys(context).length > 0) {
return context;
}
}
export function getFieldsSpec(activeFields, fields, evalContext, { orderBys, withInvisible } = {}) {
const fieldsSpec = {};
const properties = [];
for (const fieldName in activeFields) {
if (fields[fieldName].relatedPropertyField) {
continue;
}
const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName];
const isAlwaysInvisible = invisible === "True" || invisible === "1";
fieldsSpec[fieldName] = {};
switch (fields[fieldName].type) {
case "one2many":
case "many2many": {
if (related && (withInvisible || !isAlwaysInvisible)) {
fieldsSpec[fieldName].fields = getFieldsSpec(
related.activeFields,
related.fields,
evalContext,
{ withInvisible }
);
fieldsSpec[fieldName].context = getFieldContextForSpec(
activeFields,
fields,
fieldName,
evalContext
);
fieldsSpec[fieldName].limit = limit;
const orderBy = orderBys?.[fieldName] || defaultOrderBy || [];
if (orderBy.length) {
fieldsSpec[fieldName].order = orderByToString(orderBy);
}
}
break;
}
case "many2one":
case "reference": {
fieldsSpec[fieldName].fields = {};
if (!isAlwaysInvisible) {
if (related) {
fieldsSpec[fieldName].fields = getFieldsSpec(
related.activeFields,
related.fields,
evalContext
);
}
fieldsSpec[fieldName].fields.display_name = {};
fieldsSpec[fieldName].context = getFieldContextForSpec(
activeFields,
fields,
fieldName,
evalContext
);
}
break;
}
case "many2one_reference": {
if (related && !isAlwaysInvisible) {
fieldsSpec[fieldName].fields = getFieldsSpec(
related.activeFields,
related.fields,
evalContext
);
fieldsSpec[fieldName].context = getFieldContextForSpec(
activeFields,
fields,
fieldName,
evalContext
);
}
break;
}
case "properties": {
properties.push(fieldName);
break;
}
}
}
for (const fieldName of properties) {
const fieldSpec = fieldsSpec[fields[fieldName].definition_record];
if (fieldSpec) {
if (!fieldSpec.fields) {
fieldSpec.fields = {};
}
fieldSpec.fields.display_name = {};
}
}
return fieldsSpec;
}
let nextId = 0;
/**
* @param {string} [prefix]
* @returns {string}
*/
export function getId(prefix = "") {
return `${prefix}_${++nextId}`;
}
/**
* @protected
* @param {Field | false} field
* @param {any} value
* @returns {any}
*/
export function parseServerValue(field, value) {
switch (field.type) {
case "char":
case "text": {
return value || "";
}
case "html": {
return markup(value || "");
}
case "date": {
return value ? deserializeDate(value) : false;
}
case "datetime": {
return value ? deserializeDateTime(value) : false;
}
case "selection": {
if (value === false) {
// process selection: convert false to 0, if 0 is a valid key
const hasKey0 = field.selection.find((option) => option[0] === 0);
return hasKey0 ? 0 : value;
}
return value;
}
case "reference": {
if (value === false) {
return false;
}
return {
resId: value.id.id,
resModel: value.id.model,
displayName: value.display_name,
};
}
case "many2one_reference": {
if (value === 0) {
// unset many2one_reference fields' value is 0
return false;
}
if (typeof value === "number") {
// many2one_reference fetched without "fields" key in spec -> only returns the id
return { resId: value };
}
return {
resId: value.id,
displayName: value.display_name,
};
}
case "many2one": {
if (Array.isArray(value)) {
// Used for web_read_group, where the value is an array of [id, display_name]
value = { id: value[0], display_name: value[1] };
}
return value;
}
case "properties": {
return value
? value.map((property) => {
if (property.value !== undefined) {
property.value = parseServerValue(property, property.value ?? false);
}
if (property.default !== undefined) {
property.default = parseServerValue(property, property.default ?? false);
}
return property;
})
: [];
}
}
return value;
}
export function getAggregateSpecifications(fields) {
const aggregatableFields = Object.values(fields)
.filter((field) => field.aggregator && AGGREGATABLE_FIELD_TYPES.includes(field.type))
.map((field) => `${field.name}:${field.aggregator}`);
const currencyFields = unique(
Object.values(fields)
.filter((field) => field.aggregator && field.currency_field)
.map((field) => [
`${field.currency_field}:array_agg_distinct`,
`${field.name}:sum_currency`,
])
.flat()
);
return aggregatableFields.concat(currencyFields);
}
/**
* Extract useful information from a group data returned by a call to webReadGroup.
*
* @param {Object} groupData
* @param {string[]} groupBy
* @param {Object} fields
* @returns {Object}
*/
export function extractInfoFromGroupData(groupData, groupBy, fields, domain) {
const info = {};
const groupByField = fields[groupBy[0].split(":")[0]];
info.count = groupData.__count;
info.length = info.count; // TODO: remove but still used in DynamicRecordList._updateCount
info.domain = Domain.and([domain, groupData.__extra_domain]).toList();
info.rawValue = groupData[groupBy[0]];
info.value = getValueFromGroupData(groupByField, info.rawValue);
if (["date", "datetime"].includes(groupByField.type) && info.value) {
const granularity = groupBy[0].split(":")[1];
info.range = {
from: info.value,
to: info.value.plus(granularityToInterval[granularity]),
};
}
info.displayName = getDisplayNameFromGroupData(groupByField, info.rawValue);
info.serverValue = getGroupServerValue(groupByField, info.value);
info.aggregates = getAggregatesFromGroupData(groupData, fields);
info.values = groupData.__values; // Extra data of the relational groupby field record
return info;
}
/**
* @param {Object} groupData
* @returns {Object}
*/
function getAggregatesFromGroupData(groupData, fields) {
const aggregates = {};
for (const keyAggregate of getAggregateSpecifications(fields)) {
if (keyAggregate in groupData) {
const [fieldName, aggregate] = keyAggregate.split(":");
if (aggregate === "sum_currency") {
const currencies =
groupData[fields[fieldName].currency_field + ":array_agg_distinct"];
if (currencies.length === 1) {
continue;
}
}
aggregates[fieldName] = groupData[keyAggregate];
}
}
return aggregates;
}
/**
* @param {import("./datapoint").Field} field
* @param {any} rawValue
* @returns {string}
*/
function getDisplayNameFromGroupData(field, rawValue) {
switch (field.type) {
case "selection": {
return Object.fromEntries(field.selection)[rawValue];
}
case "boolean": {
return rawValue ? _t("Yes") : _t("No");
}
case "integer": {
return rawValue ? String(rawValue) : "0";
}
case "many2one":
case "many2many":
case "date":
case "datetime":
case "tags": {
return (rawValue && rawValue[1]) || field.falsy_value_label || _t("None");
}
}
return rawValue ? String(rawValue) : field.falsy_value_label || _t("None");
}
/**
* @param {import("./datapoint").Field} field
* @param {any} value
* @returns {any}
*/
export function getGroupServerValue(field, value) {
switch (field.type) {
case "many2many": {
return value ? [value] : false;
}
case "datetime": {
return value ? serializeDateTime(value) : false;
}
case "date": {
return value ? serializeDate(value) : false;
}
default: {
return value || false;
}
}
}
/**
* @param {import("./datapoint").Field} field
* @param {any} rawValue
* @param {object} [range]
* @returns {any}
*/
function getValueFromGroupData(field, rawValue) {
if (["date", "datetime"].includes(field.type)) {
if (!rawValue) {
return false;
}
return parseServerValue(field, rawValue[0]);
}
const value = parseServerValue(field, rawValue);
if (field.type === "many2one") {
return value && value.id;
}
if (field.type === "many2many") {
return value ? value[0] : false;
}
if (field.type === "tags") {
return value ? value[0] : false;
}
return value;
}
/**
* Onchanges sometimes return update commands for records we don't know (e.g. if
* they are on a page we haven't loaded yet). We may actually never load them.
* When this happens, we must still be able to send back those commands to the
* server when saving. However, we can't send the commands exactly as we received
* them, since the values they contain have been "unity read". The purpose of this
* function is to transform field values from the unity format to the format
* expected by the server for a write.
* For instance, for a many2one: { id: 3, display_name: "Marc" } => 3.
*/
export function fromUnityToServerValues(
values,
fields,
activeFields,
{ withReadonly, context } = {}
) {
const { CREATE, UPDATE } = x2ManyCommands;
const serverValues = {};
for (const fieldName in values) {
let value = values[fieldName];
const field = fields[fieldName];
const activeField = activeFields[fieldName];
if (!withReadonly) {
if (field.readonly) {
continue;
}
try {
if (evaluateExpr(activeField.readonly, context)) {
continue;
}
} catch {
// if the readonly expression depends on other fields, we can't evaluate it as we
// didn't read the record, so we simply ignore it
}
}
switch (fields[fieldName].type) {
case "one2many":
case "many2many":
value = value.map((c) => {
if (c[0] === CREATE || c[0] === UPDATE) {
const _fields = activeField.related.fields;
const _activeFields = activeField.related.activeFields;
return [
c[0],
c[1],
fromUnityToServerValues(c[2], _fields, _activeFields, { withReadonly }),
];
}
return [c[0], c[1]];
});
break;
case "many2one":
value = value ? value.id : false;
break;
// case "reference":
// // TODO
// break;
}
serverValues[fieldName] = value;
}
return serverValues;
}
/**
* @param {any} field
* @returns {boolean}
*/
export function isRelational(field) {
return field && ["one2many", "many2many", "many2one"].includes(field.type);
}
/**
* This hook should only be used in a component field because it
* depends on the record props.
* The callback will be executed once during setup and each time
* a record value read in the callback changes.
* @param {(record) => void} callback
*/
export function useRecordObserver(callback) {
const component = useComponent();
let currentId;
const observeRecord = (props) => {
currentId = uniqueId();
if (!props.record) {
return;
}
const def = new Deferred();
const effectId = currentId;
let firstCall = true;
effect(
(record) => {
if (firstCall) {
firstCall = false;
return Promise.resolve(callback(record, props))
.then(def.resolve)
.catch(def.reject);
} else {
return batched(
(record) => {
if (effectId !== currentId) {
// effect doesn't clean up when the component is unmounted.
// We must do it manually.
return;
}
return Promise.resolve(callback(record, props))
.then(def.resolve)
.catch(def.reject);
},
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
)(record);
}
},
[props.record]
);
return def;
};
onWillDestroy(() => {
currentId = uniqueId();
});
onWillStart(() => observeRecord(component.props));
onWillUpdateProps((nextProps) => {
if (nextProps.record !== component.props.record) {
return observeRecord(nextProps);
}
});
}
/**
* Resequence records based on provided parameters.
*
* @param {Object} params
* @param {Array} params.records - The list of records to resequence.
* @param {string} params.resModel - The model to be used for resequencing.
* @param {Object} params.orm
* @param {string} params.fieldName - The field used to handle the sequence.
* @param {number} params.movedId - The id of the record being moved.
* @param {number} [params.targetId] - The id of the target position, the record will be resequenced
* after the target. If undefined, the record will be resequenced
* as the first record.
* @param {Boolean} [params.asc] - Resequence in ascending or descending order
* @param {Function} [params.getSequence] - Function to get the sequence of a record.
* @param {Function} [params.getResId] - Function to get the resID of the record.
* @param {Object} [params.context]
* @returns {Promise<any>} - The list of the resequenced fieldName
*/
export async function resequence({
records,
resModel,
orm,
fieldName,
movedId,
targetId,
asc = true,
getSequence = (record) => record[fieldName],
getResId = (record) => record.id,
context,
}) {
// Find indices
const fromIndex = records.findIndex((d) => d.id === movedId);
let toIndex = 0;
if (targetId !== null) {
const targetIndex = records.findIndex((d) => d.id === targetId);
toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex;
}
// Determine which records/groups need to be modified
const firstIndex = Math.min(fromIndex, toIndex);
const lastIndex = Math.max(fromIndex, toIndex) + 1;
let reorderAll = records.some((record) => getSequence(record) === undefined);
if (!reorderAll) {
let lastSequence = (asc ? -1 : 1) * Infinity;
for (let index = 0; index < records.length; index++) {
const sequence = getSequence(records[index]);
if ((asc && lastSequence >= sequence) || (!asc && lastSequence <= sequence)) {
reorderAll = true;
break;
}
lastSequence = sequence;
}
}
// Save the original list in case of error
const originalOrder = [...records];
// Perform the resequence in the list of records/groups
const record = records[fromIndex];
if (fromIndex !== toIndex) {
records.splice(fromIndex, 1);
records.splice(toIndex, 0, record);
}
// Creates the list of records/groups to modify
let toReorder = records;
if (!reorderAll) {
toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId);
if (fromIndex < toIndex) {
toReorder.push(record);
} else {
toReorder.unshift(record);
}
}
if (!asc) {
toReorder.reverse();
}
const resIds = toReorder.map((d) => getResId(d)).filter((id) => id && !isNaN(id));
const sequences = toReorder.map(getSequence);
const offset = Math.min(...sequences) || 0;
// Try to write new sequences on the affected records/groups
try {
return await orm.webResequence(resModel, resIds, {
field_name: fieldName,
offset,
context,
specification: { [fieldName]: {} },
});
} catch (error) {
// If the server fails to resequence, rollback the original list
records.splice(0, records.length, ...originalOrder);
throw error;
}
}

View File

@@ -0,0 +1,861 @@
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { ORM } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { cartesian, sortBy as arraySortBy, unique } from "@web/core/utils/arrays";
import { parseServerValue } from "./relational_model/utils";
class UnimplementedRouteError extends Error {}
/**
* Helper function returning the value from a list of sample strings
* corresponding to the given ID.
* @param {number} id
* @param {string[]} sampleTexts
* @returns {string}
*/
function getSampleFromId(id, sampleTexts) {
return sampleTexts[(id - 1) % sampleTexts.length];
}
function serializeGroupDateValue(range, field) {
if (!range) {
return false;
}
const dateValue = parseServerValue(field, range[0]);
return field.type === "date" ? serializeDate(dateValue) : serializeDateTime(dateValue);
}
/**
* Helper function returning a regular expression specifically matching
* a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
* will match:
* - "abc"
* - "field_abc__def"
* will not match:
* - "aabc"
* - "abcd_ef"
* @param {...string} term
* @returns {RegExp}
*/
function fieldNameRegex(...terms) {
return new RegExp(`\\b((\\w+)?_)?(${terms.join("|")})(_(\\w+)?)?\\b`);
}
const MEASURE_SPEC_REGEX = /(?<fieldName>\w+):(?<func>\w+)/;
const DESCRIPTION_REGEX = fieldNameRegex("description", "label", "title", "subject", "message");
const EMAIL_REGEX = fieldNameRegex("email");
const PHONE_REGEX = fieldNameRegex("phone");
const URL_REGEX = fieldNameRegex("url");
const MAX_NUMBER_OPENED_GROUPS = 10;
/**
* Sample server class
*
* Represents a static instance of the server used when a RPC call sends
* empty values/groups while the attribute 'sample' is set to true on the
* view.
*
* This server will generate fake data and send them in the adequate format
* according to the route/method used in the RPC.
*/
export class SampleServer {
/**
* @param {string} modelName
* @param {Object} fields
*/
constructor(modelName, fields) {
this.mainModel = modelName;
this.data = {};
this.data[modelName] = {
fields,
records: [],
};
// Generate relational fields' co models
for (const fieldName in fields) {
const field = fields[fieldName];
if (["many2one", "one2many", "many2many"].includes(field.type)) {
this.data[field.relation] = this.data[field.relation] || {
fields: {
display_name: { type: "char" },
id: { type: "integer" },
color: { type: "integer" },
},
records: [],
};
}
}
// On some models, empty grouped Kanban or List view still contain
// real (empty) groups. In this case, we re-use the result of the
// web_read_group rpc to tweak sample data s.t. those real groups
// contain sample records.
this.existingGroups = null;
// Sample records generation is only done if necessary, so we delay
// it to the first "mockRPC" call. These flags allow us to know if
// the records have been generated or not.
this.populated = false;
this.existingGroupsPopulated = false;
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* This is the main entry point of the SampleServer. Mocks a request to
* the server with sample data.
* @param {Object} params
* @returns {any} the result obtained with the sample data
* @throws {Error} If called on a route/method we do not handle
*/
mockRpc(params) {
if (!(params.model in this.data)) {
throw new Error(`SampleServer: unknown model ${params.model}`);
}
this._populateModels();
switch (params.method || params.route) {
case "web_search_read":
return this._mockWebSearchReadUnity(params);
case "web_read_group":
return this._mockWebReadGroup(params);
case "formatted_read_group":
return this._mockFormattedReadGroup(params);
case "formatted_read_grouping_sets":
return this._mockFormattedReadGroupingSets(params);
case "read_progress_bar":
return this._mockReadProgressBar(params);
case "read":
return this._mockRead(params);
}
// this rpc can't be mocked by the SampleServer itself, so check if there is an handler
// in the registry: either specific for this model (with key 'model/method'), or
// global (with key 'method')
const method = params.method || params.route;
// This allows to register mock version of methods or routes,
// for all models:
// registry.category("sample_server").add('some_route', () => "abcd");
// for a specific model (e.g. 'res.partner'):
// registry.category("sample_server").add('res.partner/some_method', () => 23);
const mockFunction =
registry.category("sample_server").get(`${params.model}/${method}`, null) ||
registry.category("sample_server").get(method, null);
if (mockFunction) {
return mockFunction.call(this, params);
}
console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
throw new SampleServer.UnimplementedRouteError();
}
setExistingGroups(groups) {
this.existingGroups = groups;
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @param {Object[]} measures, each measure has the form { fieldName, type }
* @param {Object[]} records
* @returns {Object}
*/
_aggregateFields(measures, records) {
const group = {};
for (const { fieldName, func, name } of measures) {
if (["sum", "sum_currency", "avg", "max", "min"].includes(func)) {
if (!records.length) {
group[name] = false;
} else {
group[name] = 0;
for (const record of records) {
group[name] += record[fieldName];
}
}
group[name] = this._sanitizeNumber(group[name]);
} else if (func === "array_agg") {
group[name] = records.map((r) => r[fieldName]);
} else if (func === "__count") {
group[name] = records.length;
} else if (func === "count_distinct") {
group[name] = unique(records.map((r) => r[fieldName])).filter(Boolean).length;
} else if (func === "bool_or") {
group[name] = records.some((r) => Boolean(r[fieldName]));
} else if (func === "bool_and") {
group[name] = records.every((r) => Boolean(r[fieldName]));
} else if (func === "array_agg_distinct") {
group[name] = unique(records.map((r) => r[fieldName]));
} else {
throw new Error(`Aggregate "${func}" not implemented in SampleServer`);
}
}
return group;
}
/**
* @param {any} value
* @param {Object} options
* @param {string} [options.interval]
* @param {string} [options.relation]
* @param {string} [options.type]
* @returns {any}
*/
_formatValue(value, options) {
if (!value) {
return false;
}
const { type, interval, relation } = options;
if (["date", "datetime"].includes(type) && value) {
const deserialize = type === "date" ? deserializeDate : deserializeDateTime;
const serialize = type === "date" ? serializeDate : serializeDateTime;
const from = deserialize(value).startOf(interval);
const fmt = SampleServer.FORMATS[interval];
return [serialize(from), from.toFormat(fmt)];
} else if (["many2one", "many2many"].includes(type)) {
const rec = this.data[relation].records.find(({ id }) => id === value);
return [value, rec.display_name];
} else {
return value;
}
}
/**
* Generates field values based on heuristics according to field types
* and names.
*
* @private
* @param {string} modelName
* @param {string} fieldName
* @param {number} id the record id
* @returns {any} the field value
*/
_generateFieldValue(modelName, fieldName, id) {
const field = this.data[modelName].fields[fieldName];
switch (field.type) {
case "boolean":
return fieldName === "active" ? true : this._getRandomBool();
case "char":
case "text":
if (["display_name", "name"].includes(fieldName)) {
if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
} else if (modelName === "res.country") {
return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
}
}
if (fieldName === "display_name") {
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
} else if (["name", "reference"].includes(fieldName)) {
return `REF${String(id).padStart(4, "0")}`;
} else if (DESCRIPTION_REGEX.test(fieldName)) {
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
} else if (EMAIL_REGEX.test(fieldName)) {
const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
.replace(/ /, ".")
.toLowerCase();
return `${emailName}@sample.demo`;
} else if (PHONE_REGEX.test(fieldName)) {
return `+1 555 754 ${String(id).padStart(4, "0")}`;
} else if (URL_REGEX.test(fieldName)) {
return `http://sample${id}.com`;
}
return false;
case "date":
case "datetime": {
const datetime = this._getRandomDate();
return field.type === "date"
? serializeDate(datetime)
: serializeDateTime(datetime);
}
case "float":
return this._getRandomFloat(SampleServer.MAX_FLOAT);
case "integer": {
let max = SampleServer.MAX_INTEGER;
if (fieldName.includes("color")) {
max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0;
}
return this._getRandomInt(max);
}
case "monetary":
return this._getRandomInt(SampleServer.MAX_MONETARY);
case "many2one":
if (field.relation === "res.currency") {
/** @todo return session.company_currency_id */
return 1;
}
if (field.relation === "ir.attachment") {
return false;
}
return this._getRandomSubRecordId();
case "one2many":
case "many2many": {
const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
return [...new Set(ids)];
}
case "selection": {
return this._getRandomSelectionValue(modelName, field);
}
default:
return false;
}
}
/**
* @private
* @param {any[]} array
* @returns {any}
*/
_getRandomArrayEl(array) {
return array[Math.floor(Math.random() * array.length)];
}
/**
* @private
* @returns {boolean}
*/
_getRandomBool() {
return Math.random() < 0.5;
}
/**
* @private
* @returns {DateTime}
*/
_getRandomDate() {
const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA);
return luxon.DateTime.local().plus({ hours: delta });
}
/**
* @private
* @param {number} max
* @returns {number} float in [O, max[
*/
_getRandomFloat(max) {
return this._sanitizeNumber(Math.random() * max);
}
/**
* @private
* @param {number} max
* @returns {number} int in [0, max[
*/
_getRandomInt(max) {
return Math.floor(Math.random() * max);
}
/**
* @private
* @returns {string}
*/
_getRandomSelectionValue(modelName, field) {
if (field.selection.length > 0) {
return this._getRandomArrayEl(field.selection)[0];
}
return false;
}
/**
* @private
* @returns {number} id in [1, SUB_RECORDSET_SIZE]
*/
_getRandomSubRecordId() {
return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
}
/**
* Mocks calls to the read method.
* @private
* @param {Object} params
* @param {string} params.model
* @param {Array[]} params.args (args[0] is the list of ids, args[1] is
* the list of fields)
* @returns {Object[]}
*/
_mockRead(params) {
const model = this.data[params.model];
const ids = params.args[0];
const fieldNames = params.args[1];
const records = [];
for (const r of model.records) {
if (!ids.includes(r.id)) {
continue;
}
const record = { id: r.id };
for (const fieldName of fieldNames) {
const field = model.fields[fieldName];
if (!field) {
record[fieldName] = false; // unknown field
} else if (field.type === "many2one") {
const relModel = this.data[field.relation];
const relRecord = relModel.records.find((relR) => r[fieldName] === relR.id);
record[fieldName] = relRecord ? [relRecord.id, relRecord.display_name] : false;
} else {
record[fieldName] = r[fieldName];
}
}
records.push(record);
}
return records;
}
/**
* Mocks calls to the base method of formatted_read_group method.
*
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.groupBy
* @param {string[]} params.aggregates
* @returns {Object[]} Object with keys groups and length
*/
_mockFormattedReadGroup(params) {
const model = params.model;
const groupBy = params.groupBy;
const fields = this.data[model].fields;
const records = this.data[model].records;
const normalizedGroupBys = [];
for (const groupBySpec of groupBy) {
const [fieldName, interval] = groupBySpec.split(":");
const { type, relation } = fields[fieldName];
if (type) {
const gb = { fieldName, type, interval, relation, alias: groupBySpec };
normalizedGroupBys.push(gb);
}
}
const groupsFromRecord = (record) => {
const values = [];
for (const gb of normalizedGroupBys) {
const { fieldName, type, alias } = gb;
let fieldVals;
if (["date", "datetime"].includes(type)) {
fieldVals = [this._formatValue(record[fieldName], gb)];
} else if (type === "many2many") {
fieldVals = record[fieldName].length ? record[fieldName] : [false];
} else {
fieldVals = [record[fieldName]];
}
values.push(fieldVals.map((val) => ({ [alias]: val })));
}
const cart = cartesian(...values);
return cart.map((tuple) => {
if (!Array.isArray(tuple)) {
tuple = [tuple];
}
return Object.assign({}, ...tuple);
});
};
const groups = {};
for (const record of records) {
const recordGroups = groupsFromRecord(record);
for (const group of recordGroups) {
const groupId = JSON.stringify(group);
if (!(groupId in groups)) {
groups[groupId] = [];
}
groups[groupId].push(record);
}
}
const aggregates = params.aggregates || [];
const measures = [];
for (const measureSpec of aggregates) {
if (measureSpec === "__count") {
measures.push({ fieldName: "__count", func: "__count", name: measureSpec });
continue;
}
const matches = measureSpec.match(MEASURE_SPEC_REGEX);
if (!matches) {
throw new Error(`Invalidate Aggregate "${measureSpec}" in SampleServer`);
}
const { fieldName, func } = matches.groups;
measures.push({ fieldName, func, name: measureSpec });
}
let result = [];
for (const id in groups) {
const records = groups[id];
const group = { __extra_domain: [] };
const firstElem = records[0];
const parsedId = JSON.parse(id);
for (const gb of normalizedGroupBys) {
const { alias, fieldName, type } = gb;
if (type === "many2many") {
group[alias] = this._formatValue(parsedId[fieldName], gb);
} else {
group[alias] = this._formatValue(firstElem[fieldName], gb);
}
}
Object.assign(group, this._aggregateFields(measures, records));
result.push(group);
}
if (normalizedGroupBys.length > 0) {
const { alias, type } = normalizedGroupBys[0];
result = arraySortBy(result, (group) => {
const val = group[alias];
if (type === "datetime") {
return deserializeDateTime(val);
} else if (type === "date") {
return deserializeDate(val);
}
return val;
});
}
return result;
}
/**
* Mocks calls to the base method of formatted_read_grouping_sets method.
*
* @param {Object} params
* @param {string} params.model
* @param {string[][]} params.grouping_sets
* @param {string[]} params.aggregates
* @returns {Object[]} Object with keys groups and length
*/
_mockFormattedReadGroupingSets(params) {
const res = [];
for (const groupBy of params.grouping_sets) {
res.push(this._mockFormattedReadGroup({ ...params, groupBy }));
}
return res;
}
/**
* Mocks calls to the read_progress_bar method.
* @private
* @param {Object} params
* @param {string} params.model
* @param {string} params.group_by
* @param {Object} params.progress_bar
* @return {Object}
*/
_mockReadProgressBar(params) {
const groupBy = params.group_by;
const progressBar = params.progress_bar;
const groups = this._mockFormattedReadGroup({
model: params.model,
domain: params.domain,
groupBy: [groupBy, progressBar.field],
aggregates: ["__count"],
});
const data = {};
for (const group of groups) {
let groupByValue = group[groupBy];
if (Array.isArray(groupByValue)) {
groupByValue = groupByValue[0];
}
// special case for bool values: rpc call response with capitalized strings
if (!(groupByValue in data)) {
if (groupByValue === true) {
groupByValue = "True";
} else if (groupByValue === false) {
groupByValue = "False";
}
}
if (!(groupByValue in data)) {
data[groupByValue] = {};
for (const key in progressBar.colors) {
data[groupByValue][key] = 0;
}
}
data[groupByValue][group[progressBar.field]] += group.__count;
}
return data;
}
_mockWebSearchReadUnity(params) {
const fields = Object.keys(params.specification);
const model = this.data[params.model];
let rawRecords = model.records;
if ("recordIds" in params) {
rawRecords = model.records.filter((record) => params.recordIds.includes(record.id));
} else {
rawRecords = rawRecords.slice(0, SampleServer.SEARCH_READ_LIMIT);
}
const records = this._mockRead({
model: params.model,
args: [rawRecords.map((r) => r.id), fields],
});
const result = { records, length: records.length };
// populate many2one and x2many values
for (const fieldName in params.specification) {
const field = this.data[params.model].fields[fieldName];
if (field.type === "many2one") {
for (const record of result.records) {
record[fieldName] = record[fieldName]
? {
id: record[fieldName][0],
display_name: record[fieldName][1],
}
: false;
}
}
if (field.type === "one2many" || field.type === "many2many") {
const relFields = Object.keys(params.specification[fieldName].fields || {});
if (relFields.length) {
const relIds = result.records.map((r) => r[fieldName]).flat();
const relRecords = {};
const _relRecords = this._mockRead({
model: field.relation,
args: [relIds, relFields],
});
for (const relRecord of _relRecords) {
relRecords[relRecord.id] = relRecord;
}
for (const record of result.records) {
record[fieldName] = record[fieldName].map((resId) => relRecords[resId]);
}
}
}
}
return result;
}
/**
* Mocks calls to the web_read_group method to return groups populated
* with sample records. Only handles the case where the real call to
* web_read_group returned groups, but none of these groups contain
* records. In this case, we keep the real groups, and populate them
* with sample records.
* @private
* @param {Object} params
* @param {Object} [result] the result of a real call to web_read_group
* @returns {{ groups: Object[], length: number }}
*/
_mockWebReadGroup(params) {
const aggregates = [...params.aggregates, "__count"];
if (params.unfold_read_specification) {
aggregates.push("id:array_agg");
}
let groups;
if (this.existingGroups) {
this._tweakExistingGroups({ ...params, aggregates });
groups = this.existingGroups;
} else {
groups = this._mockFormattedReadGroup({ ...params, aggregates });
}
// Don't care another params - and no subgroup:
// order / opening_info / unfold_read_default_limit / groupby_read_specification
const openAllGroups = params.auto_unfold && !this.existingGroups;
let nbOpenedGroup = 0;
if (params.unfold_read_specification) {
for (const group of groups) {
if (openAllGroups || "__records" in group) {
// if group has a "__records" key, it means that it is an existing group, and
// that the real webReadGroup returned a "__records" key for that group (which
// is empty, otherwise we wouldn't be here), i.e. that group is opened.
if (nbOpenedGroup < MAX_NUMBER_OPENED_GROUPS) {
nbOpenedGroup++;
group.__records = this._mockWebSearchReadUnity({
model: params.model,
specification: params.unfold_read_specification,
recordIds: group["id:array_agg"],
}).records;
}
}
delete group["id:array_agg"];
}
}
return {
groups,
length: groups.length,
};
}
/**
* Updates the sample data such that the existing groups (in database)
* also exists in the sample, and such that there are sample records in
* those groups.
* @private
* @param {Object[]} groups empty groups returned by the server
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.groupBy
*/
_populateExistingGroups(params) {
const groups = this.existingGroups;
const groupBy = params.groupBy[0].split(":")[0];
const groupByField = this.data[params.model].fields[groupBy];
const groupedByM2O = groupByField.type === "many2one";
if (groupedByM2O) {
// re-populate co model with relevant records
this.data[groupByField.relation].records = groups.map((g) => ({
id: g[groupBy][0],
display_name: g[groupBy][1],
}));
}
for (const r of this.data[params.model].records) {
const group = getSampleFromId(r.id, groups);
if (["date", "datetime"].includes(groupByField.type)) {
r[groupBy] = serializeGroupDateValue(group[params.groupBy[0]], groupByField);
} else if (groupByField.type === "many2one") {
r[groupBy] = group[params.groupBy[0]] ? group[params.groupBy[0]][0] : false;
} else {
r[groupBy] = group[params.groupBy[0]];
}
}
}
/**
* Generates sample records for the models in this.data. Records will be
* generated once, and subsequent calls to this function will be skipped.
* @private
*/
_populateModels() {
if (!this.populated) {
for (const modelName in this.data) {
const model = this.data[modelName];
const fieldNames = Object.keys(model.fields).filter((f) => f !== "id");
const size =
modelName === this.mainModel
? SampleServer.MAIN_RECORDSET_SIZE
: SampleServer.SUB_RECORDSET_SIZE;
for (let id = 1; id <= size; id++) {
const record = { id };
for (const fieldName of fieldNames) {
record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
}
model.records.push(record);
}
}
this.populated = true;
}
}
/**
* Rounds the given number value according to the configured precision.
* @private
* @param {number} value
* @returns {number}
*/
_sanitizeNumber(value) {
return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION));
}
/**
* A real (web_)read_group call has been done, and it has returned groups,
* but they are all empty. This function updates the sample data such
* that those group values exist and those groups contain sample records.
* @private
* @param {Object[]} groups empty groups returned by the server
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.aggregates
* @param {string[]} params.groupBy
* @returns {Object[]} groups with count and aggregate values updated
*
* TODO: rename
*/
_tweakExistingGroups(params) {
const groups = this.existingGroups;
this._populateExistingGroups(params);
// update count and aggregates for each group
const fullGroupBy = params.groupBy[0];
const groupBy = fullGroupBy.split(":")[0];
const groupByField = this.data[params.model].fields[groupBy];
const records = this.data[params.model].records;
for (const g of groups) {
const recordsInGroup = records.filter((r) => {
if (["date", "datetime"].includes(groupByField.type)) {
return r[groupBy] === serializeGroupDateValue(g[fullGroupBy], groupByField);
} else if (groupByField.type === "many2one") {
return (!r[groupBy] && !g[fullGroupBy]) || r[groupBy] === g[fullGroupBy][0];
}
return r[groupBy] === g[fullGroupBy];
});
for (const aggregateSpec of params.aggregates || []) {
if (aggregateSpec === "__count") {
g.__count = recordsInGroup.length;
continue;
}
const [fieldName, func] = aggregateSpec.split(":");
if (func === "array_agg") {
g[aggregateSpec] = recordsInGroup.map((r) => r[fieldName]);
} else if (
["integer, float", "monetary"].includes(
this.data[params.model].fields[fieldName].type
)
) {
g[aggregateSpec] = recordsInGroup.reduce((acc, r) => acc + r[fieldName], 0);
}
}
}
}
}
SampleServer.FORMATS = {
day: "yyyy-MM-dd",
week: "'W'WW kkkk",
month: "MMMM yyyy",
quarter: "'Q'q yyyy",
year: "y",
};
SampleServer.INTERVALS = {
day: (dt) => dt.plus({ days: 1 }),
week: (dt) => dt.plus({ weeks: 1 }),
month: (dt) => dt.plus({ months: 1 }),
quarter: (dt) => dt.plus({ months: 3 }),
year: (dt) => dt.plus({ years: 1 }),
};
SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: "dd MMM yyyy" });
SampleServer.MAIN_RECORDSET_SIZE = 16;
SampleServer.SUB_RECORDSET_SIZE = 5;
SampleServer.SEARCH_READ_LIMIT = 10;
SampleServer.MAX_FLOAT = 100;
SampleServer.MAX_INTEGER = 50;
SampleServer.MAX_COLOR_INT = 7;
SampleServer.MAX_MONETARY = 100000;
SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days
SampleServer.FLOAT_PRECISION = 2;
SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
SampleServer.SAMPLE_PEOPLE = [
"John Miller",
"Henry Campbell",
"Carrie Helle",
"Wendi Baltz",
"Thomas Passot",
];
SampleServer.SAMPLE_TEXTS = [
"Laoreet id",
"Volutpat blandit",
"Integer vitae",
"Viverra nam",
"In massa",
];
SampleServer.PEOPLE_MODELS = [
"res.users",
"res.partner",
"hr.employee",
"mail.followers",
"mailing.contact",
];
SampleServer.UnimplementedRouteError = UnimplementedRouteError;
export function buildSampleORM(resModel, fields, user) {
const sampleServer = new SampleServer(resModel, fields);
const fakeRPC = async (_, params) => {
const { args, kwargs, method, model } = params;
const { groupby: groupBy } = kwargs;
return sampleServer.mockRpc({ method, model, args, ...kwargs, groupBy });
};
const sampleORM = new ORM(user);
sampleORM.rpc = fakeRPC;
sampleORM.isSample = true;
sampleORM.cache = () => sampleORM;
sampleORM.setGroups = (groups) => sampleServer.setExistingGroups(groups);
return sampleORM;
}