[IMP] base, web: allow to group by properties

Purpose
=======
Allow to group records by their property values,
like we can do with normal fields.

Technical
=========
Because there's no foreign key, when we group by a relational property
we need to check the existence of the ids in the query (and same for
selection and tags, because we might have value of a deleted
option / tag in database).

Task-3032464

closes odoo/odoo#103510

Related: odoo/enterprise#38778
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2023-09-08 09:46:56 +00:00
committed by Odoo Online
29 changed files with 2025 additions and 96 deletions
+4
View File
@@ -716,6 +716,8 @@
<separator orientation="vertical" />
<filter string="Creation Date" context="{'group_by':'create_date:month'}" name="month"/>
<filter string="Closed Date" name="date_closed" context="{'group_by':'date_closed'}"/>
<separator />
<filter string="Properties" name="group_by_lead_properties" context="{'group_by':'lead_properties'}"/>
</group>
</search>
</field>
@@ -1026,6 +1028,8 @@
<filter string="Conversion Date" name="date_conversion" context="{'group_by': 'date_conversion'}" groups="crm.group_use_lead"/>
<filter string="Expected Closing" name="date_deadline" context="{'group_by':'date_deadline'}"/>
<filter string="Closed Date" name="date_closed" context="{'group_by':'date_closed'}"/>
<separator/>
<filter string="Properties" name="group_by_lead_properties" context="{'group_by':'lead_properties'}"/>
</group>
</search>
</field>
@@ -79,6 +79,8 @@
</filter>
<filter name="last_stage_update" position="after">
<filter string="Deadline" name="deadline" context="{'group_by': 'date_deadline'}"/>
<separator/>
<filter string="Properties" name="group_by_properties" context="{'group_by': 'task_properties'}"/>
</filter>
</field>
</record>
+11 -5
View File
@@ -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)
+15 -6
View File
@@ -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']:
+8 -5
View File
@@ -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,
};
@@ -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(
@@ -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 ||
@@ -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;
@@ -6,6 +6,7 @@
<select class="pe-3 text-truncate o_input" t-model="state.fieldName">
<option t-foreach="props.fields" t-as="field" t-key="field.name"
t-att-value="field.name"
t-if="field.type !== 'properties' and !field.isProperty"
t-esc="field.string"
/>
</select>
@@ -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
);
}
}
@@ -0,0 +1,40 @@
<templates xml:space="preserve">
<t t-name="web.PropertiesGroupByItem" owl="1">
<AccordionItem
class="'o_add_custom_group_menu text-truncate'"
description="props.item.description"
selected="isActive">
<t t-set="items" t-value="state.groupByItems"/>
<t t-if="items and items.length">
<t t-foreach="items" t-as="item" t-key="item.name">
<t t-set="description" t-value="isSingleParent ? item.description : `${item.description} (${item.definitionRecordName})`"/>
<!-- Item with options (e.g. Date / Datetime with option "month / quarter / week") -->
<AccordionItem t-if="item.options"
description="description"
selected="item.isActive">
<t t-foreach="item.options" t-as="option" t-key="option.id">
<t t-set="optionName" t-value="`${item.name}:${option.id}`"/>
<SearchDropdownItem class="{ o_item_option: true, selected: option.isActive }"
checked="option.isActive"
parentClosingMode="'none'"
t-esc="option.description"
onSelected="() => this.onGroup({ itemId: item.id, optionId: option.id})"
/>
</t>
</AccordionItem>
<!-- Standard items without option -->
<SearchDropdownItem t-else=""
class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="description"
onSelected="() => this.onGroup({ itemId: item.id })"
/>
</t>
</t>
<SearchDropdownItem t-else="" checked="false" parentClosingMode="'none'" class="'fst-italic'">
No Properties
</SearchDropdownItem>
</AccordionItem>
</t>
</templates>
@@ -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
);
}
@@ -66,7 +66,10 @@
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<t t-if="item.fieldType === 'properties'">
<PropertiesGroupByItem item="item" onGroup.bind="onGroupBySelected"/>
</t>
<t t-elif="item.options">
<AccordionItem class="'text-truncate'" description="item.description" selected="item.isActive">
<t t-set="subGroup" t-value="null"/>
<t t-foreach="item.options" t-as="option" t-key="option.id">
+95 -20
View File
@@ -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 "<properties field name>.<property name>"
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: <list of properties 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,
};
});
}
@@ -23,4 +23,5 @@ export const GROUPABLE_TYPES = [
"many2one",
"many2many",
"selection",
"tags",
];
@@ -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,
@@ -15,7 +15,10 @@
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<t t-if="item.fieldType === 'properties'">
<PropertiesGroupByItem item="item" onGroup.bind="onGroupBySelected"/>
</t>
<t t-elif="item.options">
<Dropdown togglerClass="'o_menu_item' + (item.isActive ? ' selected' : '')">
<t t-set-slot="toggler">
<t t-esc="item.description"/>
@@ -22,6 +22,7 @@ function getDefinitions() {
is_property: true,
searchable: true,
record_name: record.display_name,
record_id: record.id,
...definition,
};
}
@@ -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: `
<search>
<filter string="Properties" name="properties" context="{'group_by': 'properties'}"/>
</search>
`,
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"]);
});
});
});
@@ -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"
);
@@ -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: `
<kanban on_create="quick_create">
<field name="product_id"/>
<templates><t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="foo"/>
<field name="properties"/>
</div>
</t></templates>
</kanban>
`,
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);
});
});
@@ -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: "<pivot/>",
searchViewArch: `
<search>
<filter name='group_by_properties' string="Properties" context="{'group_by':'properties'}"/>
</search>
`,
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");
});
});
+9
View File
@@ -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)
@@ -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'
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
114 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
115 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
116 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
117 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
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -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')
+305 -45
View File
@@ -72,7 +72,7 @@ regex_alphanumeric = re.compile(r'^[a-z0-9_]+$')
regex_order = re.compile(r'''
^
(\s*
((?P<field>[a-z0-9:_]+|"[a-z0-9:_]+")(\.(?P<property>[a-z0-9_]+))?)
(?P<term>((?P<field>[a-z0-9_]+|"[a-z0-9_]+")(\.(?P<property>[a-z0-9_]+))?(:(?P<func>[a-z_]+))?))
(\s+(?P<direction>desc|asc))?
(\s+(?P<nulls>nulls\ 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 "<field_name>:<agg>" example: "quantity:sum".\n'
'- Valid groupby specification looks like "<no_datish_field_name>" or "<datish_field_name>:<granularity>" example: "date:month".'
'- Valid groupby specification looks like "<no_datish_field_name>" or "<datish_field_name>:<granularity>" example: "date:month" or "<properties_field_name>.<property>:<granularity>".'
)
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 = [] # [<SQL expression>,]
groupby_terms_map = {} # {spec: <SQL expression>}
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 = [] # [<SQL expression>,]
@@ -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 (<SQL expression>, [<field names used in SQL expression>])
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 ([<SQL expression>], [<SQL expression>], [<field names used>])
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:
+5
View File
@@ -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