odoo18/addons/web/static/src/model/sample_server.js

842 lines
31 KiB
JavaScript

import {
deserializeDate,
deserializeDateTime,
parseDate,
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 } from "@web/core/utils/arrays";
import { parseServerValue } from "./relational_model/utils";
class UnimplementedRouteError extends Error {}
let searchReadNumber = 0;
/**
* 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;
}
let dateValue = parseServerValue(field, range.to);
dateValue = dateValue.minus({
[field.type === "date" ? "day" : "second"]: 1,
});
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 = /(?<measure>\w+):(?<aggregateFunction>\w+)(\((?<fieldName>\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");
/**
* 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 "read_group":
return this._mockReadGroup(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 values = {};
for (const { fieldName, type, aggregateFunction } of measures) {
if (["float", "integer", "monetary"].includes(type)) {
if (aggregateFunction === "array_agg") {
values[fieldName] = (records || []).map((r) => r[fieldName]);
} else if (records.length) {
let value = 0;
for (const record of records) {
value += record[fieldName];
}
values[fieldName] = this._sanitizeNumber(value);
} else {
values[fieldName] = null;
}
}
if (type === "many2one") {
const ids = new Set(records.map((r) => r[fieldName]));
values.fieldName = ids.size || null;
}
}
return values;
}
/**
* @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)) {
const fmt = SampleServer.FORMATS[interval];
return parseDate(value).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 read_group method.
*
* @param {Object} params
* @param {string} params.model
* @param {string[]} [params.fields] defaults to the list of all fields
* @param {string[]} params.groupBy
* @param {boolean} [params.lazy=true]
* @returns {Object[]} Object with keys groups and length
*/
_mockReadGroup(params) {
const lazy = "lazy" in params ? params.lazy : true;
const model = params.model;
const fields = this.data[model].fields;
const records = this.data[model].records;
const normalizedGroupBys = [];
let groupBy = [];
if (params.groupBy.length) {
groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
}
for (const groupBySpec of groupBy) {
let [fieldName, interval] = groupBySpec.split(":");
interval = interval || "month";
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 } = 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) => ({ [fieldName]: 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 measures = [];
for (const measureSpec of params.fields || Object.keys(fields)) {
const matches = measureSpec.match(MEASURE_SPEC_REGEX);
let { fieldName, aggregateFunction, measure } = (matches && matches.groups) || {};
if (!aggregateFunction && fieldName in fields && fields[fieldName].aggregator) {
aggregateFunction = fields[fieldName].aggregator;
measure = fieldName;
}
if (!fieldName && !measure) {
continue; // this is for _count measure
}
const fName = fieldName || measure;
const { type } = fields[fName];
if (
!params.groupBy.includes(fName) &&
type &&
(type !== "many2one" || aggregateFunction !== "count_distinct")
) {
measures.push({ fieldName: fName, type, aggregateFunction });
}
}
let result = [];
for (const id in groups) {
const records = groups[id];
const group = { __domain: [] };
let countKey = `__count`;
if (normalizedGroupBys.length && lazy) {
countKey = `${normalizedGroupBys[0].fieldName}_count`;
}
group[countKey] = records.length;
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);
if (["date", "datetime"].includes(type)) {
group.__range = {};
const val = firstElem[fieldName];
if (val) {
const deserialize =
type === "date" ? deserializeDate : deserializeDateTime;
const serialize = type === "date" ? serializeDate : serializeDateTime;
const from = deserialize(val).startOf(gb.interval);
const to = SampleServer.INTERVALS[gb.interval](from);
group.__range[alias] = { from: serialize(from), to: serialize(to) };
} else {
group.__range[alias] = false;
}
}
}
}
Object.assign(group, this._aggregateFields(measures, records));
result.push(group);
}
if (normalizedGroupBys.length > 0) {
const { alias, interval, type } = normalizedGroupBys[0];
result = arraySortBy(result, (group) => {
const val = group[alias];
if (["date", "datetime"].includes(type)) {
return parseDate(val, { format: SampleServer.FORMATS[interval] });
}
return val;
});
}
return result;
}
/**
* 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.split(":")[0];
const progress_bar = params.progress_bar;
const groupByField = this.data[params.model].fields[groupBy];
const data = {};
for (const record of this.data[params.model].records) {
let groupByValue = record[groupBy];
if (groupByField.type === "many2one") {
const relatedRecords = this.data[groupByField.relation].records;
const relatedRecord = relatedRecords.find((r) => r.id === groupByValue);
groupByValue = relatedRecord.display_name;
}
// 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 progress_bar.colors) {
data[groupByValue][key] = 0;
}
}
const fieldValue = record[progress_bar.field];
if (fieldValue in data[groupByValue]) {
data[groupByValue][fieldValue]++;
}
}
return data;
}
_mockWebSearchReadUnity(params) {
const fields = Object.keys(params.specification);
let result;
if (this.existingGroups) {
const groups = this.existingGroups;
const group = groups[searchReadNumber++ % groups.length];
result = {
records: this._mockRead({
model: params.model,
args: [group.__recordIds, fields],
}),
length: group.__recordIds.length,
};
} else {
const model = this.data[params.model];
const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
const records = this._mockRead({
model: params.model,
args: [rawRecords.map((r) => r.id), fields],
});
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) {
let groups;
if (this.existingGroups) {
this._tweakExistingGroups(params);
groups = this.existingGroups;
} else {
groups = this._mockReadGroup(params);
}
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) => {
return { 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.__range[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.fields
* @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;
const fields = params.fields.map((aggregate_spec) => aggregate_spec.split(":")[0])
for (const g of groups) {
const recordsInGroup = records.filter((r) => {
if (["date", "datetime"].includes(groupByField.type)) {
return (
r[groupBy] === serializeGroupDateValue(g.__range[fullGroupBy], groupByField)
);
} else if (groupByField.type === "many2one") {
return (!r[groupBy] && !g[fullGroupBy]) || r[groupBy] === g[fullGroupBy][0];
}
return r[groupBy] === g[fullGroupBy];
});
for (const field of fields) {
const fieldType = this.data[params.model].fields[field].type;
if (["integer, float", "monetary"].includes(fieldType)) {
g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
}
}
g[`${groupBy}_count`] = recordsInGroup.length;
g.__recordIds = recordsInGroup.map((r) => r.id);
}
}
}
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.setGroups = (groups) => sampleServer.setExistingGroups(groups);
return sampleORM;
}