diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-07-07 14:30:30 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-07-07 14:30:30 +0200 |
| commit | bf51fe55a149fb642a410f579dc4b6dcbd794eac (patch) | |
| tree | f4f208f06094bcef6541fd1fc034e0265de4ad21 /addons | |
| parent | bf32940142beb879211b7034bb77a7845c9d0555 (diff) | |
| parent | 0f2b2c4e2b99df8d68456016a4bea86e26c1c80e (diff) | |
[MERGE] forward port branch saas-16 up to 0f2b2c4e2b9
Diffstat (limited to 'addons')
117 files changed, 3054 insertions, 735 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 90223d85923..4d7add6879a 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -3,7 +3,7 @@ from odoo import api, fields, models, _ from odoo.osv import expression from odoo.tools import float_is_zero, pycompat -from odoo.tools import float_compare, float_round +from odoo.tools import float_compare, float_round, float_repr from odoo.tools.misc import formatLang from odoo.exceptions import UserError, ValidationError @@ -606,7 +606,8 @@ class AccountBankStatementLine(models.Model): from_clause = "FROM account_move_line aml JOIN account_account acc ON acc.id = aml.account_id " where_clause = """WHERE aml.company_id = %(company_id)s AND ( - (aml.statement_id IS NULL AND aml.account_id IN %(account_payable_receivable)s) + (aml.statement_id IS NULL AND aml.account_id IN %(account_payable_receivable)s + AND aml.payment_id IS NOT NULL) OR ("""+acc_type+""" AND aml.reconciled = false) )""" @@ -630,7 +631,7 @@ class AccountBankStatementLine(models.Model): precision = st_line_currency and st_line_currency.decimal_places or company_currency.decimal_places params = {'company_id': self.env.user.company_id.id, 'account_payable_receivable': (self.journal_id.default_credit_account_id.id, self.journal_id.default_debit_account_id.id), - 'amount': float_round(amount, precision_digits=precision), + 'amount': float_repr(float_round(amount, precision_digits=precision), precision_digits=precision), 'partner_id': self.partner_id.id, 'excluded_ids': tuple(excluded_ids), 'ref': self.name, @@ -651,9 +652,9 @@ class AccountBankStatementLine(models.Model): # Look for a single move line with the same amount field = currency and 'amount_residual_currency' or 'amount_residual' liquidity_field = currency and 'amount_currency' or amount > 0 and 'debit' or 'credit' - liquidity_amt_clause = currency and '%(amount)s' or 'abs(%(amount)s)' + liquidity_amt_clause = currency and '%(amount)s::numeric' or 'abs(%(amount)s::numeric)' sql_query = self._get_common_sql_query(excluded_ids=excluded_ids) + \ - " AND ("+field+" = %(amount)s OR (acc.internal_type = 'liquidity' AND "+liquidity_field+" = " + liquidity_amt_clause + ")) \ + " AND ("+field+" = %(amount)s::numeric OR (acc.internal_type = 'liquidity' AND "+liquidity_field+" = " + liquidity_amt_clause + ")) \ ORDER BY date_maturity desc, aml.id desc LIMIT 1" self.env.cr.execute(sql_query, params) results = self.env.cr.fetchone() diff --git a/addons/account/models/account_config_settings.py b/addons/account/models/account_config_settings.py index 21f1b10eeb7..1e64aa013a4 100644 --- a/addons/account/models/account_config_settings.py +++ b/addons/account/models/account_config_settings.py @@ -127,7 +127,12 @@ class AccountConfigSettings(models.TransientModel): # related values, including the currency_id field on res_company. This in turn will trigger the recomputation # of account_move_line related field company_currency_id which can be slow depending on the number of entries # in the database. Thus, if we do not explicitely change the currency_id, we should not write it on the company + # Same for the field `code_digits` which will trigger a write on all the account.account to complete the + # code the missing characters to complete the desired number of digit, leading to a sql_constraint. if ('company_id' in values and 'currency_id' in values): - if self.env['res.company'].browse(values.get('company_id')).currency_id.id == values.get('currency_id'): + company = self.env['res.company'].browse(values.get('company_id')) + if company.currency_id.id == values.get('currency_id'): values.pop('currency_id') + if company.accounts_code_digits == values.get('code_digits'): + values.pop('code_digits') return super(AccountConfigSettings, self).create(values) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 12c34e659b6..b84aa3513cc 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -489,7 +489,7 @@ class AccountInvoice(models.Model): @api.onchange('invoice_line_ids') def _onchange_invoice_line_ids(self): taxes_grouped = self.get_taxes_values() - tax_lines = self.tax_line_ids.browse([]) + tax_lines = self.tax_line_ids.filtered('manual') for tax in pycompat.values(taxes_grouped): tax_lines += tax_lines.new(tax) self.tax_line_ids = tax_lines diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index f50593e32cc..b7f90e49624 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -695,7 +695,7 @@ class AccountMoveLine(models.Model): company_currency = line.account_id.company_id.currency_id ret_line = { 'id': line.id, - 'name': line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name, + 'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name, 'ref': line.move_id.ref or '', # For reconciliation between statement transactions and already registered payments (eg. checks) # NB : we don't use the 'reconciled' field because the line we're selecting is not the one that gets reconciled diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index 72976451e8c..f2f0f85f7c0 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from ast import literal_eval from operator import itemgetter import time @@ -427,3 +428,11 @@ class ResPartner(models.Model): return super(ResPartner, self)._commercial_fields() + \ ['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id', 'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked'] + + @api.multi + def action_view_partner_invoices(self): + self.ensure_one() + action = self.env.ref('account.action_invoice_refund_out_tree').read()[0] + action['domain'] = literal_eval(action['domain']) + action['domain'].append(('partner_id', 'child_of', self.id)) + return action diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index cf1d70b40c0..fccaea99ecc 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -268,13 +268,13 @@ <field name="account_analytic_id" groups="analytic.group_analytic_accounting" domain="[('company_id', '=', parent.company_id)]" context="{'default_partner_id': parent.partner_id}"/> - <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="quantity"/> <field name="uom_id" groups="product.group_uom"/> <field name="price_unit"/> <field name="discount" groups="base.group_no_one"/> - <field name="invoice_line_tax_ids" widget="many2many_tags" context="{'type':parent.type}" - domain="[('type_tax_use','=','purchase'),('company_id', '=', parent.company_id)]" options="{'no_create': True}"/> + <field name="invoice_line_tax_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create': True}" context="{'type':parent.type}" + domain="[('type_tax_use','=','purchase'),('company_id', '=', parent.company_id)]"/> <field name="price_subtotal"/> <field name="currency_id" invisible="1"/> </tree> @@ -404,13 +404,13 @@ <field name="account_analytic_id" groups="analytic.group_analytic_accounting" domain="[('company_id', '=', parent.company_id)]" context="{'default_partner_id': parent.partner_id}"/> - <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="quantity"/> <field name="uom_id" groups="product.group_uom"/> <field name="price_unit"/> <field name="discount" groups="base.group_no_one"/> - <field name="invoice_line_tax_ids" widget="many2many_tags" context="{'type':parent.type}" - domain="[('type_tax_use','=','sale'),('company_id', '=', parent.company_id)]" options="{'no_create': True}"/> + <field name="invoice_line_tax_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create': True}" context="{'type':parent.type}" + domain="[('type_tax_use','=','sale'),('company_id', '=', parent.company_id)]"/> <field name="price_subtotal"/> <field name="currency_id" invisible="1"/> </tree> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 9757169d898..b71980e0ece 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -304,8 +304,8 @@ <group> <group string="Control-Access" groups="base.group_no_one"> <div class="text-muted" colspan="2">Keep empty for no control</div> - <field name="type_control_ids" widget="many2many_tags"/> - <field name="account_control_ids" widget="many2many_tags"/> + <field name="type_control_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="account_control_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> <group string="Miscellaneous"> <field name="inbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/> @@ -2037,8 +2037,8 @@ <field name="name"/> <field name="attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> <field name="lst_price"/> - <field name="taxes_id" widget="many2many_tags"/> - <field name="supplier_taxes_id" widget="many2many_tags"/> + <field name="taxes_id" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="supplier_taxes_id" widget="many2many_tags" options="{'color_field': 'color'}"/> </tree> </field> </record> diff --git a/addons/account/views/partner_view.xml b/addons/account/views/partner_view.xml index bd2466bec6e..e1aa1d774c6 100644 --- a/addons/account/views/partner_view.xml +++ b/addons/account/views/partner_view.xml @@ -95,6 +95,17 @@ <field name="priority" eval="20"/> <field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/> <field name="arch" type="xml"> + <div name="button_box" position="inside"> + <button type="object" class="oe_stat_button" icon="fa-pencil-square-o" name="action_view_partner_invoices" + attrs="{'invisible': [('customer', '=', False)]}"> + <div class="o_form_field o_stat_info"> + <span class="o_stat_value"> + <field name="total_invoiced" widget='monetary' options="{'currency_field': 'currency_id'}"/> + </span> + <span class="o_stat_text">Invoiced</span> + </div> + </button> + </div> <group name="container_row_2" position="inside"> <group string="Payments" name="banks"> <field name="currency_id" invisible="1"/> diff --git a/addons/account/views/product_view.xml b/addons/account/views/product_view.xml index f8979bd45da..8bbf5a69e0e 100644 --- a/addons/account/views/product_view.xml +++ b/addons/account/views/product_view.xml @@ -11,13 +11,13 @@ <page string="Invoicing" name="invoicing"> <group name="properties"> <group string="Receivables"> - <field name="taxes_id" widget="many2many_tags"/> + <field name="taxes_id" widget="many2many_tags" options="{'color_field': 'color'}/> <field name="property_account_income_id" domain="[('internal_type','=','other'),('deprecated','=',False)]" groups="account.group_account_user"/> </group> <group string="Payables" name="payables"> - <field name="supplier_taxes_id" widget="many2many_tags"/> + <field name="supplier_taxes_id" widget="many2many_tags" options="{'color_field': 'color'}/> <field name="property_account_expense_id" domain="[('internal_type','=','other'),('deprecated','=',False)]" groups="account.group_account_user"/> diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 22906ebf10f..bea6aa94588 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -44,7 +44,7 @@ <strong>Customer Code:</strong> <p t-field="o.partner_id.ref"/> </div> - <div name="reference" class="col-xs-2" t-if="o.reference and o.type == 'in_invoice'"> + <div name="reference" class="col-xs-2" t-if="o.reference"> <strong>Reference:</strong> <p t-field="o.reference"/> </div> diff --git a/addons/account_voucher/views/account_voucher_views.xml b/addons/account_voucher/views/account_voucher_views.xml index ced7d16a4ef..ea279a24be2 100644 --- a/addons/account_voucher/views/account_voucher_views.xml +++ b/addons/account_voucher/views/account_voucher_views.xml @@ -253,7 +253,7 @@ <field name="account_analytic_id" domain="[('company_id', '=', parent.company_id)]" groups="analytic.group_analytic_accounting"/> <field name="quantity"/> <field name="price_unit"/> - <field name="tax_ids" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/> + <field name="tax_ids" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('type_tax_use','=','sale')]"/> <field name="price_subtotal"/> <field name="currency_id" invisible="1"/> <field name="company_id" invisible="1"/> @@ -369,7 +369,7 @@ <field name="account_analytic_id" domain="[('company_id', '=', parent.company_id)]" groups="analytic.group_analytic_accounting"/> <field name="quantity"/> <field name="price_unit"/> - <field name="tax_ids" widget="many2many_tags" domain="[('type_tax_use','=','purchase')]"/> + <field name="tax_ids" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('type_tax_use','=','purchase')]"/> <field name="price_subtotal"/> <field name="currency_id" invisible="1"/> <field name="company_id" invisible="1"/> diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index afa5cd759e8..f6fcbf61d42 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -20,6 +20,8 @@ class Partner(models.Model): @api.model def _fields_view_get_address(self, arch): arch = super(Partner, self)._fields_view_get_address(arch) + if not self._context.get('no_address_format'): + return arch # render the partner address accordingly to address_view_id doc = etree.fromstring(arch) for city_node in doc.xpath("//field[@name='city']"): diff --git a/addons/base_setup/views/res_partner_views.xml b/addons/base_setup/views/res_partner_views.xml index 9eef80f6539..2fd1658d825 100644 --- a/addons/base_setup/views/res_partner_views.xml +++ b/addons/base_setup/views/res_partner_views.xml @@ -8,7 +8,7 @@ <field name="arch" type="xml"> <xpath expr="//div[contains(@class, 'oe_kanban_partner_categories')]" position="inside"> <span class="oe_kanban_list_many2many"> - <field name="category_id"/> + <field name="category_id" widget="many2many_tags" options="{'color_field': 'color'}"/> </span> </xpath> </field> diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index c3795afafe3..9043091164a 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -254,11 +254,13 @@ class Lead(models.Model): self.update(values) @api.constrains('user_id') + @api.multi def _valid_team(self): - if self.user_id: - values = self.with_context(team_id=self.team_id.id)._onchange_user_values(self.user_id.id) - if values: - self.update(values) + for lead in self: + if lead.user_id: + values = lead.with_context(team_id=lead.team_id.id)._onchange_user_values(lead.user_id.id) + if values: + lead.update(values) @api.onchange('state_id') def _onchange_state(self): diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 4934fed5bc0..eff22f19721 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -94,7 +94,7 @@ </group> <group> <field name="priority" widget="priority"/> - <field name="tag_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> </group> </group> <notebook> @@ -243,7 +243,7 @@ <group> <group> <field name="name" string="Opportunity Title" placeholder="e.g. Customer Deal"/> - <field name="partner_id"/> + <field name="partner_id" domain="[('customer', '=', True)]" context="{'search_default_customer': 1}"/> <field name="email_from" invisible="1"/> <field name="phone" invisible="1"/> <label for="planned_revenue"/> @@ -287,7 +287,6 @@ <field name="user_id"/> <field name="partner_address_email"/> <field name="message_needaction_counter"/> - <field name="tag_ids"/> <field name="partner_id"/> <field name="activity_summary"/> <field name="active"/> @@ -313,7 +312,7 @@ </div> <div class="oe_kanban_content"> <div> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </div> <div> <strong><field name="name"/></strong> @@ -486,7 +485,7 @@ </group> <group> <field name="priority" widget="priority"/> - <field name="tag_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> <field name="lost_reason" attrs="{'invisible': [('active', '=', True)]}"/> <field name="date_conversion" invisible="1"/> </group> diff --git a/addons/delivery/views/delivery_view.xml b/addons/delivery/views/delivery_view.xml index c85bf787320..d2e483bdddb 100644 --- a/addons/delivery/views/delivery_view.xml +++ b/addons/delivery/views/delivery_view.xml @@ -108,8 +108,8 @@ </group> <group> <group> - <field name="country_ids" widget="many2many_tags"/> - <field name="state_ids" widget="many2many_tags"/> + <field name="country_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="state_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> <group></group> <group> diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml index 4a5416968d7..699d06a877b 100644 --- a/addons/fleet/views/fleet_vehicle_views.xml +++ b/addons/fleet/views/fleet_vehicle_views.xml @@ -66,7 +66,7 @@ <field name="license_plate" class="oe_inline" placeholder="e.g. PAE 326"/> </h2> <label for="tag_ids" class="oe_edit_only"/> - <field name="tag_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> </div> <group col="2" string="General Properties"> <group > @@ -180,7 +180,7 @@ <t t-name="kanban-box"> <div class="oe_kanban_global_click"> <div class="o_kanban_tags_section"> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </div> <div class="o_kanban_image"> <img t-att-src="kanban_image('fleet.vehicle', 'image_small', record.id.value)"/> diff --git a/addons/google_calendar/static/src/js/google_calendar.js b/addons/google_calendar/static/src/js/google_calendar.js index 240b5568c0b..dbb7a308540 100644 --- a/addons/google_calendar/static/src/js/google_calendar.js +++ b/addons/google_calendar/static/src/js/google_calendar.js @@ -5,97 +5,143 @@ var core = require('web.core'); var Dialog = require('web.Dialog'); var framework = require('web.framework'); var CalendarRenderer = require('web.CalendarRenderer'); +var CalendarController = require('web.CalendarController'); var _t = core._t; -CalendarRenderer.include({ - events: _.extend({}, CalendarRenderer.prototype.events, { - 'click .o_google_sync_button': function () { - this.syncCalendar(this.fields_view); - } +CalendarController.include({ + custom_events: _.extend({}, CalendarController.prototype.custom_events, { + syncCalendar: '_onSyncCalendar', }), - syncCalendar: function() { + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Try to sync the calendar with Google Calendar. According to the result + * from Google API, this function may require an action of the user by the + * mean of a dialog. + * + * @private + * @returns {OdooEvent} event + */ + _onSyncCalendar: function (event) { var self = this; var context = this.getSession().user_context; - this.$google_button.prop('disabled', true); - this.trigger_up('perform_rpc', { + this._rpc({ route: '/google_calendar/sync_data', - args: { - model: this.state.model, + params: { + model: this.modelName, fromurl: window.location.href, local_context: context, - }, - on_success: function (o) { - if (o.status === "need_auth") { - Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), { + } + }).then(function (o) { + if (o.status === "need_auth") { + Dialog.alert(self, _t("You will be redirected to Google to authorize access to your calendar!"), { + confirm_callback: function() { + framework.redirect(o.url); + }, + title: _t('Redirection'), + }); + } else if (o.status === "need_config_from_admin") { + if (!_.isUndefined(o.action) && parseInt(o.action)) { + Dialog.confirm(self, _t("The Google Synchronization needs to be configured before you can use it, do you want to do it now?"), { confirm_callback: function() { - framework.redirect(o.url); + self.do_action(o.action); }, - title: _t('Redirection'), + title: _t('Configuration'), }); - } else if (o.status === "need_config_from_admin") { - if (!_.isUndefined(o.action) && parseInt(o.action)) { - Dialog.confirm(self, _t("The Google Synchronization needs to be configured before you can use it, do you want to do it now?"), { - confirm_callback: function() { - self.do_action(o.action); - }, - title: _t('Configuration'), - }); - } else { - Dialog.alert(self, _t("An administrator needs to configure Google Synchronization before you can use it!"), { - title: _t('Configuration'), - }); - } - } else if (o.status === "need_refresh") { - self.$calendar.fullCalendar('refetchEvents'); - } else if (o.status === "need_reset") { - var confirm_text1 = _t("The account you are trying to synchronize (%s) is not the same as the last one used (%s)!"); - var confirm_text2 = _t("In order to do this, you first need to disconnect all existing events from the old account."); - var confirm_text3 = _t("Do you want to do this now?"); - var text = _.str.sprintf(confirm_text1 + "\n" + confirm_text2 + "\n\n" + confirm_text3, o.info.new_name, o.info.old_name); - Dialog.confirm(self, text, { - confirm_callback: function() { - self._rpc({ - route: '/google_calendar/remove_references', - params: { - model: self.state.model, - local_context: context, - }, - }) - .done(function(o) { - if (o.status === "OK") { - Dialog.alert(self, _t("All events have been disconnected from your previous account. You can now restart the synchronization"), { - title: _t('Event disconnection success'), - }); - } else if (o.status === "KO") { - Dialog.alert(self, _t("An error occured while disconnecting events from your previous account. Please retry or contact your administrator."), { - title: _t('Event disconnection error'), - }); - } // else NOP - }); - }, - title: _t('Accounts'), + } else { + Dialog.alert(self, _t("An administrator needs to configure Google Synchronization before you can use it!"), { + title: _t('Configuration'), }); } - - self.$google_button.prop('disabled', false); - }, + } else if (o.status === "need_refresh") { + self.reload(); + } else if (o.status === "need_reset") { + var confirmText1 = _t("The account you are trying to synchronize (%s) is not the same as the last one used (%s)!"); + var confirmText2 = _t("In order to do this, you first need to disconnect all existing events from the old account."); + var confirmText3 = _t("Do you want to do this now?"); + var text = _.str.sprintf(confirmText1 + "\n" + confirmText2 + "\n\n" + confirmText3, o.info.new_name, o.info.old_name); + Dialog.confirm(self, text, { + confirm_callback: function() { + self._rpc({ + route: '/google_calendar/remove_references', + params: { + model: self.state.model, + local_context: context, + }, + }) + .done(function(o) { + if (o.status === "OK") { + Dialog.alert(self, _t("All events have been disconnected from your previous account. You can now restart the synchronization"), { + title: _t('Event disconnection success'), + }); + } else if (o.status === "KO") { + Dialog.alert(self, _t("An error occured while disconnecting events from your previous account. Please retry or contact your administrator."), { + title: _t('Event disconnection error'), + }); + } // else NOP + }); + }, + title: _t('Accounts'), + }); + } + }).always(function () { + event.data.on_always(); }); - }, - initSidebar: function() { + } +}); + +CalendarRenderer.include({ + events: _.extend({}, CalendarRenderer.prototype.events, { + 'click .o_google_sync_button': '_onSyncCalendar', + }), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the Sync with Google button in the sidebar + * + * @private + */ + _initSidebar: function () { var self = this; - this._super(); - this.$google_button = $(); - if (this.state.model === "calendar.event") { - this.$google_button = $('<button/>', {type: 'button', html: _t("Sync with <b>Google</b>")}) + this._super.apply(this, arguments); + this.$googleButton = $(); + if (this.model === "calendar.event") { + this.$googleButton = $('<button/>', {type: 'button', html: _t("Sync with <b>Google</b>")}) .addClass('o_google_sync_button oe_button btn btn-sm btn-default') .prepend($('<img/>', { src: "/google_calendar/static/src/img/calendar_32.png", })) - .appendTo(self.$('.o_calendar_sidebar')); + .appendTo(self.$sidebar); } }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Requests to sync the calendar with Google Calendar + * + * @private + */ + _onSyncCalendar: function () { + var self = this; + var context = this.getSession().user_context; + this.$googleButton.prop('disabled', true); + this.trigger_up('syncCalendar', { + on_always: function () { + self.$googleButton.prop('disabled', false); + }, + }); + }, }); }); diff --git a/addons/google_calendar/static/tests/google_calendar_tests.js b/addons/google_calendar/static/tests/google_calendar_tests.js new file mode 100644 index 00000000000..08176dde839 --- /dev/null +++ b/addons/google_calendar/static/tests/google_calendar_tests.js @@ -0,0 +1,122 @@ +odoo.define('google_calendar.calendar_tests', function (require) { +"use strict"; + +var CalendarView = require('web.CalendarView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +var initialDate = new Date("2016-12-12T08:00:00Z"); + + +QUnit.module('Google Calendar', { + beforeEach: function () { + this.data = { + 'calendar.event': { + fields: { + id: {string: "ID", type: "integer"}, + user_id: {string: "user", type: "many2one", relation: 'user'}, + partner_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.partner_id'}, + name: {string: "name", type: "char"}, + start_date: {string: "start date", type: "date"}, + stop_date: {string: "stop date", type: "date"}, + start: {string: "start datetime", type: "datetime"}, + stop: {string: "stop datetime", type: "datetime"}, + allday: {string: "allday", type: "boolean"}, + partner_ids: {string: "attendees", type: "one2many", relation: 'partner'}, + type: {string: "type", type: "integer"}, + }, + records: [ + {id: 5, user_id: 4, partner_id: 4, name: "event 1", start: "2016-12-13 15:55:05", stop: "2016-12-15 18:55:05", allday: false, partner_ids:Â [], type: 2}, + {id: 6, user_id: 4, partner_id: 4, name: "event 2", start: "2016-12-18 08:00:00", stop: "2016-12-18 09:00:00", allday: false, partner_ids:Â [], type: 3} + ], + check_access_rights: function () { + return $.when(true); + } + }, + user: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + partner_id: {string: "partner", type: "many2one", relation: 'partner'}, + image: {string: "image", type: "integer"}, + }, + records: [ + {id: 4, display_name: "user 4", partner_id: 4}, + ] + }, + partner: { + fields: { + id: {string: "ID", type: "integer"}, + display_name: {string: "Displayed name", type: "char"}, + image: {string: "image", type: "integer"}, + }, + records: [ + {id: 4, display_name: "partner 4", image: 'DDD'} + ] + }, + filter_partner: { + fields: { + id: {string: "ID", type: "integer"}, + user_id: {string: "user", type: "many2one", relation: 'user'}, + partner_id: {string: "partner", type: "many2one", relation: 'partner'}, + }, + records: [ + {id: 3, user_id: 4, partner_id: 4} + ] + }, + }; + } +}, function () { + + QUnit.test('sync google calendar', function (assert) { + assert.expect(6); + + var calendar = createView({ + View: CalendarView, + model: 'calendar.event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month">'+ + '<field name="name"/>'+ + '</calendar>', + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (route === '/google_calendar/sync_data') { + assert.step(route); + this.data['calendar.event'].records.push( + {id: 7, user_id: 4, partner_id: 4, name: "event from google calendar", start: "2016-12-28 15:55:05", stop: "2016-12-29 18:55:05", allday: false, partner_ids: [], type: 2} + ); + return $.when({status: 'need_refresh'}); + } else if (route === '/google_calendar/static/src/img/calendar_32.png') { + return $.when(); + } else if (route === '/web/dataset/call_kw/calendar.event/search_read') { + assert.step(route); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the month"); + + var $sidebar = calendar.$('.o_calendar_sidebar'); + + calendar.$('.o_google_sync_button').click(); + + assert.verifySteps([ + '/web/dataset/call_kw/calendar.event/search_read', + '/google_calendar/sync_data', + '/web/dataset/call_kw/calendar.event/search_read', + ], 'should do a search_read before and after the call to sync_data'); + + assert.strictEqual(calendar.$('.fc-event').length, 3, "should now display 3 events on the month"); + + calendar.destroy(); + }); +}); +}); diff --git a/addons/google_calendar/views/google_calendar_templates.xml b/addons/google_calendar/views/google_calendar_templates.xml index 6f8924e4af8..4ff9df7f453 100644 --- a/addons/google_calendar/views/google_calendar_templates.xml +++ b/addons/google_calendar/views/google_calendar_templates.xml @@ -6,4 +6,10 @@ <script type="text/javascript" src="/google_calendar/static/src/js/google_calendar.js"></script> </xpath> </template> + + <template id="qunit_suite" name="google_calendar_tests" inherit_id="web.qunit_suite"> + <xpath expr="//t[@t-set='head']" position="inside"> + <script type="text/javascript" src="/google_calendar/static/tests/google_calendar_tests.js"></script> + </xpath> + </template> </odoo> diff --git a/addons/google_drive/models/google_drive.py b/addons/google_drive/models/google_drive.py index e653985f20a..d9200f1e2fd 100644 --- a/addons/google_drive/models/google_drive.py +++ b/addons/google_drive/models/google_drive.py @@ -158,7 +158,7 @@ class GoogleDrive(models.Model): # check if a model is configured with a template configs = self.search([('model_id', '=', res_model)]) config_values = [] - for config in configs: + for config in configs.sudo(): if config.filter_id: if config.filter_id.user_id and config.filter_id.user_id.id != self.env.user.id: #Private diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 0e1125bae84..e910db920e6 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -52,7 +52,7 @@ <field name="name" placeholder="Employee's Name"/> </h1> </div> - <field name="category_ids" widget="many2many_tags" placeholder="e.g. Part Time" options="{'no_create_edit': True}" groups="hr.group_hr_user"/> + <field name="category_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}" placeholder="e.g. Part Time" groups="hr.group_hr_user"/> <notebook> <page name="public" string="Work Information"> <group> @@ -175,7 +175,7 @@ <templates> <t t-name="kanban-box"> <div class="oe_kanban_global_click"> - <field name="category_ids"/> + <field name="category_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <div class="o_kanban_image"> <img t-att-src="kanban_image('hr.employee', 'image_medium', record.id.value)"/> <div class="o_employee_summary_icons"> diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index f0374c92a05..cc9d9fd1c23 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -299,7 +299,7 @@ </group> <group string="Accounting"> <field name="property_account_expense_id"/> - <field name="supplier_taxes_id" widget="many2many_tags"/> + <field name="supplier_taxes_id" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> </group> </sheet> @@ -464,7 +464,7 @@ <field name="message_unread" invisible="1"/> <field name="attachment_number" string=" "/> <button name="action_get_attachment_view" string="View Attachments" type="object" icon="fa-paperclip"/> - <field name="tax_ids" widget="many2many_tags"/> + <field name="tax_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="total_amount" sum="Total Amount" widget="monetary"/> </tree> </field> diff --git a/addons/hr_expense_check/__manifest__.py b/addons/hr_expense_check/__manifest__.py index 90320ac6f64..8fe469b5900 100644 --- a/addons/hr_expense_check/__manifest__.py +++ b/addons/hr_expense_check/__manifest__.py @@ -6,7 +6,6 @@ 'description': """ Print amount in words on checks issued for expenses """, - 'category': 'Accounting & Finance', 'version': '1.0', 'depends': ['account_check_printing', 'hr_expense'], 'auto_install': True, diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index 01faec3975a..0403ae64501 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -112,7 +112,7 @@ </div> <div> <label for="categ_ids" class="oe_edit_only"/> - <field name="categ_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> </div> </h2> </div> @@ -255,7 +255,6 @@ <field name="type_id"/> <field name="job_id"/> <field name="department_id"/> - <field name="categ_ids"/> <field name="message_needaction_counter"/> <field name="attachment_number"/> <field name="active"/> @@ -283,7 +282,7 @@ </ul> </div> <div class="oe_kanban_content" tooltip="kanban-tooltip"> - <field name="categ_ids"/> + <field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <b t-if="record.partner_name.raw_value"> <field name="partner_name"/> </b><t t-else="1"> diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index a2b7a9c25e4..0fc7aa314bf 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -48,3 +48,13 @@ class Task(models.Model): timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') _constraints = [(models.BaseModel._check_recursion, 'Circular references are not permitted between tasks and sub-tasks', ['parent_id'])] + + + @api.model + def create(self, vals): + context = dict(self.env.context) + # Remove default_parent_id to avoid a confusion in get_record_data + if context.get('default_parent_id', False): + vals['parent_id'] = context.pop('default_parent_id', None) + task = super(Task, self.with_context(context)).create(vals) + return task
\ No newline at end of file diff --git a/addons/l10n_fr_certification/__manifest__.py b/addons/l10n_fr_certification/__manifest__.py index e83e9102914..7e2ea327d54 100644 --- a/addons/l10n_fr_certification/__manifest__.py +++ b/addons/l10n_fr_certification/__manifest__.py @@ -9,7 +9,7 @@ """, 'depends': ['l10n_fr'], 'installable': True, - 'auto_install': True, + 'auto_install': False, 'application': True, 'data': [ 'views/no_cancel.xml', diff --git a/addons/l10n_fr_certification/models/account.py b/addons/l10n_fr_certification/models/account.py index ecb0f5aff8b..bef0880d7ca 100644 --- a/addons/l10n_fr_certification/models/account.py +++ b/addons/l10n_fr_certification/models/account.py @@ -102,7 +102,7 @@ class AccountMove(models.Model): """ moves = self.search([('state', '=', 'posted'), ('company_id', '=', company_id), - ('l10n_fr_secure_sequence_number', '!=', False)], + ('l10n_fr_secure_sequence_number', '!=', 0)], order="l10n_fr_secure_sequence_number ASC") if not moves: diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 5fd383fee18..691d1ee968d 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -221,7 +221,9 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.$buttons.on('click', '.o_mail_chat_button_mark_read', function () { chat_manager.mark_all_as_read(self.channel, self.domain); }); - this.$buttons.on('click', '.o_mail_chat_button_unstar_all', chat_manager.unstar_all); + this.$buttons.on('click', '.o_mail_chat_button_unstar_all', function () { + chat_manager.unstar_all(); + }); this.thread.on('redirect', this, function (res_model, res_id) { chat_manager.redirect(res_model, res_id, this.set_channel.bind(this)); diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 4bb973aad1c..b66903adabf 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -710,7 +710,7 @@ </div> <group> <group> - <field name="member_ids" widget="many2many_tags"/> + <field name="member_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> </group> </sheet> diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 37d40718516..7142cb5967c 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -434,15 +434,14 @@ class MassMailing(models.Model): def _compute_next_departure(self): cron_next_call = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').sudo().nextcall + str2dt = fields.Datetime.from_string + cron_time = str2dt(cron_next_call) for mass_mailing in self: - schedule_date = mass_mailing.schedule_date - if schedule_date: - if datetime.now() > fields.Datetime.from_string(schedule_date): - mass_mailing.next_departure = cron_next_call - else: - mass_mailing.next_departure = schedule_date + if mass_mailing.schedule_date: + schedule_date = str2dt(mass_mailing.schedule_date) + mass_mailing.next_departure = max(schedule_date, cron_time) else: - mass_mailing.next_departure = cron_next_call + mass_mailing.next_departure = cron_time @api.onchange('mass_mailing_campaign_id') def _onchange_mass_mailing_campaign_id(self): diff --git a/addons/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml index 17d9818077c..d9c07aff354 100644 --- a/addons/mass_mailing/views/mass_mailing_views.xml +++ b/addons/mass_mailing/views/mass_mailing_views.xml @@ -423,7 +423,7 @@ context="{'search_default_not_opt_out':1}"/> <div attrs="{'invisible': [('mailing_model', '!=', 'mail.mass_mailing.list')]}"> <label for="contact_list_ids" string="Select mailing lists:" class="oe_edit_only"/> - <field name="contact_list_ids" widget="many2many_tags" + <field name="contact_list_ids" widget="many2many_tags" options="{'color_field': 'color'}" placeholder="Select mailing lists..." class="oe_inline"/> </div> </div> @@ -758,7 +758,7 @@ <group> <field name="name" string="Campaign Name"/> <field name="user_id"/> - <field name="tag_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> <field name="unique_ab_testing" groups="base.group_no_one"/> </group> <notebook> @@ -817,11 +817,11 @@ </ul> </div> <div class="oe_kanban_content"> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <div> <img t-att-src="kanban_image('res.users', 'image_small', record.user_id.raw_value)" t-att-title="record.user_id.value" width="24" height="24" class="oe_kanban_avatar pull-right"/> <h3 class="oe_margin_bottom_8"><field name="name"/></h3> - <field name="tag_ids"/> <a name="%(action_view_mass_mailings_from_campaign)d" type="action" class="oe_mailings"> <h4 class="oe_margin_top_8"><t t-raw="record.mass_mailing_ids.raw_value.length"/> Mailings</h4> diff --git a/addons/membership/views/product_views.xml b/addons/membership/views/product_views.xml index 909ed782082..988938a1d8d 100644 --- a/addons/membership/views/product_views.xml +++ b/addons/membership/views/product_views.xml @@ -98,7 +98,7 @@ <field name="list_price" string="Membership Fee"/> <field name="property_account_income_id"/> - <field name="taxes_id" widget="many2many_tags" string="Taxes"/> + <field name="taxes_id" widget="many2many_tags" options="{'color_field': 'color'}" string="Taxes"/> </group> </group> <label for="description"/> diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 24bc802ec7b..228bbd19082 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -282,7 +282,7 @@ class MrpWorkorder(models.Model): for move_lot in self.active_move_lot_ids: # Check if move_lot already exists if move_lot.quantity_done <= 0: # rounding... - move_lot.unlink() + move_lot.sudo().unlink() continue if not move_lot.lot_id: raise UserError(_('You should provide a lot for a component')) @@ -291,7 +291,7 @@ class MrpWorkorder(models.Model): if lots: lots[0].quantity_done += move_lot.quantity_done lots[0].lot_produced_id = self.final_lot_id.id - move_lot.unlink() + move_lot.sudo().unlink() else: move_lot.lot_produced_id = self.final_lot_id.id move_lot.done_wo = True diff --git a/addons/mrp/models/stock_quant.py b/addons/mrp/models/stock_quant.py index e94b786f568..6074735a737 100644 --- a/addons/mrp/models/stock_quant.py +++ b/addons/mrp/models/stock_quant.py @@ -11,3 +11,9 @@ class StockQuant(models.Model): 'stock.quant', 'stock_quant_consume_rel', 'produce_quant_id', 'consume_quant_id') produced_quant_ids = fields.Many2many( 'stock.quant', 'stock_quant_consume_rel', 'consume_quant_id', 'produce_quant_id') + + def _prepare_history(self): + vals = super(StockQuant, self)._prepare_history() + vals['consumed_quant_ids'] = [(4, quant.id) for quant in self.consumed_quant_ids] + vals['produced_quant_ids'] = [(4, quant.id) for quant in self.produced_quant_ids] + return vals diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index 4449a383e64..531587446a3 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -47,7 +47,7 @@ <button name="action_see_attachments" type="object" icon="fa-file" attrs="{'invisible': [('has_attachments', '=', True)]}"/> <field name="product_qty"/> <field name="product_uom_id" options="{'no_open':True,'no_create':True}" groups="product.group_uom"/> - <field name="attribute_value_ids" widget="many2many_tags" domain="[('product_ids.product_tmpl_id', '=', parent.product_tmpl_id)]" options="{'no_create':True}" groups="product.group_product_variant"/> + <field name="attribute_value_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create': True}" domain="[('product_ids.product_tmpl_id', '=', parent.product_tmpl_id)]" groups="product.group_product_variant"/> <field name="operation_id" groups="mrp.group_mrp_routings" domain="[('routing_id', '=', parent.routing_id)]" options="{'no_quick_create':True,'no_create_edit':True}"/> </tree> </field> diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py index eebf8d29962..463c30c0a7c 100644 --- a/addons/mrp/wizard/change_production_qty.py +++ b/addons/mrp/wizard/change_production_qty.py @@ -22,7 +22,7 @@ class ChangeProductionQty(models.TransientModel): if 'mo_id' in fields and not res.get('mo_id') and self._context.get('active_model') == 'mrp.production' and self._context.get('active_id'): res['mo_id'] = self._context['active_id'] if 'product_qty' in fields and not res.get('product_qty') and res.get('mo_id'): - res['product_qty'] = self.env['mrp.production'].browse(res.get['mo_id']).product_qty + res['product_qty'] = self.env['mrp.production'].browse(res['mo_id']).product_qty return res @api.model diff --git a/addons/mrp/wizard/change_production_qty_views.xml b/addons/mrp/wizard/change_production_qty_views.xml index 0ba2fc8488e..f731d2cecc1 100644 --- a/addons/mrp/wizard/change_production_qty_views.xml +++ b/addons/mrp/wizard/change_production_qty_views.xml @@ -10,6 +10,7 @@ <form string="Change Product Qty"> <group> <field name="product_qty"/> + <field name="mo_id" invisible="1"/> </group> <footer> <button name="change_prod_qty" string="Approve" diff --git a/addons/mrp_repair/views/mrp_repair_views.xml b/addons/mrp_repair/views/mrp_repair_views.xml index 53cb9e7843a..e62a411098a 100644 --- a/addons/mrp_repair/views/mrp_repair_views.xml +++ b/addons/mrp_repair/views/mrp_repair_views.xml @@ -89,7 +89,7 @@ </div> <field name="type" invisible="1"/> <field name="price_unit"/> - <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/> + <field name="tax_id" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('type_tax_use','=','sale')]"/> <field name="invoiced" invisible="1"/> <field name="price_subtotal" widget="monetary" invisible="1"/> </group> @@ -117,7 +117,7 @@ <field name="product_uom_qty" string="Quantity"/> <field name="product_uom" string="Unit of Measure" groups="product.group_uom"/> <field name="price_unit"/> - <field name="tax_id" widget="many2many_tags"/> + <field name="tax_id" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="price_subtotal" widget="monetary"/> </tree> </field> @@ -146,7 +146,7 @@ <field name="product_uom" groups="product.group_uom"/> </div> <field name="price_unit"/> - <field widget="many2many_tags" name="tax_id" domain="[('type_tax_use','=','sale')]"/> + <field widget="many2many_tags" options="{'color_field': 'color'}" name="tax_id" domain="[('type_tax_use','=','sale')]"/> <field name="price_subtotal" widget="monetary" invisible="1"/> </group> </form> @@ -156,7 +156,7 @@ <field name="product_uom_qty" string="Quantity"/> <field name="product_uom" string="Unit of Measure" groups="product.group_uom"/> <field name="price_unit"/> - <field name="tax_id" domain="[('type_tax_use', '=', 'sale')]" widget="many2many_tags"/> + <field name="tax_id" domain="[('type_tax_use', '=', 'sale')]" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="price_subtotal" widget="monetary"/> </tree> </field> diff --git a/addons/note/views/note_views.xml b/addons/note/views/note_views.xml index 83f658acd06..0373dd330a5 100644 --- a/addons/note/views/note_views.xml +++ b/addons/note/views/note_views.xml @@ -87,7 +87,6 @@ <field name="memo"/> <field name="date_done"/> <field name="message_partner_ids"/> - <field name="tag_ids"/> <field name="activity_ids" /> <field name="activity_state" /> <templates> @@ -95,7 +94,7 @@ <div t-attf-class="#{kanban_color(record.color.raw_value)} oe_kanban_global_click_edit oe_semantic_html_override oe_kanban_card"> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <div class="o_dropdown_kanban dropdown"> <a class="dropdown-toggle btn" data-toggle="dropdown" href="#" > @@ -143,7 +142,7 @@ <field name="name"/> <field name="open"/> <field name="stage_id"/> - <field name="tag_ids" widget="many2many_tags"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </tree> </field> </record> @@ -158,7 +157,7 @@ <field name="stage_id" domain="[('user_id','=',uid)]" widget="statusbar" clickable="1"/> </header> <sheet> - <field name="tag_ids" widget="many2many_tags" placeholder="Tags"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" placeholder="Tags"/> <field name="memo" widget="html" class="oe_memo" editor_height="450px" /> </sheet> <div class="oe_chatter"> diff --git a/addons/payment/views/account_payment_views.xml b/addons/payment/views/account_payment_views.xml index a284f69b77f..dd9a5400be3 100644 --- a/addons/payment/views/account_payment_views.xml +++ b/addons/payment/views/account_payment_views.xml @@ -19,8 +19,9 @@ <field name="arch" type="xml"> <field name="payment_method_id" position="after"> <field name="payment_method_code" invisible="1"/> - <field name="payment_token_id" attrs="{'invisible': [('payment_method_code', '!=', 'electronic')], - 'required': [('payment_method_code', '=', 'electronic')]}"/> + <field name="payment_token_id" options="{'no_create': True}" + attrs="{'invisible': [('payment_method_code', '!=', 'electronic')], + 'required': [('payment_method_code', '=', 'electronic')]}"/> </field> </field> </record> diff --git a/addons/payment/views/payment_views.xml b/addons/payment/views/payment_views.xml index deaf1dee0bd..d6bce864840 100644 --- a/addons/payment/views/payment_views.xml +++ b/addons/payment/views/payment_views.xml @@ -245,7 +245,7 @@ <group> <field name="acquirer_id"/> <field name="provider" invisible="1"/> - <field name="payment_token_id"/> + <field name="payment_token_id" options="{'no_create': True}"/> <field name="acquirer_reference" readonly="1"/> <field name="create_date"/> <field name="date_validate"/> diff --git a/addons/payment_authorize/views/payment_views.xml b/addons/payment_authorize/views/payment_views.xml index 7c464bc24f4..7a957e41442 100644 --- a/addons/payment_authorize/views/payment_views.xml +++ b/addons/payment_authorize/views/payment_views.xml @@ -9,7 +9,7 @@ <group attrs="{'invisible': [('provider', '!=', 'authorize')]}"> <field name="authorize_login"/> <field name="authorize_transaction_key" password="True"/> - <a colspan="2" href="http://welcome.authorize.net/about_authorizenet/" target="_blank">How to configure your Authorize.Net account (look for Getting Started Guide) ?</a> + <a colspan="2" href="https://www.odoo.com/documentation/user/online/ecommerce/shopper_experience/authorize.html" target="_blank">How to get paid with Authorize.Net</a> </group> </xpath> </field> diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index ec73843481a..5f94e7280fa 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -164,6 +164,7 @@ class PosOrder(models.Model): 'origin': self.name, 'account_id': self.partner_id.property_account_receivable_id.id, 'journal_id': self.session_id.config_id.invoice_journal_id.id, + 'company_id': self.company_id.id, 'type': 'out_invoice', 'reference': self.name, 'partner_id': self.partner_id.id, @@ -881,21 +882,14 @@ class PosOrderLine(models.Model): @api.depends('price_unit', 'tax_ids', 'qty', 'discount', 'product_id') def _compute_amount_line_all(self): for line in self: - currency = line.order_id.pricelist_id.currency_id - taxes = line.tax_ids.filtered(lambda tax: tax.company_id.id == line.order_id.company_id.id) - fiscal_position_id = line.order_id.fiscal_position_id - if fiscal_position_id: - taxes = fiscal_position_id.map_tax(taxes, line.product_id, line.order_id.partner_id) - price = self.env['account.tax']._fix_tax_included_price( - line.price_unit * (1 - (line.discount or 0.0) / 100.0), line.product_id.taxes_id, taxes) - line.price_subtotal = line.price_subtotal_incl = price * line.qty - if taxes: - taxes = taxes.compute_all(price, currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False) - line.price_subtotal = taxes['total_excluded'] - line.price_subtotal_incl = taxes['total_included'] - - line.price_subtotal = currency.round(line.price_subtotal) - line.price_subtotal_incl = currency.round(line.price_subtotal_incl) + fpos = line.order_id.fiscal_position_id + tax_ids_after_fiscal_position = fpos.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id) if fpos else line.tax_ids + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = tax_ids_after_fiscal_position.compute_all(price, line.order_id.pricelist_id.currency_id, line.qty, product=line.product_id, partner=line.order_id.partner_id) + line.update({ + 'price_subtotal_incl': taxes['total_included'], + 'price_subtotal': taxes['total_excluded'], + }) @api.onchange('product_id') def _onchange_product_id(self): @@ -907,8 +901,10 @@ class PosOrderLine(models.Model): price = self.order_id.pricelist_id.get_product_price( self.product_id, self.qty or 1.0, self.order_id.partner_id) self._onchange_qty() - self.price_unit = price - self.tax_ids = self.product_id.taxes_id + self.tax_ids = self.product_id.taxes_id.filtered(lambda r: not self.company_id or r.company_id == self.company_id) + fpos = self.order_id.fiscal_position_id + tax_ids_after_fiscal_position = fpos.map_tax(self.tax_ids, line.product_id, line.order_id.partner_id) if fpos else self.tax_ids + self.price_unit = self.env['account.tax']._fix_tax_included_price(price, self.product_id.taxes_id, tax_ids_after_fiscal_position) @api.onchange('qty', 'discount', 'price_unit', 'tax_ids') def _onchange_qty(self): diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml index 8dac1cf74fd..bc4a3fa8df8 100644 --- a/addons/point_of_sale/views/pos_order_view.xml +++ b/addons/point_of_sale/views/pos_order_view.xml @@ -37,8 +37,8 @@ <field name="qty"/> <field name="price_unit" widget="monetary"/> <field name="discount" widget="monetary"/> - <field name="tax_ids_after_fiscal_position" widget="many2many_tags"/> - <field name="tax_ids" widget="many2many_tags" invisible="1"/> + <field name="tax_ids_after_fiscal_position" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="tax_ids" widget="many2many_tags" options="{'color_field': 'color'}" invisible="1"/> <field name="price_subtotal" widget="monetary"/> <field name="price_subtotal_incl" widget="monetary"/> </tree> @@ -50,8 +50,8 @@ <field name="price_unit" widget="monetary"/> <field name="price_subtotal" invisible="1" widget="monetary"/> <field name="price_subtotal_incl" invisible="1" widget="monetary"/> - <field name="tax_ids_after_fiscal_position" widget="many2many_tags"/> - <field name="tax_ids" widget="many2many_tags" invisible="1"/> + <field name="tax_ids_after_fiscal_position" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="tax_ids" widget="many2many_tags" options="{'color_field': 'color'}" invisible="1"/> <field name="notice"/> </group> </form> diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 100b59c272b..1564cf80c07 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -21,7 +21,7 @@ class ProductTemplate(models.Model): if self._context.get('categ_id') or self._context.get('default_categ_id'): return self._context.get('categ_id') or self._context.get('default_categ_id') category = self.env.ref('product.product_category_all', raise_if_not_found=False) - return category and category.id or False + return category.id if category.type == 'normal' else False def _get_default_uom_id(self): return self.env["product.uom"].search([], limit=1, order='id').id diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 34908f9255f..0ebe971be03 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -406,7 +406,7 @@ action = model.activate_sample_project() </group> <group> <field name="date_deadline"/> - <field name="tag_ids" widget="many2many_tags" options="{'no_create_edit': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> </group> </group> <notebook> @@ -458,7 +458,6 @@ action = model.activate_sample_project() <field name="remaining_hours" groups="project.group_time_work_estimation_tasks"/> <field name="date_deadline"/> <field name="message_needaction_counter"/> - <field name="tag_ids"/> <field name="attachment_ids"/> <field name="displayed_image_id"/> <field name="active"/> @@ -484,7 +483,7 @@ action = model.activate_sample_project() </div> <div class="oe_kanban_content"> <div> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </div> <div> <strong><field name="name"/></strong> diff --git a/addons/project_issue/views/project_issue_views.xml b/addons/project_issue/views/project_issue_views.xml index a0d49f1ab75..93b34d49a59 100644 --- a/addons/project_issue/views/project_issue_views.xml +++ b/addons/project_issue/views/project_issue_views.xml @@ -25,7 +25,7 @@ <group> <field name="user_id"/> <field name="priority" groups="base.group_user" widget="priority"/> - <field name="tag_ids" widget="many2many_tags"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> <group> <field name="partner_id"/> @@ -200,7 +200,7 @@ </ul> </div> <div class="oe_kanban_content" tooltip="kanban-tooltip"> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <div> <b><field name="name"/></b> <br/> <field name="partner_id"/> <br/> diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index db703d92698..58daee24fdf 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -218,13 +218,13 @@ <field name="date_planned"/> <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> <field name="account_analytic_id" context="{'default_partner_id':parent.partner_id}" groups="purchase.group_analytic_accounting"/> - <field name="analytic_tag_ids" groups="purchase.group_analytic_accounting" widget="many2many_tags"/> + <field name="analytic_tag_ids" groups="purchase.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="product_qty"/> <field name="qty_received" invisible="not context.get('show_purchase', True)"/> <field name="qty_invoiced" invisible="not context.get('show_purchase', True)"/> <field name="product_uom" groups="product.group_uom" attrs="{'readonly': [('state', 'in', ('purchase', 'done', 'cancel'))]}"/> <field name="price_unit"/> - <field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use','=','purchase')]" context="{'default_type_tax_use': 'purchase'}" options="{'no_create': True'}"/> + <field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use','=','purchase')]" context="{'default_type_tax_use': 'purchase'}" options="{'no_create': True', 'color_field': 'color'}"/> <field name="price_subtotal" widget="monetary"/> </tree> <form string="Purchase Order Line"> @@ -242,7 +242,7 @@ <field name="price_unit"/> </group> <group> - <field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use', '=', 'purchase')]" options="{'no_create': True'}"/> + <field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use', '=', 'purchase')]" options="{'no_create': True', 'color_field': 'color'}"/> <field name="date_planned" widget="date"/> <field name="account_analytic_id" colspan="2" groups="purchase.group_analytic_accounting"/> <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> @@ -614,7 +614,7 @@ <field name="property_account_expense_id" domain="[('deprecated','=',False)]" attrs="{'readonly': [('purchase_ok', '=', 0)]}"/> </field> <field name='supplier_taxes_id' position="replace" > - <field name="supplier_taxes_id" colspan="2" widget="many2many_tags" attrs="{'readonly':[('purchase_ok','=',0)]}"/> + <field name="supplier_taxes_id" colspan="2" widget="many2many_tags" options="{'color_field': 'color'}" attrs="{'readonly':[('purchase_ok','=',0)]}"/> </field> </field> </record> diff --git a/addons/rating/controllers/main.py b/addons/rating/controllers/main.py index c167a980e72..817f26aa79e 100644 --- a/addons/rating/controllers/main.py +++ b/addons/rating/controllers/main.py @@ -28,7 +28,7 @@ class Rating(http.Controller): 'rate_name': rate_names[rate], 'rate': rate }) - @http.route(['/rating/<string:token>/<int:rate>/submit_feedback'], type="http", auth="public", method=['post']) + @http.route(['/rating/<string:token>/<int:rate>/submit_feedback'], type="http", auth="public", methods=['post']) def submit_rating(self, token, rate, **kwargs): rating = request.env['rating.rating'].sudo().search([('access_token', '=', token)]) if not rating: diff --git a/addons/sale/__init__.py b/addons/sale/__init__.py index de3c67453a7..42f44d32a03 100644 --- a/addons/sale/__init__.py +++ b/addons/sale/__init__.py @@ -4,3 +4,21 @@ from . import models from . import report from . import wizard + +from functools import partial +import odoo +from odoo import api, SUPERUSER_ID + + + +def uninstall_hook(cr, registry): + def update_dashboard_graph_model(dbname): + db_registry = odoo.modules.registry.Registry.new(dbname) + with api.Environment.manage(), db_registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + if 'crm.team' in env: + recs = env['crm.team'].search([]) + for rec in recs: + rec._onchange_team_type() + + cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
\ No newline at end of file diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py index 8c92979cbff..cd6d3322e80 100644 --- a/addons/sale/__manifest__.py +++ b/addons/sale/__manifest__.py @@ -32,6 +32,7 @@ This module contains all the common features of Sales Management and eCommerce. 'data/sale_demo.xml', 'data/product_product_demo.xml', ], + 'uninstall_hook': "uninstall_hook", 'css': ['static/src/css/sale.css'], 'installable': True, 'auto_install': False, diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 0daf25f7ffe..09d80194186 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -238,7 +238,7 @@ </div> </group> <group> - <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale'),('company_id','=',parent.company_id)]" + <field name="tax_id" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('type_tax_use','=','sale'),('company_id','=',parent.company_id)]" attrs="{'readonly': [('qty_invoiced', '>', 0)]}" options="{'no_create': True}"/> <label for="customer_lead"/> <div> @@ -246,7 +246,7 @@ </div> <label for="analytic_tag_ids"/> <div> - <field name="analytic_tag_ids" widget="many2many_tags"/> + <field name="analytic_tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </div> </group> </group> @@ -279,10 +279,10 @@ attrs="{'readonly': [('state', 'in', ('sale','done', 'cancel'))]}" context="{'company_id': parent.company_id}" groups="product.group_uom" options='{"no_open": True}'/> - <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="price_unit" attrs="{'readonly': [('qty_invoiced', '>', 0)]}"/> - <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale'),('company_id','=',parent.company_id)]" + <field name="tax_id" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('type_tax_use','=','sale'),('company_id','=',parent.company_id)]" attrs="{'readonly': [('qty_invoiced', '>', 0)]}" options="{'no_create': True}"/> <field name="discount" groups="sale.group_discount_per_so_line"/> <field name="price_subtotal" widget="monetary" groups="sale.group_show_price_subtotal"/> diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index e100d32f3ee..18d35c3bb15 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -317,12 +317,15 @@ class Quant(models.Model): # price update + accounting entries adjustments solved_quants._price_update(solving_quant.cost) # merge history (and cost?) - solved_quants.write({ - 'history_ids': [(4, history_move.id) for history_move in solving_quant.history_ids] - }) + solved_quants.write(solving_quant._prepare_history()) solving_quant.with_context(force_unlink=True).unlink() solving_quant = remaining_solving_quant + def _prepare_history(self): + return { + 'history_ids': [(4, history_move.id) for history_move in self.history_ids], + } + @api.multi def _price_update(self, newprice): # TDE note: use ACLs instead of sudoing everything diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index eea0b3a74b2..fcdb6333817 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -128,7 +128,7 @@ <group name="inventory"> <group string="Operations" name="operations"> <field name="route_ids" widget="many2many_checkboxes" attrs="{'invisible':[('type', 'in', ['service', 'digital'])]}"/> - <field name="route_from_categ_ids" widget="many2many_tags" readonly="1" attrs="{'invisible':[('route_from_categ_ids', '=', [])]}"/> + <field name="route_from_categ_ids" widget="many2many_tags" options="{'color_field': 'color'}" readonly="1" attrs="{'invisible':[('route_from_categ_ids', '=', [])]}"/> <label for="sale_delay"/> <div> <field name="sale_delay" attrs="{'readonly':[('sale_ok','=',False)]}" class="oe_inline" style="vertical-align:baseline"/> days diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index e3265bd08a2..b06b654aefc 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1016,6 +1016,8 @@ class Binary(http.Controller): elif status != 200 and download: return request.not_found() + height = int(height) + width = int(width) if content and (width or height): # resize maximum 500*500 if width > 500: diff --git a/addons/web/static/src/js/apps.js b/addons/web/static/src/js/apps.js index fcf21852d2c..fa513372269 100644 --- a/addons/web/static/src/js/apps.js +++ b/addons/web/static/src/js/apps.js @@ -59,7 +59,7 @@ var Apps = Widget.extend({ }, destroy: function() { - $(window).off("message.apps"); + $(window).off("message." + this.uniq); if (this.$ifr) { this.$ifr.remove(); this.$ifr = null; @@ -83,13 +83,13 @@ var Apps = Widget.extend({ }); }, 'rpc': function(m) { - self.session.rpc.apply(self.session, m.args).then(function(r) { + return self._rpc({route: m.args[0], params: m.args[1]}).then(function(r) { var w = self.$ifr[0].contentWindow; w.postMessage({id: m.id, result: r}, client.origin); }); }, 'Model': function(m) { - return this._rpc({model: m.model, method: m.method, args: m.args}) + return self._rpc({model: m.model, method: m.args[0], args: m.args[1]}) .then(function(r) { var w = self.$ifr[0].contentWindow; w.postMessage({id: m.id, result: r}, client.origin); @@ -114,10 +114,11 @@ var Apps = Widget.extend({ qs.debug = session.debug; } var u = $.param.querystring(client.origin + "/apps/embed/client", qs); - var css = {width: '100%', height: '400px'}; + var css = {width: '100%', height: '750px'}; self.$ifr = $('<iframe>').attr('src', u); - $(window).on("message.apps", self.proxy('_on_message')); + self.uniq = _.uniqueId('apps'); + $(window).on("message." + self.uniq, self.proxy('_on_message')); self.on('message:ready', self, function(m) { var w = this.$ifr[0].contentWindow; @@ -125,8 +126,8 @@ var Apps = Widget.extend({ type: 'ir.actions.client', tag: this.remote_action_tag, params: _.extend({}, this.params, { - db: this.session.db, - origin: this.session.origin, + db: session.db, + origin: session.origin, }) }; w.postMessage({type:'action', action: act}, client.origin); @@ -145,7 +146,10 @@ var Apps = Widget.extend({ def.resolve(); }, function() { self.do_warn(_t('Odoo Apps will be available soon'), _t('Showing locally available modules'), true); - return session.rpc('/web/action/load', {action_id: self.failback_action_id}).then(function(action) { + return self._rpc({ + route: '/web/action/load', + params: {action_id: self.failback_action_id}, + }).then(function(action) { return self.do_action(action); }).always(function () { def.reject(); diff --git a/addons/web/static/src/js/chrome/pager.js b/addons/web/static/src/js/chrome/pager.js index cd16382be0e..bbe69c6d86a 100644 --- a/addons/web/static/src/js/chrome/pager.js +++ b/addons/web/static/src/js/chrome/pager.js @@ -144,7 +144,7 @@ var Pager = Widget.extend({ _edit: function () { if (this.options.can_edit) { var self = this; - var $input = $('<input>', {type: 'text', value: this.$value.html()}); + var $input = $('<input>', {class: 'o_input', type: 'text', value: this.$value.html()}); this.$value.html($input); $input.focus(); diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js index d0beb5bf3dd..11be6fa4e6e 100644 --- a/addons/web/static/src/js/chrome/view_manager.js +++ b/addons/web/static/src/js/chrome/view_manager.js @@ -567,11 +567,10 @@ var ViewManager = Widget.extend(ControlPanelMixin, { // Wrong default_* and search_default_* values will no give the expected result // Wrong group_by values will simply fail and forbid rendering of the destination view var ncontext = new Context( - _.object(_.reject(_.pairs(self.env.context), function(pair) { + _.object(_.reject(_.pairs(_.extend({}, action_data.context, self.env.context)), function(pair) { return pair[0].match('^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null; })) ); - ncontext.add(action_data.context || {}); ncontext.add({active_model: self.env.modelName}); if (res_ids) { ncontext.add({ diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 52e0aef77e4..4ae4eec9c05 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -273,7 +273,7 @@ var AbstractField = Widget.extend({ */ reset: function (record, event) { this._reset(record, event); - return this._render(); + return this._render() || $.when(); }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 82970255b3e..1868a29a237 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -17,7 +17,6 @@ var dom = require('web.dom'); var Domain = require('web.Domain'); var DomainSelector = require('web.DomainSelector'); var DomainSelectorDialog = require('web.DomainSelectorDialog'); -var field_utils = require('web.field_utils'); var framework = require('web.framework'); var session = require('web.session'); var utils = require('web.utils'); @@ -476,15 +475,14 @@ var FieldMonetary = InputField.extend({ this._setCurrency(); - if (this.mode === 'edit' && this.currency) { + if (this.mode === 'edit') { this.tagName = 'div'; this.className += ' o_input'; - } - // use the formatFloat function in edit - if (this.mode === 'edit') { + // use the formatFloat function in edit this.formatType = 'float'; } + this.formatOptions.currency = this.currency; this.formatOptions.digits = [16, 2]; }, @@ -514,20 +512,19 @@ var FieldMonetary = InputField.extend({ * @private */ _renderEdit: function () { - if (!this.currency) { - this._super.apply(this, arguments); - return; - } - this.$el.empty(); + // Prepare and add the input this._prepareInput().appendTo(this.$el); - // prepare and add the currency symbol - var $currencySymbol = $('<span>', {text: this.currency.symbol}); - if (this.currency.position === "after") { - this.$el.append($currencySymbol); - } else { - this.$el.prepend($currencySymbol); + + if (this.currency) { + // Prepare and add the currency symbol + var $currencySymbol = $('<span>', {text: this.currency.symbol}); + if (this.currency.position === "after") { + this.$el.append($currencySymbol); + } else { + this.$el.prepend($currencySymbol); + } } }, /** @@ -791,6 +788,23 @@ var FieldText = InputField.extend(TranslatableFieldMixin, { } return this._super(); }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Stops the enter navigation in a text area. + * + * @private + * @param {OdooEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ENTER) { + return; + } + this._super.apply(this, arguments); + }, }); /** @@ -2237,6 +2251,7 @@ var AceEditor = DebouncedField.extend({ }); return { + TranslatableFieldMixin: TranslatableFieldMixin, DebouncedField: DebouncedField, FieldEmail: FieldEmail, FieldBinaryFile: FieldBinaryFile, diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index dcd8f7acd48..efafec6ba6a 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -64,6 +64,7 @@ registry .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes) .add('one2many', relational_fields.FieldOne2Many) .add('statusbar', relational_fields.FieldStatus) + .add('reference', relational_fields.FieldReference) .add('one2many_list', relational_fields.FieldOne2Many); // Special fields diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index 9a6734b7baf..ac4c06454ea 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -427,6 +427,52 @@ function parseFloat(value) { return parsed; } +/** + * Parse a String containing currency symbol and returns amount + * + * @param {string} value + * The string to be parsed + * We assume that a monetary is always a pair (symbol, amount) separated + * by a non breaking space. A simple float can also be accepted as value + * @param {Object} [field] + * a description of the field (returned by fields_get for example). + * @param {Object} [options] additional options. + * @param {Object} [options.currency] - the description of the currency to use + * @param {integer} [options.currency_id] + * the id of the 'res.currency' to use (ignored if options.currency) + * @param {string} [options.currency_field] + * the name of the field whose value is the currency id + * (ignore if options.currency or options.currency_id) + * Note: if not given it will default to the field currency_field value + * or to 'currency_id'. + * @param {Object} [options.data] + * a mapping of field name to field value, required with + * options.currency_field + * + * @returns {float} the float value contained in the string representation + * @throws {Error} if no float is found or if parameter does not respect monetary condition + */ +function parseMonetary(value, field, options) { + var values = value.split(' '); + if (values.length === 1) { + return parseFloat(value); + } + else if (values.length !== 2) { + throw new Error(_.str.sprintf(core._t("'%s' is not a correct monetary field"), value)); + } + options = options || {}; + var currency = options.currency; + if (!currency) { + var currency_id = options.currency_id; + if (!currency_id && options.data) { + var currency_field = options.currency_field || field.currency_field || 'currency_id'; + currency_id = options.data[currency_field] && options.data[currency_field].res_id; + } + currency = session.get_currency(currency_id); + } + return parseFloat(values[0] === currency.symbol ? values[1] : values[0]); +} + function parseFloatTime(value) { var factor = 1; if (value[0] === '-') { @@ -494,7 +540,7 @@ return { many2one: formatMany2one, monetary: formatMonetary, one2many: formatX2Many, - reference: _.identity, // todo + reference: formatMany2one, selection: formatSelection, text: formatChar, }, @@ -510,9 +556,9 @@ return { integer: parseInteger, many2many: _.identity, // todo many2one: parseMany2one, - monetary: parseFloat, + monetary: parseMonetary, one2many: _.identity, - reference: _.identity, // todo + reference: parseMany2one, selection: _.identity, // todo text: _.identity, // todo }, diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 419d1f5f646..567c54abcb1 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -657,7 +657,14 @@ var FieldX2Many = AbstractField.extend({ reset: function (record, ev) { if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') { var command = ev.data.changes[this.name]; - if (command.operation === 'UPDATE') { + // Here, we only consider 'UPDATE' commands with data, which occur + // with editable list view. In order to keep the current line in + // edition, we call confirmChange which will reset the widgets + // instead of re-rendering the list. 'UPDATE' commands with no data + // can be ignored: they occur in one2manys when the record is + // updated from a dialog and in this case, we can re-render the + // whole subview. + if (command.operation === 'UPDATE' && command.data) { var state = record.data[this.name]; var fieldNames = state.getFieldNames(); this.renderer.confirmChange(state, command.id, fieldNames, ev.initialEvent); @@ -677,7 +684,7 @@ var FieldX2Many = AbstractField.extend({ * * @override * @private - * @returns {Deferred} + * @returns {Deferred|undefined} */ _render: function () { if (!this.view) { @@ -1068,9 +1075,15 @@ var FieldOne2Many = FieldX2Many.extend({ // we don't want interference with the components upstream. ev.stopPropagation(); + var id = ev.data.id; + // trigger an empty 'UPDATE' operation when the user clicks on 'Save' in + // the dialog, to notify the main record that a subrecord of this + // relational field has changed (those changes will be already stored on + // that subrecord, thanks to the 'Save'). + var onSaved = this._setValue.bind(this, { operation: 'UPDATE', id: id }, {}); this._openFormDialog({ - id: ev.data.id, - on_saved: this._setValue.bind(this, { operation: 'NOOP' }, {}), + id: id, + on_saved: onSaved, readonly: this.mode === 'readonly', }); }, @@ -1354,7 +1367,6 @@ var FieldMany2ManyTags = AbstractField.extend({ 'click .o_delete': '_onDeleteTag', }), fieldsToFetch: { - color: {type: 'integer'}, display_name: {type: 'char'}, }, @@ -1367,6 +1379,8 @@ var FieldMany2ManyTags = AbstractField.extend({ if (this.mode === 'edit') { this.className += ' o_input'; } + + this.colorField = this.nodeOptions.color_field; }, //-------------------------------------------------------------------------- @@ -1431,6 +1445,7 @@ var FieldMany2ManyTags = AbstractField.extend({ _getRenderTagsContext: function () { var elements = this.value ? _.pluck(this.value.data, 'data') : []; return { + colorField: this.colorField, elements: elements, readonly: this.mode === "readonly", }; @@ -1558,7 +1573,7 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ _onOpenColorPicker: function (event) { var tag_id = $(event.currentTarget).data('id'); var tag = _.findWhere(this.value.data, { res_id: tag_id }); - if (tag && 'color' in tag.data) { // if there is a color field on the related model + if (tag && this.colorField in tag.data) { // if there is a color field on the related model this.$color_picker = $(qweb.render('FieldMany2ManyTag.colorpicker', { 'widget': this, 'tag_id': tag_id, @@ -1583,11 +1598,11 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ if (color === current_color) { return; } + var changes = {}; + changes[this.colorField] = color; this.trigger_up('field_changed', { dataPointID: _.findWhere(this.value.data, {res_id: id}).id, - changes: { - color: color, - }, + changes: changes, force_save: true, }); }, @@ -1598,12 +1613,19 @@ var KanbanFieldMany2ManyTags = FieldMany2ManyTags.extend({ var self = this; this.$el.empty().addClass('o_field_many2manytags o_kanban_tags'); _.each(this.value.data, function (m2m) { - // 10th color is invisible - if ('color' in m2m.data && m2m.data.color !== 10) { - $('<span>') - .addClass('o_tag o_tag_color_' + m2m.data.color) + var $tag = $('<span>') .attr('title', _.str.escapeHTML(m2m.data.display_name)) .appendTo(self.$el); + if (self.colorField in m2m.data) { + if (m2m.data[self.colorField] === 10) { + // 10th color is invisible + $tag.hide(); + } else { + $tag.addClass('o_tag o_tag_color_' + m2m.data[self.colorField]); + } + } else { + // display tags in grey by default + $tag.addClass('o_tag o_tag_color_0'); } }); }, @@ -1772,15 +1794,7 @@ var FieldSelection = AbstractField.extend({ */ init: function () { this._super.apply(this, arguments); - if (this.field.type === 'many2one') { - this.values = this.record.specialData[this.name]; - this.formatType = 'many2one'; - } else { - this.values = _.reject(this.field.selection, function (v) { - return v[0] === false && v[1] === ''; - }); - } - this.values = [[false, this.attrs.placeholder || '']].concat(this.values); + this._setValues(); }, //-------------------------------------------------------------------------- @@ -1810,13 +1824,12 @@ var FieldSelection = AbstractField.extend({ * @private */ _renderEdit: function () { - if (!this.$el.children().length) { - for (var i = 0 ; i < this.values.length ; i++) { - this.$el.append($('<option/>', { - value: JSON.stringify(this.values[i][0]), - html: this.values[i][1] - })); - } + this.$el.empty(); + for (var i = 0 ; i < this.values.length ; i++) { + this.$el.append($('<option/>', { + value: JSON.stringify(this.values[i][0]), + html: this.values[i][1] + })); } var value = this.value; if (this.field.type === 'many2one' && value) { @@ -1831,6 +1844,31 @@ var FieldSelection = AbstractField.extend({ _renderReadonly: function () { this.$el.empty().text(this._formatValue(this.value)); }, + /** + * @override + */ + _reset: function () { + this._super.apply(this, arguments); + this._setValues(); + }, + /** + * Sets the possible field values. If the field is a many2one, those values + * may change during the lifecycle of the widget if the domain change (an + * onchange may change the domain). + * + * @private + */ + _setValues: function () { + if (this.field.type === 'many2one') { + this.values = this.record.specialData[this.name]; + this.formatType = 'many2one'; + } else { + this.values = _.reject(this.field.selection, function (v) { + return v[0] === false && v[1] === ''; + }); + } + this.values = [[false, this.attrs.placeholder || '']].concat(this.values); + }, //-------------------------------------------------------------------------- // Handlers @@ -1856,7 +1894,9 @@ var FieldSelection = AbstractField.extend({ }); var FieldRadio = FieldSelection.extend({ - template: 'FieldRadio', + template: null, + className: 'o_field_radio', + tagName: 'span', specialData: "_fetchSpecialMany2ones", supportedFieldTypes: ['selection', 'many2one'], events: _.extend({}, AbstractField.prototype.events, { @@ -1867,14 +1907,12 @@ var FieldRadio = FieldSelection.extend({ */ init: function () { this._super.apply(this, arguments); - if (this.field.type === 'selection') { - this.values = this.field.selection || []; - } else if (this.field.type === 'many2one') { - this.values = _.map(this.record.specialData[this.name], function (val) { - return [val.id, val.display_name]; - }); + if (this.mode === 'edit') { + this.tagName = 'div'; + this.className += this.nodeOptions.horizontal ? 'o_horizontal' : 'o_vertical'; } this.unique_id = _.uniqueId("radio"); + this._setValues(); }, //-------------------------------------------------------------------------- @@ -1898,17 +1936,45 @@ var FieldRadio = FieldSelection.extend({ * @override */ _renderEdit: function () { - var value; + var self = this; + var currentValue; if (this.field.type === 'many2one') { - value = this.value && this.value.data.id; + currentValue = this.value && this.value.data.id; } else { - value = this.value; + currentValue = this.value; } - var index = _.findIndex(this.values, function (option) { - return option[0] === value; + this.$el.empty(); + _.each(this.values, function (value, index) { + self.$el.append(qweb.render('FieldRadio.button', { + checked: value[0] === currentValue, + id: self.unique_id + '_' + value[0], + index: index, + value: value, + })); }); - this.$("input").prop("checked", false); - this.$('input[data-index="' + index + '"]').prop('checked', true); + }, + /** + * @override + */ + _reset: function () { + this._super.apply(this, arguments); + this._setValues(); + }, + /** + * Sets the possible field values. If the field is a many2one, those values + * may change during the lifecycle of the widget if the domain change (an + * onchange may change the domain). + * + * @private + */ + _setValues: function () { + if (this.field.type === 'selection') { + this.values = this.field.selection || []; + } else if (this.field.type === 'many2one') { + this.values = _.map(this.record.specialData[this.name], function (val) { + return [val.id, val.display_name]; + }); + } }, //-------------------------------------------------------------------------- @@ -1930,6 +1996,106 @@ var FieldRadio = FieldSelection.extend({ }, }); +/** + * The FieldReference is a combination of a select (for the model) and + * a FieldMany2one for its value. + * Its intern representation is similar to the many2one (a datapoint with a + * `name_get` as data). + */ +var FieldReference = FieldMany2One.extend({ + supportedFieldTypes: ['reference'], + template: 'FieldReference', + events: _.extend({}, FieldMany2One.prototype.events, { + 'change select': '_onSelectionChange', + }), + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + // needs to be copied as it is an unmutable object + this.field = _.extend({}, this.field); + if (this.value) { + this._setRelation(this.value.model); + } + }, + /** + * @override + */ + start: function () { + this.$('select').val(this.field.relation); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a select in edit mode (for the model). + * + * @override + */ + _renderEdit: function () { + this._super.apply(this, arguments); + + if (this.$('select').val()) { + this.$('.o_input_dropdown').show(); + this.$el.addClass('o_row'); // this class is used to display the two + // components (select & input) on the same line + } else { + // hide the many2one if the selection is empty + this.$('.o_input_dropdown').hide(); + } + + }, + /** + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + this._setRelation(this.value && this.value.model); + }, + /** + * Set `relation` key in field properties. + * + * @private + * @param {string} model + */ + _setRelation: function (model) { + // used to generate the search in many2one + this.field.relation = model; + }, + /** + * @override + * @private + */ + _setValue: function (value, options) { + value = value || {}; + // we need to specify the model for the change in basic_model + // the value is then now a dict with id, display_name and model + value.model = this.$('select').val(); + return this._super(value, options); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When the selection (model) changes, the many2one is reset. + * + * @private + */ + _onSelectionChange: function () { + var value = this.$('select').val(); + this.reinitialize(false); + this._setRelation(value); + }, +}); + return { FieldMany2One: FieldMany2One, KanbanFieldMany2One: KanbanFieldMany2One, @@ -1947,6 +2113,8 @@ return { FieldRadio: FieldRadio, FieldSelection: FieldSelection, FieldStatus: FieldStatus, + + FieldReference: FieldReference, }; }); diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 41fc293e383..c1e69a72ce3 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -355,6 +355,11 @@ return core.Class.extend({ return {}; }), }; + if (attrs.options.color_field) { + // used by m2m tags + attrs.relatedFields[attrs.options.color_field] = { type: 'integer' }; + attrs.fieldsInfo.default[attrs.options.color_field] = {}; + } } } return attrs; diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 67a4fa504d8..82dc6f90d26 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -104,39 +104,15 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { return true; }, /** - * Discards the changes made to the record whose ID is given, if necessary. - * Automatically leaves to default mode for the given record. + * Waits for the mutex to be unlocked and then calls _.discardChanges. + * This ensures that the confirm dialog isn't displayed directly if there is + * a pending 'write' rpc. * - * @param {string} [recordID] - default to main recordID - * @param {Object} [options] - * @param {boolean} [options.readonlyIfRealDiscard=false] - * After discarding record changes, the usual option is to make the - * record readonly. However, the view manager calls this function - * at inappropriate times in the current code and in that case, we - * don't want to go back to readonly if there is nothing to discard - * (e.g. when switching record in edit mode in form view, we expect - * the new record to be in edit mode too, but the view manager calls - * this function as the URL changes...) @todo get rid of this when - * the view manager is improved. - * @returns {Deferred} + * @see _.discardChanges */ discardChanges: function (recordID, options) { - var self = this; - recordID = recordID || this.handle; - return this.canBeDiscarded(recordID).then(function (needDiscard) { - if (options && options.readonlyIfRealDiscard && !needDiscard) { - return; - } - - if (needDiscard) { // Just some optimization - self.model.discardChanges(recordID); - } - if (self.model.isNew(recordID)) { - self._abandonRecord(recordID); - return; - } - return self._confirmSave(recordID); - }); + return this.mutex.exec(function () {}) + .then(this._discardChanges.bind(this, recordID || this.handle, options)); }, /** * Method that will be overriden by the views with the ability to have selected ids @@ -327,6 +303,42 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { } }, /** + * Discards the changes made to the record whose ID is given, if necessary. + * Automatically leaves to default mode for the given record. + * + * @private + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.readonlyIfRealDiscard=false] + * After discarding record changes, the usual option is to make the + * record readonly. However, the view manager calls this function + * at inappropriate times in the current code and in that case, we + * don't want to go back to readonly if there is nothing to discard + * (e.g. when switching record in edit mode in form view, we expect + * the new record to be in edit mode too, but the view manager calls + * this function as the URL changes...) @todo get rid of this when + * the view manager is improved. + * @returns {Deferred} + */ + _discardChanges: function (recordID, options) { + var self = this; + recordID = recordID || this.handle; + return this.canBeDiscarded(recordID) + .then(function (needDiscard) { + if (options && options.readonlyIfRealDiscard && !needDiscard) { + return; + } + if (needDiscard) { // Just some optimization + self.model.discardChanges(recordID); + } + if (self.model.isNew(recordID)) { + self._abandonRecord(recordID); + return; + } + return self._confirmSave(recordID); + }); + }, + /** * Enables buttons so they can be clicked again. * * @private @@ -501,11 +513,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { var self = this; ev.stopPropagation(); var recordID = ev.data.recordID; - this.discardChanges(recordID) + this._discardChanges(recordID) .done(function () { - if (self.model.isNew(recordID)) { - self._abandonRecord(recordID); - } // TODO this will tell the renderer to rerender the widget that // asked for the discard but will unfortunately lose the click // made on another row if any diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 716624f368b..2f06168fd23 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -146,8 +146,11 @@ var BasicModel = AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * Add a default record to a list object. This method actually make a new - * record with the _makeDefaultRecord method, then add it to the list object. + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object. + * The default record is added in the data directly. This is meant to be used + * by list or kanban controllers (i.e. not for x2manys in form views, as in + * this case, we store changes as commands). * * @param {string} listID a valid handle for a list object * @param {Object} [options] @@ -156,6 +159,7 @@ var BasicModel = AbstractModel.extend({ * @returns {Deferred<string>} resolves to the id of the new created record */ addDefaultRecord: function (listID, options) { + var self = this; var list = this.localData[listID]; var context = this._getContext(list); @@ -169,12 +173,13 @@ var BasicModel = AbstractModel.extend({ }; return this._makeDefaultRecord(list.model, params).then(function (id) { list.count++; - list._changes = list._changes || list.data.slice(0); if (position === 'top') { - list._changes.unshift(id); + list.data.unshift(id); } else { - list._changes.push(id); + list.data.push(id); } + var record = self.localData[id]; + list._cache[record.res_id] = id; return id; }); }, @@ -327,15 +332,20 @@ var BasicModel = AbstractModel.extend({ } else { data[fieldName] = this.get(data[fieldName]) || false; } - } - if (field.type === 'one2many' || field.type === 'many2many') { + } else if (field.type === 'reference') { if (options.raw) { relDataPoint = this.localData[data[fieldName]]; - var relData = relDataPoint._changes || relDataPoint.data; - var ids = _.map(relData, function (id) { - return self.localData[id].res_id; - }); - data[fieldName] = ids; + data[fieldName] = relDataPoint ? + relDataPoint.model + ',' + relDataPoint.res_id : + false; + } else { + data[fieldName] = this.get(data[fieldName]) || false; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; } else { data[fieldName] = this.get(data[fieldName]) || []; } @@ -376,25 +386,15 @@ var BasicModel = AbstractModel.extend({ } // here, type === 'list' - var listData, listCount, resIDs; - if (element._changes) { - listData = element._changes; - listCount = listData.length; - resIDs = _.map(listData, function (elemID) { - return self.localData[elemID].res_id; - }); - } else { - listData = element.data; - listCount = element.count; - resIDs = element.res_ids; - } - listData = _.map(listData, function (elemID) { + // apply potential changes (only for x2many lists) + element = this._applyX2ManyOperations(element); + var listData = _.map(element.data, function (elemID) { return self.get(elemID, options); }); var list = { aggregateValues: _.extend({}, element.aggregateValues), context: _.extend({}, element.context), - count: listCount, + count: element.count, data: listData, domain: element.domain.slice(0), fields: element.fields, @@ -409,7 +409,7 @@ var BasicModel = AbstractModel.extend({ offset: element.offset, orderedBy: element.orderedBy, res_id: element.res_id, - res_ids: resIDs, + res_ids: element.res_ids, type: 'list', value: element.value, viewType: element.viewType, @@ -417,6 +417,7 @@ var BasicModel = AbstractModel.extend({ if (element.fieldsInfo) { list.fieldsInfo = element.fieldsInfo; } + this._sortList(list); return list; }, /** @@ -538,6 +539,7 @@ var BasicModel = AbstractModel.extend({ id: id, display_name: display_name, }, + parentID: record.id, }); record.data[field.name] = dataPoint.id; if (display_name === undefined) { @@ -553,6 +555,7 @@ var BasicModel = AbstractModel.extend({ var dpParams = { fieldsInfo: relatedFieldsInfo, modelName: field.relation, + parentID: record.id, static: true, type: 'list', viewType: 'default', @@ -570,9 +573,11 @@ var BasicModel = AbstractModel.extend({ var recordDP = self._makeDataPoint({ data: data, modelName: field.relation, + parentID: dataPoint.id, type: 'record', }); dataPoint.data.push(recordDP.id); + dataPoint._cache[recordDP.res_id] = recordDP.id; }); } else { dpParams.res_ids = field.value; @@ -689,7 +694,16 @@ var BasicModel = AbstractModel.extend({ removeLine: function (elementID) { var record = this.localData[elementID]; var parent = this.localData[record.parentID]; - parent._changes = _.without(parent._changes, elementID); + if (parent.parentID) { + // x2Many case: the new record has been stored in _changes, as a + // command so we remove the command(s) related to that record + parent._changes = _.filter(parent._changes, function (change) { + return change.id !== elementID; + }); + } else { + // main list view case: the new record is in data + parent.data = _.without(parent.data, elementID); + } }, /** * Save a local resource, if needed. This is a complicated operation, @@ -809,13 +823,7 @@ var BasicModel = AbstractModel.extend({ record.fieldsInfo = _.defaults(record.fieldsInfo, viewInfo.fieldsInfo); }, /** - * For list resources, this changes the orderedBy key, then performs the - * sort directly, in javascript. This is used for sorting static datasets, - * such as a one2many in a form view. For dynamic datasets, such as a list - * view, this method will be called, but then the sort will be ignored since - * we will reload data. - * - * @todo don't sort in js when we reload data anyway + * For list resources, this changes the orderedBy key. * * @param {string} list_id id for the list resource * @param {string} fieldName valid field name @@ -837,7 +845,6 @@ var BasicModel = AbstractModel.extend({ }); list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); } - this._sortList(list); return this; }, /** @@ -894,6 +901,36 @@ var BasicModel = AbstractModel.extend({ //-------------------------------------------------------------------------- /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object + * as a 'ADD' command in its _changes. This is meant to be used x2many lists, + * not by list or kanban controllers. + * + * @private + * @param {string} listID a valid handle for a list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @returns {Deferred -> string} resolves to the new record id + */ + _addX2ManyDefaultRecord: function (list, options) { + var self = this; + var params = { + context: this._getContext(list), + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + viewType: list.viewType, + }; + return this._makeDefaultRecord(list.model, params).then(function (id) { + var position = options && options.position || 'top'; + list._changes.push({operation: 'ADD', id: id, position: position}); + var record = self.localData[id]; + list._cache[record.res_id] = id; + return id; + }); + }, + /** * This method is the private version of notifyChanges. Unlike * notifyChanges, it is not protected by a mutex. Every changes from the * user to the model go through this method. @@ -917,8 +954,8 @@ var BasicModel = AbstractModel.extend({ field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], viewType)); - } else if (field.type === 'many2one') { - defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName])); + } else if (field.type === 'many2one' || field.type === 'reference') { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); } else { record._changes[fieldName] = changes[fieldName]; } @@ -960,19 +997,19 @@ var BasicModel = AbstractModel.extend({ }); }, /** - * Apply a many2one onchange. There is a need for this function because the - * server only gives an id when a onchange modifies a many2one field. For - * this reason, we need (sometimes) to do a /name_get to fetch a - * display_name. + * Apply an x2one (either a many2one or a reference field) change. There is + * a need for this function because the server only gives an id when a + * onchange modifies a many2one field. For this reason, we need (sometimes) + * to do a /name_get to fetch a display_name. * * @param {Object} record * @param {string} fieldName * @param {Object} [data] * @returns {Deferred} */ - _applyMany2OneChange: function (record, fieldName, data) { + _applyX2OneChange: function (record, fieldName, data) { var self = this; - if (!data) { + if (!data || !data.id) { record._changes[fieldName] = false; return $.when(); } @@ -993,12 +1030,16 @@ var BasicModel = AbstractModel.extend({ return $.when(); } var rel_data = _.pick(data, 'id', 'display_name'); + var field = record.fields[fieldName]; + + // the reference field doesn't store its co-model in its field metadata + // but directly in the data (as the co-model isn't fixed) + var coModel = field.type === 'reference' ? data.model : field.relation; var def; if (rel_data.display_name === undefined) { - var field = record.fields[fieldName]; // TODO: refactor this to use _fetchNameGet def = this._rpc({ - model: field.relation, + model: coModel, method: 'name_get', args: [data.id], context: record.context, @@ -1013,13 +1054,14 @@ var BasicModel = AbstractModel.extend({ data: rel_data, fields: {}, fieldsInfo: {}, - modelName: record.fields[fieldName].relation, + modelName: coModel, + parentID: record.id, }); record._changes[fieldName] = rec.id; }); }, /** - * Applies the result on an onchange RPC on a record. + * Applies the result of an onchange RPC on a record. * * @private * @param {Object} values the result of the onchange RPC (a mapping of @@ -1053,6 +1095,7 @@ var BasicModel = AbstractModel.extend({ context: record.context, data: data, modelName: field.relation, + parentID: record.id, }); id = rec.id; } @@ -1070,12 +1113,15 @@ var BasicModel = AbstractModel.extend({ list = self._makeDataPoint({ fieldsInfo: fieldInfo.fieldsInfo, modelName: field.relation, + parentID: record.id, + static: true, type: 'list', viewtype: fieldInfo.viewType, }); } record._changes[name] = list.id; var shouldLoad = false; + list._changes = list._changes || []; _.each(val, function (command) { if (command[0] === 0 || command[0] === 1) { // CREATE or UPDATE @@ -1091,23 +1137,25 @@ var BasicModel = AbstractModel.extend({ params.res_id = command[1]; } rec = self._makeDataPoint(params); - list._changes.push(rec.id); + list._cache[rec.res_id] = rec.id; + list._changes.push({operation: 'ADD', id: rec.id}); + if (command[0] === 1) { + list._changes.push({operation: 'UPDATE', id: rec.id}); + } defs.push(self._applyOnChange(command[2], rec)); } else if (command[0] === 4) { // LINK TO - list.res_ids.push(command[1]); - list.count++; + // the dataPoint id will be set when the record will be fetched (for now, + // this dataPoint doesn't exist yet) + list._changes.push({operation: 'ADD', id: null, resID: command[1]}); shouldLoad = true; } else if (command[0] === 5) { // DELETE ALL - list._changes = []; + list._changes = [{operation: 'REMOVE_ALL'}]; } }); if (shouldLoad) { - var def = self._readUngroupedList(list).then(function () { - list._changes = list.data; - }); - defs.push(def); + defs.push(self._readUngroupedList(list)); } } else { record._changes[name] = self._parseServerValue(field, val); @@ -1143,9 +1191,9 @@ var BasicModel = AbstractModel.extend({ var field = record.fields[fieldName]; var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName]; var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; - var rec; + var def, rec; var defs = []; - list._changes = list._changes || list.data.slice(0); + list._changes = list._changes || []; switch (command.operation) { case 'ADD': @@ -1163,8 +1211,8 @@ var BasicModel = AbstractModel.extend({ newRecord.fields = list.fields; newRecord.fieldsInfo = list.fieldsInfo; newRecord.viewType = list.viewType; - list._changes.push(newRecord.id); - this._sortList(list); + list._cache[newRecord.res_id] = newRecord.id; + list._changes.push(command); break; case 'ADD_M2M': // force to use link command instead of create command @@ -1184,8 +1232,8 @@ var BasicModel = AbstractModel.extend({ parentID: list.id, }); list_records[d.id] = rec; - list._changes.push(rec.id); - self._sortList(list); + list._cache[rec.res_id] = rec.id; + list._changes.push({operation: 'ADD', id: rec.id}); }); // read list's records as we only have their ids and optionally their display_name // (we can't use function readUngroupedList because those records are only in the @@ -1194,44 +1242,62 @@ var BasicModel = AbstractModel.extend({ // so that if a record is removed and then re-added, it won't be fetched twice var fieldNames = list.getFieldNames(); if (fieldNames.length) { - var def = this._rpc({ + def = this._rpc({ model: list.model, method: 'read', args: [_.pluck(data, 'id'), fieldNames] }).then(function (records) { _.each(records, function (record) { list_records[record.id].data = record; - self._parseServerData(fieldNames, list.fields, record); + self._parseServerData(fieldNames, list, record); }); - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ); }); defs.push(def); } break; case 'CREATE': - if (command.data) { - defs.push(this.addDefaultRecord(list.id).then(function (id) { + var options = {position: command.position}; + def = this._addX2ManyDefaultRecord(list, options).then(function (id) { + // FIXME: hack for lunch widget, which does useless default_get and onchange + if (command.data) { return self._applyChange(id, command.data); - })); - } else { - defs.push(this.addDefaultRecord(list.id, {position: command.position})); - } + } + }); + defs.push(def); break; case 'UPDATE': - defs.push(this._applyChange(command.id, command.data)); + list._changes.push({operation: 'UPDATE', id: command.id}); + if (command.data) { + defs.push(this._applyChange(command.id, command.data)); + } break; case 'REMOVE': - list._changes = _.difference(list._changes, command.ids); + // filter out existing operations involving the current + // dataPoint, and add a 'REMOVE' operation only if there is + // no 'ADD' operation for that dataPoint, as it would mean + // that the record wasn't in the relation yet + var idsToRemove = command.ids; + list._changes = _.reject(list._changes, function (change) { + var idInCommands = _.contains(command.ids, change.id); + if (idInCommands && change.operation === 'ADD') { + idsToRemove = _.without(idsToRemove, change.id); + } + return idInCommands; + }); + _.each(idsToRemove, function (id) { + list._changes.push({operation: 'REMOVE', id: id}); + }); break; case 'REPLACE_WITH': - // this is certainFly not optimal... and not sure that it is + // this is certainly not optimal... and not sure that it is // correct if some ids are added and some other are removed - var currentData = _.map(list._changes, function (localId) { - return self.localData[localId]; - }); - var currentIds = _.pluck(currentData, 'res_id'); - var newIds = _.difference(command.ids, currentIds); - var removedIds = _.difference(currentIds, command.ids); + list._changes = []; + var newIds = _.difference(command.ids, list.res_ids); + var removedIds = _.difference(list.res_ids, command.ids); var addDef, removedDef, values; if (newIds.length) { values = _.map(newIds, function (id) { @@ -1243,18 +1309,87 @@ var BasicModel = AbstractModel.extend({ }); } if (removedIds.length) { - values = _.filter(currentData, function (dataPoint) { - return _.contains(removedIds, dataPoint.res_id); + var listData = _.map(list.data, function (localId) { + return self.localData[localId]; }); removedDef = this._applyX2ManyChange(record, fieldName, { operation: 'REMOVE', - ids: _.pluck(values, 'id'), + ids: _.map(removedIds, function (resID) { + return _.findWhere(listData, {res_id: resID}).id; + }), }); } return $.when(addDef, removedDef); } - return $.when.apply($, defs); + return $.when.apply($, defs).then(function () { + // ensure to fetch up to 'limit' records (may be useful if records of + // the current page have been removed) + return self._readUngroupedList(list); + }); + }, + /** + * In dataPoints of type list for x2manys, the changes are stored as a list + * of operations (being of type 'ADD', 'REMOVE', 'UPDATE' or 'REMOVE_ALL'). + * This function applies the operation of such a dataPoint without altering + * the original dataPoint. It returns a copy of the dataPoint in which the + * 'count', 'data' and 'res_ids' keys have been updated. + * + * @private + * @param {Object} dataPoint of type list + * @returns {Object} element of type list in which the commands have been + * applied + */ + _applyX2ManyOperations: function (list) { + if (!list.static) { + // this function only applies on x2many lists + return list; + } + var self = this; + list = _.extend({}, list); + list.data = list.data.slice(0); + list.res_ids = list.res_ids.slice(0); + _.each(list._changes, function (change) { + var relRecord; + if (change.id) { + relRecord = self.localData[change.id]; + } + switch(change.operation) { + case 'ADD': + list.count++; + var resID = relRecord ? relRecord.res_id : change.resID; + if (change.position === 'top') { + list.res_ids.unshift(resID); + } else { + list.res_ids.push(resID); + } + break; + case 'REMOVE': + list.count--; + list.res_ids = _.without(list.res_ids, relRecord.res_id); + break; + case 'REMOVE_ALL': + list.count = 0; + list.res_ids = []; + break; + case 'UPDATE': + // nothing to do for UPDATE commands + break; + } + }); + var idsInRange; + if (list.limit) { + idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit); + } else { + idsInRange = list.res_ids; + } + list.data = []; + _.each(idsInRange, function (id) { + if (list._cache[id]) { + list.data.push(list._cache[id]); + } + }); + return list; }, /** * Helper method to build a 'spec', that is a description of all fields in @@ -1368,7 +1503,8 @@ var BasicModel = AbstractModel.extend({ var self = this; var model; var records = []; - var ids = _.map(list._changes || list.data, function (localId) { + list = this._applyX2ManyOperations(list); + var ids = _.map(list.data, function (localId) { var record = self.localData[localId]; var data = record._changes || record.data; var many2oneId = data[fieldName]; @@ -1410,7 +1546,7 @@ var BasicModel = AbstractModel.extend({ model: record.model, method: 'read', args: [[record.res_id], fieldNames], - context: _.extend({}, record.context, {bin_size: true}), + context: _.extend({}, record.getContext(), {bin_size: true}), }) .then(function (result) { if (result.length === 0) { @@ -1420,15 +1556,143 @@ var BasicModel = AbstractModel.extend({ record.data = _.extend({}, record.data, result); }) .then(function () { - self._parseServerData(fieldNames, record.fields, record.data); + self._parseServerData(fieldNames, record, record.data); }) .then(function () { - return self._fetchX2Manys(record, options).then(function () { + return $.when( + self._fetchX2Manys(record, options), + self._fetchReferences(record) + ).then(function () { return self._postprocess(record, options); }); }); }, /** + * Fetch the extra data (`name_get`) for the reference fields of the record + * model. + * + * @private + * @param {Object} record + * @returns {Deferred} + */ + _fetchReferences: function (record) { + var self = this; + var defs = []; + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'reference') { + var value = record.data[fieldName]; + if (value) { + var model = value.split(',')[0]; + var resID = parseInt(value.split(',')[1]); + var def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + var referenceDp = self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + record.data[fieldName] = referenceDp.id; + }); + defs.push(def); + } + } + }); + return $.when.apply($, defs); + }, + /** + * Batch requests for one reference field in list (one request by different + * model in the field values). + * + * @see _fetchReferencesBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchReferenceBatched: function (list, fieldName) { + var self = this; + list = this._applyX2ManyOperations(list); + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + if (value) { + var model = value.split(',')[0]; + var resID = value.split(',')[1]; + if (!(model in toFetch)) { + toFetch[model] = {}; + } + // there could be multiple datapoints with the same model/resID + if (toFetch[model][resID]) { + toFetch[model][resID].push(dataPoint); + } else { + toFetch[model][resID] = [dataPoint]; + } + } + }); + + var defs = []; + var def; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); + // we need one parent for the context (they all have the same) + var parent = datapoints[ids[0]][0]; + def = self._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: self.localData[parent].getContext({fieldName: fieldName}), + }).then(function (result) { + _.each(result, function (el) { + var parentIDs = datapoints[el[0]]; + _.each(parentIDs, function (parentID) { + var parent = self.localData[parentID]; + var referenceDp = self._makeDataPoint({ + data: { + id: el[0], + display_name: el[1], + }, + modelName: model, + parentID: parent, + }); + parent.data[fieldName] = referenceDp.id; + }); + }); + }); + defs.push(def); + }); + + return $.when.apply($, defs); + }, + /** + * Batch requests for references for datapoint of type list. + * + * @param {Object} list + * @returns {Deferred} + */ + _fetchReferencesBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceBatched(list, fieldNames[i])); + } + } + return $.when.apply($, defs); + }, + /** * This method is incorrectly named. It should be named something like * _fetchMany2OneData. * @@ -1527,7 +1791,9 @@ var BasicModel = AbstractModel.extend({ var context = record.getContext({fieldName: fieldName}); var domain = record.getDomain({fieldName: fieldName}); if (domain.length) { - var localID = record._changes && record._changes[fieldName] || record.data[fieldName]; + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; if (localID) { var element = this.localData[localID]; domain = ["|", ["id", "=", element.data.id]].concat(domain); @@ -1705,7 +1971,9 @@ var BasicModel = AbstractModel.extend({ def = this._searchReadUngroupedList(list); } return def.then(function () { - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list)); }).then(function () { return list; }); @@ -1759,7 +2027,10 @@ var BasicModel = AbstractModel.extend({ record.data[fieldName] = list.id; if (!fieldInfo.__no_fetch) { var def = self._readUngroupedList(list).then(function () { - return self._fetchX2ManysBatched(list); + return $.when( + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ); }); defs.push(def); } @@ -1783,12 +2054,12 @@ var BasicModel = AbstractModel.extend({ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; var fields = view ? view.fields : fieldInfo.relatedFields; var viewType = view ? view.type : fieldInfo.viewType; - var data = list._changes || list.data; + list = this._applyX2ManyOperations(list); var x2mRecords = []; // step 1: collect ids var ids = []; - _.each(data, function (dataPoint) { + _.each(list.data, function (dataPoint) { var record = self.localData[dataPoint]; if (typeof record.data[fieldName] === 'string') { // in this case, the value is a local ID, which means that the @@ -1803,6 +2074,7 @@ var BasicModel = AbstractModel.extend({ fieldsInfo: fieldsInfo, fields: fields, modelName: field.relation, + parentID: record.id, res_ids: record.data[fieldName], static: true, type: 'list', @@ -1834,25 +2106,20 @@ var BasicModel = AbstractModel.extend({ } return def.then(function (results) { // step 3: assign values to correct datapoints - var dataPoints = _.map(results, function (result) { - return self._makeDataPoint({ - modelName: field.relation, - data: result, - fields: fields, - fieldsInfo: fieldsInfo, - viewType: viewType, - }); - }); - _.each(x2mRecords, function (record) { var m2mList = self.localData[record.data[fieldName]]; - m2mList.data = []; _.each(m2mList.res_ids, function (res_id) { - var dataPoint = _.find(dataPoints, function (d) { - return d.res_id === res_id; + var dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: _.findWhere(results, {id: res_id}), + fields: fields, + fieldsInfo: fieldsInfo, + parentID: m2mList.id, + viewType: viewType, }); m2mList.data.push(dataPoint.id); + m2mList._cache[res_id] = dataPoint.id; }); }); }); @@ -1912,6 +2179,7 @@ var BasicModel = AbstractModel.extend({ // process relational fields and handle the null case var type = record.fields[fieldName].type; + var value; if (type === 'one2many' || type === 'many2many') { if (commands[fieldName].length) { // replace localId by commands changes[fieldName] = commands[fieldName]; @@ -1919,8 +2187,13 @@ var BasicModel = AbstractModel.extend({ delete changes[fieldName]; } } else if (type === 'many2one' && fieldName in changes) { - var value = changes[fieldName]; + value = changes[fieldName]; changes[fieldName] = value ? this.localData[value].res_id : false; + } else if (type === 'reference' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? + this.localData[value].model + ',' + this.localData[value].res_id : + false; } else if (changes[fieldName] === null) { changes[fieldName] = false; } @@ -1961,6 +2234,8 @@ var BasicModel = AbstractModel.extend({ * * @param {Object} record * @param {Object} [options] + * @param {string} [options.fieldNames] if given, generates the commands for + * these fields only * @param {boolean} [changesOnly=false] if true, only generates commands for * fields that have changed * @param {boolean} [options.withReadonly=false] if false, doesn't generate @@ -1970,11 +2245,15 @@ var BasicModel = AbstractModel.extend({ _generateX2ManyCommands: function (record, options) { var self = this; options = options || {}; + var fields = record.fields; + if (options.fieldNames) { + fields = _.pick(fields, options.fieldNames); + } var commands = {}; var data = _.extend({}, record.data, record._changes); var type; - for (var fieldName in record.fields) { - type = record.fields[fieldName].type; + for (var fieldName in fields) { + type = fields[fieldName].type; if (type === 'many2many' || type === 'one2many') { commands[fieldName] = []; @@ -1983,24 +2262,31 @@ var BasicModel = AbstractModel.extend({ continue; } var list = this.localData[data[fieldName]]; - if (options.changesOnly && !list._changes) { + if (options.changesOnly && (!list._changes || !list._changes.length)) { // if only changes are requested, skip if there is no change continue; } - var relData = _.map(list._changes || list.data, function (localId) { - return self.localData[localId]; + var oldResIDs = list.res_ids.slice(0); + var relRecordAdded = []; + var relRecordUpdated = []; + _.each(list._changes, function (change) { + if (change.operation === 'ADD') { + relRecordAdded.push(self.localData[change.id]); + } else if (change.operation === 'UPDATE') { + relRecordUpdated.push(self.localData[change.id]); + } }); - var relIds = _.pluck(relData, 'res_id'); + list = this._applyX2ManyOperations(list); if (type === 'many2many' || list._forceM2MLink) { // deliberately generate a single 'replace' command instead // of a 'delete' and a 'link' commands with the exact diff // because 1) performance-wise it doesn't change anything // and 2) to guard against concurrent updates (policy: force // a complete override of the actual value of the m2m) - commands[fieldName].push(x2ManyCommands.replace_with(relIds)); + commands[fieldName].push(x2ManyCommands.replace_with(list.res_ids)); // generate update commands for records that have been // updated (it may happen with editable lists) - _.each(relData, function (relRecord) { + _.each(relRecordUpdated, function (relRecord) { var changes = self._generateChanges(relRecord, options); if (!_.isEmpty(changes)) { var command = x2ManyCommands.update(relRecord.res_id, changes); @@ -2008,29 +2294,29 @@ var BasicModel = AbstractModel.extend({ } }); } else if (type === 'one2many') { - var removedIds = _.difference(list.res_ids, relIds); - var addedIds = _.difference(relIds, list.res_ids); - var keptIds = _.intersection(list.res_ids, relIds); + var removedIds = _.difference(oldResIDs, list.res_ids); + var addedIds = _.difference(list.res_ids, oldResIDs); + var keptIds = _.intersection(oldResIDs, list.res_ids); // the didChange variable keeps track of the fact that at // least one id was updated var didChange = false; var changes, command, relRecord; - for (var i = 0; i < relIds.length; i++) { - if (_.contains(keptIds, relIds[i])) { + for (var i = 0; i < list.res_ids.length; i++) { + if (_.contains(keptIds, list.res_ids[i])) { // this is an id that already existed - relRecord = _.findWhere(relData, {res_id: relIds[i]}); - changes = this._generateChanges(relRecord, options); + relRecord = _.findWhere(relRecordUpdated, {res_id: list.res_ids[i]}); + changes = relRecord ? this._generateChanges(relRecord, options) : {}; if (!_.isEmpty(changes)) { command = x2ManyCommands.update(relRecord.res_id, changes); didChange = true; } else { - command = x2ManyCommands.link_to(relIds[i]); + command = x2ManyCommands.link_to(list.res_ids[i]); } commands[fieldName].push(command); - } else if (_.contains(addedIds, relIds[i])) { + } else if (_.contains(addedIds, list.res_ids[i])) { // this is a new id - relRecord = _.findWhere(relData, {res_id: relIds[i]}); + relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); changes = this._generateChanges(relRecord, options); commands[fieldName].push(x2ManyCommands.create(changes)); } @@ -2118,14 +2404,14 @@ var BasicModel = AbstractModel.extend({ if (fieldInfo && fieldInfo.domain) { return Domain.prototype.stringToArray( fieldInfo.domain, - this._getEvalContext(element) + this._getEvalContext(element, true) ); } var fieldParams = element.fields[options.fieldName]; if (fieldParams.domain) { return Domain.prototype.stringToArray( fieldParams.domain, - this._getEvalContext(element) + this._getEvalContext(element, true) ); } return []; @@ -2133,7 +2419,7 @@ var BasicModel = AbstractModel.extend({ return Domain.prototype.stringToArray( element.domain, - this._getEvalContext(element) + this._getEvalContext(element, true) ); }, /** @@ -2145,10 +2431,12 @@ var BasicModel = AbstractModel.extend({ * sure. This allows some domains to use the uid key for example * * @param {Object} element - an element from the localData + * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of + * ids instead of a list of commands * @returns {Object} */ - _getEvalContext: function (element) { - var evalContext = element.type === 'record' ? this._getRecordEvalContext(element) : {}; + _getEvalContext: function (element, forDomain) { + var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; if (element.parentID) { var parent = this.localData[element.parentID]; @@ -2156,7 +2444,7 @@ var BasicModel = AbstractModel.extend({ parent = this.localData[parent.parentID]; } if (parent.type === 'record') { - evalContext.parent = this._getRecordEvalContext(parent); + evalContext.parent = this._getRecordEvalContext(parent, forDomain); } } return _.extend({ @@ -2184,12 +2472,21 @@ var BasicModel = AbstractModel.extend({ * current values for the record, with commands for x2manys fields. * * @param {Object} record an element of type 'record' + * @param {boolean} [forDomain=false] if true, x2many values are a list of + * ids instead of a list of commands * @returns Object */ - _getRecordEvalContext: function (record) { + _getRecordEvalContext: function (record, forDomain) { var self = this; var relDataPoint; var context = _.extend({}, record.data, record._changes); + + // calls _generateX2ManyCommands for a given field, and returns the array of commands + function _generateX2ManyCommands (fieldName) { + var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]}); + return commands[fieldName]; + } + for (var fieldName in context) { var field = record.fields[fieldName]; if (context[fieldName] === null) { @@ -2210,23 +2507,18 @@ var BasicModel = AbstractModel.extend({ continue; } if (field.type === 'one2many' || field.type === 'many2many') { - relDataPoint = this.localData[context[fieldName]]; - var relData = relDataPoint._changes || relDataPoint.data; - var ids = _.map(relData, function (id) { - return self.localData[id].res_id; - }); - - ids.toJSON = function () { - return _.map(relData, function (id) { - var resID = self.localData[id].res_id; - if (typeof resID === 'string') { - var changes = self._generateChanges(self.localData[id]); - return x2ManyCommands.create(changes); - } else { - return x2ManyCommands.link_to(resID); - } - }); - }; + var ids; + if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet + ids = context[fieldName] || []; + } else { + relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); + ids = relDataPoint.res_ids.slice(0); + } + if (!forDomain) { + // when sent to the server, the x2manys values must be a list + // of commands in a context, but the list of ids in a domain + ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); + } context[fieldName] = ids; } @@ -2247,15 +2539,12 @@ var BasicModel = AbstractModel.extend({ */ _isFieldReadonly: function (record, fieldName, viewType) { var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName]; - var modifiers; if (fieldInfo) { var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}"); - modifiers = this._evalModifiers(record, rawModifiers); - } - if (modifiers && 'readonly' in modifiers) { + var modifiers = this._evalModifiers(record, rawModifiers); return modifiers.readonly; } else { - return record.fields[fieldName].readonly; + return false; } }, /** @@ -2285,16 +2574,19 @@ var BasicModel = AbstractModel.extend({ * an onchange. * * @param {string} id id for a local resource of type 'list'. This is - * assumed to be a list element for a one2many + * assumed to be a list element for an x2many * @returns {boolean} */ _isX2ManyValid: function (id) { var self = this; var isValid = true; var element = this.localData[id]; - _.each(element._changes || element.data, function (recordID) { - var recordData = self.get(recordID, {raw: true}).data; - var record = self.localData[recordID]; + _.each(element._changes, function (command) { + if (command.operation === 'REMOVE' || command.operation === 'REMOVE_ALL') { + return; + } + var recordData = self.get(command.id, {raw: true}).data; + var record = self.localData[command.id]; _.each(element.getFieldNames(), function (fieldName) { var field = element.fields[fieldName]; var fieldInfo = element.fieldsInfo[element.viewType][fieldName]; @@ -2477,6 +2769,7 @@ var BasicModel = AbstractModel.extend({ context: record.context, data: {id: result[name]}, modelName: field.relation, + parentID: record.id, }); record._changes[name] = rec.id; } else if (field.type === 'one2many' || field.type === 'many2many') { @@ -2501,6 +2794,7 @@ var BasicModel = AbstractModel.extend({ viewType: viewType, }); record._changes[name] = x2manyList.id; + x2manyList._changes = []; var many2ones = {}; var r; var isCommandList = result[name].length && _.isArray(result[name][0]); @@ -2516,10 +2810,11 @@ var BasicModel = AbstractModel.extend({ context: x2manyList.context, fieldsInfo: fieldsInfo, fields: fields, + parentID: x2manyList.id, viewType: viewType, }); - x2manyList._changes = x2manyList._changes || []; - x2manyList._changes.push(r.id); + x2manyList._changes.push({operation: 'ADD', id: r.id}); + x2manyList._cache[r.res_id] = r.id; // this is necessary so the fields are initialized _.each(r.getFieldNames(), function (fieldName) { @@ -2533,7 +2828,8 @@ var BasicModel = AbstractModel.extend({ var rec = self._makeDataPoint({ context: r.context, modelName: r.fields[name].relation, - data: {id: r._changes[name]} + data: {id: r._changes[name]}, + parentID: r.id, }); r._changes[name] = rec.id; many2ones[name] = true; @@ -2545,7 +2841,10 @@ var BasicModel = AbstractModel.extend({ x2manyList.res_ids = value[2]; x2manyList.count = x2manyList.res_ids.length; var def = self._readUngroupedList(x2manyList).then(function () { - return self._fetchX2ManysBatched(x2manyList); + return $.when( + self._fetchX2ManysBatched(x2manyList), + self._fetchReferencesBatched(x2manyList) + ); }); defs.push(def); } @@ -2586,14 +2885,15 @@ var BasicModel = AbstractModel.extend({ * parse the server values to javascript framwork * * @param {[string]} fieldNames - * @param {Object} fields - * @param {Object} record + * @param {Object} element the dataPoint used as parent for the created + * dataPoints + * @param {Object} data the server data to parse */ - _parseServerData: function (fieldNames, fields, record) { + _parseServerData: function (fieldNames, element, data) { var self = this; _.each(fieldNames, function (fieldName) { - var field = fields[fieldName]; - var val = record[fieldName]; + var field = element.fields[fieldName]; + var val = data[fieldName]; if (field.type === 'many2one') { // process many2one: split [id, nameget] and create corresponding record if (val !== false) { @@ -2608,14 +2908,15 @@ var BasicModel = AbstractModel.extend({ display_name: val[1], id: val[0], }, + parentID: element.id, }); - record[fieldName] = r.id; + data[fieldName] = r.id; } else { // no value for the many2one - record[fieldName] = false; + data[fieldName] = false; } } else { - record[fieldName] = self._parseServerValue(field, val); + data[fieldName] = self._parseServerValue(field, val); } }); }, @@ -2692,6 +2993,14 @@ var BasicModel = AbstractModel.extend({ }); record._warning = true; } + if (result.domain) { + var fieldsInfo = record.fieldsInfo[viewType || record.viewType]; + for (var fieldName in result.domain) { + if (fieldsInfo[fieldName]) { + fieldsInfo[fieldName].domain = result.domain[fieldName]; + } + } + } return self._applyOnChange(result.value, record).then(function () { return result; }); @@ -2843,10 +3152,14 @@ var BasicModel = AbstractModel.extend({ var def; var ids = []; var missingIds = []; - var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count; + // generate the current count and res_ids list by applying the changes + var listWithChanges = this._applyX2ManyOperations(list); + var currentCount = listWithChanges.count; + var currentResIDs = listWithChanges.res_ids; + var upper_bound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; var fieldNames = list.getFieldNames(); for (var i = list.offset; i < upper_bound; i++) { - var id = list.res_ids[i]; + var id = currentResIDs[i]; ids.push(id); if (!list._cache[id]) { missingIds.push(id); @@ -2858,7 +3171,7 @@ var BasicModel = AbstractModel.extend({ model: list.model, method: 'read', args: [missingIds, fieldNames], - context: {} // FIXME + context: list.getContext(), }); } else { def = $.when(_.map(missingIds, function (id) { @@ -2876,6 +3189,7 @@ var BasicModel = AbstractModel.extend({ dataPoint = self.localData[list._cache[id]]; } else { dataPoint = self._makeDataPoint({ + context: list.context, data: _.findWhere(records, {id: id}), fieldsInfo: list.fieldsInfo, fields: list.fields, @@ -2885,12 +3199,17 @@ var BasicModel = AbstractModel.extend({ }); // add many2one records - self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data); + self._parseServerData(fieldNames, dataPoint, dataPoint.data); list._cache[id] = dataPoint.id; } + // set the dataPoint id in potential 'ADD' operation adding the current record + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && !change.id && change.resID === id) { + change.id = dataPoint.id; + } + }); list.data.push(dataPoint.id); }); - self._sortList(list); return list; }); }, @@ -2927,7 +3246,7 @@ var BasicModel = AbstractModel.extend({ route: '/web/dataset/search_read', model: list.model, fields: fieldNames, - context: list.context, + context: list.getContext(), domain: list.domain || [], limit: list.limit, offset: list.loadMoreOffset + list.offset, @@ -2938,6 +3257,7 @@ var BasicModel = AbstractModel.extend({ var ids = _.pluck(result.records, 'id'); var data = _.map(result.records, function (record) { var dataPoint = self._makeDataPoint({ + context: list.context, data: record, fields: list.fields, fieldsInfo: list.fieldsInfo, @@ -2947,7 +3267,7 @@ var BasicModel = AbstractModel.extend({ }); // add many2one records - self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data); + self._parseServerData(fieldNames, dataPoint, dataPoint.data); return dataPoint.id; }); if (list.loadMoreOffset) { @@ -2978,22 +3298,20 @@ var BasicModel = AbstractModel.extend({ }, /** * Do a in-memory sort of a list resource data points. This method assumes - * that the list data has already been fetched. Its intended use is for - * static datasets, such as a one2many in a form view. + * that the list data has already been fetched, and that the changes have + * been applied. Its intended use is for static datasets, such as a one2many + * in a form view. * - * @param {Object} list + * @param {Object} list list dataPoint on which changes have been applied */ _sortList: function (list) { - var self = this; if (list.orderedBy.length) { // sort records according to ordered_by[0] var order = list.orderedBy[0]; - var data = list._changes || list.data; - data.sort(function (id1, id2) { - var r1 = self.localData[id1]; - var r2 = self.localData[id2]; - var data1 = r1._changes || r1.data; - var data2 = r2._changes || r2.data; + var data = list.data; + data.sort(function (r1, r2) { + var data1 = r1.data; + var data2 = r2.data; if (data1[order.name] < data2[order.name]) { return order.asc ? -1 : 1; } @@ -3040,8 +3358,8 @@ var BasicModel = AbstractModel.extend({ } } if (element.type === 'list') { - var listData = element._changes || element.data; - _.each(listData, function (elemId) { + element = this._applyX2ManyOperations(element); + _.each(element.data, function (elemId) { var elem = self.localData[elemId]; self._visitChildren(elem, fn); }); diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index d4c96a15736..db2394ddd1d 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -112,13 +112,18 @@ var CalendarController = AbstractController.extend({ */ _onChangeDate: function (event) { var modelData = this.model.get(); - if (modelData.target_date.toString() === event.data.date) { + if (modelData.target_date.isSame(event.data.date)) { + // When clicking on same date, toggle between the two views switch (modelData.scale) { case 'month': this.model.setScale('week'); break; case 'week': this.model.setScale('day'); break; case 'day': this.model.setScale('month'); break; } + } else if (modelData.target_date.week() === event.data.date.week()) { + // When clicking on a date in the same week, switch to day view + this.model.setScale('day'); } else { + // When clicking on a random day of a random other week, switch to week view this.model.setScale('week'); } this.model.setDate(event.data.date, true); @@ -151,8 +156,10 @@ var CalendarController = AbstractController.extend({ self.quick.destroy(); self.quick = null; self.reload(id); - }, function () { + }, function (error, errorEvent) { // This will occurs if there are some more fields required + // Preventdefaulting the error event will prevent the traceback window + errorEvent.preventDefault(); event.data.options.disableQuickCreate = true; event.data.data.on_save = self.quick.destroy.bind(self.quick); self._onOpenCreate(event.data); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index cb371bfe6f9..ded7938605f 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -393,16 +393,19 @@ return AbstractModel.extend({ this.data.fc_options = this._getFullCalendarOptions(); var defs = _.map(this.data.filters, this._loadFilter.bind(this)); - return $.when.apply($, defs).then(function () { - return self._rpc({ + var eventsDef = $.Deferred().resolve([]); + var filterDomain = self._getFilterDomain(); + if (filterDomain.length || _.isEmpty(self.data.filters)) { + eventsDef = self._rpc({ model: self.modelName, method: 'search_read', context: self.data.context, fields: self.fieldNames, - domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()) - }) - .then(function (events) { + domain: self.data.domain.concat(self._getRangeDomain()).concat(filterDomain) + }); + } + return eventsDef.then(function (events) { self.data.data = _.map(events, self._recordToCalendarEvent.bind(self)); return $.when( self._loadColors(self.data, self.data.data), diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index 23d63aa9044..a4930c87d37 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -164,6 +164,7 @@ return AbstractRenderer.extend({ init: function (parent, state, params) { this._super.apply(this, arguments); this.displayFields = params.displayFields; + this.model = params.model; this.filters = []; this.color_map = {}; diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index 135224fb0cc..71a6c30b756 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -119,6 +119,7 @@ var CalendarView = AbstractView.extend({ this.rendererParams.displayFields = displayFields; this.rendererParams.eventTemplate = _.findWhere(arch.children, {'tag': 'templates'}); + this.rendererParams.model = viewInfo.model; this.loadParams.fieldNames = _.uniq(fieldNames); this.loadParams.mapping = mapping; diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index e9bc8cf2a2a..f741e98bf4d 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -409,7 +409,7 @@ var FormController = BasicController.extend({ * @private */ _onDiscard: function () { - this.discardChanges(); + this._discardChanges(); }, /** * Called when the user clicks on 'Duplicate Record' in the sidebar diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index ee0fd323ce1..2ec9cf3181e 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -50,27 +50,6 @@ var ListController = BasicController.extend({ //-------------------------------------------------------------------------- /** - * To improve performance, list view must not be rerendered if it is asked - * to discard all its changes. Indeed, only the in-edition row needs to be - * discarded in that case. - * - * @override - * @param {string} [recordID] - default to main recordID - * @returns {Deferred} - */ - discardChanges: function (recordID) { - if ((recordID || this.handle) === this.handle) { - recordID = this.renderer.getEditableRecordID(); - if (recordID === null) { - return $.when(); - } - } - var self = this; - return this._super(recordID).then(function () { - self._updateButtons('readonly'); - }); - }, - /** * Calculate the active domain of the list view. This should be done only * if the header checkbox has been checked. This is done by evaluating the * search results, and then adding the dataset domain (i.e. action domain). @@ -246,6 +225,28 @@ var ListController = BasicController.extend({ .then(this._setMode.bind(this, 'readonly', id)); }, /** + * To improve performance, list view must not be rerendered if it is asked + * to discard all its changes. Indeed, only the in-edition row needs to be + * discarded in that case. + * + * @override + * @private + * @param {string} [recordID] - default to main recordID + * @returns {Deferred} + */ + _discardChanges: function (recordID) { + if ((recordID || this.handle) === this.handle) { + recordID = this.renderer.getEditableRecordID(); + if (recordID === null) { + return $.when(); + } + } + var self = this; + return this._super(recordID).then(function () { + self._updateButtons('readonly'); + }); + }, + /** * @override * @private */ @@ -362,7 +363,7 @@ var ListController = BasicController.extend({ */ _onDiscard: function (ev) { ev.stopPropagation(); // So that it is not considered as a row leaving - this.discardChanges(); + this._discardChanges(); }, /** * Called when the user asks to edit a row -> Updates the controller buttons diff --git a/addons/web/static/src/less/control_panel.less b/addons/web/static/src/less/control_panel.less index 83048112815..4a022112e09 100644 --- a/addons/web/static/src/less/control_panel.less +++ b/addons/web/static/src/less/control_panel.less @@ -99,13 +99,16 @@ .o-flex-display(); .o-flex-flow(row, wrap); - .o_cp_buttons, .o_cp_pager { + .o_cp_buttons { .o-flex-display(); margin-right: auto; + > div { + margin-top: 5px; + } } - - .o_cp_buttons > div { - margin-top: 5px; + .o_cp_pager { + .o-flex-display(); + margin-left: auto; } } diff --git a/addons/web/static/src/less/web_calendar.less b/addons/web/static/src/less/web_calendar.less index ed25f8f8dd0..d6aab5e0e38 100644 --- a/addons/web/static/src/less/web_calendar.less +++ b/addons/web/static/src/less/web_calendar.less @@ -64,9 +64,6 @@ .o_field_type_char:first-child { font-weight: bold; } - .o_field_type_many2many:last-child img { - float: right; - } .fc-view { overflow-x: auto; > table { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 78cc5fd9ad5..b8793053758 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -725,15 +725,11 @@ t-att-autofocus="widget.attrs.autofocus" t-att-id="widget.idForLabel"/> </t> -<t t-name="FieldRadio"> - <span t-if="widget.mode === 'readonly'"/> - - <div t-if="widget.mode !== 'readonly'" t-attf-class="o_field_radio #{widget.nodeOptions.horizontal ? 'o_horizontal' : 'o_vertical'}"> - <div t-foreach="widget.values" t-as="value" class="o_radio_item"> - <input type="radio" class="o_radio_input" t-att-id="widget.unique_id + '_' + value[0]" - t-att-name="widget.unique_id" t-att-data-value="value[0]" t-att-data-index="value_index"/> - <label t-if="!widget.nodeOptions.no_radiolabel" class="o_form_label" t-att-for="widget.unique_id + '_' + value[0]"><t t-esc="value[1]"/></label> - </div> +<t t-name="FieldRadio.button"> + <div class="o_radio_item"> + <input type="radio" class="o_radio_input" t-att-checked="checked ? true : undefined" + t-att-data-value="value[0]" t-att-data-index="index" t-att-id="id"/> + <label class="o_form_label" t-att-for="id"><t t-esc="value[1]"/></label> </div> </t> <t t-name="FieldMany2One"> @@ -754,9 +750,17 @@ <button type="button" t-if="!widget.nodeOptions.no_open" class="fa fa-external-link btn btn-default o_external_button" tabindex="-1" draggable="false"/> </div> </t> +<t t-name="FieldReference" t-extend="FieldMany2One"> + <t t-jquery=".o_input_dropdown" t-operation="before"> + <select class="o_input o_field_widget"> + <option/> + <option t-foreach="widget.field.selection" t-as="model" t-att-value="model[0]"><t t-esc="model[1]"/></option> + </select> + </t> +</t> <t t-name="FieldMany2ManyTag"> <t t-foreach="elements" t-as="el"> - <span t-attf-class="badge dropdown o_tag_color_#{el.color || 10}" t-att-data-color="el.color || 10" t-att-data-index="el_index" t-att-data-id="el.id"> + <span t-attf-class="badge dropdown o_tag_color_#{el[colorField] || 10}" t-att-data-color="el[colorField] || 10" t-att-data-index="el_index" t-att-data-id="el.id"> <span class="o_badge_text"><t t-raw="el.display_name"/></span> <span t-if="!readonly" class="fa fa-times o_delete"/> </span> diff --git a/addons/web/static/src/xml/web_calendar.xml b/addons/web/static/src/xml/web_calendar.xml index e38d98e46de..430c2d3772c 100644 --- a/addons/web/static/src/xml/web_calendar.xml +++ b/addons/web/static/src/xml/web_calendar.xml @@ -20,7 +20,11 @@ <t t-foreach="widget.displayFields" t-as="name"> <div t-attf-class="o_field_#{name} o_field_type_#{fields[name].type}"> <t t-if="widget.displayFields[name].avatar_field"> - <t t-esc="fields[name].string"/>: <t t-foreach="widget.getAvatars(record, name, widget.displayFields[name].avatar_field).slice(0,5)" t-as="image"><t t-raw="image"/></t> + <t t-esc="fields[name].string"/>: + <div class="o_calendar_avatars pull-right"> + <t t-foreach="widget.getAvatars(record, name, widget.displayFields[name].avatar_field).slice(0,3)" t-as="image"><t t-raw="image"/></t> + <span t-if="record[name].length - 3 > 0">+<t t-esc="record[name].length - 3"/></span> + </div> </t> <t t-else=""> <t t-esc="format(record, name)"/> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index c492537c63f..c732fe35ce6 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1043,6 +1043,40 @@ QUnit.module('basic_fields', { _t.database.multi_lang = multiLang; }); + QUnit.test('go to next line (and not the next row) when pressing enter', function (assert) { + assert.expect(4); + + this.data.partner.fields.foo.type = 'text'; + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<list editable="top">' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '<field name="qux"/>' + + '</list>', + }); + + list.$('tbody tr:first .o_list_text').click(); + var $textarea = list.$('textarea.o_field_text'); + assert.strictEqual($textarea.length, 1, "should have a text area"); + assert.strictEqual($textarea.val(), 'yop', 'should still be "yop" in edit'); + + assert.strictEqual(list.$('textarea').get(0), document.activeElement, + "text area should have the focus"); + + // click on enter + list.$('textarea') + .trigger({type: "keydown", which: $.ui.keyCode.ENTER}) + .trigger({type: "keyup", which: $.ui.keyCode.ENTER}); + + assert.strictEqual(list.$('textarea').first().get(0), document.activeElement, + "text area should still have the focus"); + + list.destroy(); + }); + QUnit.module('FieldBinary'); QUnit.test('binary fields are correctly rendered', function (assert) { @@ -1973,6 +2007,63 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('monetary field with currency set by an onchange', function (assert) { + // this test ensures that the monetary field can be re-rendered with and + // without currency (which can happen as the currency can be set by an + // onchange) + assert.expect(8); + + this.data.partner.onchanges = { + int_field: function (obj) { + obj.currency_id = obj.int_field ? 2 : null; + }, + }; + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="top">' + + '<field name="int_field"/>' + + '<field name="qux" widget="monetary"/>' + + '<field name="currency_id" invisible="1"/>' + + '</tree>', + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + list.$buttons.find('.o_list_button_add').click(); + assert.strictEqual(list.$('div.o_field_widget[name=qux] input').length, 1, + "monetary field should have been rendered correctly (without currency)"); + assert.strictEqual(list.$('.o_field_widget[name=qux] span').length, 0, + "monetary field should have been rendered correctly (without currency)"); + + // set a value for int_field -> should set the currency and re-render qux + list.$('.o_field_widget[name=int_field]').click().val('7').trigger('input'); + assert.strictEqual(list.$('div.o_field_widget[name=qux] input').length, 1, + "monetary field should have been re-rendered correctly (with currency)"); + assert.strictEqual(list.$('.o_field_widget[name=qux] span:contains(€)').length, 1, + "monetary field should have been re-rendered correctly (with currency)"); + var $quxInput = list.$('.o_field_widget[name=qux] input'); + $quxInput.click(); // check that the field is focusable + assert.strictEqual(document.activeElement, $quxInput[0], + "focus should be on the qux field's input"); + + // unset the value of int_field -> should unset the currency and re-render qux + list.$('.o_field_widget[name=int_field]').click().val('0').trigger('input'); + $quxInput = list.$('div.o_field_widget[name=qux] input'); + assert.strictEqual($quxInput.length, 1, + "monetary field should have been re-rendered correctly (without currency)"); + assert.strictEqual(list.$('.o_field_widget[name=qux] span').length, 0, + "monetary field should have been re-rendered correctly (without currency)"); + $quxInput.click(); // check that the field is still focusable + assert.strictEqual(document.activeElement, $quxInput[0], + "focus should be on the qux field's input"); + + list.destroy(); + }); + QUnit.module('FieldInteger'); QUnit.test('integer field when unset', function (assert) { @@ -2024,7 +2115,7 @@ QUnit.module('basic_fields', { }); QUnit.test('integer field in form view with virtual id', function (assert) { - assert.expect(2); + assert.expect(1); var params = { View: FormView, model: 'partner', @@ -2036,15 +2127,7 @@ QUnit.module('basic_fields', { var form = createView(params); assert.strictEqual(form.$('.o_field_widget').text(), "2-20170808020000", "Should display virtual id"); - form.destroy(); - params.res_id = this.data.partner.records[1].id = "2.65-20170808020000"; - try { - form = createView(params); - } catch (error) { - assert.strictEqual(error.message, '"2.65-20170808020000" is not an integer or a virtual id', - "Should throw an exception because it's a wrong value"); - } form.destroy(); }); diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js index e00d45ef91f..a49197f6e65 100644 --- a/addons/web/static/tests/fields/field_utils_tests.js +++ b/addons/web/static/tests/fields/field_utils_tests.js @@ -2,6 +2,7 @@ odoo.define('web.field_utils_tests', function (require) { "use strict"; var core = require('web.core'); +var session = require('web.session'); var fieldUtils = require('web.field_utils'); QUnit.module('fields', {}, function () { @@ -122,7 +123,20 @@ QUnit.test('parse float', function(assert) { }); QUnit.test('parse monetary', function(assert) { - assert.expect(6); + assert.expect(11); + var originalCurrencies = session.currencies; + session.currencies = { + 1: { + digits: [69, 2], + position: "after", + symbol: "€" + }, + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + }; assert.strictEqual(fieldUtils.parse.monetary(""), 0); assert.strictEqual(fieldUtils.parse.monetary("0"), 0); @@ -130,6 +144,13 @@ QUnit.test('parse monetary', function(assert) { assert.strictEqual(fieldUtils.parse.monetary("-100.00"), -100); assert.strictEqual(fieldUtils.parse.monetary("1,000.00"), 1000); assert.strictEqual(fieldUtils.parse.monetary("1,000,000.00"), 1000000); + assert.strictEqual(fieldUtils.parse.monetary("$ 125.00", {}, {currency_id: 3}), 125); + assert.strictEqual(fieldUtils.parse.monetary("1,000.00 €", {}, {currency_id: 1}), 1000); + assert.throws(function() {fieldUtils.parse.monetary("$ 12.00", {}, {currency_id: 3})}, /is not a correct/); + assert.throws(function() {fieldUtils.parse.monetary("$ 12.00", {}, {currency_id: 1})}, /is not a correct/); + assert.throws(function() {fieldUtils.parse.monetary("$ 12.00 34", {}, {currency_id: 3})}, /is not a correct/); + + session.currencies = originalCurrencies; }); }); }); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 3bf541b097d..d4d3a6307f9 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -34,6 +34,8 @@ QUnit.module('relational_fields', { date: {string: "Some Date", type: "date"}, datetime: {string: "Datetime Field", type: 'datetime'}, user_id: {string: "User", type: 'many2one', relation: 'user'}, + reference: {string: "Reference Field", type: 'reference', selection: [ + ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]}, }, records: [{ id: 1, @@ -47,6 +49,7 @@ QUnit.module('relational_fields', { timmy: [], trululu: 4, user_id: 17, + reference: 'product,37', }, { id: 2, display_name: "second record", @@ -963,7 +966,9 @@ QUnit.module('relational_fields', { }); QUnit.test('domain and context are correctly used when doing a name_search in a m2o', function (assert) { - assert.expect(2); + assert.expect(4); + + this.data.partner.records[0].timmy = [12]; var form = createView({ View: FormView, @@ -975,11 +980,13 @@ QUnit.module('relational_fields', { 'domain="[[\'foo\', \'=\', \'bar\'], [\'foo\', \'=\', foo]]" ' + 'context="{\'hello\': \'world\', \'test\': foo}"/>' + '<field name="foo"/>' + + '<field name="trululu" context="{\'timmy\': timmy}" domain="[[\'id\', \'in\', timmy]]"/>' + + '<field name="timmy" widget="many2many_tags" invisible="1"/>' + '</form>', res_id: 1, session: {user_context: {hey: "ho"}}, mockRPC: function (route, args) { - if (route === '/web/dataset/call_kw/product/name_search') { + if (args.method === 'name_search' && args.model === 'product') { assert.deepEqual( args.kwargs.args, [['foo', '=', 'bar'], ['foo', '=', 'yop']], @@ -990,12 +997,20 @@ QUnit.module('relational_fields', { 'the field attr context should have been used for the ' + 'RPC (evaluated and merged with the session one)'); } + if (args.method === 'name_search' && args.model === 'partner') { + assert.deepEqual(args.kwargs.args, [['id', 'in', [12]]], + 'the field attr domain should have been used for the RPC (and evaluated)'); + assert.deepEqual(args.kwargs.context, {hey: 'ho', timmy: [[6, false, [12]]]}, + 'the field attr context should have been used for the RPC (and evaluated)'); + } return this._super.apply(this, arguments); }, }); form.$buttons.find('.o_form_button_edit').click(); - form.$('input').first().click(); + form.$('.o_field_widget[name=product_id] input').click(); + + form.$('.o_field_widget[name=trululu] input').click(); form.destroy(); }); @@ -1235,6 +1250,56 @@ QUnit.module('relational_fields', { }); }); + QUnit.test('many2one: domain updated by an onchange', function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + int_field: function () {}, + }; + + var domain = []; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field"/>' + + '<field name="trululu"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + domain = [['id', 'in', [10]]]; + return $.when({ + domain: { + trululu: domain, + unexisting_field: domain, + } + }); + } + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, domain, + "sent domain should be correct"); + } + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + + // trigger a name_search (domain should be []) + form.$('.o_field_widget[name=trululu] input').click(); + // close the dropdown + form.$('.o_field_widget[name=trululu] input').click(); + // trigger an onchange that will update the domain + form.$('.o_field_widget[name=int_field]').val(2).trigger('input'); + // trigger a name_search (domain should be [['id', 'in', [10]]]) + form.$('.o_field_widget[name=trululu] input').click(); + + form.destroy(); + }); + QUnit.module('FieldOne2Many'); QUnit.test('one2many basic properties', function (assert) { @@ -1522,6 +1587,178 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('edition of one2many field with pager', function (assert) { + assert.expect(31); + + var ids = []; + for (var i = 0; i < 45; i++) { + var id = 10 + i; + ids.push(id); + this.data.partner.records.push({ + id: id, + display_name: "relational record " + id, + }); + } + this.data.partner.records[0].p = ids; + + var saveCount = 0; + var checkRead = false; + var readIDs; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<kanban>' + + '<field name="display_name"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<a t-if="!read_only_mode" type="delete" class="fa fa-times pull-right delete_icon"/>' + + '<span><t t-esc="record.display_name.value"/></span>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + archs: { + 'partner,false,form': '<form><field name="display_name"/></form>', + }, + mockRPC: function (route, args) { + if (args.method === 'read' && checkRead) { + readIDs = args.args[0]; + checkRead = false; + } + if (args.method === 'write') { + saveCount++; + var nbCommands = args.args[1].p.length; + var nbLinkCommands = _.filter(args.args[1].p, function (command) { + return command[0] === 4; + }).length; + switch(saveCount) { + case 1: + assert.strictEqual(nbCommands, 46, + "should send 46 commands (one for each record)"); + assert.strictEqual(nbLinkCommands, 45, + "should send a LINK_TO command for each existing record"); + assert.deepEqual(args.args[1].p[45], [0, false, { + display_name: 'new record', + }], "should sent a CREATE command for the new record"); + break; + case 2: + assert.strictEqual(nbCommands, 46, + "should send 46 commands"); + assert.strictEqual(nbLinkCommands, 45, + "should send a LINK_TO command for each existing record"); + assert.deepEqual(args.args[1].p[45], [2, 10, false], + "should sent a DELETE command for the deleted record"); + break; + case 3: + assert.strictEqual(nbCommands, 47, + "should send 47 commands"); + assert.strictEqual(nbLinkCommands, 43, + "should send a LINK_TO command for each existing record"); + assert.deepEqual(args.args[1].p[43], + [0, false, {display_name: 'new record page 1'}], + "should sent correct CREATE command"); + assert.deepEqual(args.args[1].p[44], + [0, false, {display_name: 'new record page 2'}], + "should sent correct CREATE command"); + assert.deepEqual(args.args[1].p[45], + [2, 11, false], + "should sent correct DELETE command"); + assert.deepEqual(args.args[1].p[46], + [2, 52, false], + "should sent correct DELETE command"); + break; + } + } + return this._super.apply(this, arguments); + }, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records on page 1'); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 45', "pager range should be correct"); + + // add a record on page one + checkRead = true; + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o-kanban-button-new').click(); + $('.modal input').val('new record').trigger('input'); + $('.modal .modal-footer .btn-primary:first').click(); // save and close + // checks + assert.strictEqual(readIDs, undefined, "should not have read any record"); + assert.strictEqual(form.$('span:contains(new record)').length, 0, + "new record should be on page 2"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records on page 1'); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 46', "pager range should be correct"); + assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length, + 0, 'new record should not be on page 1'); + // save + form.$buttons.find('.o_form_button_save').click(); + + // delete a record on page one + checkRead = true; + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 10)').length, + 1, 'first record should be the one with id 10 (next checks rely on that)'); + form.$('.delete_icon:first').click(); + // checks + assert.deepEqual(readIDs, [50], + "should have read a record (to display 40 records on page 1)"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records on page 1'); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 45', "pager range should be correct"); + // save + form.$buttons.find('.o_form_button_save').click(); + + // add and delete records in both pages + form.$buttons.find('.o_form_button_edit').click(); + checkRead = true; + readIDs = undefined; + // add and delete a record in page 1 + form.$('.o-kanban-button-new').click(); + $('.modal input').val('new record page 1').trigger('input'); + $('.modal .modal-footer .btn-primary:first').click(); // save and close + assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 11)').length, + 1, 'first record should be the one with id 11 (next checks rely on that)'); + form.$('.delete_icon:first').click(); + assert.deepEqual(readIDs, [51], + "should have read a record (to display 40 records on page 1)"); + // add and delete a record in page 2 + form.$('.o_x2m_control_panel .o_pager_next').click(); + assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 52)').length, + 1, 'first record should be the one with id 52 (next checks rely on that)'); + checkRead = true; + readIDs = undefined; + form.$('.delete_icon:first').click(); + form.$('.o-kanban-button-new').click(); + $('.modal input').val('new record page 2').trigger('input'); + $('.modal .modal-footer .btn-primary:first').click(); // save and close + assert.strictEqual(readIDs, undefined, "should not have read any record"); + // checks + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5, + 'there should be 5 records on page 2'); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '41-45 / 45', "pager range should be correct"); + assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 1)').length, + 1, 'new records should be on page 2'); + assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length, + 1, 'new records should be on page 2'); + // save + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + QUnit.test('sorting one2many fields', function (assert) { assert.expect(4); @@ -2357,6 +2594,45 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('editable o2m with onchange and required field: delete an invalid line', function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { + turtles: function () {}, + }; + this.data.partner.records[0].turtles = [1]; + this.data.turtle.records[0].product_id = 37; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('.o_data_cell:first').click(); + form.$('.o_field_widget[name="product_id"] input').val('').trigger('keyup'); + assert.verifySteps(['read', 'read'], 'no onchange should be done as line is invalid'); + form.$('.o_list_record_delete').click(); + assert.verifySteps(['read', 'read', 'onchange'], 'onchange should have been done'); + + form.destroy(); + }); + QUnit.test('onchange in a one2many', function (assert) { assert.expect(1); @@ -2515,6 +2791,75 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many and onchange (with command DELETE_ALL)', function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.p = [[5]]; + }, + p: function () {}, // dummy onchange on the o2m to execute _isX2ManyValid() + }; + this.data.partner.records[0].p = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="foo"/>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (method, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1].p, [ + [0, false, {display_name: 'z'}], + [2, 2, false], + ], "correct commands should be sent"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "o2m should contain one row"); + + // empty o2m by triggering the onchange + form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 0, + "rows of the o2m should have been deleted"); + + // add two new subrecords + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_field_widget[name=display_name]').val('x').trigger('input'); + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_field_widget[name=display_name]').val('y').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 2, + "o2m should contain two rows"); + + // empty o2m by triggering the onchange + form.$('.o_field_widget[name=foo]').val('trigger onchange again').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 0, + "rows of the o2m should have been deleted"); + + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_field_widget[name=display_name]').val('z').trigger('input'); + + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) { assert.expect(2); @@ -2782,6 +3127,41 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('value of invisible x2many fields is correctly evaluated in context', function (assert) { + assert.expect(1); + + this.data.partner.records[0].timmy = [12]; + this.data.partner.records[0].p = [2, 3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form string="Partners">' + + '<field name="product_id" context="{\'p\': p, \'timmy\': timmy}"/>' + + '<field name="p" invisible="1"/>' + + '<field name="timmy" invisible="1"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'name_search') { + assert.deepEqual( + args.kwargs.context, { + p: [[4, 2, false], [4, 3, false]], + timmy: [[6, false, [12]]], + }, 'values of x2manys should have been correctly evaluated in context'); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_widget[name=product_id] input').click(); + + form.destroy(); + }); + QUnit.test('one2many list, editable, with many2one and with context with parent key', function (assert) { assert.expect(1); @@ -4328,6 +4708,93 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('editable list: contexts are correctly sent', function (assert) { + assert.expect(5); + + this.data.partner.records[0].timmy = [12]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '<field name="timmy" context="{\'key\': parent.foo}">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner') { + assert.deepEqual(args.kwargs.context, { + active_field: 2, + bin_size: true, + someKey: 'some value', + }, "sent context should be correct"); + } + if (args.method === 'read' && args.model === 'partner_type') { + assert.deepEqual(args.kwargs.context, { + key: 'yop', + someKey: 'some value', + }, "sent context should be correct"); + } + if (args.method === 'write') { + assert.deepEqual(args.kwargs.context, { + active_field: 2, + someKey: 'some value', + }, "sent context should be correct"); + } + return this._super.apply(this, arguments); + }, + session: { + user_context: {someKey: 'some value'}, + }, + viewOptions: { + mode: 'edit', + context: {active_field: 2}, + }, + res_id: 1, + }); + + form.$('.o_data_cell:first').click(); + form.$('.o_field_widget[name=display_name]').val('abc').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('resetting invisible one2manys', function (assert) { + assert.expect(3); + + this.data.partner.records[0].turtles = []; + this.data.partner.onchanges.foo = function (obj) { + obj.turtles = [[5], [4, 1]]; + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '<field name="turtles" invisible="1"/>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + form.$('input[name="foo"]').val('abcd').trigger('input'); + assert.verifySteps(['read', 'onchange']); + + form.destroy(); + }); + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { @@ -4987,6 +5454,96 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange with 40+ commands for a many2many', function (assert) { + // this test ensures that the basic_model correctly handles more LINK_TO + // commands than the limit of the dataPoint (40 for x2many kanban) + assert.expect(23); + + // create a lot of partner_types that will be linked by the onchange + var commands = [[5]]; + for (var i = 0; i < 45; i++) { + var id = 100 + i; + this.data.partner_type.records.push({id: id, display_name: "type " + id}); + commands.push([4, id]); + } + this.data.partner.onchanges = { + foo: function (obj) { + obj.timmy = commands; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="foo"/>' + + '<field name="timmy">' + + '<kanban>' + + '<field name="display_name"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div><t t-esc="record.display_name.value"/></div>' + + '</t>' + + '</templates>' + + '</kanban>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method); + if (args.method === 'write') { + assert.strictEqual(args.args[1].timmy[0][0], 6, + "should send a command 6"); + assert.strictEqual(args.args[1].timmy[0][2].length, 45, + "should replace with 45 ids"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + assert.verifySteps(['read']); + + form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input'); + + assert.verifySteps(['read', 'onchange', 'read']); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 45', "pager should be correct"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records displayed on page 1'); + + form.$('.o_pager_next').click(); + assert.verifySteps(['read', 'onchange', 'read', 'read']); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '41-45 / 45', "pager should be correct"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5, + 'there should be 5 records displayed on page 2'); + + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 45', "pager should be correct"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records displayed on page 1'); + + form.$('.o_pager_next').click(); + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '41-45 / 45', "pager should be correct"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5, + 'there should be 5 records displayed on page 2'); + + form.$('.o_pager_next').click(); // back to page 1 + assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(), + '1-40 / 45', "pager should be correct"); + assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40, + 'there should be 40 records displayed on page 1'); + + form.destroy(); + }); + QUnit.module('FieldStatus'); QUnit.test('static statusbar widget on many2one field', function (assert) { @@ -5380,10 +5937,117 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('unset selection on a many2one field', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="trululu" widget="selection"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].trululu, false, + "should send 'false' as trululu value"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('select').val("false").trigger('change'); + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + + QUnit.test('widget selection on a many2one: domain updated by an onchange', function (assert) { + assert.expect(4); + + this.data.partner.onchanges = { + int_field: function () {}, + }; + + var domain = []; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field"/>' + + '<field name="trululu" widget="selection"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + domain = [['id', 'in', [10]]]; + return $.when({ + domain: { + trululu: domain, + } + }); + } + if (args.method === 'name_search') { + assert.deepEqual(args.args[1], domain, + "sent domain should be correct"); + } + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_field_widget[name=trululu] option').length, 4, + "should be 4 options in the selection"); + + // trigger an onchange that will update the domain + form.$('.o_field_widget[name=int_field]').val(2).trigger('input'); + + assert.strictEqual(form.$('.o_field_widget[name=trululu] option').length, 1, + "should be 1 option in the selection"); + + form.destroy(); + }); + QUnit.module('FieldMany2ManyTags'); - QUnit.test('fieldmany2many tags: rendering and edition', function (assert) { - assert.expect(17); + QUnit.test('fieldmany2many tags without color', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method ==='read' && args.model === 'partner_type') { + assert.deepEqual(args.args , [[12], ['display_name']], "should not read any color field"); + } + return this._super.apply(this, arguments); + } + }); + var $input = form.$('.o_field_many2manytags input'); + $input.click(); // opens the dropdown + assert.strictEqual($input.autocomplete('widget').find('li').length, 3, + "autocomplete dropdown should have 3 entries (2 values + 'Search and Edit...')"); + $input.autocomplete('widget').find('li:first()').click(); // adds a tag + assert.strictEqual(form.$('.o_field_many2manytags > span').length, 1, + "should contain 1 tag"); + assert.ok(form.$('.o_field_many2manytags > span:contains("gold")').length, + "should contain newly added tag 'gold'"); + form.destroy(); + }); + + QUnit.test('fieldmany2many tags with color: rendering and edition', function (assert) { + assert.expect(20); this.data.partner.records[0].timmy = [12, 14]; this.data.partner_type.records.push({id: 13, display_name: "red", color: 8}); @@ -5392,7 +6056,7 @@ QUnit.module('relational_fields', { model: 'partner', data: this.data, arch:'<form string="Partners">' + - '<field name="timmy" widget="many2many_tags" options="{\'no_create_edit\': True}"/>' + + '<field name="timmy" widget="many2many_tags" options="{\'color_field\': \'color\', \'no_create_edit\': True}"/>' + '</form>', res_id: 1, mockRPC: function (route, args) { @@ -5403,6 +6067,9 @@ QUnit.module('relational_fields', { assert.ok(_.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), [12, 13]), "new value should be [12, 13]"); } + if (args.method ==='read' && args.model === 'partner_type') { + assert.deepEqual(args.args[1], ['display_name', 'color'], "should read the color field"); + } return this._super.apply(this, arguments); }, }); @@ -5548,7 +6215,7 @@ QUnit.module('relational_fields', { model: 'partner', data: this.data, arch:'<form string="Partners">' + - '<field name="timmy" widget="many2many_tags"/>' + + '<field name="timmy" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' + '</form>', res_id: 1, }); @@ -5794,6 +6461,58 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('widget radio on a many2one: domain updated by an onchange', function (assert) { + assert.expect(4); + + this.data.partner.onchanges = { + int_field: function () {}, + }; + + var domain = []; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field"/>' + + '<field name="trululu" widget="radio"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + domain = [['id', 'in', [10]]]; + return $.when({ + value: { + trululu: false, + }, + domain: { + trululu: domain, + }, + }); + } + if (args.method === 'search_read') { + assert.deepEqual(args.kwargs.domain, domain, + "sent domain should be correct"); + } + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_field_widget[name=trululu] .o_radio_item').length, 3, + "should be 3 radio buttons"); + + // trigger an onchange that will update the domain + form.$('.o_field_widget[name=int_field]').val(2).trigger('input'); + + assert.strictEqual(form.$('.o_field_widget[name=trululu] .o_radio_item').length, 0, + "should be no more radio button"); + + form.destroy(); + }); + QUnit.module('FieldMany2ManyCheckBoxes'); QUnit.test('widget many2many_checkboxes', function (assert) { @@ -5973,6 +6692,103 @@ QUnit.module('relational_fields', { }); }); + QUnit.module('FieldReference'); + + QUnit.test('reference in form view', function (assert) { + assert.expect(15); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="reference" string="custom label"/>' + + '</group>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': '<form string="Product"><field name="display_name"/></form>', + }, + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'get_formview_action') { + assert.deepEqual(args.args[0], [37], "should call get_formview_action with correct id"); + return $.when({ + res_id: 17, + type: 'ir.actions.act_window', + target: 'current', + res_model: 'res.partner' + }); + } + if (args.method === 'get_formview_id') { + assert.deepEqual(args.args[0], [37], "should call get_formview_id with correct id"); + return $.when(false); + } + if (args.method === 'name_search') { + assert.strictEqual(args.model, 'partner_type', + "the name_search should be done on the newly set model"); + } + if (args.method === 'write') { + assert.strictEqual(args.model, 'partner', + "should write on the current model"); + assert.deepEqual(args.args, [[1], {reference: 'partner_type,12'}], + "should write the correct value"); + } + return this._super(route, args); + }, + }); + + testUtils.intercept(form, 'do_action', function (event) { + assert.strictEqual(event.data.action.res_id, 17, + "should do a do_action with correct parameters"); + }); + + assert.strictEqual(form.$('a.o_form_uri:contains(xphone)').length, 1, + "should contain a link"); + form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action) + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_field_widget').length, 2, + "should contain two field widgets (selection and many2one)"); + assert.strictEqual(form.$('.o_field_many2one').length, 1, + "should contain one many2one"); + assert.strictEqual(form.$('.o_field_widget select').val(), "product", + "widget should contain one select with the model"); + assert.strictEqual(form.$('.o_field_widget input').val(), "xphone", + "widget should contain one input with the record"); + + var options = _.map(form.$('.o_field_widget select > option'), function (el) { + return $(el).val(); + }); + assert.deepEqual(options, ['', 'product', 'partner_type', 'partner'], + "the options should be correctly set"); + + + form.$('.o_external_button').click(); // click on the external button (should do an RPC) + + assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: custom label', + "dialog title should display the custom string label"); + $('.modal .o_form_button_cancel').click(); + + form.$('.o_field_widget select').val('partner_type').trigger('change'); + assert.strictEqual(form.$('.o_field_widget input').val(), "", + "many2one value should be reset after model change"); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + // change the value of the m2o with a suggestion of the dropdown + form.$('.o_field_many2one input').click(); // will trigger a name_search on partner_type model + $dropdown.find('li:first()').click(); + + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.$('a.o_form_uri:contains(gold)').length, 1, + "should contain a link with the new value"); + + form.destroy(); + }); + }); }); }); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index cdd4ff4e523..cab291ec012 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -94,6 +94,7 @@ var MockServer = Class.extend({ args = JSON.parse(JSON.stringify(args)); if (logLevel === 2) { console.log('%c[rpc] request ' + route, 'color: blue; font-weight: bold;', args); + args = JSON.parse(JSON.stringify(args)); } return this._performRpc(route, args).then(function (result) { var resultString = JSON.stringify(result || false); @@ -567,8 +568,10 @@ var MockServer = Class.extend({ } else { result[fields[i]] = false; } + } else if (field.type === 'one2many' || field.type === 'many2many') { + result[fields[i]] = record[fields[i]] || []; } else { - result[fields[i]] = record[fields[i]]; + result[fields[i]] = record[fields[i]] || false; } } return result; diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index fafe2dee1c6..0b47e9d0df8 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -356,6 +356,15 @@ function createModel(params) { addMockEnvironment(widget, params); + // override the model's 'destroy' so that it calls 'destroy' on the widget + // instead, as the widget is the parent of the model and the mockServer. + model.destroy = function () { + // remove the override to properly destroy the model when it will be + // called the second time (by its parent) + delete model.destroy; + widget.destroy(); + }; + return model; } diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 9fbc2acdd88..7f7547262da 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -20,6 +20,7 @@ QUnit.module('Views', { product_ids: {string: "Favorite products", type: 'one2many', relation: 'product'}, category: {string: "Category M2M", type: 'many2many', relation: 'partner_type'}, date: {string: "Date Field", type: 'date'}, + reference: {string: "Reference Field", type: 'reference', selection: [["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]}, }, records: [ {id: 1, foo: 'blip', bar: 1, product_id: 37, category: [12], display_name: "first partner", date: "2017-01-25"}, @@ -71,9 +72,15 @@ QUnit.module('Views', { Model: BasicModel, data: this.data, mockRPC: function (route, args) { - assert.strictEqual(args.kwargs.context.active_field, 2, - "should have sent the correct context"); + assert.deepEqual(args.kwargs.context, { + active_field: 2, + bin_size: true, + someKey: 'some value', + }, "should have sent the correct context"); return this._super.apply(this, arguments); + }, + session: { + user_context: {someKey: 'some value'}, } }); @@ -302,6 +309,89 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('notifyChange on a one2many', function (assert) { + assert.expect(9); + + this.data.partner.records[1].product_ids = [37]; + this.params.fieldNames = ['product_ids']; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.strictEqual(args.model, 'product'); + } + return this._super(route, args); + }, + }); + + var o2mParams = { + modelName: 'product', + fields: this.data.product.fields, + fieldNames: ['display_name'], + }; + model.load(this.params).then(function (resultID) { + model.load(o2mParams).then(function (newRecordID) { + var record = model.get(resultID); + var x2mListID = record.data.product_ids.id; + + assert.strictEqual(record.data.product_ids.count, 1, + "there should be one record in the relation"); + + // trigger a 'ADD' command + model.notifyChanges(resultID, {product_ids: {operation: 'ADD', id: newRecordID}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + // trigger a 'UPDATE' command + model.notifyChanges(resultID, {product_ids: {operation: 'UPDATE', id: newRecordID}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }, { + operation: 'UPDATE', id: newRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 2, + "there should be two records in the relation"); + + // trigger a 'REMOVE' command on the existing record + var existingRecordID = record.data.product_ids.data[0].id; + model.notifyChanges(resultID, {product_ids: {operation: 'REMOVE', ids: [existingRecordID]}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'ADD', id: newRecordID, + }, { + operation: 'UPDATE', id: newRecordID, + }, { + operation: 'REMOVE', id: existingRecordID, + }], + "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 1, + "there should be one record in the relation"); + + // trigger a 'REMOVE' command on the new record + model.notifyChanges(resultID, {product_ids: {operation: 'REMOVE', ids: [newRecordID]}}); + + assert.deepEqual(model.localData[x2mListID]._changes, [{ + operation: 'REMOVE', id: existingRecordID, + }], "_changes should be correct"); + record = model.get(resultID); + assert.strictEqual(record.data.product_ids.count, 0, + "there should be no record in the relation"); + }); + + }); + model.destroy(); + }); + QUnit.test('notifyChange on a many2one, without display_name', function (assert) { assert.expect(3); @@ -1328,7 +1418,14 @@ QUnit.module('Views', { this.data.partner.onchanges.foo = function (obj) { obj.bar = obj.foo.length; }; - this.data.partner.fields.bar.readonly = true; + this.params.fieldsInfo = { + default: { + foo: {}, + bar: { + modifiers:"{\"readonly\": true}", + }, + } + }; var model = createModel({ Model: BasicModel, @@ -1694,10 +1791,97 @@ QUnit.module('Views', { product_id: 37, product_ids: [], qux: false, + reference: false, total: 0 }, "should use the proper eval context"); }); model.destroy(); }); + QUnit.test('x2manys in contexts and domains are correctly evaluated', function (assert) { + assert.expect(4); + + this.data.partner.records[0].product_ids = [37, 41]; + this.params.fieldNames = Object.keys(this.data.partner.fields); + this.params.fieldsInfo = { + form: { + qux: { + context: "{'category': category, 'product_ids': product_ids}", + domain: "[['id', 'in', category], ['id', 'in', product_ids]]", + relatedFields: this.data.partner.fields, + }, + category: { + relatedFields: this.data.partner_type.fields, + }, + product_ids: { + relatedFields: this.data.product.fields, + }, + }, + }; + this.params.viewType = 'form'; + this.params.res_id = 1; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + var context = record.getContext({fieldName: 'qux'}); + var domain = record.getDomain({fieldName: 'qux'}); + + assert.deepEqual(context, { + category: [12], + product_ids: [37, 41], + }, "x2many values in context manipulated client-side should be lists of ids"); + assert.strictEqual(JSON.stringify(context), + "{\"category\":[[6,false,[12]]],\"product_ids\":[[4,37,false],[4,41,false]]}", + "x2many values in context sent to the server should be commands"); + assert.deepEqual(domain, [ + ['id', 'in', [12]], + ['id', 'in', [37, 41]], + ], "x2many values in domains should be lists of ids"); + assert.strictEqual(JSON.stringify(domain), + "[[\"id\",\"in\",[12]],[\"id\",\"in\",[37,41]]]", + "x2many values in domains should be lists of ids"); + }); + model.destroy(); + }); + + QUnit.test('fetch references in list, with not too many rpcs', function (assert) { + assert.expect(5); + + this.data.partner.records[0].reference = 'product,37'; + this.data.partner.records[1].reference = 'product,41'; + + this.params.fieldNames = ['reference']; + this.params.domain = []; + this.params.groupedBy = []; + this.params.res_id = undefined; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + assert.step(route); + if (route === "/web/dataset/call_kw/product/name_get") { + assert.deepEqual(args.args, [[37, 41]], + "the name_get should contain the product ids"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + + assert.strictEqual(record.data[0].data.reference.data.display_name, "xphone", + "name_get should have been correctly fetched"); + assert.verifySteps(["/web/dataset/search_read", "/web/dataset/call_kw/product/name_get"], + "should have done 2 rpc (searchread and name_get for product)"); + }); + model.destroy(); + }); + });}); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 8aa1f692072..011843eefee 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -143,7 +143,7 @@ QUnit.module('Views', { }; QUnit.test('simple calendar rendering', function (assert) { - assert.expect(19); + assert.expect(22); var calendar = createView({ View: CalendarView, @@ -200,6 +200,13 @@ QUnit.module('Views', { assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)"); assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter"); + assert.strictEqual(calendar.$('.fc-event').length, 6, + "should display 6 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)"); + calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter + assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event"); + calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter + assert.strictEqual(calendar.$('.fc-event').length, 0, "should not display any event anymore"); + // test search bar in filter $sidebar.find('input[type="text"]').trigger('click'); @@ -208,7 +215,7 @@ QUnit.module('Views', { assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 4, "should display 4 filter items for 'attendees'"); $sidebar.find('input[type="text"]').trigger('click'); assert.strictEqual($('ul.ui-autocomplete li:not(.o_m2o_dropdown_option)').text(), "partner 4", "should display the last choice in one2many autocomplete"); // TODO: remove :not(.o_m2o_dropdown_option) because can't have "create & edit" choice - $sidebar.find('.o_calendar_filter_item[data-id="1"] .o_remove').trigger('click'); + $sidebar.find('.o_calendar_filter_item .o_remove').first().trigger('click'); assert.ok($('.modal button.btn:contains(Ok)').length, "should display the confirm message"); $('.modal button.btn:contains(Ok)').trigger('click'); assert.strictEqual($sidebar.find('.o_calendar_filter:has(h3:contains(attendees)) .o_calendar_filter_item').length, 3, "click on remove then should display 3 filter items for 'attendees'"); @@ -349,6 +356,63 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('quickcreate switching to actual create for required fields', function (assert) { + assert.expect(4); + + var event = $.Event(); + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'all_day="allday" '+ + 'mode="month" '+ + 'readonly_form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + return $.Deferred().reject({ + code: 200, + data: {}, + message: "Odoo server error", + }, event); + } + return this._super(route, args); + }, + }); + + // create a new event + var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + + assert.strictEqual($('.modal-dialog.modal-sm .modal-title').text(), 'Create', + "should open the quick create dialog"); + + $('.modal-body input:first').val('new event in quick create').trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click').trigger('click'); + + // If the event is not default-prevented, a traceback will be raised, which we do not want + assert.ok(event.isDefaultPrevented(), "fail deferred event should have been default-prevented"); + + assert.strictEqual($('.modal-dialog.modal-lg .modal-title').text(), 'Create: Events', + "should have switched to a bigger modal for an actual create rather than quickcreate"); + assert.strictEqual($('.modal-dialog.modal-lg .modal-body .o_form_view.o_form_editable').length, 1, + "should open the full event form view in a dialog"); + + calendar.destroy(); + }); + QUnit.test('create event with timezone in week mode', function (assert) { assert.expect(5); @@ -640,7 +704,7 @@ QUnit.module('Views', { }); QUnit.test('use mini calendar', function (assert) { - assert.expect(2); + assert.expect(12); var calendar = createView({ View: CalendarView, @@ -665,17 +729,36 @@ QUnit.module('Views', { }, }); + assert.strictEqual(calendar.$('.fc-agendaWeek-view').length, 1, "should be in week mode"); assert.strictEqual(calendar.$('.fc-event').length, 9, "should display 9 events on the week (4 event + 5 days event)"); - $('.o_calendar_mini a:contains(19)').click(); + calendar.$('.o_calendar_mini a:contains(19)').click(); + // Clicking on a day in another week should switch to the other week view + assert.strictEqual(calendar.$('.fc-agendaWeek-view').length, 1, "should be in week mode"); + assert.strictEqual(calendar.$('.fc-event').length, 4, "should display 4 events on the week (1 event + 3 days event)"); + // Clicking on a day in the same week should switch to that particular day view + calendar.$('.o_calendar_mini a:contains(18)').click(); + assert.strictEqual(calendar.$('.fc-agendaDay-view').length, 1, "should be in day mode"); + assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the day"); + // Clicking on the same day should toggle between day, month and week views + calendar.$('.o_calendar_mini a:contains(18)').click(); + assert.strictEqual(calendar.$('.fc-month-view').length, 1, "should be in month mode"); + assert.strictEqual(calendar.$('.fc-event').length, 7, "should display 7 events on the month (event 5 is on multiple weeks and generates to .fc-event)"); + calendar.$('.o_calendar_mini a:contains(18)').click(); + assert.strictEqual(calendar.$('.fc-agendaWeek-view').length, 1, "should be in week mode"); assert.strictEqual(calendar.$('.fc-event').length, 4, "should display 4 events on the week (1 event + 3 days event)"); + calendar.$('.o_calendar_mini a:contains(18)').click(); + assert.strictEqual(calendar.$('.fc-agendaDay-view').length, 1, "should be in day mode"); + assert.strictEqual(calendar.$('.fc-event').length, 2, "should display 2 events on the day"); calendar.destroy(); }); QUnit.test('rendering, with many2many', function (assert) { - assert.expect(1); + assert.expect(5); this.data.event.fields.partner_ids.type = 'many2many'; + this.data.event.records[0].partner_ids = [1,2,3,4,5]; + this.data.partner.records.push({id: 5, display_name: "partner 5", image: 'EEE'}); var calendar = createView({ View: CalendarView, @@ -698,6 +781,15 @@ QUnit.module('Views', { assert.strictEqual(calendar.$('.o_calendar_filter_items .o_cal_avatar').length, 3, "should have 3 avatars in the side bar"); + var $event1Avatars = calendar.$('.fc-event .o_calendar_avatars').first(); + assert.strictEqual($event1Avatars.find('img').length, 1, "should have 1 avatar"); + assert.strictEqual($event1Avatars.find('span').length, 0, + "should not have a span for more attendees since there is only one"); + + var $event2Avatars = calendar.$('.fc-event:contains(All the day) .o_calendar_avatars'); + assert.strictEqual($event2Avatars.find('img').length, 3, "should have 3 avatars"); + assert.strictEqual($event2Avatars.find('span').text(), '+2', + "should indicate there are 2 more attendees that we don't show"); calendar.destroy(); }); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 0e53d6814d4..0857cb50038 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -2968,6 +2968,53 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('discarding before save returns', function (assert) { + assert.expect(4); + + var def = $.Deferred(); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group><field name="foo"/></group>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'write') { + return def.then(_.constant(result)); + } + return result; + }, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('input').val("1234").trigger('input'); + + // save the value and discard directly + form.$buttons.find('.o_form_button_save').click(); + form.discardChanges(); // Simulate click on breadcrumb + + assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), "1234", + "field foo should still contain new value"); + assert.strictEqual($('.modal').length, 0, + "Confirm dialog should not be displayed"); + + // complete the write + def.resolve(); + + assert.strictEqual($('.modal').length, 0, + "Confirm dialog should not be displayed"); + assert.strictEqual(form.$('.o_field_widget[name="foo"]').text(), "1234", + "value should have been saved and rerendered in readonly"); + + form.destroy(); + }); + QUnit.test('unchanged relational data is sent for onchanges', function (assert) { assert.expect(1); @@ -4361,7 +4408,7 @@ QUnit.module('Views', { }); QUnit.test('default_order on x2many embedded view', function (assert) { - assert.expect(8); + assert.expect(11); this.data.partner.fields.display_name.sortable = true; this.data.partner.records[0].p = [1, 4]; @@ -4421,6 +4468,18 @@ QUnit.module('Views', { assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length, "record xop should be third"); + // client-side sort on edit + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').click(); + $('.modal input[name="foo"]').val('zzz').trigger("input"); + $('.modal .modal-footer button:first').click(); // Save + assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length, + "record zzz should be first"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(zop)').length, + "record zop should be second"); + assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length, + "record xop should be third"); + form.destroy(); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 549b724ca68..89ec104fc41 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -457,7 +457,7 @@ QUnit.module('Views', { arch: '<kanban class="o_kanban_test">' + '<templates><t t-name="kanban-box">' + '<div>' + - '<field name="category_ids"/>' + + '<field name="category_ids" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' + '<field name="foo"/>' + '<field name="state" widget="priority"/>' + '</div>' + diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index c39e6d2b1b0..09109d92b84 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2,6 +2,7 @@ odoo.define('web.list_tests', function (require) { "use strict"; var config = require('web.config'); +var FormView = require('web.FormView'); var ListView = require('web.ListView'); var testUtils = require('web.test_utils'); @@ -23,6 +24,8 @@ QUnit.module('Views', { currency_id: {string: "Currency", type: "many2one", relation: "res_currency", default: 1}, datetime: {string: "Datetime Field", type: 'datetime'}, + reference: {string: "Reference Field", type: 'reference', selection: [ + ["bar", "Bar"], ["res_currency", "Currency"], ["event", "Event"]]}, }, records: [ { @@ -37,11 +40,12 @@ QUnit.module('Views', { currency_id: 2, date: "2017-01-25", datetime: "2016-12-12 10:55:05", + reference: 'bar,1', }, {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, - m2o: 2, m2m: [1, 2, 3], amount: 500}, + m2o: 2, m2m: [1, 2, 3], amount: 500, reference: 'res_currency,1'}, {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, - m2o: 1, m2m: [], amount: 300}, + m2o: 1, m2m: [], amount: 300, reference: 'res_currency,2'}, {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, m2o: 1, m2m: [1], amount: 0}, ] @@ -2400,6 +2404,68 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('reference field rendering', function (assert) { + assert.expect(4); + + this.data.foo.records.push({ + id: 5, + reference: 'res_currency,2', + }); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="reference"/></tree>', + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.step(args.model); + } + return this._super.apply(this, arguments); + }, + }); + + assert.verifySteps(['bar', 'res_currency'], "should have done 1 name_get by model in reference values"); + assert.strictEqual(list.$('tbody td').text(), "Value 1USDEUREUR", + "should have the display_name of the reference"); + list.destroy(); + }); + + QUnit.test('editable list view: contexts are correctly sent', function (assert) { + assert.expect(6); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>', + mockRPC: function (route, args) { + var context; + if (route === '/web/dataset/search_read') { + context = args.context; + } else { + context = args.kwargs.context; + } + assert.strictEqual(context.active_field, 2, "context should be correct"); + assert.strictEqual(context.someKey, 'some value', "context should be correct"); + return this._super.apply(this, arguments); + }, + session: { + user_context: {someKey: 'some value'}, + }, + viewOptions: { + context: {active_field: 2}, + }, + }); + + list.$('.o_data_cell:first').click(); + list.$('.o_field_widget[name=foo]').val('abc').trigger('input'); + list.$buttons.find('.o_list_button_save').click(); + + list.destroy(); + }); }); }); diff --git a/addons/web_editor/models/ir_http.py b/addons/web_editor/models/ir_http.py index 04845688ea9..d6e2b2d5ee6 100644 --- a/addons/web_editor/models/ir_http.py +++ b/addons/web_editor/models/ir_http.py @@ -15,7 +15,7 @@ class IrHttp(models.AbstractModel): context['editable'] = True if 'edit_translations' in request.httprequest.args and 'edit_translations' not in context: context['edit_translations'] = True - if context.get('lang') != "en_US" and 'translatable' not in context: + if context.get('edit_translations') and 'translatable' not in context: context['translatable'] = True request.context = context return super(IrHttp, cls)._dispatch() diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index 8c60e7923f2..7cf9e2f3a27 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -49,7 +49,7 @@ class IrUiView(models.Model): if value is not None: # TODO: batch writes? - if self.get_default_lang_code() and field != 'arch' and not isinstance(Model._fields[field], fields.Html): + if not self.env.context.get('lang') and self.get_default_lang_code(): Model.browse(int(el.get('data-oe-id'))).with_context(lang=self.get_default_lang_code()).write({field: value}) else: Model.browse(int(el.get('data-oe-id'))).write({field: value}) diff --git a/addons/web_editor/static/src/js/backend.js b/addons/web_editor/static/src/js/backend.js index 34ca6c9f5d4..06053dd1cea 100644 --- a/addons/web_editor/static/src/js/backend.js +++ b/addons/web_editor/static/src/js/backend.js @@ -10,7 +10,10 @@ var field_registry = require('web.field_registry'); var transcoder = require('web_editor.transcoder'); +var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin; + var QWeb = core.qweb; +var _t = core._t; /** @@ -18,11 +21,8 @@ var QWeb = core.qweb; * Intended to display HTML content. This widget uses the summernote editor * improved by odoo. * - * FIXME: this field has a custom handling of the translation feature, which - * hasn't been re-introduced yet (because this feature hasn't been introduced - * yet in the fields in general) */ -var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({ +var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { className: 'oe_form_field oe_form_field_html_text', supportedFieldTypes: ['html'], @@ -133,6 +133,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({ if (history) { history.reset(); } + this.$('.note-toolbar').append(this._renderTranslateButton()); }, /** * @override @@ -187,6 +188,19 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({ } return value; }, + /** + * @override + * @private + * @returns {jQueryElement} + */ + _renderTranslateButton: function () { + if (_t.database.multi_lang && this.field.translate && this.res_id) { + return $(QWeb.render('web_editor.FieldTextHtml.button.translate', {widget: this})) + .on('click', this._onTranslate.bind(this)); + } + return $(); + }, + }); var FieldTextHtml = AbstractField.extend({ @@ -196,6 +210,7 @@ var FieldTextHtml = AbstractField.extend({ start: function () { var self = this; + this.loaded = false; this.callback = _.uniqueId('FieldTextHtml_'); window.odoo[this.callback+"_editor"] = function (EditorBar) { setTimeout(function () { @@ -206,8 +221,7 @@ var FieldTextHtml = AbstractField.extend({ self.on_content_loaded(); }; window.odoo[this.callback+"_updown"] = null; - window.odoo[this.callback+"_downup"] = function (value) { - self._setValue(value); + window.odoo[this.callback+"_downup"] = function () { self.resize(); }; @@ -233,8 +247,6 @@ var FieldTextHtml = AbstractField.extend({ this.old_initialize_content(); var def = this._super.apply(this, arguments); - // this.$translate.remove(); - // this.$translate = $(); return def; }, getDatarecord: function () { @@ -261,18 +273,13 @@ var FieldTextHtml = AbstractField.extend({ if (this.nodeOptions.template) { attr.template = this.nodeOptions.template; } - if (this. mode === "edit") { + if (this.mode === "edit") { attr.enable_editor = 1; } - if (this.field.translate) { - attr.translatable = 1; - } if (session.debug) { attr.debug = session.debug; } - attr.lang = attr.enable_editor ? 'en_US' : this.getSession().user_context.lang; - for (k in _attr) { attr[k] = _attr[k]; } @@ -306,26 +313,10 @@ var FieldTextHtml = AbstractField.extend({ this.document = this.$iframe.contents()[0]; this.$body = $("body", this.document); this.$content = this.$body.find("#editable_area"); - // this._toggle_label(); - this.lang = this.$iframe.attr('src').match(/[?&]lang=([^&]+)/); - this.lang = this.lang ? this.lang[1] : session.user_context.lang; - this._dirty_flag = false; this.render(); - setTimeout(function () { - self.trigger_up('perform_model_rpc', { - model: 'res.lang', - method: 'search_read', - args: [ - [['code', '!=', 'en_US']], - ["name", "code"] - ], - on_success: function (res) { - self.languages = res; - self.add_button(); - setTimeout(self.resize,0); - } - }); - }, 0); + this.add_button(); + this.loaded = true; + setTimeout(self.resize, 0); }, on_editor_loaded: function (EditorBar) { var self = this; @@ -341,19 +332,6 @@ var FieldTextHtml = AbstractField.extend({ var self = this; var $to = this.$body.find("#web_editor-top-edit, #wrapwrap").first(); - $(QWeb.render('FieldTextHtml.translate', {'widget': this})) - .appendTo($to) - .on('change', 'select', function () { - var lang = $(this).val(); - var edit = self. mode === "edit"; - var trans = lang !== 'en_US'; - self.$iframe.attr("src", self.get_url({ - 'edit_translations': edit && trans, - 'enable_editor': edit && !trans, - 'lang': lang - })); - }); - $(QWeb.render('web_editor.FieldTextHtml.fullscreen')) .appendTo($to) .on('click', '.o_fullscreen', function () { @@ -369,9 +347,6 @@ var FieldTextHtml = AbstractField.extend({ }); }, render: function () { - if (this.lang !== session.user_context.lang || this.$iframe.attr('src').match(/[?&]edit_translations=1/)) { - return; - } var value = (this.value || "").replace(/^<p[^>]*>(\s*|<br\/?>)<\/p>$/, ''); if (!this.$content) { return; @@ -392,24 +367,34 @@ var FieldTextHtml = AbstractField.extend({ has_no_value: function () { return this.value === false || !this.$content.html() || !this.$content.html().match(/\S/); }, - // FIXME: not sure what this is supposed to do that is still relevant now - // commit_value: function () { - // if (this.lang !== 'en_US' && this.$body.find('.o_dirty').length) { - // this.internal_set_value( this.view.datarecord[this.name] ); - // this._dirty_flag = false; - // return this.editor.save(); - // } else if (this._dirty_flag && this.editor && this.editor.buildingBlock) { - // this.editor.buildingBlock.clean_for_save(); - // this.internal_set_value( this.$content.html() ); - // } - // }, destroy: function () { $(window).off('resize', this.resize); delete window.odoo[this.callback+"_editor"]; delete window.odoo[this.callback+"_content"]; delete window.odoo[this.callback+"_updown"]; delete window.odoo[this.callback+"_downup"]; - } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + commitChanges: function () { + if (!this.loaded) { + return; + } + // switch to WYSIWYG mode if currently in code mode to get all changes + if (config.debug && this.mode === 'edit' && this.editor.rte) { + var layoutInfo = this.editor.rte.editable().data('layoutInfo'); + $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); + } + this.editor.buildingBlock.clean_for_save(); + this._setValue(this.$content.html()); + return this._super.apply(this, arguments); + }, }); field_registry diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/base.js index e4e2fcd6cad..4368f6de274 100644 --- a/addons/web_editor/static/src/js/base.js +++ b/addons/web_editor/static/src/js/base.js @@ -36,7 +36,7 @@ data.ready = function () { // TODO find a better way to find if we are in the backend or not // We don't load translation if we are in the backend since it's already done by the webclient if (!$(document).find('.o_web_client').length) { - load_translations(); + return load_translations(); } }); } else if(all_ready.state() === "resolved") { // can add async template diff --git a/addons/web_editor/static/src/less/web_editor.ui.less b/addons/web_editor/static/src/less/web_editor.ui.less index f83a73984c4..3ba3621db9f 100644 --- a/addons/web_editor/static/src/less/web_editor.ui.less +++ b/addons/web_editor/static/src/less/web_editor.ui.less @@ -1090,8 +1090,15 @@ body.editor_enable { background-color: @odoo-brand-secondary; #wrapwrap { + position: relative; + display: table; + table-layout: fixed; + width: 100%; height: 100%; + > * { + display: table-row; + } > main { height: 100%; diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml index 69f6e53d6de..082efdbc5a0 100644 --- a/addons/web_editor/static/src/xml/backend.xml +++ b/addons/web_editor/static/src/xml/backend.xml @@ -7,41 +7,14 @@ </div> </t> - <t t-name="web_editor.FieldTextHtml.button.reset"> - <div class="btn-group pull-right"> - <button class="o_reset_translation btn btn-default btn-sm btn-small" title="Reset from source"><span class="fa fa-upload"></span></button> - </div> - </t> - <t t-name="web_editor.FieldTextHtml.button.translate"> <div class="btn-group pull-right"> - <button t-if="widget.field.translate" class="oe_field_translate btn btn-default btn-sm btn-small" style="height: 24px; padding: 1px 3px 0px 5px"> + <button t-if="widget.field.translate" class="o_field_translate btn btn-default btn-sm btn-small" style="height: 24px; padding: 1px 17px 0px 5px"> <span class="fa fa-language fa-lg oe_input_icon"/> </button> </div> </t> - <t t-name="web_editor.FieldTextHtml.translate"> - <div t-if="widget.recordData.id && widget.languages.length" style="margin: 5px; position: fixed; top: 0; left: 0; z-index: 2000;"> - <select style=" - width: 148px; - height: 22px; - padding: 2px 4px; - border: 1px solid #ccc; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; - background: white;"> - <option value="en_US" t-att-selected="'en_US' == widget.lang ? 'selected' : null"> - <t t-if="!widget.get('effective_readonly')">English (edit mode)</t> - <t t-if="widget.get('effective_readonly')">English</t> - </option> - <t t-foreach="widget.languages" t-as="lang"> - <option t-att-value="lang.code" t-att-selected="lang.code == widget.lang ? 'selected' : null"><t t-esc="lang.name"/></option> - </t> - </select> - </div> - </t> <t t-name="web_editor.FieldTextHtml.fullscreen"> <span style="margin: 5px; position: fixed; top: 0; right: 0; z-index: 2000;"> <button class="o_fullscreen btn btn-primary" style="width: 24px; height: 24px; background-color: #337ab7; border: 1px solid #2e6da4; border-radius: 4px; padding: 0; position: relative;"> diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js index a053ad99043..283931e9b0d 100644 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ b/addons/web_editor/static/tests/web_editor_tests.js @@ -3,6 +3,9 @@ odoo.define('web_editor.web_editor_tests', function (require) { var FormView = require('web.FormView'); var testUtils = require('web.test_utils'); +var core = require('web.core'); + +var _t = core._t; QUnit.module('web_editor', { beforeEach: function() { @@ -87,6 +90,43 @@ QUnit.test('field html widget (with options inline-style)', function (assert) { }, 0); }); +QUnit.test('field html translatable', function (assert) { + assert.expect(3); + + var multiLang = _t.database.multi_lang; + _t.database.multi_lang = true; + + this.data['mass.mailing'].fields.body.translate = true; + + var form = testUtils.createView({ + View: FormView, + model: 'mass.mailing', + data: this.data, + arch: '<form string="Partners">' + + '<field name="body" widget="html"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_button' && args.method === 'translate_fields') { + assert.deepEqual(args.args, ['mass.mailing',1,'body',{}], "should call 'call_button' route"); + return $.when(); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.oe_form_field_html_text .o_field_translate').length, 0, + "should not have a translate button in readonly mode"); + + form.$buttons.find('.o_form_button_edit').click(); + var $button = form.$('.oe_form_field_html_text .o_field_translate'); + assert.strictEqual($button.length, 1, "should have a translate button"); + $button.click(); + + form.destroy(); + _t.database.multi_lang = multiLang; +}); + QUnit.test('field html_frame widget', function (assert) { assert.expect(6); diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 4349eaa1774..077a4622651 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -208,7 +208,7 @@ class Http(models.AbstractModel): path.pop(1) request.context = context return cls.reroute('/'.join(path) or '/') - if path[1] == request.website.default_lang_code: + if request.lang == request.website.default_lang_code: context['edit_translations'] = False if not context.get('tz'): context['tz'] = request.session.get('geoip', {}).get('time_zone') diff --git a/addons/website/static/src/js/website.js b/addons/website/static/src/js/website.js index 6b221b801d1..75350dce58a 100644 --- a/addons/website/static/src/js/website.js +++ b/addons/website/static/src/js/website.js @@ -7,6 +7,7 @@ odoo.define('website.website', function (require) { var Widget = require('web.Widget'); var session = require('web.session'); var base = require('web_editor.base'); + var utils = require('web.utils'); var qweb = core.qweb; var _t = core._t; @@ -244,6 +245,10 @@ odoo.define('website.website', function (require) { } }); + /* Load localizations */ + var lang = utils.get_cookie('website_lang') || $('html').attr('lang') || 'en_US'; + ajax.loadJS('/web/webclient/locale/' + lang.replace('-', '_')); + /** * Object who contains all method and bind for the top bar, the template is create server side. */ diff --git a/addons/website_blog/controllers/main.py b/addons/website_blog/controllers/main.py index 3a5ef95c3d4..c400b8b792f 100644 --- a/addons/website_blog/controllers/main.py +++ b/addons/website_blog/controllers/main.py @@ -174,6 +174,7 @@ class WebsiteBlog(http.Controller): v['blog'] = blog v['base_url'] = request.env['ir.config_parameter'].sudo().get_param('web.base.url') v['posts'] = request.env['blog.post'].search([('blog_id','=', blog.id)], limit=min(int(limit), 50)) + v['html2plaintext'] = html2plaintext r = request.render("website_blog.blog_feed", v, headers=[('Content-Type', 'application/atom+xml')]) return r diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml index c2ecb61d5aa..a7877cdc8c1 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -555,7 +555,7 @@ <link t-att-href="'%s%s' % (base_url, post.website_url)"/> <id t-esc="'%s%s' % (base_url, post.website_url)"/> <author><name t-esc="post.sudo().author_id.name"/></author> - <summary t-esc="post.subtitle"/> + <summary t-esc="html2plaintext(post.teaser)"/> <updated t-esc="post.write_date.replace(' ', 'T') + 'Z'"/> </entry> </feed> diff --git a/addons/website_customer/views/res_partner_views.xml b/addons/website_customer/views/res_partner_views.xml index 69c59989879..a756453c4f6 100644 --- a/addons/website_customer/views/res_partner_views.xml +++ b/addons/website_customer/views/res_partner_views.xml @@ -7,7 +7,7 @@ <field name="arch" type="xml"> <data> <xpath expr="//field[@name='company_id']" position="after"> - <field name="website_tag_ids" widget="many2many_tags" string="Website Tags"/> + <field name="website_tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" string="Website Tags"/> </xpath> </data> </field> diff --git a/addons/website_event/views/event_templates.xml b/addons/website_event/views/event_templates.xml index 9b1606fc97e..51346d24171 100644 --- a/addons/website_event/views/event_templates.xml +++ b/addons/website_event/views/event_templates.xml @@ -57,7 +57,7 @@ <h4 class="media-heading"> <a itemprop="url" t-att-class="event.state == 'done' and 'text-success'" t-attf-href="/event/#{ slug(event) }/#{(not event.menu_id) and 'register' or ''}"><span itemprop="name" t-field="event.name"> </span></a> <small t-if="event.is_participating" class="label label-info">Participating</small> - <small t-if="not event.address_id" class="label label-info">Online</small> + <small t-if="not event.is_online" class="label label-info">Online</small> <small t-if="not event.website_published" class="label label-danger">unpublished</small> </h4> <div> @@ -68,10 +68,12 @@ <div> <i class="fa fa-clock-o"></i> <span itemprop="startDate" t-field="event.with_context(tz=event.date_tz).date_begin" t-options='{"hide_seconds":"True"}'> </span> <i>to</i> <span itemprop="endDate" t-field="event.with_context(tz=event.date_tz).date_end" t-options='{"hide_seconds":"True"}'> </span> </div> - <div itemprop="location" t-field="event.address_id" t-options='{ - "widget": "contact", - "fields": ["city"] - }'/> + <t t-if="not event.is_online"> + <div itemprop="location" t-field="event.address_id" t-options='{ + "widget": "contact", + "fields": ["city"] + }'/> + </t> <div class="text-muted" t-if="event.event_type_id"> <i class="fa fa-tag"></i> <span t-field="event.event_type_id"/> </div> @@ -333,25 +335,27 @@ <div class="col-md-4 css_noprint"> <div class="clearfix"/> - <div class="panel panel-default" t-if="event.address_id"> - <div class="panel-heading"> - <h4>Where</h4> - </div> - <div class="panel-body"> - <t t-if="event.google_map_img()"> - <a t-att-href="event.google_map_link()" target="_BLANK"> - <img t-att-src="event.google_map_img()" width="100%%"/> - </a> - </t> - <t t-else="1"> - <a t-att-href="event.google_map_link()" target="_BLANK">Visualize on Google Maps</a> - </t> - <div itemprop="location" class="mt16 mb8" t-field="event.address_id" t-options='{ - "widget": "contact", - "fields": ["name", "address", "phone", "mobile", "fax", "email"] - }'/> + <t t-if="not event.is_online"> + <div class="panel panel-default" t-if="event.address_id"> + <div class="panel-heading"> + <h4>Where</h4> + </div> + <div class="panel-body"> + <t t-if="event.google_map_img()"> + <a t-att-href="event.google_map_link()" target="_BLANK"> + <img t-att-src="event.google_map_img()" width="100%%"/> + </a> + </t> + <t t-else="1"> + <a t-att-href="event.google_map_link()" target="_BLANK">Visualize on Google Maps</a> + </t> + <div itemprop="location" class="mt16 mb8" t-field="event.address_id" t-options='{ + "widget": "contact", + "fields": ["name", "address", "phone", "mobile", "fax", "email"] + }'/> + </div> </div> - </div> + </t> <div class="panel panel-default"> <div class="panel-heading"> diff --git a/addons/website_event_questions/models/event.py b/addons/website_event_questions/models/event.py index 4645d73c4d2..e1b95dcee46 100644 --- a/addons/website_event_questions/models/event.py +++ b/addons/website_event_questions/models/event.py @@ -33,10 +33,6 @@ class EventEvent(models.Model): 'title': question.title, 'sequence': question.sequence, 'is_individual': question.is_individual, - 'answer_ids': [(0, 0, { - 'name': answer.name, - 'sequence': answer.sequence, - }) for answer in question.answer_ids] }) for question in self.event_type_id.question_ids ] @@ -81,6 +77,18 @@ class EventQuestion(models.Model): if any(question.event_type_id and question.event_id for question in self): raise UserError(_('Question should belong to either event category or event but not both')) + @api.model + def create(self, vals): + event_id = vals.get('event_id', False) + if event_id: + event = self.env['event.event'].browse([event_id]) + if event.event_type_id.use_questions and event.event_type_id.question_ids: + vals['answer_ids'] = vals.get('answer_ids', []) + [(0, 0, { + 'name': answer.name, + 'sequence': answer.sequence, + }) for answer in event.event_type_id.question_ids.filtered(lambda question: question.title == vals.get('title')).mapped('answer_ids')] + return super(EventQuestion, self).create(vals) + class EventAnswer(models.Model): _name = 'event.answer' diff --git a/addons/website_event_track/views/event_track_views.xml b/addons/website_event_track/views/event_track_views.xml index f071619b3a3..5a679f2f644 100644 --- a/addons/website_event_track/views/event_track_views.xml +++ b/addons/website_event_track/views/event_track_views.xml @@ -29,7 +29,7 @@ </ul> </div> <div class="oe_kanban_content"> - <field name="tag_ids"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <h4><field name="name"/></h4> <div> <t t-if="duration"><field name="duration" widget="float_time"/> hours</t> @@ -147,7 +147,7 @@ <field name="duration" class="oe_inline" widget="float_time"/> hours </div> <field name="location_id"/> - <field name="tag_ids" widget="many2many_tags"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </group> </group> </sheet> diff --git a/addons/website_form/models/models.py b/addons/website_form/models/models.py index 75a2ce33fb2..654fbfa9ee4 100644 --- a/addons/website_form/models/models.py +++ b/addons/website_form/models/models.py @@ -27,12 +27,6 @@ class website_form_model(models.Model): website_form_default_field_id = fields.Many2one('ir.model.fields', 'Field for custom form data', domain="[('model', '=', model), ('ttype', '=', 'text')]", help="Specify the field which will contain meta and custom form fields datas.") website_form_label = fields.Char("Label for form action", help="Form action label. Ex: crm.lead could be 'Send an e-mail' and project.issue could be 'Create an Issue'.") - def _all_inherited_model_ids(self): - return list(itertools.chain( - [self.id], - *(m._all_inherited_model_ids() for m in self.inherited_model_ids) - )) - def _get_form_writable_fields(self): """ Restriction of "authorized fields" (fields which can be used in the @@ -40,16 +34,16 @@ class website_form_model(models.Model): builders and are writable. By default no field is writable by the form builder. """ - excluded = { + included = { field.name for field in self.env['ir.model.fields'].sudo().search([ - ('model_id', 'in', self._all_inherited_model_ids()), - ('website_form_blacklisted', '=', True) + ('model_id', '=', self.id), + ('website_form_blacklisted', '=', False) ]) } return { k: v for k, v in pycompat.items(self.get_authorized_fields(self.model)) - if k not in excluded + if k in included } @api.model diff --git a/addons/website_forum/views/forum.xml b/addons/website_forum/views/forum.xml index 2221667662d..b2ccc640939 100644 --- a/addons/website_forum/views/forum.xml +++ b/addons/website_forum/views/forum.xml @@ -159,7 +159,7 @@ <field name="forum_id"/> <field name="parent_id"/> </group><group> - <field name="tag_ids" widget="many2many_tags"/> + <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="state"/> <field name="closed_reason_id"/> <field name="closed_uid"/> diff --git a/addons/website_hr_recruitment/__manifest__.py b/addons/website_hr_recruitment/__manifest__.py index 0e661b173e2..23d6f2d93b4 100644 --- a/addons/website_hr_recruitment/__manifest__.py +++ b/addons/website_hr_recruitment/__manifest__.py @@ -17,6 +17,7 @@ Odoo Contact Form 'security/ir.model.access.csv', 'security/website_hr_recruitment_security.xml', 'data/config_data.xml', + 'views/website_hr_recuitment_snippets.xml', 'views/website_hr_recruitment_templates.xml', 'views/hr_recruitment_views.xml', ], diff --git a/addons/website_hr_recruitment/static/src/img/ui/s_hr_rating_thumb.jpg b/addons/website_hr_recruitment/static/src/img/ui/s_hr_rating_thumb.jpg Binary files differnew file mode 100644 index 00000000000..1817c270b6e --- /dev/null +++ b/addons/website_hr_recruitment/static/src/img/ui/s_hr_rating_thumb.jpg diff --git a/addons/website_hr_recruitment/static/src/less/s_hr_rating.less b/addons/website_hr_recruitment/static/src/less/s_hr_rating.less new file mode 100644 index 00000000000..76b6edb8005 --- /dev/null +++ b/addons/website_hr_recruitment/static/src/less/s_hr_rating.less @@ -0,0 +1,65 @@ +.s_hr_rating { + @star: "\f005"; + @star-o: "\f006"; + @bolt: "\f0e7"; + @bolt-o: "_"; + @heart: "\f004"; + @heart-o: "\f08a"; + + .s_hr_rating_generate_icons (@off, @on) { + .fa:before { + content: @off; + } + .loop(@counter) when (@counter > 0) { + &.s_hr_rating_@{counter} { + .fa:nth-of-type(-n+@{counter}):before { + content: @on; + } + } + .loop((@counter - 1)); + } + .loop(5); + } + + &.s_hr_rating_stars { + .s_hr_rating_generate_icons(@star-o, @star); + } + + &.s_hr_rating_bolts { + .s_hr_rating_generate_icons(@bolt-o, @bolt); + } + + &.s_hr_rating_hearts { + .s_hr_rating_generate_icons(@heart-o, @heart); + } + + &.s_hr_rating_bar { + .fa { + display: none; + } + + .s_hr_rating_bar { + height: 15px; + padding: 1px; + background-color: @gray-lighter; + border-radius: 2px; + &:before { + content: ""; + border-radius: 2px; + background-color: @brand-primary; + display: block; + .size(0, 100%); + } + } + + .loop(@counter) when (@counter > 0) { + &.s_hr_rating_@{counter} { + .s_hr_rating_bar:before { + width: percentage(@counter/5); + } + } + .loop((@counter - 1)); + } + .loop(5); + } +}
\ No newline at end of file diff --git a/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml b/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml index c46607c2a4c..7a399265790 100644 --- a/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml +++ b/addons/website_hr_recruitment/views/website_hr_recruitment_templates.xml @@ -242,36 +242,51 @@ <div class="container"> <div class="row"> <div class="col-sm-6 col-md-4 mt16 mb16"> - <h6 class="mb0">Job Complexity:</h6> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <h6 class="mb0">Personal Evolution:</h6> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <h6 class="mb0">Variability of the Job:</h6> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star-o"/> - <h6 class="mb0">Job Security:</h6> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star-o"/> - <i class="fa fa-star-o"/> - <h6 class="mb0">Overachieving Possibilities:</h6> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> - <i class="fa fa-star"/> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_3"> + <h6 class="mb0">Job Complexity:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_1"> + <h6 class="mb0">Personal Evolution:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_4"> + <h6 class="mb0">Variability of the Job:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_5"> + <h6 class="mb0">Job Security:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_2"> + <h6 class="mb0">Overachieving Possibilities:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> </div> <div class="col-sm-6 col-md-4 mt16 mb16"> <h6 class="mb0">Team / Company Size:</h6> diff --git a/addons/website_hr_recruitment/views/website_hr_recuitment_snippets.xml b/addons/website_hr_recruitment/views/website_hr_recuitment_snippets.xml new file mode 100644 index 00000000000..3713dfede12 --- /dev/null +++ b/addons/website_hr_recruitment/views/website_hr_recuitment_snippets.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<!-- Snippets' Assets --> +<template id="website_hr_recuitment_snippets_assets" name="Website HR Recruitment Snippets Assets" inherit_id="website.assets_frontend"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/less" href="/website_hr_recruitment/static/src/less/s_hr_rating.less"/> + </xpath> +</template> + +<!-- Snippets' Templates --> +<template id="s_hr_rating" name="HR Rating"> + <div class="s_hr_rating s_hr_rating_stars s_hr_rating_3"> + <h6 class="mb0">Job Security:</h6> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <i class="fa"/> + <div class="s_hr_rating_bar"/> + </div> +</template> + +<!-- Add Snippets' into library --> +<template id="website_hr_recuitment_snippets_include" inherit_id="website.snippets" name="Website HR Recruitment Snippets Include"> + <xpath expr="//div[@id='snippet_content']/div[@class='o_panel_body']/*[1]" position="before"> + <t t-snippet="website_hr_recruitment.s_hr_rating" t-thumbnail="/website_hr_recruitment/static/src/img/ui/s_hr_rating_thumb.jpg"/> + </xpath> +</template> + +<!-- Snippets' Options --> +<template id="s_hr_rating_options" inherit_id="website.snippet_options" name="Website HR Rectuitment s_hr_rating options"> + <xpath expr="." position="inside"> + <div data-js='s_hr_rating_option_type' data-selector=".s_hr_rating" data-drop-in="[class^='col-']"> + <li class="dropdown-submenu"> + <a tabindex="-1" href="#">Rating type</a> + <ul class="dropdown-menu"> + <li data-select_class="s_hr_rating_stars"><a href="#"><i class="fa fa-star"/> Stars</a></li> + <li data-select_class="s_hr_rating_bolts"><a href="#"><i class="fa fa-bolt"/> Bolts</a></li> + <li data-select_class="s_hr_rating_hearts"><a href="#"><i class="fa fa-heart"/> Hearts</a></li> + <li data-select_class="s_hr_rating_bar"><a href="#"><i class="fa fa-tasks"/> Bar</a></li> + </ul> + </li> + </div> + <div data-js='s_hr_rating_option_value' data-selector=".s_hr_rating"> + <li class="dropdown-submenu"> + <a tabindex="-1" href="#">Rating value</a> + <ul class="dropdown-menu"> + <li data-select_class=""><a href="#">0</a></li> + <li data-select_class="s_hr_rating_1"><a href="#">1/5</a></li> + <li data-select_class="s_hr_rating_2"><a href="#">2/5</a></li> + <li data-select_class="s_hr_rating_3"><a href="#">3/5</a></li> + <li data-select_class="s_hr_rating_4"><a href="#">4/5</a></li> + <li data-select_class="s_hr_rating_5"><a href="#">5/5</a></li> + </ul> + </li> + </div> + </xpath> +</template> + +</odoo>
\ No newline at end of file diff --git a/addons/website_portal_sale/controllers/main.py b/addons/website_portal_sale/controllers/main.py index 88e211bccfd..3103b3037e3 100644 --- a/addons/website_portal_sale/controllers/main.py +++ b/addons/website_portal_sale/controllers/main.py @@ -158,3 +158,4 @@ class website_account(website_account): }) values.update(get_records_pager(history, order_sudo)) return request.render("website_portal_sale.orders_followup", values) + diff --git a/addons/website_portal_sale/views/website_portal_sale_templates.xml b/addons/website_portal_sale/views/website_portal_sale_templates.xml index 2e3598c3f55..7d0098e67c0 100644 --- a/addons/website_portal_sale/views/website_portal_sale_templates.xml +++ b/addons/website_portal_sale/views/website_portal_sale_templates.xml @@ -197,7 +197,7 @@ </div> <div> <t t-foreach="invoices" t-as="i"> - <t t-set="report_url" t-value="'/my/invoices/%s' % i.id"/> + <t t-set="report_url" t-value="'/my/invoices/pdf/%s' % i.id"/> <div> <a t-att-href="report_url"><span class="fa fa-download"/></a> <a t-att-href="report_url"><span t-esc="i.number"/></a> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index dbedb81ab88..9e135847e19 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -275,7 +275,9 @@ class WebsiteSale(http.Controller): @http.route(['/shop/product/<model("product.template"):product>'], type='http', auth="public", website=True) def product(self, product, category='', search='', **kwargs): - product_context = dict(request.env.context, active_id=product.id) + product_context = dict(request.env.context, + active_id=product.id, + partner=request.env.user.partner_id) ProductCategory = request.env['product.public.category'] if category: diff --git a/addons/website_sale/i18n/website_sale.pot b/addons/website_sale/i18n/website_sale.pot index 1e1a4e53796..37b736949aa 100644 --- a/addons/website_sale/i18n/website_sale.pot +++ b/addons/website_sale/i18n/website_sale.pot @@ -1848,12 +1848,12 @@ msgstr "" #. module: website_sale #: model:ir.ui.view,arch_db:website_sale.sort -msgid "Price <i class=\"fa fa-caret-down\" aria-hidden=\"true\"/>" +msgid "Catalog price: decreasing order" msgstr "" #. module: website_sale #: model:ir.ui.view,arch_db:website_sale.sort -msgid "Price <i class=\"fa fa-caret-up\" aria-hidden=\"true\"/>" +msgid "Catalog price: increasing order" msgstr "" #. module: website_sale diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index bc18e1de049..3be9cb503d8 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -70,8 +70,8 @@ <group name="website" string="Website" > <field name="website_url" invisible="1"/> <field name="public_categ_ids" widget="many2many_tags" string="Website Categories"/> - <field name="alternative_product_ids" widget="many2many_tags"/> - <field name="accessory_product_ids" widget="many2many_tags"/> + <field name="alternative_product_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="accessory_product_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> <field name="availability" widget="selection"/> <field name="availability_warning" attrs="{'invisible': [('availability', '!=', 'warning')]}"/> <field name="website_style_ids" widget="many2many_tags" groups="base.group_no_one"/> diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index cba93b108d6..2d25cc8d4fe 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -223,8 +223,8 @@ <template id="sort" inherit_id="website_sale.products" active="True" customize_show="True" name="Show Sort by"> <xpath expr="//div[@class='products_pager']/div/t[@t-call][last()]" position="after"> - <t t-set="list_price_desc_label">Price <i class="fa fa-caret-up" aria-hidden="true"/></t> - <t t-set="list_price_asc_label">Price <i class="fa fa-caret-down" aria-hidden="true"/></t> + <t t-set="list_price_desc_label">Catalog price: decreasing order</t> + <t t-set="list_price_asc_label">Catalog price: increasing order</t> <t t-set="name_asc_label">Name <i class="fa fa-caret-down" aria-hidden="true"/></t> <t t-set="website_sale_sortable" t-value="[ (list_price_desc_label, 'list_price desc'), diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py index 2df86980bc6..ec0aeec82a7 100644 --- a/addons/website_sale_digital/controllers/main.py +++ b/addons/website_sale_digital/controllers/main.py @@ -85,7 +85,7 @@ class WebsiteSaleDigital(website_account): # Also check for attachments in the product templates elif res_model == 'product.template': - template_ids = request.env['product.product'].browse(purchased_products).mapped('product_tmpl_id').ids + template_ids = request.env['product.product'].sudo().browse(purchased_products).mapped('product_tmpl_id').ids if res_id not in template_ids: return redirect(self.orders_page) diff --git a/addons/website_sale_options/views/product_views.xml b/addons/website_sale_options/views/product_views.xml index 154a06c1e6a..c75b2d215e3 100644 --- a/addons/website_sale_options/views/product_views.xml +++ b/addons/website_sale_options/views/product_views.xml @@ -7,7 +7,7 @@ <field name="inherit_id" ref="website_sale.product_template_form_view"/> <field name="arch" type="xml"> <field name="accessory_product_ids" position="after"> - <field name="optional_product_ids" widget="many2many_tags"/> + <field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> </field> </field> </record> |
