diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index aaa5a2fab37..3a22a5a2558 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -716,6 +716,8 @@
+
+
@@ -1026,6 +1028,8 @@
+
+
diff --git a/addons/project/views/project_task_views.xml b/addons/project/views/project_task_views.xml
index 62131f10958..cc24e38502c 100644
--- a/addons/project/views/project_task_views.xml
+++ b/addons/project/views/project_task_views.xml
@@ -79,6 +79,8 @@
+
+
diff --git a/addons/web/models/ir_model.py b/addons/web/models/ir_model.py
index ade00f6ee89..44ce0e9c074 100644
--- a/addons/web/models/ir_model.py
+++ b/addons/web/models/ir_model.py
@@ -19,7 +19,7 @@ class IrModel(models.Model):
accessible_models = []
not_accessible_models = []
for model in models:
- if self._check_model_access(model):
+ if self._is_valid_for_model_selector(model):
accessible_models.append(model)
else:
not_accessible_models.append({"display_name": model, "model": model})
@@ -34,9 +34,15 @@ class IrModel(models.Model):
} for model in records]
@api.model
- def _check_model_access(self, model):
- return (self.env.user._is_internal() and model in self.env
- and self.env[model].check_access_rights("read", raise_exception=False))
+ def _is_valid_for_model_selector(self, model):
+ model = self.env.get(model)
+ return (
+ self.env.user._is_internal()
+ and model is not None
+ and model.check_access_rights("read", raise_exception=False)
+ and not model._transient
+ and not model._abstract
+ )
@api.model
def get_available_models(self):
@@ -44,5 +50,5 @@ class IrModel(models.Model):
Return the list of models the current user has access to, with their
corresponding display name.
"""
- accessible_models = [model for model in self.pool.keys() if self._check_model_access(model)]
+ accessible_models = [model for model in self.pool if self._is_valid_for_model_selector(model)]
return self._display_name_for(accessible_models)
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
index 993da2a539d..61b7b7265ac 100644
--- a/addons/web/models/models.py
+++ b/addons/web/models/models.py
@@ -275,8 +275,9 @@ class Base(models.AbstractModel):
:return a dictionnary mapping group_by values to dictionnaries mapping
progress bar field values to the related number of records
"""
- group_by_fname = group_by.partition(':')[0]
- field_type = self._fields[group_by_fname].type
+ group_by_fullname = group_by.partition(':')[0]
+ group_by_fieldname = group_by_fullname.split(".")[0] # split on "." in case we group on a property
+ field_type = self._fields[group_by_fieldname].type
if field_type == 'selection':
selection_labels = dict(self.fields_get()[group_by]['selection'])
@@ -311,14 +312,22 @@ class Base(models.AbstractModel):
# Workaround to match read_group's infrastructure
# TO DO in master: harmonize this function and readgroup to allow factorization
- group_by_name = group_by.partition(':')[0]
+ group_by_fullname = group_by.partition(':')[0]
+ group_by_fieldname = group_by_fullname.split(".")[0] # split on "." in case we group on a property
group_by_modifier = group_by.partition(':')[2] or 'month'
- records_values = self.search_read(domain or [], [progress_bar['field'], group_by_name])
- field_type = self._fields[group_by_name].type
+ records_values = self.search_read(domain or [], [progress_bar['field'], group_by_fieldname])
+ field_type = self._fields[group_by_fieldname].type
for record_values in records_values:
- group_by_value = record_values.pop(group_by_name)
+ group_by_value = record_values.pop(group_by_fieldname)
+ property_name = group_by_fullname.partition('.')[2]
+ if field_type == "properties" and group_by_value:
+ group_by_value = next(
+ (definition['value'] for definition in group_by_value
+ if definition['name'] == property_name),
+ False,
+ )
# Again, imitating what _read_group_format_result and _read_group_prepare_data do
if group_by_value and field_type in ['date', 'datetime']:
diff --git a/addons/web/static/src/core/field_service.js b/addons/web/static/src/core/field_service.js
index 4c405efa855..99555dd17a2 100644
--- a/addons/web/static/src/core/field_service.js
+++ b/addons/web/static/src/core/field_service.js
@@ -55,19 +55,22 @@ export const fieldService = {
domain = Domain.and([[[definitionRecordField, "!=", false]], domain]).toList();
- const records = await orm.searchRead(definitionRecordModel, domain, [
- "display_name",
- definitionRecordField,
- ]);
+ const result = await orm.webSearchRead(definitionRecordModel, domain, {
+ specification: {
+ display_name: {},
+ [definitionRecordField]: {},
+ },
+ });
const definitions = {};
- for (const record of records) {
+ for (const record of result.records) {
for (const definition of record[definitionRecordField]) {
definitions[definition.name] = {
is_property: true,
// for now, all properties are searchable but their definitions don't contain that info
searchable: true,
// differentiate definitions with same name but on different parent
+ record_id: record.id,
record_name: record.display_name,
...definition,
};
diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js
index 52afbb48d70..c9b76a69c8a 100644
--- a/addons/web/static/src/model/relational_model/record.js
+++ b/addons/web/static/src/model/relational_model/record.js
@@ -981,10 +981,22 @@ export class Record extends DataPoint {
for (const [fieldName, value] of Object.entries(changes)) {
const field = this.fields[fieldName];
if (field && field.relatedPropertyField) {
- const propertyFieldName = field.relatedPropertyField.fieldName;
- changes[propertyFieldName] = this.data[propertyFieldName].map((property) =>
- property.name === field.propertyName ? { ...property, value } : property
+ const [propertyFieldName, propertyName] = field.name.split(".");
+ const propertiesData = this.data[propertyFieldName] || [];
+ if (!propertiesData.find((property) => property.name === propertyName)) {
+ // try to change the value of a properties that has a different parent
+ this.model.notification.add(
+ _t(
+ "This record belongs to a different parent so you can not change this property."
+ ),
+ { type: "warning" }
+ );
+ return;
+ }
+ changes[propertyFieldName] = propertiesData.map((property) =>
+ property.name === propertyName ? { ...property, value } : property
);
+ delete changes[field.name];
}
}
const onChangeFields = Object.keys(changes).filter(
diff --git a/addons/web/static/src/model/relational_model/relational_model.js b/addons/web/static/src/model/relational_model/relational_model.js
index ed9468725ae..1b1af02b6dc 100644
--- a/addons/web/static/src/model/relational_model/relational_model.js
+++ b/addons/web/static/src/model/relational_model/relational_model.js
@@ -22,6 +22,7 @@ import {
getBasicEvalContext,
getFieldsSpec,
isRelational,
+ makeActiveField,
} from "./utils";
/**
@@ -186,6 +187,30 @@ export class RelationalModel extends Model {
// Protected
// -------------------------------------------------------------------------
+ /**
+ * If we group by default based on a property, the property might not be loaded in `fields`.
+ */
+ 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;
+ }
+ }
+
_askChanges() {
const proms = [];
this.bus.trigger("NEED_LOCAL_CHANGES", { proms });
@@ -330,6 +355,17 @@ export class RelationalModel extends Model {
}
config.groups = config.groups || {};
const firstGroupByName = config.groupBy[0].split(":")[0];
+ if (firstGroupByName.includes(".")) {
+ if (!config.fields[firstGroupByName]) {
+ await this._getPropertyDefinition(config, firstGroupByName);
+ }
+ const propertiesFieldName = firstGroupByName.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 orderBy = config.orderBy.filter(
(o) =>
o.name === firstGroupByName ||
diff --git a/addons/web/static/src/model/relational_model/utils.js b/addons/web/static/src/model/relational_model/utils.js
index 6e847445d8a..91f00ed6737 100644
--- a/addons/web/static/src/model/relational_model/utils.js
+++ b/addons/web/static/src/model/relational_model/utils.js
@@ -521,7 +521,7 @@ function getDisplayNameFromGroupData(field, rawValue) {
if (field.type === "selection") {
return Object.fromEntries(field.selection)[rawValue];
}
- if (["many2one", "many2many"].includes(field.type)) {
+ if (["many2one", "many2many", "tags"].includes(field.type)) {
return rawValue ? rawValue[1] : false;
}
return rawValue;
diff --git a/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml b/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml
index 51a41755b56..313b7d74374 100644
--- a/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml
+++ b/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml
@@ -6,6 +6,7 @@
diff --git a/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js
new file mode 100644
index 00000000000..6b0fcc20f96
--- /dev/null
+++ b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js
@@ -0,0 +1,71 @@
+/** @odoo-module **/
+
+import { AccordionItem, ACCORDION } from "@web/core/dropdown/accordion_item";
+import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
+import { Component, useState, useChildSubEnv } from "@odoo/owl";
+
+export class PropertiesGroupByItem extends Component {
+ static template = "web.PropertiesGroupByItem";
+ static components = { AccordionItem, SearchDropdownItem };
+ static props = {
+ item: Object,
+ onGroup: Function,
+ };
+
+ setup() {
+ this.state = useState({ groupByItems: [] });
+ useChildSubEnv({
+ [ACCORDION]: {
+ accordionStateChanged: this.beforeOpen.bind(this),
+ },
+ });
+ }
+
+ /**
+ * The properties field is considered as active if one of its property is active.
+ */
+ get isActive() {
+ return this.state.groupByItems.some((item) => item.isActive);
+ }
+
+ /**
+ * True if all group items come from the same definition record.
+ */
+ get isSingleParent() {
+ const uniqueNames = new Set(this.state.groupByItems.map((item) => item.definitionRecordId));
+ return uniqueNames.size < 2;
+ }
+
+ /**
+ * Dynamically load the definition, only when needed (if we open the dropdown).
+ */
+ async beforeOpen() {
+ if (this.definitionLoaded) {
+ return;
+ }
+ this.definitionLoaded = true;
+
+ await this.env.searchModel.fillSearchViewItemsProperty();
+ this._updateGroupByItems();
+ }
+
+ /**
+ * Callback to group records per one property.
+ */
+ onGroup(ids) {
+ this.props.onGroup(ids);
+ this._updateGroupByItems(); // isActive state might have changed
+ }
+
+ /**
+ * Update the component state to sync it with the search model group item.
+ */
+ _updateGroupByItems() {
+ this.state.groupByItems = this.env.searchModel.getSearchItems(
+ (searchItem) =>
+ ["groupBy", "dateGroupBy"].includes(searchItem.type) &&
+ searchItem.isProperty &&
+ searchItem.propertyFieldName === this.props.item.fieldName
+ );
+ }
+}
diff --git a/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml
new file mode 100644
index 00000000000..abb41b33f2a
--- /dev/null
+++ b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/addons/web/static/src/search/search_bar_menu/search_bar_menu.js b/addons/web/static/src/search/search_bar_menu/search_bar_menu.js
index 75587be4637..43314badd6c 100644
--- a/addons/web/static/src/search/search_bar_menu/search_bar_menu.js
+++ b/addons/web/static/src/search/search_bar_menu/search_bar_menu.js
@@ -4,6 +4,7 @@ import { Component } from "@odoo/owl";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { DomainSelectorDialog } from "../../core/domain_selector_dialog/domain_selector_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
+import { PropertiesGroupByItem } from "@web/search/properties_group_by_item/properties_group_by_item";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { sortBy } from "@web/core/utils/arrays";
@@ -25,6 +26,7 @@ export class SearchBarMenu extends Component {
SearchDropdownItem,
CustomGroupByItem,
AccordionItem,
+ PropertiesGroupByItem,
};
static props = {
slots: {
@@ -104,8 +106,9 @@ export class SearchBarMenu extends Component {
* @returns {Object[]}
*/
get groupByItems() {
- return this.env.searchModel.getSearchItems((searchItem) =>
- ["groupBy", "dateGroupBy"].includes(searchItem.type)
+ return this.env.searchModel.getSearchItems(
+ (searchItem) =>
+ ["groupBy", "dateGroupBy"].includes(searchItem.type) && !searchItem.isProperty
);
}
diff --git a/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml b/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml
index c44d2a66859..559f6b98c13 100644
--- a/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml
+++ b/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml
@@ -66,7 +66,10 @@
-
+
+
+
+
diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js
index 12f7e86bda1..3c5698ed8b7 100644
--- a/addons/web/static/src/search/search_model.js
+++ b/addons/web/static/src/search/search_model.js
@@ -3,7 +3,7 @@
import { makeContext } from "@web/core/context";
import { Domain } from "@web/core/domain";
import { evaluateExpr } from "@web/core/py_js/py";
-import { sortBy } from "@web/core/utils/arrays";
+import { sortBy, groupBy } from "@web/core/utils/arrays";
import { deepCopy } from "@web/core/utils/objects";
import { SearchArchParser } from "./search_arch_parser";
import {
@@ -170,6 +170,7 @@ export class SearchModel extends EventBus {
// services
const { field: fieldService, name: nameService, orm, user, view } = services;
this.orm = orm;
+ this.fieldService = fieldService;
this.userService = user;
this.viewService = view;
@@ -996,13 +997,7 @@ export class SearchModel extends EventBus {
}
const field = this.searchViewFields[searchItem.fieldName];
const definitionRecord = field.definition_record;
- const definitionRecordModel = this.searchViewFields[definitionRecord].relation;
- const definitionRecordField = field.definition_record_field;
-
- const result = await this._fetchPropertiesDefinition(
- definitionRecordModel,
- definitionRecordField
- );
+ const result = await this._fetchPropertiesDefinition(this.resModel, searchItem.fieldName);
const searchItemIds = new Set();
const existingFieldProperties = {};
@@ -1052,6 +1047,87 @@ export class SearchModel extends EventBus {
// Private methods
//--------------------------------------------------------------------------
+ /**
+ * Because it require a RPC to get the properties search views items,
+ * it's done lazily, only when we need them.
+ */
+ async fillSearchViewItemsProperty() {
+ if (!this.searchViewFields) {
+ return;
+ }
+
+ const fields = Object.values(this.searchViewFields);
+
+ for (const field of fields) {
+ if (field.type !== "properties") {
+ continue;
+ }
+
+ const result = await this._fetchPropertiesDefinition(this.resModel, field.name);
+
+ const searchItemsNames = Object.values(this.searchItems)
+ .filter((item) => item.isProperty && ["groupBy", "dateGroupBy"].includes(item.type))
+ .map((item) => item.fieldName);
+
+ for (const { definitionRecordId, definitionRecordName, definitions } of result) {
+ // some properties might have been deleted
+ const groupNames = definitions.map(
+ (definition) => `group_by_${field.name}.${definition.name}`
+ );
+ Object.values(this.searchItems).forEach((searchItem) => {
+ if (
+ searchItem.isProperty &&
+ searchItem.definitionRecordId === definitionRecordId &&
+ ["groupBy", "dateGroupBy"].includes(searchItem.type) &&
+ !groupNames.includes(searchItem.name)
+ ) {
+ // we can not just remove the element from the list because index are used as id
+ // so we use a different type to hide it everywhere (until the user refresh his
+ // browser and the item won't be created again)
+ searchItem.type = "group_by_property_deleted";
+ }
+ });
+
+ for (const definition of definitions) {
+ // we need the definition of the "field" (fake field, property) to be
+ // in searchViewFields to be able to have the type, it's description, etc
+ // the name of the property is stored as "."
+ const fullName = `${field.name}.${definition.name}`;
+ this.searchViewFields[fullName] = {
+ name: fullName,
+ readonly: false,
+ relation: definition.comodel,
+ required: false,
+ searchable: false,
+ selection: definition.selection,
+ sortable: true,
+ store: true,
+ string: `${definition.string} (${definitionRecordName})`,
+ type: definition.type,
+ relatedPropertyField: field,
+ };
+
+ if (!searchItemsNames.includes(fullName)) {
+ const groupByItem = {
+ description: definition.string,
+ definitionRecordId,
+ definitionRecordName,
+ fieldName: fullName,
+ fieldType: definition.type,
+ isProperty: true,
+ name: `group_by_${field.name}.${definition.name}`,
+ propertyFieldName: field.name,
+ type: ["datetime", "date"].includes(definition.type)
+ ? "dateGroupBy"
+ : "groupBy",
+ };
+ this._createGroupOfSearchItems([groupByItem]);
+ }
+ }
+ }
+ }
+ }
+
/**
* Fetch the properties definitions.
*
@@ -1064,7 +1140,7 @@ export class SearchModel extends EventBus {
* definitions:
* }
*/
- async _fetchPropertiesDefinition(definitionRecordModel, definitionRecordField) {
+ async _fetchPropertiesDefinition(resModel, fieldName) {
const domain = [];
if (this.context.active_id) {
// assume the active id is the definition record
@@ -1072,18 +1148,17 @@ export class SearchModel extends EventBus {
domain.push(["id", "=", this.context.active_id]);
}
- const result = await this.orm.webSearchRead(definitionRecordModel, domain, {
- specification: {
- display_name: {},
- [definitionRecordField]: {},
- },
- });
-
- return result.records.map((values) => {
+ const definitions = await this.fieldService.loadPropertyDefinitions(
+ resModel,
+ fieldName,
+ domain
+ );
+ const result = groupBy(Object.values(definitions), (definition) => definition.record_id);
+ return Object.entries(result).map(([recordId, definitions]) => {
return {
- definitionRecordId: values.id,
- definitionRecordName: values.display_name,
- definitions: values[definitionRecordField],
+ definitionRecordId: parseInt(recordId),
+ definitionRecordName: definitions[0]?.record_name,
+ definitions,
};
});
}
diff --git a/addons/web/static/src/search/utils/misc.js b/addons/web/static/src/search/utils/misc.js
index b6c747537a9..67565bb0a74 100644
--- a/addons/web/static/src/search/utils/misc.js
+++ b/addons/web/static/src/search/utils/misc.js
@@ -23,4 +23,5 @@ export const GROUPABLE_TYPES = [
"many2one",
"many2many",
"selection",
+ "tags",
];
diff --git a/addons/web/static/src/views/pivot/pivot_group_by_menu.js b/addons/web/static/src/views/pivot/pivot_group_by_menu.js
index dba07bb77ec..f4dc07bfa7a 100644
--- a/addons/web/static/src/views/pivot/pivot_group_by_menu.js
+++ b/addons/web/static/src/views/pivot/pivot_group_by_menu.js
@@ -4,6 +4,7 @@ import { Dropdown } from "@web/core/dropdown/dropdown";
import { sortBy } from "@web/core/utils/arrays";
import { useBus } from "@web/core/utils/hooks";
import { CustomGroupByItem } from "@web/search/custom_group_by_item/custom_group_by_item";
+import { PropertiesGroupByItem } from "@web/search/properties_group_by_item/properties_group_by_item";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { getIntervalOptions } from "@web/search/utils/dates";
import { FACET_ICONS, GROUPABLE_TYPES } from "@web/search/utils/misc";
@@ -105,7 +106,12 @@ export class PivotGroupByMenu extends Component {
this.props.onAddCustomGroupBy(fieldName);
}
}
-PivotGroupByMenu.components = { CustomGroupByItem, Dropdown, SearchDropdownItem };
+PivotGroupByMenu.components = {
+ CustomGroupByItem,
+ Dropdown,
+ SearchDropdownItem,
+ PropertiesGroupByItem,
+};
PivotGroupByMenu.template = "web.PivotGroupByMenu";
PivotGroupByMenu.defaultProps = {
showCaretDown: false,
diff --git a/addons/web/static/src/views/pivot/pivot_group_by_menu.xml b/addons/web/static/src/views/pivot/pivot_group_by_menu.xml
index caf250720ff..b091ffc121d 100644
--- a/addons/web/static/src/views/pivot/pivot_group_by_menu.xml
+++ b/addons/web/static/src/views/pivot/pivot_group_by_menu.xml
@@ -15,7 +15,10 @@
-
+
+
+
+
diff --git a/addons/web/static/tests/core/field_service_tests.js b/addons/web/static/tests/core/field_service_tests.js
index 454bd4cc0bb..c18fe3249ce 100644
--- a/addons/web/static/tests/core/field_service_tests.js
+++ b/addons/web/static/tests/core/field_service_tests.js
@@ -22,6 +22,7 @@ function getDefinitions() {
is_property: true,
searchable: true,
record_name: record.display_name,
+ record_id: record.id,
...definition,
};
}
diff --git a/addons/web/static/tests/search/search_bar_menu_tests.js b/addons/web/static/tests/search/search_bar_menu_tests.js
index 8368fe888a6..7c7e60ec77d 100644
--- a/addons/web/static/tests/search/search_bar_menu_tests.js
+++ b/addons/web/static/tests/search/search_bar_menu_tests.js
@@ -8,6 +8,7 @@ import {
getNodesTextContent,
patchDate,
patchWithCleanup,
+ nextTick,
} from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import {
@@ -64,6 +65,24 @@ QUnit.module("Search", (hooks) => {
store: true,
sortable: true,
},
+ properties: {
+ string: "Properties",
+ type: "properties",
+ definition_record: "parent_id",
+ definition_record_field: "properties_definition",
+ name: "properties",
+ },
+ parent_id: {
+ string: "Parent",
+ type: "many2one",
+ relation: "parentModel",
+ name: "parent_id",
+ },
+ },
+ },
+ parentModel: {
+ fields: {
+ properties_definition: { type: "properties_definition" },
},
},
},
@@ -1905,5 +1924,122 @@ QUnit.module("Search", (hooks) => {
["id", "=", 2],
]);
});
+
+ QUnit.test("group by properties", async function (assert) {
+ assert.expect(11);
+
+ async function mockRPC(route, { method, model, args, kwargs }) {
+ if (method === "web_search_read" && model === "parentModel") {
+ assert.step("definitionFetched");
+ const records = [
+ {
+ id: 1337,
+ display_name: "First Parent",
+ properties_definition: [
+ {
+ name: "my_text",
+ type: "text",
+ string: "My Text",
+ },
+ {
+ name: "my_partner",
+ type: "many2one",
+ string: "My Partner",
+ comodel: "res.partner",
+ },
+ {
+ name: "my_datetime",
+ type: "datetime",
+ string: "My Datetime",
+ },
+ ],
+ },
+ {
+ id: 1338,
+ display_name: "Second Parent",
+ properties_definition: [
+ {
+ name: "my_integer",
+ type: "integer",
+ string: "My Integer",
+ },
+ ],
+ },
+ ];
+ return { records };
+ }
+ }
+
+ patchWithCleanup(browser, { setTimeout: (fn) => fn() });
+
+ const searchBar = await makeWithSearch({
+ serverData,
+ resModel: "foo",
+ Component: SearchBar,
+ searchViewId: false,
+ searchViewArch: `
+
+
+
+ `,
+ hideCustomGroupBy: true,
+ searchMenuTypes: ["groupBy"],
+ mockRPC,
+ });
+
+ // definition is fetched only when we open the properties menu
+ assert.verifySteps([]);
+
+ await click(target, ".o_searchview_dropdown_toggler");
+
+ // definition is fetched only when we open the properties menu
+ assert.verifySteps([]);
+ const items = [...target.querySelectorAll(".o_menu_item")];
+ assert.deepEqual(
+ items.map((item) => item.innerText),
+ ["Properties"]
+ );
+
+ await click(target, ".o_accordion_toggle");
+ await nextTick();
+
+ // now that we open the properties we fetch the definition
+ assert.verifySteps(["definitionFetched"]);
+
+ const propertiesItems = [
+ ...target.querySelectorAll(".o_accordion_values .dropdown-item"),
+ ];
+ assert.deepEqual(
+ propertiesItems.map((item) => item.innerText),
+ [
+ "My Text (First Parent)",
+ "My Partner (First Parent)",
+ "My Datetime (First Parent)",
+ "My Integer (Second Parent)",
+ ]
+ );
+
+ // open the datetime item
+ await click(propertiesItems[2]);
+
+ const optionsItems = [
+ ...target.querySelectorAll(
+ ".o_accordion_values .o_accordion_values .dropdown-item"
+ ),
+ ];
+ assert.deepEqual(
+ optionsItems.map((item) => item.innerText),
+ ["Year", "Quarter", "Month", "Week", "Day"]
+ );
+
+ assert.deepEqual(searchBar.env.searchModel.groupBy, []);
+ assert.deepEqual(getFacetTexts(target), []);
+
+ await optionsItems[1].click();
+ await nextTick();
+
+ assert.deepEqual(searchBar.env.searchModel.groupBy, ["properties.my_datetime:quarter"]);
+ assert.deepEqual(getFacetTexts(target), ["My Datetime: Quarter"]);
+ });
});
});
diff --git a/addons/web/static/tests/search/search_bar_tests.js b/addons/web/static/tests/search/search_bar_tests.js
index a3ceb8b6a2c..b8e5142a820 100644
--- a/addons/web/static/tests/search/search_bar_tests.js
+++ b/addons/web/static/tests/search/search_bar_tests.js
@@ -52,6 +52,7 @@ QUnit.module("Search", (hooks) => {
definition_record: "bar",
definition_record_field: "child_properties",
},
+ child_properties: { type: "properties_definition" },
},
records: [
{
@@ -1381,7 +1382,7 @@ QUnit.module("Search", (hooks) => {
) {
assert.deepEqual(
kwargs.domain,
- [["id", "=", 2]],
+ ["&", ["child_properties", "!=", false], ["id", "=", 2]],
"Should search only the active parent properties"
);
diff --git a/addons/web/static/tests/views/kanban/kanban_view_tests.js b/addons/web/static/tests/views/kanban/kanban_view_tests.js
index c68e63b28a7..53a0ea71be1 100644
--- a/addons/web/static/tests/views/kanban/kanban_view_tests.js
+++ b/addons/web/static/tests/views/kanban/kanban_view_tests.js
@@ -209,6 +209,23 @@ QUnit.module("Views", (hooks) => {
default: 1,
},
salary: { string: "Monetary field", type: "monetary" },
+ properties: {
+ string: "Properties",
+ type: "properties",
+ definition_record: "parent_id",
+ definition_record_field: "properties_definition",
+ name: "properties",
+ },
+ parent_id: {
+ string: "Parent",
+ type: "many2one",
+ relation: "partner",
+ name: "parent_id",
+ },
+ properties_definition: {
+ string: "Properties",
+ type: "properties_definition",
+ },
},
records: [
{
@@ -223,6 +240,13 @@ QUnit.module("Views", (hooks) => {
image: "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==",
salary: 1750,
currency_id: 1,
+ properties_definition: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ },
+ ],
},
{
id: 2,
@@ -235,6 +259,15 @@ QUnit.module("Views", (hooks) => {
category_ids: [6],
salary: 1500,
currency_id: 1,
+ parent_id: 1,
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: "aaa",
+ },
+ ],
},
{
id: 3,
@@ -247,6 +280,15 @@ QUnit.module("Views", (hooks) => {
category_ids: [7],
salary: 2000,
currency_id: 2,
+ parent_id: 1,
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: "bbb",
+ },
+ ],
},
{
id: 4,
@@ -259,6 +301,7 @@ QUnit.module("Views", (hooks) => {
category_ids: [],
salary: 2222,
currency_id: 1,
+ parent_id: 2,
},
],
},
@@ -13826,4 +13869,102 @@ QUnit.module("Views", (hooks) => {
);
}
);
+
+ QUnit.test("group by properties and drag and drop", async (assert) => {
+ assert.expect(10);
+
+ await makeView({
+ type: "kanban",
+ resModel: "partner",
+ serverData,
+ arch: `
+
+
+
+
+
+
+
+
+
+ `,
+ groupBy: ["properties.my_char"],
+ async mockRPC(route, args) {
+ if (route === "/web/dataset/call_kw/partner/web_read_group") {
+ return {
+ groups: [
+ {
+ "properties.my_char": false,
+ __domain: [["properties.my_char", "=", false]],
+ "properties.my_char_count": 2,
+ },
+ {
+ "properties.my_char": "aaa",
+ __domain: [["properties.my_char", "=", "aaa"]],
+ "properties.my_char_count": 1,
+ },
+ {
+ "properties.my_char": "bbb",
+ __domain: [["properties.my_char", "=", "bbb"]],
+ "properties.my_char_count": 1,
+ },
+ ],
+ length: 3,
+ };
+ } else if (route === "/web/dataset/call_kw/partner/web_search_read") {
+ const value = args.kwargs.domain[0][2];
+ return {
+ length: 1,
+ records: [
+ {
+ id: value === "aaa" ? 2 : 3,
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: value,
+ },
+ ],
+ },
+ ],
+ };
+ } else if (route === "/web/dataset/resequence") {
+ assert.step("resequence");
+ } else if (route === "/web/dataset/call_kw/partner/web_save") {
+ assert.step("web_save");
+ const expected = {
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: "bbb",
+ },
+ ],
+ };
+ assert.deepEqual(args.args[1], expected);
+ } else if (route === "/web/dataset/call_kw/partner/get_property_definition") {
+ assert.step("get_property_definition");
+ return {
+ name: "my_char",
+ type: "char",
+ };
+ }
+ },
+ });
+
+ assert.verifySteps(["get_property_definition"]);
+ assert.containsN(target, ".o_kanban_group:nth-child(2) .o_kanban_record", 1);
+ assert.containsN(target, ".o_kanban_group:nth-child(3) .o_kanban_record", 1);
+
+ await dragAndDrop(
+ ".o_kanban_group:nth-child(2) .o_kanban_record",
+ ".o_kanban_group:nth-child(3)"
+ );
+
+ assert.verifySteps(["web_save", "resequence"]);
+ assert.containsN(target, ".o_kanban_group:nth-child(2) .o_kanban_record", 0);
+ assert.containsN(target, ".o_kanban_group:nth-child(3) .o_kanban_record", 2);
+ });
});
diff --git a/addons/web/static/tests/views/pivot_view_tests.js b/addons/web/static/tests/views/pivot_view_tests.js
index add9e8c1a66..e744a3fe4c9 100644
--- a/addons/web/static/tests/views/pivot_view_tests.js
+++ b/addons/web/static/tests/views/pivot_view_tests.js
@@ -125,6 +125,23 @@ QUnit.module("Views", (hooks) => {
],
group_operator: "count_distinct",
},
+ properties: {
+ string: "Properties",
+ type: "properties",
+ definition_record: "parent_id",
+ definition_record_field: "properties_definition",
+ name: "properties",
+ },
+ parent_id: {
+ string: "Parent",
+ type: "many2one",
+ relation: "partner",
+ name: "parent_id",
+ },
+ properties_definition: {
+ string: "Properties",
+ type: "properties_definition",
+ },
},
records: [
{
@@ -137,6 +154,13 @@ QUnit.module("Views", (hooks) => {
computed_field: 19,
company_type: "company",
ref: "product,37",
+ properties_definition: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ },
+ ],
},
{
id: 2,
@@ -148,6 +172,15 @@ QUnit.module("Views", (hooks) => {
computed_field: 23,
company_type: "individual",
ref: "product,41",
+ parent_id: 1,
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: "aaa",
+ },
+ ],
},
{
id: 3,
@@ -159,6 +192,15 @@ QUnit.module("Views", (hooks) => {
computed_field: 26,
company_type: "company",
ref: "customer,1",
+ parent_id: 1,
+ properties: [
+ {
+ name: "my_char",
+ string: "My Char",
+ type: "char",
+ value: "bbb",
+ },
+ ],
},
{
id: 4,
@@ -5636,4 +5678,82 @@ QUnit.module("Views", (hooks) => {
assert.containsNone(target, ".o_pivot_view table");
}
);
+
+ QUnit.test("group by properties in pivot view", async function (assert) {
+ assert.expect(18);
+
+ await makeView({
+ type: "pivot",
+ resModel: "partner",
+ serverData,
+ arch: "",
+ searchViewArch: `
+
+
+
+ `,
+ mockRPC(route, args) {
+ if (
+ route === "/web/dataset/call_kw/partner/web_search_read" &&
+ args.kwargs.specification?.properties_definition
+ ) {
+ assert.step("fetch_definition");
+ } else if (
+ route === "/web/dataset/call_kw/partner/read_group" &&
+ args.kwargs.groupby?.includes("properties.my_char")
+ ) {
+ assert.step("read_group");
+ return [
+ {
+ "properties.my_char": false,
+ __domain: [["properties.my_char", "=", false]],
+ __count: 2,
+ },
+ {
+ "properties.my_char": "aaa",
+ __domain: [["properties.my_char", "=", "aaa"]],
+ __count: 1,
+ },
+ {
+ "properties.my_char": "bbb",
+ __domain: [["properties.my_char", "=", "bbb"]],
+ __count: 1,
+ },
+ ];
+ }
+ },
+ });
+
+ assert.strictEqual(target.querySelectorAll(".o_value").length, 1);
+ assert.strictEqual(target.querySelector(".o_value").innerText, "4");
+
+ await click(target, ".border-top-0 span");
+ assert.verifySteps([]);
+
+ const propertiesItem = target.querySelector(".o_accordion_toggle");
+ assert.ok(propertiesItem);
+ assert.strictEqual(propertiesItem.innerText, "Properties");
+ await click(propertiesItem);
+
+ await nextTick();
+ assert.verifySteps(["fetch_definition"]);
+
+ await click(target, ".o_accordion_values .o_menu_item");
+
+ await nextTick();
+ assert.verifySteps(["read_group"]);
+
+ const cells = target.querySelectorAll(".o_value");
+ assert.strictEqual(cells.length, 4);
+ assert.strictEqual(cells[0].innerText, "2");
+ assert.strictEqual(cells[1].innerText, "1");
+ assert.strictEqual(cells[2].innerText, "1");
+ assert.strictEqual(cells[3].innerText, "4");
+
+ const columns = target.querySelectorAll(".o_pivot_header_cell_closed");
+ assert.strictEqual(columns.length, 4);
+ assert.strictEqual(columns[0].innerText, "None");
+ assert.strictEqual(columns[1].innerText, "aaa");
+ assert.strictEqual(columns[2].innerText, "bbb");
+ });
});
diff --git a/addons/web/tests/test_ir_model.py b/addons/web/tests/test_ir_model.py
index 8ba412761cb..3b718bbd3e9 100644
--- a/addons/web/tests/test_ir_model.py
+++ b/addons/web/tests/test_ir_model.py
@@ -63,3 +63,12 @@ class IrModelAccessTest(TransactionCase):
# non existent model comes after existent model
result = self.env['ir.model'].display_name_for(["res.company", "unexistent"])
self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}, {"display_name": "unexistent", "model": "unexistent"}])
+ # transient models
+ result = self.env['ir.model'].display_name_for(["res.company", "base.language.export"])
+ self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}, {"display_name": "base.language.export", "model": "base.language.export"}])
+
+ # do not return results for transient models
+ result = self.env['ir.model'].get_available_models()
+ result = {values["model"] for values in result}
+ self.assertIn("res.company", result)
+ self.assertNotIn("base.language.export", result)
diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py
index 17c875fa9bf..bd3aa6d176a 100644
--- a/odoo/addons/test_new_api/models/test_new_api.py
+++ b/odoo/addons/test_new_api/models/test_new_api.py
@@ -962,6 +962,11 @@ class RequiredM2OTransient(models.TransientModel):
bar = fields.Many2one('res.country', required=True)
+class TestTransient(models.TransientModel):
+ _name = 'test_new_api.transient_model'
+ _description = 'Transient Model'
+
+
class Attachment(models.Model):
_name = 'test_new_api.attachment'
_description = 'Attachment'
diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv
index 1c25a03c3d1..8207897d07e 100644
--- a/odoo/addons/test_new_api/security/ir.model.access.csv
+++ b/odoo/addons/test_new_api/security/ir.model.access.csv
@@ -114,3 +114,4 @@ access_test_new_api_unsearchable_o2m_employee,access_test_new_api_unsearchable_o
access_test_new_api_any_parent,access_test_new_api_any_parent,model_test_new_api_any_parent,base.group_user,1,0,0,0
access_test_new_api_any_child,access_test_new_api_any_child,model_test_new_api_any_child,base.group_user,1,0,0,0
access_test_new_api_any_tag,access_test_new_api_any_tag,model_test_new_api_any_tag,base.group_user,1,0,0,0
+access_test_new_api_transient_model,access_test_new_api_transient_model,model_test_new_api_transient_model,base.group_user,1,1,1,1
diff --git a/odoo/addons/test_new_api/tests/test_properties.py b/odoo/addons/test_new_api/tests/test_properties.py
index 5bb2f5a2578..62751f4cd2b 100644
--- a/odoo/addons/test_new_api/tests/test_properties.py
+++ b/odoo/addons/test_new_api/tests/test_properties.py
@@ -1,16 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import datetime
import json
-
import unittest
+
from unittest.mock import patch
from odoo import Command
from odoo.exceptions import AccessError, UserError
+from odoo.osv import expression
from odoo.tests.common import Form, TransactionCase, users
-from odoo.tools import mute_logger
+from odoo.tools import mute_logger, get_lang
class TestPropertiesMixin(TransactionCase):
@@ -186,6 +188,34 @@ class PropertiesCase(TestPropertiesMixin):
'default': 'blue',
}])
+ def test_properties_field_injection(self):
+ for c in '!#"\'- |+/\\':
+ with self.assertRaises(ValueError):
+ self.message_1.attributes = [{
+ 'name': f'discussion_color_code{c}',
+ 'type': 'char',
+ 'definition_changed': True
+ }]
+
+ with self.assertRaises(ValueError):
+ self.discussion_1.attributes_definition = [{
+ 'name': f'discussion_color_code{c}',
+ 'type': 'char',
+ }]
+
+ with self.assertRaises(ValueError):
+ self.message_1.attributes = [{
+ 'name': 'a' * 513,
+ 'type': 'char',
+ 'definition_changed': True
+ }]
+
+ with self.assertRaises(ValueError):
+ self.discussion_1.attributes_definition = [{
+ 'name': 'a' * 513,
+ 'type': 'char',
+ }]
+
@mute_logger('odoo.fields')
def test_properties_field_write_batch(self):
"""Test the behavior of the write called in batch.
@@ -604,6 +634,41 @@ class PropertiesCase(TestPropertiesMixin):
})
self.assertEqual(self._get_sql_properties(record), {'my_char': 'my char', 'test': self.partner_2.id})
+ # use the context to set the default value, the default key in the definition is ignored
+ # (e.g. when you create a new record in a Kanban view grouped by a property)
+ del property_definition[0]['value']
+ self.discussion_1.attributes_definition = property_definition
+ partner = self.env['test_new_api.partner'].create({'name': 'Test Default'})
+ message = self.env['test_new_api.message'] \
+ .with_context({'default_attributes.my_many2one': partner.id}) \
+ .create({
+ 'name': 'Test Message',
+ 'author': self.user.id,
+ 'discussion': self.discussion_1.id,
+ 'attributes': property_definition,
+ })
+
+ sql_values = self._get_sql_properties(message)
+ self.assertEqual(sql_values, {'my_many2one': partner.id})
+ properties = message.read(['attributes'])[0]['attributes']
+ self.assertEqual(properties[0]['value'], (partner.id, partner.display_name))
+
+ # "None" is a valid default value
+ del property_definition[0]['value']
+ message = self.env['test_new_api.message'] \
+ .with_context({'default_attributes.my_many2one': None}) \
+ .create({
+ 'name': 'Test Message',
+ 'author': self.user.id,
+ 'discussion': self.discussion_1.id,
+ 'attributes': property_definition,
+ })
+
+ sql_values = self._get_sql_properties(message)
+ self.assertEqual(sql_values, {'my_many2one': False})
+ properties = message.read(['attributes'])[0]['attributes']
+ self.assertEqual(properties[0]['value'], False)
+
def test_properties_field_read(self):
"""Test the behavior of the read method.
@@ -677,6 +742,21 @@ class PropertiesCase(TestPropertiesMixin):
self.assertEqual(properties[1]['value'], (self.partner_2.id, self.partner_2.display_name))
self.assertEqual(properties[1]['comodel'], 'test_new_api.partner')
+ # should not be able to set a transient model
+ with self.assertRaises(ValueError):
+ self.message_2.attributes = [{
+ "name": "moderator_partner_id",
+ "type": "many2one",
+ "comodel": "test_new_api.transient_model",
+ "definition_changed": True,
+ }]
+ with self.assertRaises(ValueError):
+ self.discussion_1.attributes_definition = [{
+ "name": "moderator_partner_id",
+ "type": "many2one",
+ "comodel": "test_new_api.transient_model",
+ }]
+
@mute_logger('odoo.models.unlink', 'odoo.fields')
def test_properties_field_many2one_unlink(self):
"""Test the case where we unlink the many2one record."""
@@ -1169,6 +1249,21 @@ class PropertiesCase(TestPropertiesMixin):
'value': [(partners[9].id, partners[9].display_name)],
}])
+ # should not be able to set a transient model
+ with self.assertRaises(ValueError):
+ self.message_2.attributes = [{
+ "name": "partner_ids",
+ "type": "many2many",
+ "comodel": "test_new_api.transient_model",
+ "definition_changed": True,
+ }]
+ with self.assertRaises(ValueError):
+ self.discussion_1.attributes_definition = [{
+ "name": "partner_ids",
+ "type": "many2many",
+ "comodel": "test_new_api.transient_model",
+ }]
+
@users('test')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.fields')
def test_properties_field_many2many_filtering(self):
@@ -1918,3 +2013,877 @@ class PropertiesSearchCase(TestPropertiesMixin):
for order in orders:
with self.assertRaises(UserError), self.assertQueryCount(0):
self.env['test_new_api.message'].search(domain=[], order=order)
+
+
+class PropertiesGroupByCase(PropertiesCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.message_4 = cls.env['test_new_api.message'].create({
+ 'name': 'Test Message',
+ 'discussion': cls.discussion_1.id,
+ 'author': cls.user.id,
+ })
+
+ cls.messages = cls.message_1 | cls.message_2 | cls.message_3 | cls.message_4
+ cls.env['test_new_api.message'].search([('id', 'not in', cls.messages.ids)]).unlink()
+
+ cls.wrong_discussion_id = cls.env['test_new_api.discussion'].search(
+ [], order="id DESC", limit=1).id + 1000
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_basic(self):
+ Model = self.env['test_new_api.message']
+
+ self.messages.discussion = self.discussion_1
+ # search on text properties
+ self.message_1.attributes = [{
+ 'name': 'mychar',
+ 'type': 'char',
+ 'value': 'qsd',
+ 'definition_changed': True,
+ }, {
+ 'name': 'myinteger',
+ 'type': 'integer',
+ 'value': 1337,
+ 'definition_changed': True,
+ }]
+ self.message_2.attributes = {'mychar': 'qsd', 'myinteger': 5}
+ self.message_3.attributes = {'mychar': 'boum', 'myinteger': 1337}
+ self.env.flush_all()
+
+ # group by the char property
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mychar'],
+ )
+
+ self.assertEqual(len(result), 3)
+
+ # check counts
+ count_by_values = {
+ value['attributes.mychar']: value['attributes.mychar_count']
+ for value in result
+ }
+ self.assertEqual(count_by_values['boum'], 1)
+ self.assertEqual(count_by_values['qsd'], 2)
+ self.assertEqual(count_by_values[False], 1)
+
+ # check domains
+ domain_by_values = {
+ value['attributes.mychar']: value['__domain']
+ for value in result
+ }
+ self.assertEqual(domain_by_values['boum'], [('attributes.mychar', '=', 'boum')])
+ self.assertEqual(domain_by_values['qsd'], [('attributes.mychar', '=', 'qsd')])
+ self._check_domains_count(result)
+
+ # group by the integer property
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ )
+
+ self.assertEqual(len(result), 3)
+ count_by_values = {
+ value['attributes.myinteger']: value['attributes.myinteger_count']
+ for value in result
+ }
+
+ self.assertEqual(count_by_values[5], 1)
+ self.assertEqual(count_by_values[1337], 2)
+ self.assertEqual(count_by_values[False], 1)
+
+ # falsy properties
+ self.message_3.attributes = {'mychar': False, 'myinteger': False}
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ )
+
+ self.assertEqual(result[-1]['attributes.myinteger_count'], 2)
+ self.assertEqual(result[-1]['__domain'], [('attributes.myinteger', '=', False)])
+ self._check_domains_count(result)
+
+ # non existing keys in the dict values should be grouped with False value
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_message
+ SET attributes = '{}'
+ WHERE id = %s
+ """,
+ [self.message_2.id],
+ )
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ )
+
+ self.assertEqual(result[-1]['attributes.myinteger_count'], 3)
+ self.assertEqual(result[-1]['__domain'], [('attributes.myinteger', '=', False)])
+ result = Model.search(result[-1]['__domain']) # check the domain is correct for the search
+ self.assertEqual(result, self.message_2 | self.message_3 | self.message_4)
+
+ # test the order by
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ orderby='attributes.myinteger ASC'
+ )
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0]['attributes.myinteger'], 1337)
+ self.assertEqual(result[1]['attributes.myinteger'], False)
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ orderby='attributes.myinteger DESC'
+ )
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0]['attributes.myinteger'], False)
+ self.assertEqual(result[1]['attributes.myinteger'], 1337)
+ self._check_domains_count(result)
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger', 'name'],
+ orderby='attributes.myinteger DESC',
+ lazy=False,
+ )
+ self.assertEqual(
+ result[0]['__domain'],
+ ['&', ('attributes.myinteger', '=', False), ('name', '=', self.message_1.name)],
+ )
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_progress_bar(self):
+ """Test the fallback of "_read_progress_bar" when we read a field non-stored."""
+ Model = self.env['test_new_api.message']
+
+ self.messages.discussion = self.discussion_1
+ self.message_1.attributes = [{
+ 'name': 'myinteger',
+ 'type': 'integer',
+ 'value': 1337,
+ 'definition_changed': True,
+ }]
+ self.message_2.attributes = {'myinteger': 5}
+ self.message_3.attributes = {'myinteger': 1337}
+
+ result = Model.read_progress_bar(
+ domain=[],
+ group_by='attributes.myinteger',
+ progress_bar={'field': 'size', 'colors': [0]},
+ )
+ self.assertEqual(result, {'1337': {0: 2}, '5': {0: 1}, 'False': {0: 1}})
+
+ def _properties_field_read_group_date_prepare(self, date_type='date'):
+ # Format in database: 2023-03-29 09:30:16
+ self.messages.discussion = self.discussion_1
+ self.discussion_1.attributes_definition = [{
+ 'type': date_type,
+ 'name': 'mydate',
+ }]
+ hour = ' 13:05:34' if date_type == 'datetime' else ''
+ # message 5 has a different year
+ # message 6 has a False value
+ # message 7 is in a different discussion
+ self.message_5, self.message_6, self.message_7 = self.env['test_new_api.message'].create([
+ {'discussion': self.discussion_1.id, 'attributes': {'mydate': f'2077-05-02{hour}'}},
+ {'discussion': self.discussion_1.id, 'attributes': {'mydate': False}},
+ {'discussion': self.discussion_2.id},
+ ])
+ self.message_1.attributes = {'mydate': f'2023-01-02{hour}'}
+ self.message_2.attributes = {'mydate': f'2023-02-03{hour}'}
+ self.message_3.attributes = {'mydate': f'2023-01-02{hour}'}
+ self.message_4.attributes = {'mydate': f'2023-02-05{hour}'}
+ self.env.flush_all()
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_date_day(self, date_type='date'):
+ self._properties_field_read_group_date_prepare(date_type)
+ Model = self.env['test_new_api.message']
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:day'],
+ orderby='attributes.mydate DESC',
+ )
+
+ self.assertEqual(len(result), 5)
+ # check values and count
+ self.assertEqual(result[0]['attributes.mydate_count'], 2)
+ self.assertEqual(result[0]['attributes.mydate:day'], False)
+ self.assertEqual(result[1]['attributes.mydate_count'], 1)
+ self.assertEqual(result[1]['attributes.mydate:day'], '02 May 2077')
+ self.assertEqual(result[2]['attributes.mydate_count'], 1)
+ self.assertEqual(result[2]['attributes.mydate:day'], '05 Feb 2023')
+ self.assertEqual(result[3]['attributes.mydate_count'], 1)
+ self.assertEqual(result[3]['attributes.mydate:day'], '03 Feb 2023')
+ self.assertEqual(result[4]['attributes.mydate_count'], 2)
+ self.assertEqual(result[4]['attributes.mydate:day'], '02 Jan 2023')
+ # check domain
+ self.assertEqual(Model.search(result[0]['__domain']), self.message_6 | self.message_7)
+ self.assertEqual(Model.search(result[1]['__domain']), self.message_5)
+ self.assertEqual(Model.search(result[2]['__domain']), self.message_4)
+ self.assertEqual(Model.search(result[3]['__domain']), self.message_2)
+ self.assertEqual(Model.search(result[4]['__domain']), self.message_1 | self.message_3)
+ self._check_domains_count(result)
+
+ # when the order is not specified, the ORM will generate one
+ # based on "attributes.mydate ASC", make sure it works
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:year'],
+ )
+ self.assertEqual(len(result), 3)
+ self.assertEqual(result[0]['attributes.mydate:year'], '2023')
+ self.assertEqual(result[1]['attributes.mydate:year'], '2077')
+ self.assertEqual(result[2]['attributes.mydate:year'], False)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_date_quarter(self, date_type='date'):
+ self._properties_field_read_group_date_prepare(date_type)
+ Model = self.env['test_new_api.message']
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:quarter'],
+ orderby='attributes.mydate DESC',
+ )
+
+ self.assertEqual(len(result), 3)
+ # check values and count
+ self.assertEqual(result[0]['attributes.mydate_count'], 2)
+ self.assertEqual(result[0]['attributes.mydate:quarter'], False)
+ self.assertEqual(result[1]['attributes.mydate_count'], 1)
+ self.assertEqual(result[1]['attributes.mydate:quarter'], 'Q2 2077')
+ self.assertEqual(result[2]['attributes.mydate_count'], 4)
+ self.assertEqual(result[2]['attributes.mydate:quarter'], 'Q1 2023')
+ # check domain
+ self.assertEqual(Model.search(result[0]['__domain']), self.message_6 | self.message_7)
+ self.assertEqual(Model.search(result[1]['__domain']), self.message_5)
+ self.assertEqual(
+ Model.search(result[2]['__domain']),
+ self.message_1 | self.message_2 | self.message_3 | self.message_4)
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_date_month(self, date_type='date'):
+ self._properties_field_read_group_date_prepare()
+ Model = self.env['test_new_api.message']
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:month'],
+ orderby='attributes.mydate DESC',
+ )
+
+ self.assertEqual(len(result), 4)
+ # check values and count
+ self.assertEqual(result[0]['attributes.mydate_count'], 2)
+ self.assertEqual(result[0]['attributes.mydate:month'], False)
+ self.assertEqual(result[1]['attributes.mydate_count'], 1)
+ self.assertEqual(result[1]['attributes.mydate:month'], 'May 2077')
+ self.assertEqual(result[2]['attributes.mydate_count'], 2)
+ self.assertEqual(result[2]['attributes.mydate:month'], 'February 2023')
+ self.assertEqual(result[3]['attributes.mydate_count'], 2)
+ self.assertEqual(result[3]['attributes.mydate:month'], 'January 2023')
+ # check domain
+ self.assertEqual(Model.search(result[0]['__domain']), self.message_6 | self.message_7)
+ self.assertEqual(Model.search(result[1]['__domain']), self.message_5)
+ self.assertEqual(Model.search(result[2]['__domain']), self.message_2 | self.message_4)
+ self.assertEqual(Model.search(result[3]['__domain']), self.message_1 | self.message_3)
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_date_week(self, date_type='date'):
+ first_week_day = int(get_lang(self.env).week_start) - 1
+ self.assertEqual(first_week_day, 6, "First day of the week must be Sunday")
+
+ self._properties_field_read_group_date_prepare()
+ Model = self.env['test_new_api.message']
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:week'],
+ orderby='attributes.mydate DESC',
+ )
+
+ self.assertEqual(len(result), 5)
+ # check values and count
+ self.assertEqual(result[0]['attributes.mydate_count'], 2)
+ self.assertEqual(result[0]['attributes.mydate:week'], False)
+ self.assertEqual(result[1]['attributes.mydate_count'], 1)
+ self.assertEqual(result[1]['attributes.mydate:week'], 'W19 2077')
+ self.assertEqual(result[2]['attributes.mydate_count'], 1)
+ self.assertEqual(result[2]['attributes.mydate:week'], 'W6 2023')
+ self.assertEqual(result[3]['attributes.mydate_count'], 1)
+ self.assertEqual(result[3]['attributes.mydate:week'], 'W5 2023')
+ self.assertEqual(result[4]['attributes.mydate_count'], 2)
+ self.assertEqual(result[4]['attributes.mydate:week'], 'W1 2022')
+ # check domain
+ self.assertEqual(Model.search(result[0]['__domain']), self.message_6 | self.message_7)
+ self.assertEqual(Model.search(result[1]['__domain']), self.message_5)
+ self.assertEqual(Model.search(result[2]['__domain']), self.message_4)
+ self.assertEqual(Model.search(result[3]['__domain']), self.message_2)
+ self.assertEqual(Model.search(result[4]['__domain']), self.message_1 | self.message_3)
+ self._check_domains_count(result)
+
+ # test that the first day of the week in the domain care about the local
+ # (based on the lang, the first day of the week might change)
+ for line in result[1:]:
+ self.assertEqual(line['__domain'][1][1], ">=")
+ self.assertEqual(line['__domain'][2][1], "<")
+ start = datetime.datetime.strptime(line['__domain'][1][2], "%Y-%m-%d")
+ end = datetime.datetime.strptime(line['__domain'][2][2], "%Y-%m-%d")
+ self.assertEqual(start.weekday(), first_week_day)
+ self.assertEqual(end.weekday(), first_week_day)
+
+ # now, first day of the week is "Wednesday"
+ lang = self.env['res.lang'].with_context(active_test=False).search([('code', '=', 'fr_FR')])
+ self.assertEqual(len(lang), 1)
+ lang.write({'active': True, 'week_start': '3'})
+ result = Model.with_context(lang='fr_FR').read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:week'],
+ orderby='attributes.mydate DESC',
+ )
+ for line in result[1:]:
+ self.assertEqual(line['__domain'][1][1], ">=")
+ self.assertEqual(line['__domain'][2][1], "<")
+ start = datetime.datetime.strptime(line['__domain'][1][2], "%Y-%m-%d")
+ end = datetime.datetime.strptime(line['__domain'][2][2], "%Y-%m-%d")
+ self.assertEqual(start.weekday(), 2, "First day of the week must be Wednesday")
+ self.assertEqual(end.weekday(), 2, "First day of the week must be Wednesday")
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_date_year(self, date_type='date'):
+ self._properties_field_read_group_date_prepare()
+ Model = self.env['test_new_api.message']
+
+ result = Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.mydate:year'],
+ orderby='attributes.mydate DESC',
+ )
+
+ self.assertEqual(len(result), 3)
+ # check values and count
+ self.assertEqual(result[0]['attributes.mydate_count'], 2)
+ self.assertEqual(result[0]['attributes.mydate:year'], False)
+ self.assertEqual(result[1]['attributes.mydate_count'], 1)
+ self.assertEqual(result[1]['attributes.mydate:year'], '2077')
+ self.assertEqual(result[2]['attributes.mydate_count'], 4)
+ self.assertEqual(result[2]['attributes.mydate:year'], '2023')
+ # check domain
+ self.assertEqual(Model.search(result[0]['__domain']), self.message_6 | self.message_7)
+ self.assertEqual(Model.search(result[1]['__domain']), self.message_5)
+ self.assertEqual(
+ Model.search(result[2]['__domain']),
+ self.message_1 | self.message_2 | self.message_3 | self.message_4)
+ self._check_domains_count(result)
+
+ def test_properties_field_read_group_datetime_day(self):
+ self.test_properties_field_read_group_date_day('datetime')
+
+ def test_properties_field_read_group_datetime_quarter(self):
+ self.test_properties_field_read_group_date_quarter('datetime')
+
+ def test_properties_field_read_group_datetime_month(self):
+ self.test_properties_field_read_group_date_month('datetime')
+
+ def test_properties_field_read_group_datetime_week(self):
+ self.test_properties_field_read_group_date_week('datetime')
+
+ def test_properties_field_read_group_datetime_year(self):
+ self.test_properties_field_read_group_date_year('datetime')
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_injection(self):
+ Model = self.env['test_new_api.message']
+ self.message_1.attributes = [{
+ 'name': 'myinteger',
+ 'type': 'integer',
+ 'value': 1337,
+ 'definition_changed': True,
+ }]
+ self.env.flush_all()
+
+ with self.assertRaises(ValueError), self.assertQueryCount(0):
+ Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger'],
+ orderby='attributes.myinteger OR 1=1 DESC'
+ )
+
+ with self.assertRaises(ValueError), self.assertQueryCount(0):
+ Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger OR 1=1'],
+ orderby='attributes.myinteger DESC'
+ )
+
+ with self.assertRaises(ValueError), self.assertQueryCount(0):
+ Model.read_group(
+ domain=[],
+ fields=[],
+ groupby=['attributes.myinteger:wrongfunction'],
+ orderby='attributes.myinteger DESC'
+ )
+
+ @mute_logger('odoo.fields', 'odoo.models.unlink')
+ def test_properties_field_read_group_many2many(self):
+ Model = self.env['test_new_api.message']
+
+ partners = self.env['test_new_api.partner'].create([
+ {'name': f'Partner {i}'}
+ for i in range(10)
+ ])
+
+ self.discussion_1.attributes_definition = [{
+ 'name': 'mypartners',
+ 'string': 'Partners',
+ 'type': 'many2many',
+ 'comodel': 'test_new_api.partner',
+ }]
+
+ self.messages.discussion = self.discussion_1
+
+ self.message_1.attributes = {'mypartners': partners[:5].ids}
+ self.message_2.attributes = {'mypartners': partners[3:8].ids}
+ self.message_3.attributes = {'mypartners': partners[8:].ids}
+
+ (partners[4] | partners[7] | partners[9]).unlink()
+
+ with self.assertQueryCount(4):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mypartners'],
+ lazy=False,
+ )
+
+ self.assertEqual(len(result), 8)
+ existing_partners = partners.exists()
+ self.assertEqual(len(existing_partners), 7)
+ for partner, line in zip(existing_partners, result):
+ self.assertEqual(partner.id, line['attributes.mypartners'][0])
+ self.assertEqual(partner.display_name, line['attributes.mypartners'][1])
+ self.assertEqual(
+ line['__domain'],
+ [
+ '&',
+ ('discussion', '!=', self.wrong_discussion_id),
+ ('attributes.mypartners', 'in', partner.id),
+ ],
+ )
+ # only the fourth partner is in 2 messages
+ self.assertEqual(line['__count'], 2 if partner == partners[3] else 1)
+
+ # message 4 is in a different discussion, so it's value is False
+ self.assertEqual(Model.search(result[-1]['__domain']), self.message_4)
+ self._check_many_falsy_group('mypartners', result)
+ self._check_domains_count(result)
+
+ # now message 1 and 2 will also be in the falsy group
+ partners[:8].unlink()
+ with self.assertQueryCount(4):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mypartners'],
+ lazy=False,
+ )
+
+ self.assertEqual(len(result), 2)
+ self.assertEqual(Model.search(result[-1]['__domain']), self.message_1 | self.message_2 | self.message_4)
+ self._check_many_falsy_group('mypartners', result)
+ self._check_domains_count(result)
+
+ # special case, no partner exists
+ existing_partners.unlink()
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mypartners'],
+ lazy=False,
+ )
+ self.assertEqual(len(result), 1)
+ self.assertFalse(result[0]['attributes.mypartners'])
+ self.assertEqual(result[0]['__count'], 4)
+ self._check_domains_count(result)
+
+ # test an invalid model name (e.g. if we uninstalled the module of the model)
+ for invalid_model_name in ("invalid_model_name", "test_new_api.transient_model"):
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_discussion
+ SET attributes_definition
+ = jsonb_set(attributes_definition, '{0,comodel}', %s)
+ WHERE id = %s
+ """,
+ [json.dumps(invalid_model_name), self.discussion_1.id],
+ ) # bypass the ORM to set an invalid model name
+ definition = self._get_sql_definition(self.discussion_1)
+ self.assertEqual(definition[0]['comodel'], invalid_model_name)
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mypartners'],
+ lazy=False,
+ )
+
+ self.assertEqual(len(result), 1)
+ self.assertFalse(result[0]['attributes.mypartners'])
+ self.assertEqual(result[0]['__count'], 4)
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_many2one(self):
+ Model = self.env['test_new_api.message']
+
+ # group by many2one property
+ self.message_1.attributes = [{
+ 'name': 'mypartner',
+ 'type': 'many2one',
+ 'value': self.partner_2.id,
+ 'comodel': 'test_new_api.partner',
+ 'definition_changed': True,
+ }]
+ self.message_2.attributes = {'mypartner': self.partner.id}
+ self.message_4.attributes = {'mypartner': False} # explicit False value
+
+ # this partner id doesn't exist
+ unexisting_record_id = self.env['test_new_api.partner'].search(
+ [], order="id DESC", limit=1).id + 1
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_message
+ SET attributes = '{"mypartner": %s}'
+ WHERE id = %s
+ """,
+ [unexisting_record_id, self.message_3.id],
+ )
+
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[],
+ fields=['name', 'attributes', 'discussion'],
+ groupby=['attributes.mypartner'],
+ )
+
+ self.assertEqual(len(result), 3, 'Should ignore the partner that has been removed')
+
+ self.assertEqual(result[0]['attributes.mypartner_count'], 1)
+ self.assertEqual(result[0]['attributes.mypartner'][0], self.partner.id)
+ self.assertEqual(result[0]['attributes.mypartner'][1], self.partner.display_name)
+ self.assertEqual(result[0]['__domain'], [('attributes.mypartner', '=', self.partner.id)])
+
+ self.assertEqual(result[1]['attributes.mypartner_count'], 1)
+ self.assertEqual(result[1]['attributes.mypartner'][0], self.partner_2.id)
+ self.assertEqual(result[1]['attributes.mypartner'][1], self.partner_2.display_name)
+ self.assertEqual(result[1]['__domain'], [('attributes.mypartner', '=', self.partner_2.id)])
+
+ # falsy domain, automatically generated, contains the false value
+ # and the ids of the records that doesn't exist in the database
+ self.assertEqual(result[2]['attributes.mypartner_count'], 2)
+ self.assertEqual(result[2]['attributes.mypartner'], False)
+ self.assertEqual(
+ result[2]['__domain'],
+ [
+ '|',
+ ('attributes.mypartner', '=', False),
+ ('attributes.mypartner', 'not in', (self.partner.id, self.partner_2.id)),
+ ],
+ )
+
+ # when there's no "('property', '=', False)" domain, it should be created
+ self.message_4.attributes = {'mypartner': self.partner.id}
+ result = Model.read_group(
+ domain=[],
+ fields=['name', 'attributes', 'discussion'],
+ groupby=['attributes.mypartner'],
+ )
+ self.assertEqual(result[2]['attributes.mypartner_count'], 1)
+ self.assertEqual(
+ result[2]['__domain'],
+ [
+ '|',
+ ('attributes.mypartner', '=', False),
+ ('attributes.mypartner', 'not in', (self.partner.id, self.partner_2.id)),
+ ],
+ )
+
+ # test an invalid model name (e.g. if we uninstalled the module of the model)
+ # should have only one group with the value "False", and all records
+ for invalid_model_name in ("invalid_model_name", "test_new_api.transient_model"):
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_discussion
+ SET attributes_definition
+ = jsonb_set(attributes_definition, '{0,comodel}', %s::jsonb)
+ WHERE id = %s
+ """,
+ [json.dumps(invalid_model_name), self.discussion_1.id],
+ ) # bypass the ORM to set an invalid model name
+ definition = self._get_sql_definition(self.discussion_1)
+ self.assertEqual(definition[0]['comodel'], invalid_model_name)
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mypartner'],
+ lazy=False,
+ )
+
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]['__count'], 4)
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_selection(self):
+ Model = self.env['test_new_api.message']
+
+ # group by selection property
+ self.message_1.attributes = [{
+ 'name': 'myselection',
+ 'type': 'selection',
+ 'value': 'optionA',
+ 'selection': [['optionA', 'A'], ['optionB', 'B']],
+ 'definition_changed': True,
+ }, {
+ 'name': 'mychar2',
+ 'type': 'char',
+ 'value': 'qsd',
+ 'definition_changed': True,
+ }]
+ self.message_2.attributes = {'myselection': False}
+
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_message
+ SET attributes = '{"myselection": "invalid_option"}'
+ WHERE id = %s
+ """,
+ [self.message_3.id],
+ )
+
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=['name', 'attributes', 'discussion'],
+ groupby=['attributes.myselection'],
+ )
+
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0]['attributes.myselection_count'], 1)
+ self.assertEqual(
+ result[0]['__domain'],
+ [
+ '&',
+ ('discussion', '!=', self.wrong_discussion_id), # original domain should be preserved
+ ('attributes.myselection', '=', 'optionA'),
+ ],
+ )
+ self.assertEqual(result[0]['attributes.myselection'], 'optionA')
+
+ # check that the option that is not valid is included in the "False" domain
+ # the count should be updated as well
+ self.assertEqual(result[1]['attributes.myselection_count'], 3)
+ self.assertEqual(
+ result[1]['__domain'],
+ [
+ '&',
+ ('discussion', '!=', self.wrong_discussion_id),
+ '|',
+ ('attributes.myselection', '=', False),
+ ('attributes.myselection', 'not in', ('optionA', 'optionB')),
+ ],
+ )
+ self.assertEqual(result[1]['attributes.myselection'], False)
+ # double check that the returned domain filter the right record
+ self.assertEqual(
+ self.env['test_new_api.message'].search(result[1]['__domain']),
+ self.message_2 | self.message_3 | self.message_4,
+ )
+
+ # special case, there's no option
+ self.message_1.attributes = [{
+ 'name': 'myselection',
+ 'type': 'selection',
+ 'value': 'optionA',
+ 'selection': [],
+ 'definition_changed': True,
+ }]
+ self.env.flush_all()
+ result = Model.read_group(
+ domain=[],
+ fields=['attributes'],
+ groupby=['attributes.myselection'],
+ )
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]['attributes.myselection_count'], 4)
+ self._check_domains_count(result)
+
+ @mute_logger('odoo.fields')
+ def test_properties_field_read_group_tags(self):
+ Model = self.env['test_new_api.message']
+
+ (self.message_1 | self.message_2 | self.message_3).discussion = self.discussion_1
+
+ # group by tags property
+ self.message_1.attributes = [{
+ 'name': 'mytags',
+ 'type': 'tags',
+ 'value': ['a', 'c', 'g'],
+ 'tags': [[x.lower(), x, i] for i, x in enumerate('ABCDEFG')],
+ 'definition_changed': True,
+ }]
+ self.message_2.attributes = {'mytags': ['a', 'e', 'g']}
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_message
+ SET attributes = '{"mytags": ["a", "d", "invalid", "e"]}'
+ WHERE id = %s
+ """,
+ [self.message_3.id],
+ )
+ self.env.invalidate_all()
+
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mytags'],
+ lazy=False,
+ )
+
+ self.assertNotIn('invalid', str(result))
+ self.assertEqual(len(result), 6)
+
+ all_tags = self.message_1.read(['attributes'])[0]['attributes'][0]['tags']
+ all_tags = {tag[0]: tag for tag in all_tags}
+
+ for group, (tag, count) in zip(result, (('a', 3), ('c', 1), ('d', 1), ('e', 2), ('g', 2))):
+ self.assertEqual(group['attributes.mytags'], all_tags[tag])
+ self.assertEqual(group['__count'], count)
+ self.assertEqual(
+ group['__domain'],
+ [
+ '&',
+ ('discussion', '!=', self.wrong_discussion_id),
+ ('attributes.mytags', 'in', tag),
+ ],
+ )
+ # check that the value when we read the record match the value of the group
+ property_values = [
+ next(pro['value'] for pro in values['attributes'])
+ for values in Model.search(group['__domain']).read(['attributes'])
+ ]
+ self.assertTrue(all(tag in property_value for property_value in property_values))
+
+ self.assertEqual(Model.search(result[-1]['__domain']), self.message_4)
+ self._check_many_falsy_group('mytags', result)
+ self._check_domains_count(result)
+
+ # now message 3 has *only* invalid tags, so it should be in the falsy group
+ self.env.cr.execute(
+ """
+ UPDATE test_new_api_message
+ SET attributes = '{"mytags": ["invalid 1", "invalid 2", "invalid 3"]}'
+ WHERE id = %s
+ """,
+ [self.message_3.id],
+ )
+ self.env.invalidate_all()
+
+ with self.assertQueryCount(3):
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mytags'],
+ lazy=False,
+ )
+ self.assertEqual(Model.search(result[-1]['__domain']), self.message_3 | self.message_4)
+ self._check_many_falsy_group('mytags', result)
+ self._check_domains_count(result)
+
+ # special case, there's no tag
+ for tags in ([], False, None):
+ self.message_1.attributes = [{
+ 'name': 'mytags',
+ 'type': 'tags',
+ 'value': tags,
+ 'tags': tags,
+ 'definition_changed': True,
+ }]
+ self.env.flush_all()
+ result = Model.read_group(
+ domain=[('discussion', '!=', self.wrong_discussion_id)],
+ fields=[],
+ groupby=['attributes.mytags'],
+ lazy=False,
+ )
+ self.assertEqual(len(result), 1)
+ self.assertFalse(result[0]['attributes.mytags'])
+ self.assertEqual(result[0]['__count'], 4)
+ self._check_domains_count(result)
+
+ def _check_domains_count(self, result):
+ """Check that the domains in the result match the __count key."""
+ for line in result:
+ records = self.env['test_new_api.message'].search(line['__domain'])
+ count_key = next(key for key in line if "_count" in key)
+ self.assertEqual(len(records), line[count_key])
+
+ def _check_many_falsy_group(self, property_name, result):
+ """Check the falsy group from the many2many and tags read group result.
+
+ - if a record is in the falsy group, it can't be in a other group
+ (that sentence is not true for non-falsy group, a record
+ with a non-falsy value can be in many other groups)
+ - read the value of all records and check if they belongs to the correct group
+ """
+ Model = self.env['test_new_api.message']
+ falsy_group = result[-1]
+ self.assertFalse(falsy_group[f'attributes.{property_name}'])
+ falsy_records = Model.search(falsy_group['__domain'])
+ nonfalsy_records = Model.search(expression.OR([line['__domain'] for line in result[:-1]]))
+ self.assertEqual(Model.search_count([]), len(falsy_records) + len(nonfalsy_records))
+ for falsy_record in falsy_records:
+ self.assertNotIn(falsy_record, nonfalsy_records)
+
+ def _get_records_values(records):
+ return [
+ next(
+ (pro['value'] for pro in properties['attributes']
+ if pro['name'] == property_name),
+ )
+ for properties in records.read(['attributes'])
+ ]
+
+ self.assertTrue(not any(_get_records_values(falsy_records)))
+ self.assertTrue(all(_get_records_values(nonfalsy_records)))
diff --git a/odoo/fields.py b/odoo/fields.py
index b0ede449b2f..1d95386fe10 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -3477,7 +3477,6 @@ class Properties(Field):
]
for definition in value:
definition.pop('definition_changed', None)
- check_property_field_value_name(definition.get('name', ''))
# update the properties definition on the container
container = records[self.definition_record]
@@ -3545,7 +3544,12 @@ class Properties(Field):
for properties_value in properties_list_values:
if properties_value.get('value') is None:
- default = properties_value.get('default') or False
+ property_name = properties_value.get('name')
+ context_key = f"default_{self.name}.{property_name}"
+ if property_name and context_key in env.context:
+ default = env.context[context_key]
+ else:
+ default = properties_value.get('default') or False
properties_value['value'] = default
return properties_list_values
@@ -3941,6 +3945,8 @@ class PropertiesDefinition(Field):
', '.join(invalid_keys),
)
+ check_property_field_value_name(property_definition['name'])
+
required_keys = set(cls.REQUIRED_KEYS) - property_definition_keys
if required_keys:
raise ValueError(
@@ -3958,7 +3964,7 @@ class PropertiesDefinition(Field):
raise ValueError(f'Wrong property type {property_type!r}.')
model = property_definition.get('comodel')
- if model and model not in env:
+ if model and (model not in env or env[model].is_transient() or env[model]._abstract):
raise ValueError(f'Invalid model name {model!r}')
property_selection = property_definition.get('selection')
diff --git a/odoo/models.py b/odoo/models.py
index 2dfb8a9dfc5..cc98824c9bf 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -72,7 +72,7 @@ regex_alphanumeric = re.compile(r'^[a-z0-9_]+$')
regex_order = re.compile(r'''
^
(\s*
- ((?P[a-z0-9:_]+|"[a-z0-9:_]+")(\.(?P[a-z0-9_]+))?)
+ (?P((?P[a-z0-9_]+|"[a-z0-9_]+")(\.(?P[a-z0-9_]+))?(:(?P[a-z_]+))?))
(\s+(?Pdesc|asc))?
(\s+(?Pnulls\ first|nulls\ last))?
\s*
@@ -84,7 +84,7 @@ regex_order = re.compile(r'''
regex_object_name = re.compile(r'^[a-z0-9_.]+$')
regex_pg_name = re.compile(r'^[a-z_][a-z0-9_$]*$', re.I)
regex_field_agg = re.compile(r'(\w+)(?::(\w+)(?:\((\w+)\))?)?') # For read_group
-regex_read_group_spec = re.compile(r'(\w+)(?::(\w+))?$') # For _read_group
+regex_read_group_spec = re.compile(r'(\w+)(\.(\w+))?(?::(\w+))?$') # For _read_group
AUTOINIT_RECALCULATE_STORED_FIELDS = 1000
@@ -92,15 +92,17 @@ INSERT_BATCH_SIZE = 100
SQL_DEFAULT = psycopg2.extensions.AsIs("DEFAULT")
def parse_read_group_spec(spec: str) -> tuple:
- """ Return a pair corresponding to the given groupby/aggregate specification. """
+ """ Return a triplet corresponding to the given groupby/path/aggregate specification. """
res_match = regex_read_group_spec.match(spec)
if not res_match:
raise ValueError(
f'Invalid aggregate/groupby specification {spec!r}.\n'
'- Valid aggregate specification looks like ":" example: "quantity:sum".\n'
- '- Valid groupby specification looks like "" or ":" example: "date:month".'
+ '- Valid groupby specification looks like "" or ":" example: "date:month" or ".:".'
)
- return res_match.groups()
+
+ groups = res_match.groups()
+ return groups[0], groups[2], groups[3]
def check_object_name(name):
""" Check if the given name is a valid model name.
@@ -146,8 +148,8 @@ def check_method_name(name):
def check_property_field_value_name(property_name):
- if not regex_alphanumeric.match(property_name):
- raise ValueError(_("Wrong property field value name %r.", property_name))
+ if not regex_alphanumeric.match(property_name) or len(property_name) > 512:
+ raise ValueError(f"Wrong property field value name {property_name!r}.")
def fix_import_export_id_paths(fieldname):
@@ -1858,10 +1860,10 @@ class BaseModel(metaclass=MetaModel):
query = self._search(domain)
fnames_to_flush = OrderedSet()
- groupby_terms = [] # [,]
+ groupby_terms_map = {} # {spec: }
for spec in groupby:
sql_expression, fnames_used = self._read_group_groupby(spec, query)
- groupby_terms.append(sql_expression)
+ groupby_terms_map[spec] = sql_expression
fnames_to_flush.update(fnames_used)
select_terms = [] # [,]
@@ -1873,9 +1875,11 @@ class BaseModel(metaclass=MetaModel):
having_expression, having_params, fnames_used = self._read_group_having(having, query)
fnames_to_flush.update(fnames_used)
- orderby_terms, extra_groupby_terms, fnames_used = self._read_group_orderby(order, groupby, query)
+ orderby_terms, extra_groupby_terms, fnames_used = self._read_group_orderby(order, groupby_terms_map, query)
fnames_to_flush.update(fnames_used)
+ groupby_terms = list(groupby_terms_map.values())
+
from_clause, where_clause, query_params = query.get_sql()
query_parts = [
f"SELECT {', '.join(groupby_terms + select_terms)}",
@@ -1932,12 +1936,14 @@ class BaseModel(metaclass=MetaModel):
if aggregate_spec == '__count':
return 'COUNT(*)', []
- fname, func = parse_read_group_spec(aggregate_spec)
+ fname, property_name, func = parse_read_group_spec(aggregate_spec)
+
+ access_fname = f"{fname}.{property_name}" if property_name else fname
if fname not in self:
raise ValueError(f"Invalid field {fname!r} on model {self._name!r} for {aggregate_spec!r}.")
if not func:
- raise ValueError(f"Aggregate method is mandatory for {fname!r}")
+ raise ValueError(f"Aggregate method is mandatory for {access_fname!r}")
if func not in READ_GROUP_AGGREGATE:
raise ValueError(f"Invalid aggregate method {func!r} for {aggregate_spec!r}.")
@@ -1945,7 +1951,7 @@ class BaseModel(metaclass=MetaModel):
if func == 'recordset' and not (field.relational or fname == 'id'):
raise ValueError(f"Aggregate method {func!r} can be only used on relational field (or id) (for {aggregate_spec!r}).")
- field_expression = self._inherits_join_calc(self._table, fname, query)
+ field_expression = self._inherits_join_calc(self._table, access_fname, query)
expression = READ_GROUP_AGGREGATE[func](self._table, field_expression)
return expression, [fname]
@@ -1955,24 +1961,33 @@ class BaseModel(metaclass=MetaModel):
""" Return a pair (, [])
corresponding to the given groupby element.
"""
- fname, granularity = parse_read_group_spec(groupby_spec)
+ fname, property_name, granularity = parse_read_group_spec(groupby_spec)
if fname not in self:
raise ValueError(f"Invalid field {fname!r} on model {self._name!r}")
field = self._fields[fname]
- if granularity and field.type not in ('datetime', 'date'):
- raise ValueError(f"Granularity set on a no-datetime field: {groupby_spec!r}")
+ if property_name:
+ check_property_field_value_name(property_name)
+ if field.type != "properties":
+ raise ValueError(f"Property set on a non properties field: {property_name!r}")
+
+ access_fname = f"{fname}.{property_name}"
+ else:
+ access_fname = fname
+
+ if granularity and field.type not in ('datetime', 'date', 'properties'):
+ raise ValueError(f"Granularity set on a no-datetime field or property: {groupby_spec!r}")
if not field.store and not field.inherited: # TODO: should be manage by _inherits_join_calc
raise ValueError(f"Groupby on no-store field: {groupby_spec!r}")
- field_expression = self._inherits_join_calc(self._table, fname, query)
+ field_expression = self._inherits_join_calc(self._table, access_fname, query)
if field.type == 'datetime' and self.env.context.get('tz') in pytz.all_timezones_set:
# TODO: `self.env.context.get('tz')` should be passed as args
field_expression = f"timezone('{self.env.context.get('tz')}', timezone('UTC', {field_expression}))"
- if field.type in ('datetime', 'date'):
+ if field.type in ('datetime', 'date') or (field.type == 'properties' and granularity):
if not granularity:
raise ValueError(f"Granularity not set on a date(time) field: {groupby_spec!r}")
if granularity not in READ_GROUP_TIME_GRANULARITY:
@@ -2033,14 +2048,18 @@ class BaseModel(metaclass=MetaModel):
return expr, params, fnames_used
@api.model
- def _read_group_orderby(self, order, groupby, query):
+ def _read_group_orderby(self, order, groupby_terms, query):
""" Return ([], [], [])
corresponding to the given order and groupby terms.
+
+ :param order: The order to use in SQL format
+ :param groupby_terms: The group by terms ({spec: sql_expression})
+ :param query: The query we are building
"""
if order:
traverse_many2one = True
else:
- order = ','.join(groupby)
+ order = ','.join(groupby_terms)
traverse_many2one = False
orderby_terms = []
@@ -2053,29 +2072,27 @@ class BaseModel(metaclass=MetaModel):
order_match = regex_order.match(order_part)
if not order_match:
raise ValueError(f"Invalid order {order!r} for _read_group()")
- order_field = order_match['field']
- if order_match['property']:
- raise ValueError(f"Invalid order {order!r} for _read_group(): properties are not supported")
+ order_term = order_match['term']
order_direction = (order_match['direction'] or 'ASC').upper()
order_nulls = (order_match['nulls'] or '').upper()
- if order_field not in groupby:
+ if order_term not in groupby_terms:
try:
- sql_expression, fnames_used_select = self._read_group_select(order_field, query)
+ sql_expression, fnames_used_select = self._read_group_select(order_term, query)
fnames_used.extend(fnames_used_select)
orderby_terms.append(f'{sql_expression} {order_direction} {order_nulls}')
continue
except ValueError as e:
raise ValueError(f"Order term {order_part!r} is not a valid aggregate nor valid groupby") from e
- if traverse_many2one and order_field in self and self._fields[order_field].type == 'many2one':
+ if traverse_many2one and order_term in self and self._fields[order_term].type == 'many2one':
# It will generated extra clause to add in the group by.
- extra_order = self._generate_order_by(f'{order_field} {order_direction} {order_nulls}', query)
+ extra_order = self._generate_order_by(f'{order_term} {order_direction} {order_nulls}', query)
extra_order = extra_order.replace(' ORDER BY ', '')
orderby_terms.extend(order.strip() for order in extra_order.split(",") if order.strip())
extra_groupby_terms.extend(order.strip().split()[0] for order in extra_order.split(",") if order.strip())
else:
- sql_expression, __ = self._read_group_groupby(order_field, query)
+ sql_expression = groupby_terms[order_term]
orderby_terms.append(f'{sql_expression} {order_direction} {order_nulls}')
return orderby_terms, extra_groupby_terms, fnames_used
@@ -2090,7 +2107,7 @@ class BaseModel(metaclass=MetaModel):
""" Return the empty value corresponding to the given groupby spec or aggregate spec. """
if spec == '__count':
return 0
- fname, func = parse_read_group_spec(spec) # func is either None, granularity or an aggregate
+ fname, __, func = parse_read_group_spec(spec) # func is either None, granularity or an aggregate
if func in ('count', 'count_distinct'):
return 0
if func == 'array_agg':
@@ -2110,7 +2127,7 @@ class BaseModel(metaclass=MetaModel):
"""
empty_value = self._read_group_empty_value(groupby_spec)
- fname, __ = parse_read_group_spec(groupby_spec)
+ fname, *__ = parse_read_group_spec(groupby_spec)
field = self._fields[fname]
if field.relational or fname == 'id':
@@ -2137,7 +2154,7 @@ class BaseModel(metaclass=MetaModel):
if aggregate_spec == '__count':
return ((value if value is not None else empty_value) for value in raw_values)
- fname, func = parse_read_group_spec(aggregate_spec)
+ fname, __, func = parse_read_group_spec(aggregate_spec)
if func == 'recordset':
field = self._fields[fname]
Model = self.pool[field.comodel_name] if field.relational else self.pool[self._name]
@@ -2166,7 +2183,7 @@ class BaseModel(metaclass=MetaModel):
def _read_group_fill_results(self, domain, groupby, annoted_aggregates, read_group_result, read_group_order=None):
"""Helper method for filling in empty groups for all possible values of
the field being grouped by"""
- field_name, __ = groupby.split(':') if ':' in groupby else (groupby, 'month')
+ field_name = groupby.split('.')[0].split(':')[0]
field = self._fields[field_name]
if not field or not field.group_expand:
return read_group_result
@@ -2326,9 +2343,9 @@ class BaseModel(metaclass=MetaModel):
"""
# TODO: remove min_groups
first_group = groupby[0]
- field_name = first_group.split(':')[0]
+ field_name = first_group.split(':')[0].split(".")[0]
field = self._fields[field_name]
- if field.type not in ('date', 'datetime'):
+ if field.type not in ('date', 'datetime') and not (field.type == 'properties' and ':' in first_group):
return data
granularity = first_group.split(':')[1] if ':' in first_group else 'month'
@@ -2408,11 +2425,9 @@ class BaseModel(metaclass=MetaModel):
:param data: a single group
:param annotated_groupbys: expanded grouping metainformation
:param groupby: original grouping metainformation
- :param domain: original domain for read_group
"""
-
for group in lazy_groupby:
- field_name = group.split(':')[0]
+ field_name = group.split(':')[0].split('.')[0]
field = self._fields[field_name]
if field.type in ('date', 'datetime'):
@@ -2421,6 +2436,10 @@ class BaseModel(metaclass=MetaModel):
granularity = group.split(':')[1] if ':' in group else 'month'
interval = READ_GROUP_TIME_GRANULARITY[granularity]
+ elif field.type == "properties":
+ self._read_group_format_result_properties(rows_dict, group)
+ continue
+
for row in rows_dict:
value = row[group]
@@ -2468,6 +2487,123 @@ class BaseModel(metaclass=MetaModel):
row['__domain'] = expression.AND([row['__domain'], additional_domain])
+ def _read_group_format_result_properties(self, rows_dict, group):
+ """Modify the final read group properties result.
+
+ Replace the relational properties ids by a tuple with their display names,
+ replace the "raw" tags and selection values by a list containing their labels.
+ Adapt the domains for the Falsy group (we can't just keep (selection, =, False)
+ e.g. because some values in database might correspond to option that have
+ been remove on the parent).
+ """
+ if '.' not in group:
+ raise ValueError('You must choose the property you want to group by.')
+ fullname, __, func = group.partition(':')
+
+ definition = self.get_property_definition(fullname)
+ property_type = definition.get('type')
+
+ if property_type == 'selection':
+ options = definition.get('selection') or []
+ options = tuple(option[0] for option in options)
+ for row in rows_dict:
+ if not row[fullname]:
+ # can not do ('selection', '=', False) because we might have
+ # option in database that does not exist anymore
+ additional_domain = expression.OR([
+ [(fullname, '=', False)],
+ [(fullname, 'not in', options)],
+ ])
+ else:
+ additional_domain = [(fullname, '=', row[fullname])]
+
+ row['__domain'] = expression.AND([row['__domain'], additional_domain])
+
+ elif property_type == 'many2one':
+ comodel = definition.get('comodel')
+ prefetch_ids = tuple(row[fullname] for row in rows_dict if row[fullname])
+ all_groups = tuple(row[fullname] for row in rows_dict if row[fullname])
+ for row in rows_dict:
+ if not row[fullname]:
+ # can not only do ('many2one', '=', False) because we might have
+ # record in database that does not exist anymore
+ additional_domain = expression.OR([
+ [(fullname, '=', False)],
+ [(fullname, 'not in', all_groups)],
+ ])
+ else:
+ additional_domain = [(fullname, '=', row[fullname])]
+ record = self.env[comodel].browse(row[fullname]).with_prefetch(prefetch_ids)
+ row[fullname] = (row[fullname], record.display_name)
+
+ row['__domain'] = expression.AND([row['__domain'], additional_domain])
+
+ elif property_type == 'many2many':
+ comodel = definition.get('comodel')
+ prefetch_ids = tuple(row[fullname] for row in rows_dict if row[fullname])
+ all_groups = tuple(row[fullname] for row in rows_dict if row[fullname])
+ for row in rows_dict:
+ if not row[fullname]:
+ additional_domain = expression.OR([
+ [(fullname, '=', False)],
+ expression.AND([[(fullname, 'not in', group)] for group in all_groups]),
+ ]) if all_groups else []
+ else:
+ additional_domain = [(fullname, 'in', row[fullname])]
+ record = self.env[comodel].browse(row[fullname]).with_prefetch(prefetch_ids)
+ row[fullname] = (row[fullname], record.display_name)
+
+ row['__domain'] = expression.AND([row['__domain'], additional_domain])
+
+ elif property_type == 'tags':
+ tags = definition.get('tags') or []
+ tags = {tag[0]: tag for tag in tags}
+ for row in rows_dict:
+ if not row[fullname]:
+ additional_domain = expression.OR([
+ [(fullname, '=', False)],
+ expression.AND([[(fullname, 'not in', tag)] for tag in tags]),
+ ]) if tags else []
+ else:
+ additional_domain = [(fullname, 'in', row[fullname])]
+ # replace tag raw value with list of raw value, label and color
+ row[fullname] = tags.get(row[fullname])
+
+ row['__domain'] = expression.AND([row['__domain'], additional_domain])
+
+ elif property_type in ('date', 'datetime'):
+ for row in rows_dict:
+ if not row[group]:
+ row[group] = False
+ row['__domain'] = expression.AND([row['__domain'], [(fullname, '=', False)]])
+ row['__range'] = {}
+ continue
+
+ # Date / Datetime are not JSONifiable, so they are stored as raw text
+ db_format = '%Y-%m-%d' if property_type == 'date' else '%Y-%m-%d %H:%M:%S'
+
+ if func == 'week':
+ # the value is the first day of the week (based on local)
+ start = row[group].strftime(db_format)
+ end = (row[group] + datetime.timedelta(days=7)).strftime(db_format)
+ else:
+ start = (date_utils.start_of(row[group], func)).strftime(db_format)
+ end = (date_utils.end_of(row[group], func) + datetime.timedelta(minutes=1)).strftime(db_format)
+
+ row['__domain'] = expression.AND([
+ row['__domain'],
+ [(fullname, '>=', start), (fullname, '<', end)],
+ ])
+ row['__range'] = {group: {'from': start, 'to': end}}
+ row[group] = babel.dates.format_date(
+ row[group],
+ format=READ_GROUP_DISPLAY_FORMAT[func],
+ locale=get_lang(self.env).code
+ )
+ else:
+ for row in rows_dict:
+ row['__domain'] = expression.AND([row['__domain'], [(fullname, '=', row[fullname])]])
+
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
"""Get the list of records in list view grouped by the given ``groupby`` fields.
@@ -2516,10 +2652,12 @@ class BaseModel(metaclass=MetaModel):
# - Modify the order to be compatible with the _read_group specification
annoted_groupby = {} # Key as the name in the result, value as the explicit groupby specification
for group_spec in lazy_groupby:
- field_name, granularity = parse_read_group_spec(group_spec)
+ field_name, property_name, granularity = parse_read_group_spec(group_spec)
if field_name not in self._fields:
raise ValueError(f"Invalid field {field_name!r} on model {self._name!r}")
field = self._fields[field_name]
+ if property_name and field.type != 'properties':
+ raise ValueError(f"Property name {property_name!r} has to be used on a property field.")
if field.type in ('date', 'datetime'):
annoted_groupby[group_spec] = f"{field_name}:{granularity or 'month'}"
else:
@@ -2613,6 +2751,11 @@ class BaseModel(metaclass=MetaModel):
:param query: query object on which the JOIN should be added
:return: qualified name of field, to be used in SELECT clause
"""
+ property_name = None
+ if '.' in fname:
+ fname, property_name = fname.split('.', 1)
+ check_property_field_value_name(property_name)
+
# INVARIANT: alias is the SQL alias of model._table in query
model, field = self, self._fields[fname]
while field.inherited:
@@ -2650,9 +2793,127 @@ class BaseModel(metaclass=MetaModel):
if lang == 'en_US':
return f'"{alias}"."{fname}"->>\'en_US\''
return f'COALESCE("{alias}"."{fname}"->>\'{lang}\', "{alias}"."{fname}"->>\'en_US\')'
+ elif field.type == 'properties' and property_name:
+ return self._inherits_join_calc_properties(alias, fname, query, property_name)
else:
return '"%s"."%s"' % (alias, fname)
+ @api.model
+ def _inherits_join_calc_properties(self, alias, fname, query, property_name):
+ definition = self.get_property_definition(f"{fname}.{property_name}")
+ property_sql = f""""{alias}"."{fname}" -> '{property_name}'"""
+ property_type = definition.get('type')
+ property_alias = tools._generate_table_alias(alias, f'{fname}_{property_name}')
+
+ # JOIN on the JSON array
+ if property_type in ('tags', 'many2many'):
+ property_sql = f'''
+ CASE
+ WHEN jsonb_typeof({property_sql}) = 'array'
+ THEN {property_sql}
+ ELSE '[]'::jsonb
+ END
+ '''
+ join_property = f'LEFT JOIN jsonb_array_elements({property_sql}) {property_alias}'
+ if property_type == 'tags':
+ # ignore invalid tags
+ tags = [tag[0] for tag in definition.get('tags') or []]
+ # `->>0 : convert "JSON string" into string
+ expr = f'{join_property} ON {property_alias}->>0 = ANY(%s::text[])'
+ query._raw_joins[expr] = [tags]
+
+ elif property_type == 'many2many':
+ comodel = self.env.get(definition.get('comodel'))
+ if comodel is None or comodel._transient or comodel._abstract:
+ # all value are false, because the model does not exist anymore
+ # (or is a transient model e.g.)
+ query._raw_joins[f'{join_property} ON FALSE'] = []
+ else:
+ # check the existences of the many2many
+ expr = f'{join_property} ON {property_alias}::int IN (SELECT id FROM {comodel._table})'
+ query._raw_joins[expr] = []
+
+ return property_alias
+
+ elif property_type == 'selection':
+ options = definition.get('selection') or []
+ options = [option[0] for option in options]
+
+ # check the existence of the option
+ expr = f'''
+ LEFT JOIN (SELECT unnest(%s::text[]) {property_alias}) {property_alias}
+ ON {property_sql}->>0 = {property_alias}
+ '''
+ query._raw_joins[expr] = [options]
+
+ return property_alias
+
+ elif property_type == 'many2one':
+ comodel = self.env.get(definition.get('comodel'))
+ if comodel is None or comodel._transient or comodel._abstract:
+ # all value are false, because the model does not exist anymore
+ # (or is a transient model e.g.)
+ return 'FALSE'
+
+ return f'''
+ CASE
+ WHEN jsonb_typeof({property_sql}) = 'number'
+ AND ({property_sql})::int IN (SELECT id FROM {comodel._table})
+ THEN {property_sql}
+ ELSE NULL
+ END
+ '''
+
+ elif property_type == 'date':
+ return f'''
+ CASE
+ WHEN jsonb_typeof({property_sql}) = 'string'
+ THEN ({property_sql}->>0)::DATE
+ ELSE NULL
+ END
+ '''
+
+ elif property_type == 'datetime':
+ return f'''
+ CASE
+ WHEN jsonb_typeof({property_sql}) = 'string'
+ THEN to_timestamp({property_sql}->>0, 'YYYY-MM-DD HH24:MI:SS')
+ ELSE NULL
+ END
+ '''
+
+ # if the key is not present in the dict, fallback to false instead of none
+ return f"COALESCE({property_sql}, 'false')"
+
+ @api.model
+ def get_property_definition(self, full_name):
+ """Return the definition of the given property.
+
+ :param full_name: Name of the field / property
+ (e.g. "property.integer")
+ """
+ self.check_access_rights("read")
+ field_name, property_name = full_name.split(".")
+ check_property_field_value_name(property_name)
+ if field_name not in self._fields:
+ raise ValueError(f"Wrong field name {field_name!r}.")
+
+ field = self._fields[field_name]
+ target_model = self.env[self._fields[field.definition_record].comodel_name]
+ query = psycopg2.sql.SQL("""
+ SELECT definition
+ FROM {table},
+ jsonb_array_elements({field}) definition
+ WHERE {field} IS NOT NULL
+ AND definition->>'name' = %(property_name)s
+ LIMIT 1
+ """).format(
+ table=psycopg2.sql.Identifier(target_model._table),
+ field=psycopg2.sql.Identifier(field.definition_record_field))
+ self.env.cr.execute(query, {'property_name': property_name})
+ result = self.env.cr.dictfetchone()
+ return result["definition"] if result else {}
+
def _parent_store_compute(self):
""" Compute parent_path field from scratch. """
if not self._parent_store:
@@ -3534,10 +3795,6 @@ class BaseModel(metaclass=MetaModel):
continue
assert field.store
(column_fields if field.column_type else other_fields).add(field)
- if field.type == 'properties':
- # force calling fields.read for properties field in order to
- # read all relational properties in batch
- other_fields.add(field)
# necessary to retrieve the en_US value of fields without a translation
translated_field_names = [field.name for field in column_fields if field.translate]
@@ -4798,9 +5055,6 @@ class BaseModel(metaclass=MetaModel):
for order_part in order_spec.split(','):
order_match = regex_order.match(order_part)
order_field = order_match['field']
- property_name = order_match['property']
- if property_name:
- check_property_field_value_name(property_name)
order_direction = (order_match['direction'] or '').upper()
order_nulls = (order_match['nulls'] or '').upper()
if reverse_direction:
@@ -4813,6 +5067,12 @@ class BaseModel(metaclass=MetaModel):
if not field:
raise ValueError("Invalid field %r on model %r" % (order_field, self._name))
+ property_name = order_match['property']
+ if property_name:
+ check_property_field_value_name(property_name)
+ if field.type != 'properties':
+ raise ValueError(f'Order a property ({property_name!r}) on a non-properties field ({field.name!r})')
+
if order_field == 'id':
order_by_elements.append(f'"{alias}"."{order_field}" {order_direction} {order_nulls}')
else:
diff --git a/odoo/tools/query.py b/odoo/tools/query.py
index 00f1db65037..7cbc7327700 100644
--- a/odoo/tools/query.py
+++ b/odoo/tools/query.py
@@ -57,6 +57,7 @@ class Query(object):
# joins {alias: (kind, table, condition, condition_params)}
self._joins = {}
+ self._raw_joins = {} # {query: params}
# holds the list of WHERE clause elements (to be joined with 'AND'), and
# the list of parameters
@@ -206,6 +207,10 @@ class Query(object):
joins.append(f'{kind} {_from_table(table, alias)} ON ({condition})')
params.extend(condition_params)
+ for join_query, query_params in self._raw_joins.items():
+ joins.append(join_query)
+ params.extend(query_params)
+
from_clause = " ".join([", ".join(tables)] + joins)
where_clause = " AND ".join(self._where_clauses)
return from_clause, where_clause, params + self._where_params