[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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user