diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-03-09 16:25:58 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-03-09 16:25:58 +0100 |
| commit | 0f57bdba72b592ca097d4cd3da99a75883d8c623 (patch) | |
| tree | 94eef0c9aaf885c7f717bab5e468ca72b59a219d /addons | |
| parent | 35f0797002e44ce5673e9fab63710d2764329694 (diff) | |
| parent | 42659dc70c2b9b124e7e4fdc0449f660c3de5c76 (diff) | |
[MERGE] forward port branch saas-11.2 up to 42659dc70c2
Diffstat (limited to 'addons')
216 files changed, 2438 insertions, 998 deletions
diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index a1a7754a076..8ead6af8961 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -17,7 +17,6 @@ Core mechanisms for the accounting modules. To display the menuitems, install th 'security/ir.model.access.csv', 'data/data_account_type.xml', 'data/account_data.xml', - 'data/ir_cron_data.xml', 'views/account_menuitem.xml', 'views/account_payment_view.xml', 'wizard/account_reconcile_view.xml', diff --git a/addons/account/controllers/mail.py b/addons/account/controllers/mail.py index f30c69e21ba..564856b909c 100644 --- a/addons/account/controllers/mail.py +++ b/addons/account/controllers/mail.py @@ -11,6 +11,7 @@ from odoo.tools.misc import consteq class MailController(MailController): + @classmethod def _redirect_to_record(cls, model, res_id, access_token=None): # If the current user doesn't have access to the invoice, but provided # a valid access token, redirect him to the front-end view. diff --git a/addons/account/data/ir_cron_data.xml b/addons/account/data/ir_cron_data.xml deleted file mode 100644 index 6e3ceb702aa..00000000000 --- a/addons/account/data/ir_cron_data.xml +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo noupdate="1"> - <!-- Cron job for creating recurring vendor bills --> - <record id="recurrent_vendor_bills_cron" model="ir.cron"> - <field name="name">Account: generate recurring vendor bills</field> - <field name="model_id" ref="account.model_account_invoice"/> - <field name="state">code</field> - <field name="active">False</field> - <field name="code">model._cron_generate_recurring_vendor_bill()</field> - <field name="interval_number">1</field> - <field name="interval_type">days</field> - <field name="numbercall">-1</field> - </record> -</odoo> diff --git a/addons/account/data/payment_receipt_data.xml b/addons/account/data/payment_receipt_data.xml index 3190edfb151..d3c02413732 100644 --- a/addons/account/data/payment_receipt_data.xml +++ b/addons/account/data/payment_receipt_data.xml @@ -14,7 +14,7 @@ <field name="lang">${object.partner_id.lang}</field> <field name="body_html" type="xml"> <p>Dear ${object.partner_id.name},</p> -<p>Thank you for your payment.<br />Here is your payment receipt <strong>${(object.name or '').replace('/','-')}</strong> amounting to <strong>${object.amount} ${object.currency_id.name}</strong> from ${object.company_id.name}.</p> +<p>Thank you for your payment.<br />Here is your payment receipt <strong>${(object.name or '').replace('/','-')}</strong> amounting to <strong>${format_amount(object.amount, object.currency_id)}</strong> from ${object.company_id.name}.</p> <p>If you have any questions, please do not hesitate to contact us.</p> <p>Best regards, % if user and user.signature: diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 2f5c1421a60..b7d8d52f096 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -441,7 +441,7 @@ class AccountJournal(models.Model): for journal in self: if journal.sequence_id and journal.sequence_number_next: sequence = journal.sequence_id._get_current_sequence() - sequence.number_next = journal.sequence_number_next + sequence.sudo().number_next = journal.sequence_number_next @api.multi # do not depend on 'refund_sequence_id.date_range_ids', because @@ -523,6 +523,8 @@ class AccountJournal(models.Model): if ('company_id' in vals and journal.company_id.id != vals['company_id']): if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1): raise UserError(_('This journal already contains items, therefore you cannot modify its company.')) + if self.bank_account_id: + self.bank_account_id.company_id = vals['company_id'] if ('code' in vals and journal.code != vals['code']): if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1): raise UserError(_('This journal already contains items, therefore you cannot modify its short name.')) @@ -536,6 +538,8 @@ class AccountJournal(models.Model): self.default_debit_account_id.currency_id = vals['currency_id'] if not 'default_credit_account_id' in vals and self.default_credit_account_id: self.default_credit_account_id.currency_id = vals['currency_id'] + if self.bank_account_id: + self.bank_account_id.currency_id = vals['currency_id'] if 'bank_account_id' in vals and not vals.get('bank_account_id'): raise UserError(_('You cannot remove the bank account from the journal once set.')) result = super(AccountJournal, self).write(vals) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index e1e3dfa0499..47d7bcaf446 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -229,12 +229,6 @@ class AccountInvoice(models.Model): payment_lines.update(line.mapped('matched_debit_ids.debit_move_id.id')) self.payment_move_line_ids = self.env['account.move.line'].browse(list(payment_lines)) - @api.depends('date_invoice', 'is_recurrency_enabled', 'recurrency_interval', 'recurrency_type') - def _compute_next_recurrency_date(self): - #initially set the recurrency_date - for invoice in self.filtered(lambda inv: inv.is_recurrency_enabled and inv.recurrency_interval and inv.recurrency_type and inv.date_invoice and not inv.next_recurrency_date): - invoice.next_recurrency_date = invoice._get_next_recurrency_date(invoice.date_invoice) - name = fields.Char(string='Reference/Description', index=True, readonly=True, states={'draft': [('readonly', False)]}, copy=False, help='The name that will be used on account move lines') @@ -373,18 +367,6 @@ class AccountInvoice(models.Model): #fields use to set the sequence, on the first invoice of the journal sequence_number_next = fields.Char(string='Next Number', compute="_get_sequence_number_next", inverse="_set_sequence_next") sequence_number_next_prefix = fields.Char(string='Next Number Prefix', compute="_get_sequence_prefix") - #recurrent vendor bills fields - is_recurrency_enabled = fields.Boolean(string="Generate Recurring Document(s)", copy=False, help="Tick this box to generate recurring bills. The recurring bills will only be generated if this bill is validated.") - recurrency_interval = fields.Integer(string='Every', default=1, copy=False, help="Define the recurrency of the vendor bill.") - recurrency_type = fields.Selection([ - ('days', 'Day(s)'), - ('weeks', 'Week(s)'), - ('months', 'Month(s)'), - ('years', 'Year(s)'), - ], string="Recurrency Type", default='months', copy=False) - # Technical field depicting whether the document is generated by cron or by user - is_recurring_document = fields.Boolean(string="Recurring Document", copy=False) - next_recurrency_date = fields.Date(compute=_compute_next_recurrency_date, _string='Next Recurrency Date', copy=False, store=True) _sql_constraints = [ ('number_uniq', 'unique(number, company_id, journal_id, type)', 'Invoice Number must be unique per Company!'), @@ -468,52 +450,6 @@ class AccountInvoice(models.Model): self.type == 'in_refund' and self.state == 'draft' and _('Vendor Credit Note') or \ self.type == 'in_refund' and _('Vendor Credit Note - %s') % (self.number) - def _get_next_recurrency_date(self, current_date): - """ - This method is used to return next date based on recurrency configuration of the record. - - :param current_date: Origin date from which next date is to be generated. - :returns: future date on which a duplicated document will be generated by crons. - """ - assert self.recurrency_type, "Invalid `Recurrency` interval! Please set it to either of these: 'Day(s)', 'Week(s)', 'Month(s)' or 'Year(s)'." - self.ensure_one() - recurring_next_date = fields.Date.from_string(current_date) + relativedelta(**{self.recurrency_type: self.recurrency_interval}) - # In case of monthly recurrent invoice, future invoices should always be created on same day as of it's main invoice but - # if the reference invoice was made on last day of the month, then the recurring invoice should be created on last day of next month. - # example, main invoice is set to 31st Jan, the next invoices should be created on 28/29th Feb, 31st Mar, 30 Apr and so on - if self.recurrency_type == 'months': - reference_invoice_day = fields.Date.from_string(self.date_invoice).day - recurring_next_date += relativedelta(day=reference_invoice_day) - if reference_invoice_day == fields.Date.from_string(self.date_invoice) + relativedelta(day=31): - recurring_next_date += relativedelta(day=31) - return recurring_next_date - - @api.model - def _cron_generate_recurring_vendor_bill(self): - today = fields.Date.today() - domain = [ - ('is_recurrency_enabled', '=', True), - ('state', 'not in', ['draft', 'cancel']), - ('next_recurrency_date', '<=', today) - ] - for invoice in self.search(domain): - while invoice.next_recurrency_date <= today: - invoice._create_recurrent_invoice() - invoice.next_recurrency_date = invoice._get_next_recurrency_date(invoice.next_recurrency_date) - - def _create_recurrent_invoice(self): - self.ensure_one() - invoice = self.copy({ - 'origin': '', - 'date_invoice': self.next_recurrency_date, - 'is_recurring_document': True, - }) - # set the due date - #invoice._onchange_payment_term_date_invoice() - link = '<a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a>' % (self.id, self.number) - invoice.message_post(body=_('This bill has been generated by %s') % link) - return invoice - @api.model def create(self, vals): onchanges = { @@ -1862,6 +1798,7 @@ class AccountPaymentTermLine(models.Model): if self.value == 'percent' and (self.value_amount < 0.0 or self.value_amount > 100.0): raise ValidationError(_('Percentages for Payment Terms Line must be between 0 and 100.')) + @api.one @api.constrains('days') def _check_days(self): if self.option in ('day_following_month', 'day_current_month') and self.days <= 0: diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index e66a6ab068e..a769a3f8d17 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -341,12 +341,13 @@ class account_journal(models.Model): ctx.update({ 'journal_type': self.type, 'default_journal_id': self.id, - 'search_default_journal_id': self.id, 'default_type': invoice_type, 'type': invoice_type }) [action] = self.env.ref('account.%s' % action_name).read() + if not self.env.context.get('use_domain'): + ctx['search_default_journal_id'] = self.id action['context'] = ctx action['domain'] = self._context.get('use_domain', []) account_invoice_filter = self.env.ref('account.view_account_invoice_filter', False) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 78e9cc0e3e9..f5196eb02d6 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -106,7 +106,7 @@ class AccountMove(models.Model): default=lambda self: self.env.user.company_id) matched_percentage = fields.Float('Percentage Matched', compute='_compute_matched_percentage', digits=0, store=True, readonly=True, help="Technical field used in cash basis method") # Dummy Account field to search on account.move by account_id - dummy_account_id = fields.Many2one('account.account', related='line_ids.account_id', string='Account', store=False) + dummy_account_id = fields.Many2one('account.account', related='line_ids.account_id', string='Account', store=False, readonly=True) tax_cash_basis_rec_id = fields.Many2one( 'account.partial.reconcile', string='Tax Cash Basis Entry of', @@ -318,7 +318,7 @@ class AccountMoveLine(models.Model): if not cr.fetchone(): cr.execute('CREATE INDEX account_move_line_partner_id_ref_idx ON account_move_line (partner_id, ref)') - @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'account_id.currency_id', 'move_id.state') + @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'move_id.state') def _amount_residual(self): """ Computes the residual amount of a move line from a reconcilable account in the company currency and the line's currency. This amount will be 0 for fully reconciled lines or lines from a non-reconcilable account, the original line amount @@ -983,7 +983,7 @@ class AccountMoveLine(models.Model): record.payment_id.state = 'reconciled' result = super(AccountMoveLine, self).write(vals) - if self._context.get('check_move_validity', True): + if self._context.get('check_move_validity', True) and any(key in vals for key in ('account_id', 'journal_id', 'date', 'move_id', 'debit', 'credit')): move_ids = set() for line in self: if line.move_id.id not in move_ids: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 2090d03bc30..ca1e13511d4 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -588,9 +588,9 @@ class account_payment(models.Model): writeoff_line['amount_currency'] = amount_currency_wo writeoff_line['currency_id'] = currency_id writeoff_line = aml_obj.create(writeoff_line) - if counterpart_aml['debit'] or writeoff_line['credit']: + if counterpart_aml['debit'] or (writeoff_line['credit'] and not counterpart_aml['credit']): counterpart_aml['debit'] += credit_wo - debit_wo - if counterpart_aml['credit'] or writeoff_line['debit']: + if counterpart_aml['credit'] or (writeoff_line['debit'] and not counterpart_aml['debit']): counterpart_aml['credit'] += debit_wo - credit_wo counterpart_aml['amount_currency'] -= amount_currency_wo diff --git a/addons/account/models/company.py b/addons/account/models/company.py index d73dbf82c64..8ae4230a7b2 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -4,7 +4,7 @@ from datetime import timedelta, datetime import calendar from odoo import fields, models, api, _ -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.exceptions import UserError from odoo.tools.float_utils import float_round, float_is_zero @@ -130,6 +130,12 @@ Best Regards,''')) if values.get('cash_account_code_prefix'): new_cash_code = values.get('cash_account_code_prefix') or company.cash_account_code_prefix company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code) + + #forbid the change of currency_id if there are already some accounting entries existing + if 'currency_id' in values and values['currency_id'] != company.currency_id.id: + if self.env['account.move.line'].search([('company_id', '=', company.id)]): + raise UserError(_('You cannot change the currency of the company since some journal items already exist')) + return super(ResCompany, self).write(values) @api.model diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index 78a743ce9d7..68d8ef3e83b 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -57,12 +57,10 @@ class ResConfigSettings(models.TransientModel): tax_exigibility = fields.Boolean(string='Cash Basis', related='company_id.tax_exigibility') tax_cash_basis_journal_id = fields.Many2one('account.journal', related='company_id.tax_cash_basis_journal_id', string="Tax Cash Basis Journal") account_hide_setup_bar = fields.Boolean(string='Hide Setup Bar', related='company_id.account_setup_bar_closed',help="Tick if you wish to hide the setup bar on the dashboard") - group_allow_recurring = fields.Boolean(string="Recurring Vendor Bills", implied_group="account.group_allow_recurring") @api.multi def set_values(self): super(ResConfigSettings, self).set_values() - self.env.ref('account.recurrent_vendor_bills_cron').write({'active': self.group_allow_recurring}) if self.group_multi_currency: self.env.ref('base.group_user').write({'implied_ids': [(4, self.env.ref('product.group_sale_pricelist').id)]}) """ install a chart of accounts for the given company (if required) """ diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 1bcf45e5121..c527729765c 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -32,11 +32,6 @@ <field name="category_id" ref="base.module_category_hidden"/> </record> - <record id="group_allow_recurring" model="res.groups"> - <field name="name">Allow to create recurring vendor bills</field> - <field name="category_id" ref="base.module_category_hidden"/> - </record> - <record id="group_cash_rounding" model="res.groups"> <field name="name">Allow the cash rounding management</field> <field name="category_id" ref="base.module_category_hidden"/> diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 8108b0efa09..9003e9e7f4d 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -426,20 +426,24 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { this._renderCreate(state); } var data = this.model.get(this.handleCreateRecord).data; - - this.model.notifyChanges(this.handleCreateRecord, state.createForm); - this.model.notifyChanges(this.handleCreateRecord, {analytic_tag_ids: {operation: 'REPLACE_WITH', ids: []}}); - _.each(state.createForm.analytic_tag_ids, function (tag) { - self.model.notifyChanges(self.handleCreateRecord, {analytic_tag_ids: {operation: 'ADD_M2M', ids: tag}}); - }); - - var record = this.model.get(this.handleCreateRecord); - _.each(this.fields, function (field, fieldName) { - if (self._avoidFieldUpdate[fieldName]) return; - if (fieldName === "partner_id") return; - if ((data[fieldName] || state.createForm[fieldName]) && !_.isEqual(state.createForm[fieldName], data[fieldName])) { - field.reset(record); - } + this.model.notifyChanges(this.handleCreateRecord, state.createForm).then(function () { + // FIXME can't it directly written REPLACE_WITH ids=state.createForm.analytic_tag_ids + self.model.notifyChanges(self.handleCreateRecord, {analytic_tag_ids: {operation: 'REPLACE_WITH', ids: []}}).then(function (){ + var defs = []; + _.each(state.createForm.analytic_tag_ids, function (tag) { + defs.push(self.model.notifyChanges(self.handleCreateRecord, {analytic_tag_ids: {operation: 'ADD_M2M', ids: tag}})); + }); + $.when.apply($, defs).then(function () { + var record = self.model.get(self.handleCreateRecord); + _.each(self.fields, function (field, fieldName) { + if (self._avoidFieldUpdate[fieldName]) return; + if (fieldName === "partner_id") return; + if ((data[fieldName] || state.createForm[fieldName]) && !_.isEqual(state.createForm[fieldName], data[fieldName])) { + field.reset(record); + } + }); + }); + }); }); } this.$('.create .add_line').toggle(!!state.balance.amount_currency); @@ -655,7 +659,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { return; } if(event.keyCode === 13) { - if (_.findWhere(this.model.lines, {mode: 'create'}).balance.amount) { + var created_lines = _.findWhere(this.model.lines, {mode: 'create'}); + if (created_lines && created_lines.balance.amount) { this._onCreateProposition(); } return; diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 6a26ad45f2a..13427bb6870 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -43,18 +43,23 @@ var db = { fields: { id: {string: "ID", type: 'integer'}, code: {string: "code", type: 'integer'}, - display_name: {string: "Displayed name", type: 'char'}, + name: {string: "Displayed name", type: 'char'}, company_id: {string: "Company", type: 'many2one', relation: 'res.company'}, }, records: [ - {id: 282, code: 100000, display_name: "100000 Fixed Asset Account", company_id: 1}, - {id: 283, code: 101000, display_name: "101000 Current Assets", company_id: 1}, - {id: 284, code: 101110, display_name: "101110 Stock Valuation Account", company_id: 1}, - {id: 285, code: 101120, display_name: "101120 Stock Interim Account (Received)", company_id: 1}, - {id: 286, code: 101130, display_name: "101130 Stock Interim Account (Delivered)", company_id: 1}, - {id: 287, code: 101200, display_name: "101200 Account Receivable", company_id: 1}, - {id: 288, code: 101300, display_name: "101300 Tax Paid", company_id: 1}, - {id: 308, code: 101401, display_name: "101401 Bank", company_id: 1}, + {id: 282, code: 100000, name: "100000 Fixed Asset Account", company_id: 1}, + {id: 283, code: 101000, name: "101000 Current Assets", company_id: 1}, + {id: 284, code: 101110, name: "101110 Stock Valuation Account", company_id: 1}, + {id: 285, code: 101120, name: "101120 Stock Interim Account (Received)", company_id: 1}, + {id: 286, code: 101130, name: "101130 Stock Interim Account (Delivered)", company_id: 1}, + {id: 287, code: 101200, name: "101200 Account Receivable", company_id: 1}, + {id: 288, code: 101300, name: "101300 Tax Paid", company_id: 1}, + {id: 308, code: 101401, name: "101401 Bank", company_id: 1}, + {id: 500, code: 500, name: "500 Account", company_id: 1}, + {id: 501, code: 501, name: "501 Account", company_id: 1}, + {id: 502, code: 502, name: "502 Account", company_id: 1}, + {id: 503, code: 503, name: "503 Account", company_id: 1}, + {id: 504, code: 504, name: "504 Account", company_id: 1}, ], mark_as_reconciled: function () { return $.when(); @@ -1050,13 +1055,77 @@ QUnit.module('account', { clientAction.destroy(); }); + QUnit.test('Reconciliation create line (many2one test)', function (assert) { + assert.expect(5); + + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + var def = $.Deferred(); + + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + archs: { + "account.account,false,list": '<tree string="Account"><field name="code"/><field name="name"/></tree>', + "account.account,false,search": '<search string="Account"><field name="code"/></search>', + }, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + return def.then(this._super.bind(this, route, args)); + } + return this._super(route, args); + }, + }); + + clientAction.prependTo($('#qunit-fixture')); + + var widget = clientAction.widgets[0]; + + // open the first line in write-off mode + widget.$('.accounting_view tfoot td:first').trigger('click'); + + // select an account with the many2one (drop down) + widget.$('.create .create_account_id input').trigger('click'); + $('.ui-autocomplete .ui-menu-item a:contains(101200)').trigger('mouseenter').trigger('click'); + assert.strictEqual(widget.$('.create .create_account_id input').val(), "101200 Account Receivable", "Display the selected account"); + assert.strictEqual(widget.$('tbody:first .cell_account_code').text(), "101200", "Display the code of the selected account"); + + // use the many2one select dialog to change the account + widget.$('.create .create_account_id input').trigger('click'); + $('.ui-autocomplete .ui-menu-item a:contains(Search)').trigger('mouseenter').trigger('click'); + // select the account who does not appear in the drop drown + $('.modal tr.o_data_row:contains(502)').click(); + assert.strictEqual(widget.$('.create .create_account_id input').val(), "101200 Account Receivable", "Selected account does not change"); + // wait the name_get to render the changes + def.resolve(); + assert.strictEqual(widget.$('.create .create_account_id input').val(), "502 Account", "Display the selected account"); + assert.strictEqual(widget.$('tbody:first .cell_account_code').text(), "502", "Display the code of the selected account"); + clientAction.destroy(); + }); + QUnit.test('Reconciliation create line with taxes', function (assert) { assert.expect(13); var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); testUtils.addMockEnvironment(clientAction, { - 'data': this.params.data, + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, }); clientAction.appendTo($('#qunit-fixture')); @@ -1068,26 +1137,26 @@ QUnit.module('account', { widget.$('.create .create_label input').val('test1').trigger('input'); widget.$('.create .create_amount input').val('1100').trigger('input'); - assert.strictEqual(widget.$('.accounting_view tbody .cell_right:last').text(), "1100.00", "should display the value 1100.00 in left column"); + assert.strictEqual(widget.$('.accounting_view tbody .cell_right:last').text(), "$\u00a01100.00", "should display the value 1100.00 in left column"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'"); - assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "75.00", "should display 'Open Balance' with 75.00 in right column"); + assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$\u00a075.00", "should display 'Open Balance' with 75.00 in right column"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have 1 created reconcile lines"); widget.$('.create .create_tax_id input').trigger('click'); $('.ui-autocomplete .ui-menu-item a:contains(10.00%)').trigger('mouseenter').trigger('click'); - assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "1000.00100.00", "should have 2 created reconcile lines with right column values"); + assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "$\u00a01000.00$\u00a0100.00", "should have 2 created reconcile lines with right column values"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'"); - assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "75.00", "should display 'Open Balance' with 75.00 in right column"); + assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$\u00a075.00", "should display 'Open Balance' with 75.00 in right column"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "", "should display 'Open Balance' without any value in left column"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines"); widget.$('.create .create_tax_id input').trigger('click'); $('.ui-autocomplete .ui-menu-item a:contains(20.00%)').trigger('mouseenter').trigger('click'); - assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "1100.00220.00", "should have 2 created reconcile lines with right column values"); + assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "$\u00a01100.00$\u00a0220.00", "should have 2 created reconcile lines with right column values"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Create Write-off", "should display 'Create Write-off'"); - assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "145.00", "should display 'Create Write-off' with 145.00 in right column"); + assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "$\u00a0145.00", "should display 'Create Write-off' with 145.00 in right column"); assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines"); clientAction.destroy(); @@ -1099,7 +1168,7 @@ QUnit.module('account', { var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); testUtils.addMockEnvironment(clientAction, { - 'data': this.params.data, + data: this.params.data, }); clientAction.appendTo($('#qunit-fixture')); diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 813896bdec0..d50e277028c 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -6,7 +6,6 @@ from . import test_account_move_closed_period from . import test_account_supplier_invoice from . import test_account_validate_account_move from . import test_account_invoice_rounding -from . import test_account_supplier_invoice_recurrent from . import test_bank_statement_reconciliation from . import test_fiscal_position from . import test_reconciliation_widget diff --git a/addons/account/tests/test_account_supplier_invoice_recurrent.py b/addons/account/tests/test_account_supplier_invoice_recurrent.py deleted file mode 100644 index 47f681dd3cb..00000000000 --- a/addons/account/tests/test_account_supplier_invoice_recurrent.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- -from datetime import datetime -from dateutil.relativedelta import relativedelta - -from odoo.addons.account.tests.account_test_classes import AccountingTestCase -from odoo.tests import tagged - - -@tagged('post_install', '-at_install') -class TestAccountRecurrent(AccountingTestCase): - - def apply_vendor_cron(self): - self.env.ref('account.recurrent_vendor_bills_cron').method_direct_trigger() - - def test_create_recurring_vendor_bills(self): - AccountInvoice = self.env['account.invoice'] - invoice_account_id = self.env['account.account'].search([('user_type_id', '=', self.ref('account.data_account_type_receivable'))], limit=1).id - invoice_line_account_id = self.env['account.account'].search([('user_type_id', '=', self.ref('account.data_account_type_expenses'))], limit=1).id - purchase_journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1) - - invoice_line_data = [ - (0, 0, - { - 'product_id': self.ref('product.product_product_4'), - 'name': 'product 4 that cost 100', - 'quantity': 1.0, - 'price_unit': 100.0, - 'account_id': invoice_line_account_id, - } - ), - (0, 0, - { - 'product_id': self.ref('product.product_product_5'), - 'name': 'product 5 that cost 200', - 'quantity': 2.0, - 'price_unit': 200.0, - 'account_id': invoice_line_account_id, - } - ) - ] - - # Create an invoice dated 2 months before today - invoice = AccountInvoice.create({ - 'partner_id': self.ref('base.res_partner_2'), - 'account_id': invoice_account_id, - 'name': 'invoice test recurrent', - 'type': 'in_invoice', - 'reference_type': 'none', - 'date_invoice': datetime.today() + relativedelta(months=-2), - 'journal_id': purchase_journal.id, - 'is_recurrency_enabled': True, - 'recurrency_interval': 1, - 'recurrency_type': 'months', - 'invoice_line_ids': invoice_line_data - }) - recurring_domain = [('type', '=', 'in_invoice'), ('state', '=', 'draft'), ('is_recurring_document', '=', True)] - - # After executing cron, verify that no bill is auto generated if the recurrent bill is in `draft` state - self.apply_vendor_cron() - recurring_invoice_count = AccountInvoice.search_count(recurring_domain) - self.assertEquals(recurring_invoice_count, 0, 'Recurring bills should not be generated when reference bill is still `draft`.') - - # After validating bill, run cron and check that 2 recurring bill have been generated - invoice.action_invoice_open() # Validate invoice - self.apply_vendor_cron() - recurring_invoice_count = previous_recurring_invoice_count = AccountInvoice.search_count(recurring_domain) - self.assertEquals(recurring_invoice_count, 2, '2 recurring bills should be generated.') - - # verify that bills aren't generated if reference bill is in `cancel` state - purchase_journal.write({'update_posted': True}) # Allow to cancel the invoice - inv2_data = { - 'name': 'invoice test recurrent 2', - 'date_invoice': datetime.today() + relativedelta(months=-2), - 'is_recurrency_enabled': True, - 'recurrency_interval': 1, - 'recurrency_type': 'months', - } - invoice2 = invoice.copy(default=inv2_data) - invoice2.action_invoice_cancel() # Cancel the invoice - self.apply_vendor_cron() - recurring_invoice_count = AccountInvoice.search_count(recurring_domain) - self.assertEquals(previous_recurring_invoice_count, recurring_invoice_count, 'Recurring bills should not be generated when reference bill is in `cancel` state.') - - # verify that vendor bills are generated even when invoice is in `paid` state - inv3_data = { - 'name': 'invoice test recurrent 3', - 'date_invoice': datetime.today() + relativedelta(months=-2), - 'is_recurrency_enabled': True, - 'recurrency_interval': 1, - 'recurrency_type': 'months', - } - - invoice3 = invoice.copy(default=inv3_data) - invoice3.action_invoice_open() # Validate invoice - Payment = self.env['account.payment'] - payment_vals = Payment.with_context(default_invoice_ids=[(4, invoice3.id, None)]).default_get(Payment._fields.keys()) - payment_vals.update({ - 'payment_method_id': self.ref("account.account_payment_method_manual_out"), - 'journal_id': self.env['account.journal'].search([('type', '=', 'bank')], limit=1).id - }) - Payment.create(payment_vals).action_validate_invoice_payment() # Register the payment - self.apply_vendor_cron() - recurring_invoice_count = AccountInvoice.search_count(recurring_domain) - self.assertEquals(recurring_invoice_count, 4, '4 recurring bills should be generated.') diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index 3c22a127180..3d490eedc49 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -20,7 +20,9 @@ class TestPayment(AccountingTestCase): self.currency_chf_id = self.env.ref("base.CHF").id self.currency_usd_id = self.env.ref("base.USD").id self.currency_eur_id = self.env.ref("base.EUR").id - self.env.ref('base.main_company').write({'currency_id': self.currency_eur_id}) + + company = self.env.ref('base.main_company') + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_eur_id, company.id]) self.product = self.env.ref("product.product_product_4") self.payment_method_manual_in = self.env.ref("account.account_payment_method_manual_in") self.payment_method_manual_out = self.env.ref("account.account_payment_method_manual_out") @@ -361,3 +363,53 @@ class TestPayment(AccountingTestCase): {'account_id': self.account_eur.id, 'debit': 300.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, {'account_id': inv_3.account_id.id, 'debit': 0.0, 'credit': 300.0, 'amount_currency': 0.0, 'currency_id': False}, ]) + + def test_payment_and_writeoff_in_other_currency(self): + # Use case: + # Company is in EUR, create a customer invoice for 25 EUR and register payment of 25 USD. + # Mark invoice as fully paid with a write_off + # Check that all the aml are correctly created. + invoice = self.create_invoice(amount=25, type='out_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) + # register payment on invoice + payment = self.payment_model.create({'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait.id, + 'amount': 25, + 'currency_id': self.currency_usd_id, + 'payment_date': time.strftime('%Y') + '-07-15', + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.account_payable.id, + 'journal_id': self.bank_journal_euro.id, + 'invoice_ids': [(4, invoice.id, None)] + }) + payment.post() + self.check_journal_items(payment.move_line_ids, [ + {'account_id': self.account_eur.id, 'debit': 16.35, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': self.currency_usd_id}, + {'account_id': self.account_payable.id, 'debit': 8.65, 'credit': 0.0, 'amount_currency': 13.22, 'currency_id': self.currency_usd_id}, + {'account_id': self.account_receivable.id, 'debit': 0.0, 'credit': 25.0, 'amount_currency': -38.22, 'currency_id': self.currency_usd_id}, + ]) + # Use case: + # Company is in EUR, create a vendor bill for 25 EUR and register payment of 25 USD. + # Mark invoice as fully paid with a write_off + # Check that all the aml are correctly created. + invoice = self.create_invoice(amount=25, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) + # register payment on invoice + payment = self.payment_model.create({'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'supplier', + 'partner_id': self.partner_agrolait.id, + 'amount': 25, + 'currency_id': self.currency_usd_id, + 'payment_date': time.strftime('%Y') + '-07-15', + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.account_payable.id, + 'journal_id': self.bank_journal_euro.id, + 'invoice_ids': [(4, invoice.id, None)] + }) + payment.post() + self.check_journal_items(payment.move_line_ids, [ + {'account_id': self.account_eur.id, 'debit': 16.35, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': self.currency_usd_id}, + {'account_id': self.account_payable.id, 'debit': 0.0, 'credit': 8.65, 'amount_currency': -13.22, 'currency_id': self.currency_usd_id}, + {'account_id': self.account_receivable.id, 'debit': 0.0, 'credit': 7.7, 'amount_currency': -11.78, 'currency_id': self.currency_usd_id}, + ]) diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index aa4fc950279..1180df172c9 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -27,7 +27,8 @@ class TestReconciliation(AccountingTestCase): self.currency_swiss_id = self.env.ref("base.CHF").id self.currency_usd_id = self.env.ref("base.USD").id self.currency_euro_id = self.env.ref("base.EUR").id - self.env.ref('base.main_company').write({'currency_id': self.currency_euro_id}) + company = self.env.ref('base.main_company') + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_euro_id, company.id]) self.account_rcv = partner_agrolait.property_account_receivable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1) self.account_rsa = partner_agrolait.property_account_payable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1) self.product = self.env.ref("product.product_product_4") diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index b75bd604ee2..30b9382e607 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -339,12 +339,6 @@ <field name="move_id" groups="account.group_account_user" attrs="{'invisible': [('move_id', '=', False)]}"/> <field name="date" domain="[('state', '=', 'draft'), ('company_id', '=', company_id)]" groups="account.group_account_user"/> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> - <field name="is_recurrency_enabled" string="Recurring Bills" help="Tick this box to generate recurring bills. The recurring bills will only be generated if this bill is validated." groups="account.group_allow_recurring" attrs="{'invisible': [('type', '!=', 'in_invoice')]}"/> - <label for="recurrency_interval" attrs="{'invisible': [('is_recurrency_enabled', '=', False)]}" groups="account.group_allow_recurring"/> - <div class="o_row" attrs="{'invisible': [('is_recurrency_enabled', '=', False)]}" groups="account.group_allow_recurring"> - <field name="recurrency_interval" help="Define the recurrency of the vendor bill." attrs="{'required': [('is_recurrency_enabled', '=', True)]}"/> - <field name="recurrency_type" attrs="{'required': [('is_recurrency_enabled', '=', True)]}"/> - </div> </group> </group> </page> @@ -541,10 +535,8 @@ <field name="number" string="Invoice" filter_domain="['|','|','|', ('number','ilike',self), ('origin','ilike',self), ('reference', 'ilike', self), ('partner_id', 'child_of', self)]"/> <field name="journal_id" /> <filter name="draft" string="Draft" domain="[('state','=','draft')]"/> - <filter name="invoices" string="Invoices" domain="['&', ('state','in',['draft','open','paid']),('type','in',('out_invoice','in_invoice'))]"/> - <filter name="refunds" string="Credit Notes" domain="['&', ('state','in',['draft','open','paid']),('type','in',('out_refund','in_refund'))]"/> - <separator/> - <filter name="unpaid" string="Not Paid" domain="[('state','=','open')]"/> + <filter name="unpaid" string="Open" domain="[('state', '=', 'open')]"/> + <filter name="paid" string="Paid" domain="[('state', '=', 'paid')]"/> <filter name="late" string="Overdue" domain="['&', ('date_due', '<', time.strftime('%%Y-%%m-%%d')), ('state', '=', 'open')]" help="Overdue invoices, maturity date passed"/> <separator/> <field name="partner_id" operator="child_of"/> @@ -552,8 +544,6 @@ <field name="date" string="Period"/> <separator/> <filter domain="[('user_id','=',uid)]" name="myinvoices" help="My Invoices"/> - <filter domain="[('is_recurrency_enabled','=',True)]" name="is_recurrency_enabled" help="Bills Generating Recurring Bills" groups="account.group_allow_recurring" invisible="context.get('type') != 'in_invoice'"/> - <filter domain="[('is_recurring_document','=',True)]" name="is_recurring_document" help="Bills Generated Automatically" groups="account.group_allow_recurring" invisible="context.get('type') != 'in_invoice'"/> <separator/> <filter string="My Activities" name="activities_my" domain="[('activity_ids.user_id', '=', uid)]"/> @@ -578,36 +568,6 @@ </field> </record> - <record id="view_account_invoice_filter_inherit_invoices" model="ir.ui.view"> - <field name="name">account.invoice.select.invoices</field> - <field name="model">account.invoice</field> - <field name="mode">primary</field> - <field name="inherit_id" ref="view_account_invoice_filter"/> - <field name="arch" type="xml"> - <data> - <xpath expr="filter[@name='invoices']" position="attributes"> - <attribute name="string">Not Draft</attribute> - </xpath> - <filter name="refunds" position="replace"/> - </data> - </field> - </record> - - <record id="view_account_invoice_filter_inherit_credit_notes" model="ir.ui.view"> - <field name="name">account.invoice.select.credit.notes</field> - <field name="model">account.invoice</field> - <field name="mode">primary</field> - <field name="inherit_id" ref="view_account_invoice_filter"/> - <field name="arch" type="xml"> - <data> - <xpath expr="filter[@name='refunds']" position="attributes"> - <attribute name="string">Not Draft</attribute> - </xpath> - <filter name="invoices" position="replace"/> - </data> - </field> - </record> - <record id="action_invoice_tree" model="ir.actions.act_window"> <field name="name">Invoices</field> <field name="res_model">account.invoice</field> @@ -675,7 +635,7 @@ <field eval="False" name="view_id"/> <field name="domain">[('type','=','out_invoice')]</field> <field name="context">{'type':'out_invoice', 'journal_type': 'sale'}</field> - <field name="search_view_id" ref="view_account_invoice_filter_inherit_invoices"/> + <field name="search_view_id" ref="view_account_invoice_filter"/> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a customer invoice @@ -710,7 +670,7 @@ <field eval="False" name="view_id"/> <field name="domain">[('type','=','out_refund')]</field> <field name="context">{'default_type': 'out_refund', 'type': 'out_refund', 'journal_type': 'sale'}</field> - <field name="search_view_id" ref="view_account_invoice_filter_inherit_credit_notes"/> + <field name="search_view_id" ref="view_account_invoice_filter"/> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a credit note @@ -748,7 +708,7 @@ <field eval="False" name="view_id"/> <field name="domain">[('type','=','in_invoice')]</field> <field name="context">{'default_type': 'in_invoice', 'type': 'in_invoice', 'journal_type': 'purchase'}</field> - <field name="search_view_id" ref="view_account_invoice_filter_inherit_invoices"/> + <field name="search_view_id" ref="view_account_invoice_filter"/> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Record a new vendor bill @@ -784,7 +744,7 @@ <field eval="False" name="view_id"/> <field name="domain">[('type','=','in_refund')]</field> <field name="context">{'default_type': 'in_refund', 'type': 'in_refund', 'journal_type': 'purchase'}</field> - <field name="search_view_id" ref="view_account_invoice_filter_inherit_credit_notes"/> + <field name="search_view_id" ref="view_account_invoice_filter"/> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a vendor credit note diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index dfa08fbadce..9c7f1aca551 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -117,16 +117,12 @@ <span>View</span> </div> <div> - <a type="object" name="open_action" context="{'use_domain': [('type','in',('out_invoice','in_invoice'))]}"> - <span t-if="journal_type == 'sale'">Invoices</span> - <span t-if="journal_type == 'purchase'">Bills</span> - </a> + <a t-if="journal_type == 'sale'" type="object" name="open_action" context="{'use_domain': [('type', '=', 'out_invoice')]}">Invoices</a> + <a t-if="journal_type == 'purchase'" type="object" name="open_action" context="{'use_domain': [('type', '=', 'in_invoice')]}">Bills</a> </div> <div> - <a type="object" name="open_action" context="{'use_domain': [('type','in',('out_refund','in_refund'))], 'invoice_type': 'refund'}"> - <span t-if="journal_type == 'sale'">Credit Notes</span> - <span t-if="journal_type == 'purchase'">Credit Notes</span> - </a> + <a t-if="journal_type == 'sale'" type="object" name="open_action" context="{'use_domain': [('type', '=', 'out_refund')], 'invoice_type': 'refund'}">Credit Notes</a> + <a t-if="journal_type == 'purchase'" type="object" name="open_action" context="{'use_domain': [('type', '=', 'in_refund')], 'invoice_type': 'refund'}">Credit Notes</a> </div> <div> <a type="object" name="action_open_reconcile">Payments Matching</a> diff --git a/addons/account/views/account_report.xml b/addons/account/views/account_report.xml index 06e077e7d8c..2ba116e884a 100644 --- a/addons/account/views/account_report.xml +++ b/addons/account/views/account_report.xml @@ -56,6 +56,7 @@ id="action_report_financial" model="account.financial.report" string="Financial report" + menu="False" report_type="qweb-pdf" name="account.report_financial" file="account.report_financial" diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 7b57066bce8..6d8ffdd1ac0 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -16,7 +16,7 @@ <field name="code" placeholder="code"/> <field name="name"/> <field name="user_type_id" widget="selection"/> - <field name="tax_ids" widget="many2many_tags"/> + <field name="tax_ids" widget="many2many_tags" domain="[('company_id','=',company_id)]"/> <field name="tag_ids" widget="many2many_tags" domain="[('applicability', '!=', 'taxes')]" context="{'default_applicability': 'accounts'}" options="{'no_create_edit': True}"/> <field name="group_id"/> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 5ba661a110f..179699fa726 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -446,20 +446,9 @@ </div> </div> </div> - <h2>Automated Entries</h2> - <div class="row mt16 o_settings_container" id="recommended_apps"> - <div class="col-xs-12 col-md-6 o_setting_box"> - <div class="o_setting_left_pane"> - <field name="group_allow_recurring"/> - </div> - <div class="o_setting_right_pane"> - <label for="group_allow_recurring"/> - <div class="text-muted"> - Create recurring vendor bills - </div> - </div> - </div> - <div groups="account.group_account_user" class="col-xs-12 col-md-6 o_setting_box" id="asset_types" title="This allows you to manage the assets owned by a company or a person. It keeps track of the depreciation occurred on those assets, and creates account move for those depreciation lines."> + <h2 groups="account.group_account_user">Automated Entries</h2> + <div class="row mt16 o_settings_container" id="recommended_apps" groups="account.group_account_user"> + <div class="col-xs-12 col-md-6 o_setting_box" id="asset_types" title="This allows you to manage the assets owned by a company or a person. It keeps track of the depreciation occurred on those assets, and creates account move for those depreciation lines."> <div class="o_setting_left_pane"> <field name="module_account_asset"/> </div> @@ -475,7 +464,7 @@ </div> </div> </div> - <div groups="account.group_account_user" class="col-xs-12 col-md-6 o_setting_box" title="This allows you to manage the revenue recognition on selling products. It keeps track of the installments occurred on those revenue recognitions, and creates account moves for those installment lines."> + <div class="col-xs-12 col-md-6 o_setting_box" title="This allows you to manage the revenue recognition on selling products. It keeps track of the installments occurred on those revenue recognitions, and creates account moves for those installment lines."> <div class="o_setting_left_pane"> <field name="module_account_deferred_revenue" widget="upgrade_boolean"/> </div> diff --git a/addons/account_analytic_default/models/account_analytic_default.py b/addons/account_analytic_default/models/account_analytic_default.py index ac9c6e57be3..77f9b5fcad8 100644 --- a/addons/account_analytic_default/models/account_analytic_default.py +++ b/addons/account_analytic_default/models/account_analytic_default.py @@ -66,7 +66,7 @@ class AccountInvoiceLine(models.Model): @api.onchange('product_id') def _onchange_product_id(self): res = super(AccountInvoiceLine, self)._onchange_product_id() - rec = self.env['account.analytic.default'].account_get(self.product_id.id, self.invoice_id.partner_id.id, self.env.uid, + rec = self.env['account.analytic.default'].account_get(self.product_id.id, self.invoice_id.commercial_partner_id.id, self.env.uid, fields.Date.today(), company_id=self.company_id.id) self.account_analytic_id = rec.analytic_id.id self.analytic_tag_ids = rec.analytic_tag_ids.ids @@ -75,7 +75,7 @@ class AccountInvoiceLine(models.Model): def _set_additional_fields(self, invoice): if not self.account_analytic_id or not self.analytic_tag_ids: rec = self.env['account.analytic.default'].account_get( - self.product_id.id, self.invoice_id.partner_id.id, self.env.uid, + self.product_id.id, self.invoice_id.commercial_partner_id.id, self.env.uid, fields.Date.today(), company_id=self.company_id.id) if rec: if self.account_analytic_id: diff --git a/addons/account_analytic_default/models/sale_order_line.py b/addons/account_analytic_default/models/sale_order_line.py index 9997f9ba1cd..51f8d308188 100644 --- a/addons/account_analytic_default/models/sale_order_line.py +++ b/addons/account_analytic_default/models/sale_order_line.py @@ -13,4 +13,5 @@ class SaleOrderLine(models.Model): default_analytic_account = self.env['account.analytic.default'].account_get(self.product_id.id, self.order_id.partner_id.id, self.order_id.user_id.id, fields.Date.today()) if default_analytic_account: res.update({'account_analytic_id': default_analytic_account.analytic_id.id}) + res.update({'analytic_tag_ids' : [(6, 0, default_analytic_account.analytic_tag_ids.ids)]}) return res diff --git a/addons/account_invoicing/__init__.py b/addons/account_invoicing/__init__.py index 91206cf6094..371975a409a 100644 --- a/addons/account_invoicing/__init__.py +++ b/addons/account_invoicing/__init__.py @@ -11,3 +11,12 @@ def uninstall_hook(cr, registry): ('module', '=', 'account') ]).mapped('res_id') env['ir.ui.menu'].browse(res_ids).update({'active': False}) + + +def post_init_hook(cr, registry): + env = Environment(cr, SUPERUSER_ID, {}) + res_ids = env['ir.model.data'].search([ + ('model', '=', 'ir.ui.menu'), + ('module', '=', 'account'), + ]).mapped('res_id') + env['ir.ui.menu'].browse(res_ids).update({'active': True}) diff --git a/addons/account_invoicing/__manifest__.py b/addons/account_invoicing/__manifest__.py index faa54037c69..880c5b899c3 100644 --- a/addons/account_invoicing/__manifest__.py +++ b/addons/account_invoicing/__manifest__.py @@ -28,4 +28,5 @@ You could use this simplified accounting in case you work with an (external) acc ], 'application': True, 'uninstall_hook': 'uninstall_hook', + 'post_init_hook': 'post_init_hook', } diff --git a/addons/auth_signup/models/__init__.py b/addons/auth_signup/models/__init__.py index 44dc1ee6b11..a25160266e7 100644 --- a/addons/auth_signup/models/__init__.py +++ b/addons/auth_signup/models/__init__.py @@ -4,3 +4,4 @@ from . import res_config_settings from . import ir_http from . import res_partner from . import res_users +from . import ir_model_fields diff --git a/addons/auth_signup/models/ir_model_fields.py b/addons/auth_signup/models/ir_model_fields.py new file mode 100644 index 00000000000..828b9de8346 --- /dev/null +++ b/addons/auth_signup/models/ir_model_fields.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details + +from odoo import models + + +# This is a nasty hack, targeted for V11 only +class IrModelFields(models.Model): + _inherit = 'ir.model.fields' + + def unlink(self): + # Prevent the deletion of some `shared` fields... -_- + self = self.filtered( + lambda rec: not ( + rec.model == 'res.config.settings' + and rec.name == 'auth_signup_uninvited' + ) + ) + return super(IrModelFields, self).unlink() diff --git a/addons/barcodes/static/src/js/barcode_form_view.js b/addons/barcodes/static/src/js/barcode_form_view.js index fa5b27e2a9c..1730b42290e 100644 --- a/addons/barcodes/static/src/js/barcode_form_view.js +++ b/addons/barcodes/static/src/js/barcode_form_view.js @@ -155,7 +155,7 @@ FormController.include({ id: candidate.id, data: candidateChanges, }; - return this.model.notifyChanges(this.handle, changes); + return this.model.notifyChanges(this.handle, changes, {notifyChange: activeBarcode.notifyChange}); }, /** * @private @@ -211,6 +211,8 @@ FormController.include({ * @param {OdooEvent} event * @param {string} event.data.name: the current field name * @param {string} [event.data.fieldName] optional for x2many sub field + * @param {boolean} [event.data.notifyChange] optional for x2many sub field + * do not trigger on change server side if a candidate has been found * @param {string} [event.data.quantity] optional field to increase quantity * @param {Object} [event.data.commands] optional added methods * can use comand with specific barcode (with ReservedBarcodePrefixes) @@ -225,7 +227,9 @@ FormController.include({ handle: this.handle, target: event.target, widget: event.target.attrs && event.target.attrs.widget, + setQuantityWithKeypress: !! event.data.setQuantityWithKeypress, fieldName: event.data.fieldName, + notifyChange: (event.data.notifyChange !== undefined) ? event.data.notifyChange : true, quantity: event.data.quantity, commands: event.data.commands || {}, candidate: this.activeBarcode[name] && this.activeBarcode[name].handle === this.handle ? @@ -319,7 +323,7 @@ FormController.include({ // only catch the event if we're not focused in // another field and it's a number - if (!$(event.target).is('body') || !/[0-9]/.test(character)) { + if (!$(event.target).is('body, .modal') || !/[0-9]/.test(character)) { return; } diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py index 43d2e81ca33..3b260dd5a61 100644 --- a/addons/base_gengo/wizard/base_gengo_translations.py +++ b/addons/base_gengo/wizard/base_gengo_translations.py @@ -63,7 +63,7 @@ class BaseGengoTranslations(models.TransientModel): 'view_type': 'form', 'view_mode': 'form', 'res_model': 'res.config.settings', - 'target': 'current', + 'target': 'inline', 'context': {'module' : 'general_settings'}, } diff --git a/addons/base_iban/models/res_partner_bank.py b/addons/base_iban/models/res_partner_bank.py index 53c33f2b897..0b5d6c47f16 100644 --- a/addons/base_iban/models/res_partner_bank.py +++ b/addons/base_iban/models/res_partner_bank.py @@ -91,6 +91,7 @@ _map_iban_template = { 'bg': 'BGkk BBBB SSSS DDCC CCCC CC', # Bulgaria 'bh': 'BHkk BBBB CCCC CCCC CCCC CC', # Bahrain 'br': 'BRkk BBBB BBBB SSSS SCCC CCCC CCCT N', # Brazil + 'by': 'BYkk BBBB AAAA CCCC CCCC CCCC CCCC', # Belarus 'ch': 'CHkk BBBB BCCC CCCC CCCC C', # Switzerland 'cr': 'CRkk BBBC CCCC CCCC CCCC C', # Costa Rica 'cy': 'CYkk BBBS SSSS CCCC CCCC CCCC CCCC', # Cyprus diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index e6c53740f73..fd4dcddb4df 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -302,7 +302,8 @@ class Import(models.TransientModel): try: thousand_separator = decimal_separator = False for val in preview_values: - if val == '': + val = val.strip() + if not val: continue # value might have the currency symbol left or right from the value val = self._remove_currency_symbol(val) @@ -587,6 +588,7 @@ class Import(models.TransientModel): thousand_separator = options.get('float_thousand_separator', ' ') decimal_separator = options.get('float_decimal_separator', '.') for line in data: + line[index] = line[index].strip() if not line[index]: continue line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.') diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index aaa1e69946a..d9c3d4092a2 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -145,12 +145,13 @@ def _is_studio_custom(path): Returns True if any of the records contains a context with the key studio in it, False if none of the records do """ - path = os.path.join(path, 'data') - filenames = next(iter(os.walk(path)))[2] - filenames = [f for f in filenames if f.lower().endswith('.xml')] + filepaths = [] + for level in os.walk(path): + filepaths += [os.path.join(level[0], fn) for fn in level[2]] + filepaths = [fp for fp in filepaths if fp.lower().endswith('.xml')] - for filename in filenames: - root = lxml.etree.parse(os.path.join(path, filename)).getroot() + for fp in filepaths: + root = lxml.etree.parse(fp).getroot() for record in root: # there might not be a context if it's a non-studio module diff --git a/addons/calendar_sms/__manifest__.py b/addons/calendar_sms/__manifest__.py index 0e734074aaa..70156156a8a 100644 --- a/addons/calendar_sms/__manifest__.py +++ b/addons/calendar_sms/__manifest__.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { - 'name': "Calendar SMS", + 'name': "Calendar - SMS", 'summary': 'Send text messages as event reminders', 'description': "Send text messages as event reminders", 'category': 'Hidden', diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 9a96a892b29..9fdb29a19f6 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -973,6 +973,8 @@ class Lead(models.Model): 'nb_opportunities': 0, } + today = fields.Date.from_string(fields.Date.context_today(self)) + opportunities = self.search([('type', '=', 'opportunity'), ('user_id', '=', self._uid)]) for opp in opportunities: @@ -980,28 +982,28 @@ class Lead(models.Model): if opp.activity_date_deadline: if opp.date_deadline: date_deadline = fields.Date.from_string(opp.date_deadline) - if date_deadline == date.today(): + if date_deadline == today: result['closing']['today'] += 1 - if date.today() <= date_deadline <= date.today() + timedelta(days=7): + if today <= date_deadline <= today + timedelta(days=7): result['closing']['next_7_days'] += 1 - if date_deadline < date.today() and not opp.date_closed: + if date_deadline < today and not opp.date_closed: result['closing']['overdue'] += 1 # Next activities for activity in opp.activity_ids: date_deadline = fields.Date.from_string(activity.date_deadline) - if date_deadline == date.today(): + if date_deadline == today: result['activity']['today'] += 1 - if date.today() <= date_deadline <= date.today() + timedelta(days=7): + if today <= date_deadline <= today + timedelta(days=7): result['activity']['next_7_days'] += 1 - if date_deadline < date.today(): + if date_deadline < today: result['activity']['overdue'] += 1 # Won in Opportunities if opp.date_closed and opp.stage_id.probability == 100: date_closed = fields.Date.from_string(opp.date_closed) - if date.today().replace(day=1) <= date_closed <= date.today(): + if today.replace(day=1) <= date_closed <= today: if opp.planned_revenue: result['won']['this_month'] += opp.planned_revenue - elif date.today() + relativedelta(months=-1, day=1) <= date_closed < date.today().replace(day=1): + elif today + relativedelta(months=-1, day=1) <= date_closed < today.replace(day=1): if opp.planned_revenue: result['won']['last_month'] += opp.planned_revenue @@ -1026,9 +1028,9 @@ class Lead(models.Model): for activity in activites_done: if activity['date']: date_act = fields.Date.from_string(activity['date']) - if date.today().replace(day=1) <= date_act <= date.today(): + if today.replace(day=1) <= date_act <= today: result['done']['this_month'] += 1 - elif date.today() + relativedelta(months=-1, day=1) <= date_act < date.today().replace(day=1): + elif today + relativedelta(months=-1, day=1) <= date_act < today.replace(day=1): result['done']['last_month'] += 1 # Meetings @@ -1043,9 +1045,9 @@ class Lead(models.Model): for meeting in meetings: if meeting['start']: start = datetime.strptime(meeting['start'], tools.DEFAULT_SERVER_DATETIME_FORMAT).date() - if start == date.today(): + if start == today: result['meeting']['today'] += 1 - if date.today() <= start <= date.today() + timedelta(days=7): + if today <= start <= today + timedelta(days=7): result['meeting']['next_7_days'] += 1 result['done']['target'] = self.env.user.target_sales_done diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py index a944c9d6b71..c2ca5a60735 100644 --- a/addons/crm/models/res_config_settings.py +++ b/addons/crm/models/res_config_settings.py @@ -10,7 +10,7 @@ class ResConfigSettings(models.TransientModel): crm_alias_prefix = fields.Char('Default Alias Name for Leads') generate_lead_from_alias = fields.Boolean('Manual Assignation of Emails', config_parameter='crm.generate_lead_from_alias') group_use_lead = fields.Boolean(string="Leads", implied_group='crm.group_use_lead') - module_crm_phone_validation = fields.Boolean("Phone Validation") + module_crm_phone_validation = fields.Boolean("Phone Formatting") module_web_clearbit = fields.Boolean("Customer Autocomplete") def _find_default_lead_alias_id(self): @@ -33,7 +33,7 @@ class ResConfigSettings(models.TransientModel): @api.onchange('generate_lead_from_alias') def _onchange_generate_lead_from_alias(self): - self.crm_alias_prefix = 'info' if self.generate_lead_from_alias else False + self.crm_alias_prefix = (self.crm_alias_prefix or 'info') if self.generate_lead_from_alias else False @api.model def get_values(self): diff --git a/addons/crm/views/res_config_settings_views.xml b/addons/crm/views/res_config_settings_views.xml index 582063ae90a..4b421d928f1 100644 --- a/addons/crm/views/res_config_settings_views.xml +++ b/addons/crm/views/res_config_settings_views.xml @@ -62,7 +62,7 @@ <div class="o_setting_right_pane"> <label for="module_crm_phone_validation"/> <div class="text-muted" id="module_crm_phone_validation"> - Formats phone numbers + Format phone numbers based on national conventions </div> </div> </div> diff --git a/addons/delivery/models/__init__.py b/addons/delivery/models/__init__.py index 5229017a189..9e162e76592 100644 --- a/addons/delivery/models/__init__.py +++ b/addons/delivery/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import res_config_settings from . import delivery_carrier from . import delivery_grid from . import product_template diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index fdc568725d6..41ea4cc7682 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -210,8 +210,11 @@ class DeliveryCarrier(models.Model): carrier.product_id.list_price = carrier.fixed_price def fixed_rate_shipment(self, order): + price = self.fixed_price + if self.company_id.currency_id.id != order.currency_id.id: + price = self.env['res.currency']._compute(self.company_id.currency_id, order.currency_id, price) return {'success': True, - 'price': self.fixed_price, + 'price': price, 'error_message': False, 'warning_message': False} diff --git a/addons/delivery/models/res_config_settings.py b/addons/delivery/models/res_config_settings.py new file mode 100644 index 00000000000..070f8dcdb29 --- /dev/null +++ b/addons/delivery/models/res_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + def set_values(self): + super(ResConfigSettings, self).set_values() + rule = self.env.ref('delivery.delivery_carrier_comp_rule', False) + if rule: + rule.write({'active': not bool(self.company_share_product)}) diff --git a/addons/delivery/security/delivery_carrier_security.xml b/addons/delivery/security/delivery_carrier_security.xml index 8fad0891676..db718fbf361 100644 --- a/addons/delivery/security/delivery_carrier_security.xml +++ b/addons/delivery/security/delivery_carrier_security.xml @@ -6,6 +6,7 @@ <field name="model_id" ref="model_delivery_carrier"/> <field name="global" eval="True"/> <field name="domain_force"> ['|',('company_id','=',user.company_id.id),('company_id','=',False)]</field> + <field name="active" eval="False"/> </record> </data> </odoo> diff --git a/addons/event/data/email_template_data.xml b/addons/event/data/email_template_data.xml index f2a742d9318..b7788cba9a3 100644 --- a/addons/event/data/email_template_data.xml +++ b/addons/event/data/email_template_data.xml @@ -86,18 +86,6 @@ <td style="padding:15px 20px 0px 20px;"> <table style="width:100%;border-top:1px solid #e1e1e1;"> <tr> - <td style="padding:25px 0px; text-align:center;"> - <p> - Don't forget to <strong>add it to your calendar</strong>: - </p> - <a href="https://www.google.com/calendar/render?action=TEMPLATE&text=${object.event_id.name}&dates=${date_begin}/${date_end}&location=${location}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Google</a> - <a href="https://bay02.calendar.live.com/calendar/calendar.aspx?rru=addevent&summary=${object.event_id.name}&dtstart=${date_begin}&dtend=${date_end}&location=${location}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Outlook</a> - <a href="https://calendar.yahoo.com/?v=60&view=d&type=20&title=${object.event_id.name}&in_loc=${location}&st=${format_tz(object.event_id.date_begin, tz='UTC', format='%Y%m%dT%H%M%S')}&et=${format_tz(object.event_id.date_end, tz='UTC', format='%Y%m%dT%H%M%S')}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Yahoo</a> - </td> - </tr> - </table> - <table style="width:100%;border-top:1px solid #e1e1e1;"> - <tr> <td style="padding:25px 0px;vertical-align:top;"> <img src="/web_editor/font_to_img/61555/rgb(81,81,102)/34" style="padding:4px;max-width:inherit;" height="34"> </td> @@ -165,6 +153,16 @@ </tr> </table> % endif + <table style="width:100%;border-top:1px solid #e1e1e1;"> + <tr> + <td style="padding:25px 0px;"> + <strong>Add this event to your calendar</strong> + <a href="https://www.google.com/calendar/render?action=TEMPLATE&text=${object.event_id.name}&dates=${date_begin}/${date_end}&location=${location}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Google</a> + <a href="https://bay02.calendar.live.com/calendar/calendar.aspx?rru=addevent&summary=${object.event_id.name}&dtstart=${date_begin}&dtend=${date_end}&location=${location}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Outlook</a> + <a href="https://calendar.yahoo.com/?v=60&view=d&type=20&title=${object.event_id.name}&in_loc=${location}&st=${format_tz(object.event_id.date_begin, tz='UTC', format='%Y%m%dT%H%M%S')}&et=${format_tz(object.event_id.date_end, tz='UTC', format='%Y%m%dT%H%M%S')}" style="padding:3px 5px;border:1px solid #875A7B;color:#875A7B;text-decoration:none;border-radius:3px;" target="new"><img src="/web_editor/font_to_img/61525/rgb(135,90,123)/16" style="vertical-align:middle;" height="16"> Yahoo</a> + </td> + </tr> + </table> </td> </tr> </tbody> diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index da5d9fb69b1..213c5716059 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -247,9 +247,11 @@ class FleetVehicle(models.Model): @return: the costs log view """ self.ensure_one() + copy_context = dict(self.env.context) + copy_context.pop('group_by', None) res = self.env['ir.actions.act_window'].for_xml_id('fleet', 'fleet_vehicle_costs_action') res.update( - context=dict(self.env.context, default_vehicle_id=self.id, search_default_parent_false=True), + context=dict(copy_context, default_vehicle_id=self.id, search_default_parent_false=True), domain=[('vehicle_id', '=', self.id)] ) return res diff --git a/addons/google_calendar/README.md b/addons/google_calendar/README.md index 47b00887fc2..2473da7b922 100644 --- a/addons/google_calendar/README.md +++ b/addons/google_calendar/README.md @@ -48,5 +48,5 @@ a notification in you Odoo which ever the page you are. Google Calendar --------------- -With the plugin Google\_calendar, you can synchronize your Odoo calendar with Google Calendar. +With this plugin, you can synchronize your Odoo calendar with Google Calendar. diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 60b308dc36e..a97052e0445 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -416,7 +416,8 @@ class HrExpense(models.Model): product = default_product else: expense_description = expense_description.replace(product_code.group(), '') - product = self.env['product.product'].search([('default_code', 'ilike', product_code.group(1))]) or default_product + products = self.env['product.product'].search([('default_code', 'ilike', product_code.group(1))]) or default_product + product = products.filtered(lambda p: p.default_code == product_code.group(1)) or products[0] pattern = '[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?' # Match the last occurence of a float in the string @@ -591,10 +592,13 @@ class HrExpenseSheet(models.Model): MailFollowers = self.env['mail.followers'] for partner in users.mapped('partner_id'): values['message_follower_ids'] += MailFollowers._add_follower_command(self._name, [], {partner.id: None}, {})[0] - - if values.get('user_id') and values.get('user_id') != employee.user_id.id: + + if values.get('user_id') and values.get('user_id') != employee.user_id.id and values.get('user_id') != self.env.uid: resp_partner = self.env['res.users'].browse(values['user_id']) - values['message_follower_ids'] += MailFollowers._add_follower_command(self._name, [], {resp_partner.partner_id.id: None}, {})[0] + # Parsing ORM command to avoid adding several times the same partner + # TDE note: a better implementation should come in saas 11.3 or 11.4, allowing to normally remove that code + if resp_partner.partner_id.id not in (x[2].get('partner_id') for x in values['message_follower_ids'] if len(x) == 3): + values['message_follower_ids'] += MailFollowers._add_follower_command(self._name, [], {resp_partner.partner_id.id: None}, {})[0] # -------------------------------------------- # Actions diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index 6d27b2b9cdb..3d5343ca5ec 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -8,7 +8,7 @@ <field name="arch" type="xml"> <xpath expr="//group[@name='active_group']" position="before"> <group string="Expenses" name="timesheet"> - <field name="expense_manager_id"/> + <field name="expense_manager_id"/> </group> </xpath> </field> @@ -35,14 +35,29 @@ </field> </record> - <record id="hr_expense_view_form_without_header" model="ir.ui.view"> + <record id="hr_expense_view_form" model="ir.ui.view"> <field name="name">hr.expense.view.form</field> <field name="model">hr.expense</field> - <field eval="35" name="priority"/> <field name="arch" type="xml"> <form string="Expenses" class="o_expense_form"> - <field name="state" invisible="True"/> + <header> + <widget name="attach_document" string="Attach Document" action="message_post" attrs="{'invisible': [('attachment_number', '<=', 0)]}"/> + <widget name="attach_document" string="Attach Document" action="message_post" class="oe_highlight" attrs="{'invisible': [('attachment_number', '>=', 1)]}"/> + <button name="action_submit_expenses" string="Submit to Manager" type="object" class="o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '>=', 1), ('sheet_id', '!=', False)]}"/> + <button name="action_submit_expenses" string="Submit to Manager" type="object" class="oe_highlight o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '<=', 0), ('sheet_id', '!=', False)]}"/> + <field name="state" widget="statusbar" statusbar_visible="draft,reported,approved,done,refused"/> + <button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('state', '=','draft')]}"/> + </header> <sheet> + <div class="oe_button_box"> + <button name="action_get_attachment_view" + class="oe_stat_button" + icon="fa-book" + type="object" + attrs="{'invisible': [('attachment_number', '=', 0)]}"> + <field name="attachment_number" widget="statinfo" string="Documents" options="{'reload_on_button': true}"/> + </button> + </div> <div class="oe_title"> <label for="name"/> <h1> @@ -91,32 +106,18 @@ </field> </record> - <record id="hr_expense_view_form" model="ir.ui.view"> + <record id="hr_expense_view_form_without_header" model="ir.ui.view"> <field name="name">hr.expense.view.form</field> <field name="model">hr.expense</field> - <field name="inherit_id" ref="hr_expense.hr_expense_view_form_without_header"/> + <field name="inherit_id" ref="hr_expense.hr_expense_view_form"/> + <field eval="35" name="priority"/> <field name="mode">primary</field> <field name="arch" type="xml"> - <xpath expr="/form/*" position="before"> - <header> - <widget name="attach_document" string="Attach Document" action="message_post" attrs="{'invisible': [('attachment_number', '<=', 0)]}"/> - <widget name="attach_document" string="Attach Document" action="message_post" class="oe_highlight" attrs="{'invisible': [('attachment_number', '>=', 1)]}"/> - <button name="action_submit_expenses" string="Submit to Manager" type="object" class="o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '>=', 1), ('sheet_id', '!=', False)]}"/> - <button name="action_submit_expenses" string="Submit to Manager" type="object" class="oe_highlight o_expense_submit" attrs="{'invisible': ['|', ('attachment_number', '<=', 0), ('sheet_id', '!=', False)]}"/> - <field name="state" widget="statusbar" statusbar_visible="draft,reported,approved,done,refused"/> - <button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('state', '=','draft')]}"/> - </header> + <xpath expr="/form/header" position="attributes"> + <attribute name="invisible">1</attribute> </xpath> - <xpath expr="/form/sheet/*" position="before"> - <div class="oe_button_box"> - <button name="action_get_attachment_view" - class="oe_stat_button" - icon="fa-book" - type="object" - attrs="{'invisible': [('attachment_number', '=', 0)]}"> - <field name="attachment_number" widget="statinfo" string="Documents" options="{'reload_on_button': true}"/> - </button> - </div> + <xpath expr="/form/sheet/div[hasclass('oe_button_box')]" position="attributes"> + <attribute name="invisible">1</attribute> </xpath> </field> </record> diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index c7f50a175c7..4a58d151f55 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -100,7 +100,7 @@ class HolidaysRequest(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): self.manager_id = self.employee_id and self.employee_id.parent_id - self.department_id = self.employee_id and self.employee_id.department_id + self.department_id = self.employee_id.department_id @api.multi @api.depends('number_of_days_temp') diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index 726e5efe375..c9d14c47495 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -190,7 +190,8 @@ class HrPayslip(models.Model): leave_time = (interval[1] - interval[0]).seconds / 3600 current_leave_struct['number_of_hours'] += leave_time work_hours = contract.employee_id.get_day_work_hours_count(interval[0].date(), calendar=contract.resource_calendar_id) - current_leave_struct['number_of_days'] += leave_time / work_hours + if work_hours: + current_leave_struct['number_of_days'] += leave_time / work_hours # compute worked days work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id) diff --git a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py index 73a304daa2e..d9a59cba3a0 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py @@ -35,6 +35,7 @@ class HrPayslipEmployees(models.TransientModel): 'date_from': from_date, 'date_to': to_date, 'credit_note': run_data.get('credit_note'), + 'company_id': employee.company_id.id, } payslips += self.env['hr.payslip'].create(res) payslips.compute_sheet() diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index ee1d5833fb0..ddfd68e0d56 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -14,7 +14,7 @@ class AccountAnalyticLine(models.Model): result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id return result - task_id = fields.Many2one('project.task', 'Task') + task_id = fields.Many2one('project.task', 'Task', index=True) project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)]) employee_id = fields.Many2one('hr.employee', "Employee") diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index e335789a230..72a88277886 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -15,13 +15,18 @@ class Project(models.Model): class Task(models.Model): _inherit = "project.task" - remaining_hours = fields.Float("Remaining Hours", compute='_compute_progress_hours', store=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.") + remaining_hours = fields.Float("Remaining Hours", compute='_compute_progress_hours', inverse='_inverse_remaining_hours', store=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.") effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Computed using the sum of the task work done.") total_hours_spent = fields.Float("Total Hours", compute='_compute_progress_hours', store=True, help="Computed as: Time Spent + Sub-tasks Hours.") - progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task. In case if the total spent hours exceeds planned hours then the progress bar may go above 100%") + progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task.") subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)", oldname='children_hours') timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') + @api.onchange('remaining_hours') + def _inverse_remaining_hours(self): + for task in self: + task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours + @api.depends('timesheet_ids.unit_amount') def _compute_effective_hours(self): for task in self: @@ -31,7 +36,11 @@ class Task(models.Model): def _compute_progress_hours(self): for task in self: if (task.planned_hours > 0.0): - task.progress = round(100.0 * (task.effective_hours + task.subtask_effective_hours) / task.planned_hours, 2) + task_total_hours = task.effective_hours + task.subtask_effective_hours + if task_total_hours > task.planned_hours: + task.progress = 100 + else: + task.progress = round(100.0 * task_total_hours / task.planned_hours, 2) else: task.progress = 0.0 diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 47224328b99..e9c2d94808a 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -62,13 +62,16 @@ <group> <group> <field name="planned_hours" widget="float_time"/> - <field name="subtask_planned_hours" widget="float_time" groups="project.group_subtask_project" attrs="{'invisible': [('subtask_count', '=', 0)]}"/> + <label for="subtask_planned_hours" groups="project.group_subtask_project" attrs="{'invisible': [('subtask_count', '=', 0)]}"/> + <div class="o_row" groups="project.group_subtask_project" attrs="{'invisible': [('subtask_count', '=', 0)]}"> + <field name="subtask_planned_hours" widget="float_time"/><span> planned hours</span> + </div> </group> <group> <field name="progress" widget="progressbar"/> </group> </group> - <field name="timesheet_ids" context="{'default_project_id': project_id}"> + <field name="timesheet_ids" context="{'default_project_id': project_id, 'default_name':''}"> <tree editable="bottom" string="Timesheet Activities" default_order="date"> <field name="date"/> <field name="user_id" invisible="1"/> @@ -83,7 +86,7 @@ <field name="effective_hours" widget="float_time" /> <field name="subtask_effective_hours" widget="float_time" groups="project.group_subtask_project" attrs="{'invisible' : [('subtask_count', '=', 0)]}" /> <field name="total_hours_spent" widget="float_time" class="oe_subtotal_footer_separator" attrs="{'invisible' : [('subtask_effective_hours', '=', 0.0)]}" /> - <field name="remaining_hours" widget="float_time" class="oe_subtotal_footer_separator" readonly="1"/> + <field name="remaining_hours" widget="float_time" class="oe_subtotal_footer_separator"/> </group> </group> </page> diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 11eabd18423..e228524893a 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -110,7 +110,8 @@ class LivechatController(http.Controller): @http.route('/im_livechat/history', type="json", auth="public") def history_pages(self, pid, channel_uuid, page_history=None): - channel = request.env['mail.channel'].search([('uuid', '=', channel_uuid)]) + partner_ids = (pid, request.env.user.partner_id.id) + channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid), ('channel_partner_ids', 'in', partner_ids)]) if channel: channel._send_history_message(pid, page_history) return True diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index c2741fcef92..783fbd417ef 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -238,7 +238,7 @@ class ImLivechatChannelRule(models.Model): """ def _match(rules): for rule in rules: - if re.search(rule.regex_url, url): + if re.search(rule.regex_url or '', url): return rule return False # first, search the country specific rules (the first match is returned) diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index d652325d757..e4efb5906ff 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -155,8 +155,8 @@ var LivechatButton = Widget.extend({ self.send_welcome_message(); self.render_messages(); - this.busBus.add_channel(channel.uuid); - this.busBus.start_polling(); + self.busBus.add_channel(channel.uuid); + self.busBus.start_polling(); utils.set_cookie('im_livechat_session', JSON.stringify(channel), 60*60); utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60); diff --git a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py index 0d182afcc72..46c0748f5fa 100644 --- a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py +++ b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py @@ -95,7 +95,7 @@ class HrContract(models.Model): + 12.0 * contract.internet \ + 12.0 * (contract.mobile + contract.mobile_plus) \ + 12.0 * contract.transport_employer_cost \ - + (1.326 / 1.05 * 12.0) * contract.commission_on_target \ + + contract.warrants_cost \ + 220.0 * contract.meal_voucher_paid_by_employer contract.final_yearly_costs = yearly_cost / (1.0 - (contract.holidays - 20.0) / 231.0) contract.wage = contract._get_gross_from_employer_costs(contract.final_yearly_costs) @@ -116,8 +116,8 @@ class HrContract(models.Model): @api.depends('commission_on_target') def _compute_warrants_cost(self): for contract in self: - contract.warrants_cost = contract.commission_on_target * 1.05 * 12.0 - contract.warrant_value_employee = contract.commission_on_target * (1.00 - 0.535) * 12.0 + contract.warrants_cost = contract.commission_on_target * 1.326 * 1.05 * 12.0 + contract.warrant_value_employee = contract.commission_on_target * 1.326 * (1.00 - 0.535) * 12.0 @api.depends('wage', 'fuel_card', 'representation_fees', 'transport_employer_cost', 'internet', 'mobile', 'mobile_plus') diff --git a/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml b/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml index 4c095e2eea5..f4800282ec2 100644 --- a/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml +++ b/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml @@ -15,9 +15,9 @@ <div class="o_setting_left_pane"/> <div class="o_setting_right_pane"> <label string="New Car Request"/> - <div class="text-muted o_row"> + <div class="text-muted content-group mt16"> <span>An employee cannot request a new car if there are more than </span> - <field name="max_unused_cars"/> + <field name="max_unused_cars" class="text-center oe_inline" /> <span> existing vehicles availables.</span> </div> </div> diff --git a/addons/l10n_fr/data/account_chart_template_data.xml b/addons/l10n_fr/data/account_chart_template_data.xml index db836b0fd3b..aac0137d150 100644 --- a/addons/l10n_fr/data/account_chart_template_data.xml +++ b/addons/l10n_fr/data/account_chart_template_data.xml @@ -2836,98 +2836,98 @@ <record id="pcg_501" model="account.account.template"> <field name="name">Valeurs mobilières de placement - Parts dans entreprises liées</field> <field name="code">501</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_502" model="account.account.template"> <field name="name">Valeurs mobilières de placement - Actions propres</field> <field name="code">502</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5031" model="account.account.template"> <field name="name">Valeurs mobilières de placement - Titres cotés</field> <field name="code">5031</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5035" model="account.account.template"> <field name="name">Valeurs mobilières de placement - Titres non cotés</field> <field name="code">5035</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_504" model="account.account.template"> <field name="name">Valeurs mobilières de placement - Autres titres conférant un droit de propriété</field> <field name="code">504</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_505" model="account.account.template"> <field name="name">Obligations et bons émis par la société et rachetés par elle</field> <field name="code">505</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5061" model="account.account.template"> <field name="name">Obligations cotés</field> <field name="code">5061</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5065" model="account.account.template"> <field name="name">Obligations non cotés</field> <field name="code">5065</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_507" model="account.account.template"> <field name="name">Bons du Trésor et bons de caisse à court terme</field> <field name="code">507</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5081" model="account.account.template"> <field name="name">Autres valeurs mobilières de placement</field> <field name="code">5081</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5082" model="account.account.template"> <field name="name">Bons de souscription</field> <field name="code">5082</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5088" model="account.account.template"> <field name="name">Intérêts courus sur obligations, bons et valeurs assimilées</field> <field name="code">5088</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_509" model="account.account.template"> <field name="name">Versements restant à effectuer sur valeurs mobilières de placement non libérées</field> <field name="code">509</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5111" model="account.account.template"> <field name="name">Coupons échus à l'encaissement</field> <field name="code">5111</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="reconcile" eval="True"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> @@ -2935,7 +2935,7 @@ <record id="pcg_5112" model="account.account.template"> <field name="name">Chèques à encaisser</field> <field name="code">5112</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="reconcile" eval="True"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> @@ -2943,7 +2943,7 @@ <record id="pcg_5113" model="account.account.template"> <field name="name">Effets à l'encaissement</field> <field name="code">5113</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="reconcile" eval="True"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> @@ -2951,7 +2951,7 @@ <record id="pcg_5114" model="account.account.template"> <field name="name">Effets à l'escompte</field> <field name="code">5114</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="reconcile" eval="True"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> @@ -2980,56 +2980,56 @@ <record id="pcg_516" model="account.account.template"> <field name="name">Sociétés de bourse</field> <field name="code">516</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_517" model="account.account.template"> <field name="name">Autres organismes financiers</field> <field name="code">517</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5181" model="account.account.template"> <field name="name">Intérêts courus à payer</field> <field name="code">5181</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5188" model="account.account.template"> <field name="name">Intérêts courus à recevoir</field> <field name="code">5188</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5191" model="account.account.template"> <field name="name">Concours bancaires courants - Crédit de mobilisation de créances commerciales (CMCC)</field> <field name="code">5191</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5193" model="account.account.template"> <field name="name">Concours bancaires courants - Mobilisation de créances nées à l'étranger</field> <field name="code">5193</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5198" model="account.account.template"> <field name="name">Concours bancaires courants - Intérêts courus sur concours bancaires courants</field> <field name="code">5198</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_52" model="account.account.template"> <field name="name">Instruments de trésorerie</field> <field name="code">52</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> @@ -3064,35 +3064,35 @@ <record id="pcg_54" model="account.account.template"> <field name="name">Régies d'avances et accréditifs</field> <field name="code">54</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5903" model="account.account.template"> <field name="name">Provisions pour dépréciation des actions</field> <field name="code">5903</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5904" model="account.account.template"> <field name="name">Provisions pour dépréciation des autres titres conférant un droit de propriété</field> <field name="code">5904</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5906" model="account.account.template"> <field name="name">Provisions pour dépréciation des obligations</field> <field name="code">5906</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> <record id="pcg_5908" model="account.account.template"> <field name="name">Provisions pour dépréciation des autres valeurs mobilières de placement et créances assimilées (provisions)</field> <field name="code">5908</field> - <field name="user_type_id" ref="account.data_account_type_liquidity"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml index 3441689d3d5..7fb7337becf 100644 --- a/addons/l10n_in/views/report_invoice.xml +++ b/addons/l10n_in/views/report_invoice.xml @@ -73,21 +73,21 @@ <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_cgst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_sgst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_igst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_igst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> @@ -95,10 +95,10 @@ <td class="text-right" t-if="is_cess"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_cess_id in tax_data['tag_ids']"> - <t t-set="total_cess" t-value="total_cess + tax_data['amount']"/> + <t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> - <t t-esc="total_cess"/> + <t t-esc="total_cess" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </td> </t> </tr> diff --git a/addons/l10n_mx/data/account_tax_data.xml b/addons/l10n_mx/data/account_tax_data.xml index 8675040c026..168b1955609 100644 --- a/addons/l10n_mx/data/account_tax_data.xml +++ b/addons/l10n_mx/data/account_tax_data.xml @@ -18,6 +18,10 @@ <field name="name">DIOT: 16%</field> <field name="applicability">taxes</field> </record> + <record id="tag_diot_16_imp" model="account.account.tag"> + <field name="name">DIOT: 16% IMP</field> + <field name="applicability">taxes</field> + </record> <record id="tag_diot_0" model="account.account.tag"> <field name="name">DIOT: 0%</field> <field name="applicability">taxes</field> diff --git a/addons/mail/data/mail_shortcode_data.xml b/addons/mail/data/mail_shortcode_data.xml index afd0cf91146..ac648e3c738 100644 --- a/addons/mail/data/mail_shortcode_data.xml +++ b/addons/mail/data/mail_shortcode_data.xml @@ -5,18 +5,25 @@ <!-- Smileys --> <record id="mail_smiley_normal_smile" model="mail.shortcode"> <field name="source">:)</field> - <field name="unicode_source">😊</field> - <field name="substitution"><img src='/mail/static/src/img/smiley/normal_smile.png' alt='😊'/></field> + <field name="unicode_source">🙂</field> + <field name="substitution"><img src='/mail/static/src/img/smiley/smile.png' alt='🙂'/></field> <field name="description">Smile</field> <field name="shortcode_type">image</field> </record> <record id="mail_smiley_smile" model="mail.shortcode"> <field name="source">:D</field> - <field name="unicode_source">😄</field> - <field name="substitution"><img src='/mail/static/src/img/smiley/smile.png' alt='😄'/></field> + <field name="unicode_source">😀</field> + <field name="substitution"><img src='/mail/static/src/img/smiley/smile_open_mouth.png' alt='😀'/></field> <field name="description">Smile with open mouth</field> <field name="shortcode_type">image</field> </record> + <record id="mail_smiley_smile_eyes" model="mail.shortcode"> + <field name="source">^^</field> + <field name="unicode_source">😊</field> + <field name="substitution"><img src='/mail/static/src/img/smiley/normal_smile.png' alt='😊'/></field> + <field name="description">Smile with with smiling eyes</field> + <field name="shortcode_type">image</field> + </record> <record id="mail_smiley_laugh" model="mail.shortcode"> <field name="source">xD</field> <field name="unicode_source">😆</field> @@ -25,7 +32,7 @@ <field name="shortcode_type">image</field> </record> <record id="mail_smiley_tears_of_joy" model="mail.shortcode"> - <field name="source">:-)</field> + <field name="source">:joy</field> <field name="unicode_source">😂</field> <field name="substitution"><img src='/mail/static/src/img/smiley/tears_of_joy.png' alt='😂'/></field> <field name="description">Tears of joy</field> diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 33ebd368b79..c08866fa6a4 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -358,6 +358,15 @@ class MailActivityMixin(models.AbstractModel): return [('activity_ids.summary', operator, operand)] @api.multi + def write(self, vals): + # Delete activities of archived record. + if 'active' in vals and vals['active'] is False: + self.env['mail.activity'].sudo().search( + [('res_model', '=', self._name), ('res_id', 'in', self.ids)] + ).unlink() + return super(MailActivityMixin, self).write(vals) + + @api.multi def unlink(self): """ Override unlink to delete records activities through (res_model, res_id). """ record_ids = self.ids diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 5f663963bfa..f94eead0736 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -56,7 +56,7 @@ class Channel(models.Model): ('channel', 'Channel')], 'Channel Type', default='channel') description = fields.Text('Description') - uuid = fields.Char('UUID', size=50, index=True, default=lambda self: '%s' % uuid.uuid4()) + uuid = fields.Char('UUID', size=50, index=True, default=lambda self: '%s' % uuid.uuid4(), copy=False) email_send = fields.Boolean('Send messages by email', default=False) # multi users channel channel_last_seen_partner_ids = fields.One2many('mail.channel.partner', 'channel_id', string='Last Seen') diff --git a/addons/mail/static/src/img/smiley/smile_open_mouth.png b/addons/mail/static/src/img/smiley/smile_open_mouth.png Binary files differnew file mode 100644 index 00000000000..7dcdd3f55fe --- /dev/null +++ b/addons/mail/static/src/img/smiley/smile_open_mouth.png diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 37903e3861f..4bb81489789 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -2,7 +2,7 @@ odoo.define('mail.Activity', function (require) { "use strict"; var AbstractField = require('web.AbstractField'); -var concurrency = require('web.concurrency'); +var BasicModel = require('web.BasicModel'); var core = require('web.core'); var field_registry = require('web.field_registry'); var time = require('web.time'); @@ -12,6 +12,63 @@ var QWeb = core.qweb; var _t = core._t; /** + * Fetches activities and postprocesses them. + * + * This standalone function performs an RPC, but to do so, it needs an instance + * of a widget that implements the _rpc() function. + * + * @todo i'm not very proud of the widget instance given in arguments, we should + * probably try to do it a better way in the future. + * + * @param {Widget} self a widget instance that can perform RPCs + * @param {Array} ids the ids of activities to read + * @return {Deferred<Array>} resolved with the activities + */ +function _readActivities(self, ids) { + if (!ids.length) { + return $.when([]); + } + return self._rpc({ + model: 'mail.activity', + method: 'read', + args: [ids], + }).then(function (activities) { + // convert create_date and date_deadline to moments + _.each(activities, function (activity) { + activity.create_date = moment(time.auto_str_to_date(activity.create_date)); + activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline)); + }); + + // sort activities by due date + activities = _.sortBy(activities, 'date_deadline'); + + return activities; + }); +} + +BasicModel.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetches the activities displayed by the activity field widget in form + * views. + * + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName + * @return {Deferred<Array>} resolved with the activities + */ + _fetchSpecialActivity: function (record, fieldName) { + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + return _readActivities(this, this.localData[localID].res_ids); + }, +}); + +/** * Set the 'label_delay' entry in activity data according to the deadline date * @param {Array} activities list of activity Object * @return {Array} : list of modified activity Object @@ -44,12 +101,6 @@ var setDelayLabel = function(activities){ }; var AbstractActivityField = AbstractField.extend({ - // inherited - init: function () { - this._super.apply(this, arguments); - this.activities = []; - }, - // private _markActivityDone: function (id, feedback) { return this._rpc({ @@ -59,33 +110,6 @@ var AbstractActivityField = AbstractField.extend({ kwargs: {feedback: feedback}, }); }, - _readActivities: function () { - var self = this; - var missing_ids = _.difference(this.value.res_ids, _.pluck(this.activities, 'id')); - var fetch_def; - if (missing_ids.length) { - fetch_def = this._rpc({ - model: 'mail.activity', - method: 'read', - args: [missing_ids], - }); - } - return $.when(fetch_def).then(function (results) { - // convert create_date and date_deadline to moments - _.each(results, function (activity) { - activity.create_date = moment(time.auto_str_to_date(activity.create_date)); - activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline)); - }); - - // filter out activities that are no longer linked to this record - self.activities = _.filter(self.activities.concat(results || []), function (activity) { - return _.contains(self.value.res_ids, activity.id); - }); - - // sort activities by due date - self.activities = _.sortBy(self.activities, 'date_deadline'); - }); - }, _scheduleActivity: function (id, previous_activity_type_id, callback) { var action = { type: 'ir.actions.act_window', @@ -116,41 +140,37 @@ var Activity = AbstractActivityField.extend({ 'click .o_activity_unlink': '_onUnlinkActivity', 'click .o_activity_done': '_onMarkActivityDone', }, + specialData: '_fetchSpecialActivity', // inherited init: function () { this._super.apply(this, arguments); - this.dp = new concurrency.DropPrevious(); + this.activities = this.record.specialData[this.name]; }, _render: function () { - // note: the rendering of this widget is asynchronous as it needs to - // fetch the details of the linked activities - var self = this; - var fetch_def = this.dp.add(this._readActivities()); - return fetch_def.then(function () { - _.each(self.activities, function (activity) { - if (activity.note) { - activity.note = utils.parse_and_transform(activity.note, utils.add_link); - } - }); - var activities = setDelayLabel(self.activities); - if (activities.length) { - var nbActivities = _.countBy(activities, 'state'); - self.$el.html(QWeb.render('mail.activity_items', { - activities: activities, - nbPlannedActivities: nbActivities.planned, - nbTodayActivities: nbActivities.today, - nbOverdueActivities: nbActivities.overdue, - date_format: time.getLangDateFormat(), - datetime_format: time.getLangDatetimeFormat(), - })); - } else { - self.$el.empty(); + _.each(this.activities, function (activity) { + if (activity.note) { + activity.note = utils.parse_and_transform(activity.note, utils.add_link); } }); + var activities = setDelayLabel(this.activities); + if (activities.length) { + var nbActivities = _.countBy(activities, 'state'); + this.$el.html(QWeb.render('mail.activity_items', { + activities: activities, + nbPlannedActivities: nbActivities.planned, + nbTodayActivities: nbActivities.today, + nbOverdueActivities: nbActivities.overdue, + date_format: time.getLangDateFormat(), + datetime_format: time.getLangDatetimeFormat(), + })); + } else { + this.$el.empty(); + } }, _reset: function (record) { this._super.apply(this, arguments); + this.activities = this.record.specialData[this.name]; // the mail widgets being persistent, one need to update the res_id on reset this.res_id = record.res_id; }, @@ -196,9 +216,6 @@ var Activity = AbstractActivityField.extend({ }); return this.do_action(action, { on_close: function () { - // remove the edited activity from the array of fetched activities to - // force a reload of that activity - self.activities = _.reject(self.activities, {id: activity_id}); self._reload({activity: true, thread: true}); }, }); @@ -312,10 +329,10 @@ var KanbanActivity = AbstractActivityField.extend({ _renderDropdown: function () { var self = this; this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading")); - return this._readActivities().then(function () { + return _readActivities(this, this.value.res_ids).then(function (activities) { self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", { selection: self.selection, - records: _.groupBy(setDelayLabel(self.activities), 'state'), + records: _.groupBy(setDelayLabel(activities), 'state'), uid: self.getSession().uid, })); }); @@ -328,7 +345,9 @@ var KanbanActivity = AbstractActivityField.extend({ // handlers _onButtonClicked: function (event) { event.preventDefault(); - this._renderDropdown(); + if (!this.$el.hasClass('open')) { + this._renderDropdown(); + } }, _onMarkActivityDone: function (event) { event.stopPropagation(); diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js index 8de8a242e68..3a7b9c320e1 100644 --- a/addons/mail/static/src/js/composer.js +++ b/addons/mail/static/src/js/composer.js @@ -591,7 +591,7 @@ var BasicComposer = Widget.extend({ */ _onKeydown: function (event) { switch(event.which) { - // UP, DOWN: prevent moving cursor if navigation in mention propositions + // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: case $.ui.keyCode.TAB: diff --git a/addons/mail/static/src/js/discuss_mobile.js b/addons/mail/static/src/js/discuss_mobile.js index 52622b0ce32..b427ab4b98c 100644 --- a/addons/mail/static/src/js/discuss_mobile.js +++ b/addons/mail/static/src/js/discuss_mobile.js @@ -41,7 +41,7 @@ Discuss.include({ */ on_attach_callback: function () { if (this.channel && this._isInInboxTab()) { - this.thread.scroll_to({offset: this.channels_scrolltop[this.channel.id]}); + this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); } }, /** @@ -49,7 +49,7 @@ Discuss.include({ */ on_detach_callback: function () { if (this._isInInboxTab()) { - this.channels_scrolltop[this.channel.id] = this.thread.get_scrolltop(); + this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); } }, diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js index 2fa34b613ba..0aac950cf5b 100644 --- a/addons/mail/static/src/js/document_viewer.js +++ b/addons/mail/static/src/js/document_viewer.js @@ -17,6 +17,7 @@ var DocumentViewer = Widget.extend({ 'click .o_viewer_video': '_onVideoClicked', 'click .move_next': '_onNext', 'click .move_previous': '_onPrevious', + 'click .o_rotate': '_onRotate', 'click .o_zoom_in': '_onZoomIn', 'click .o_zoom_out': '_onZoomOut', 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose', @@ -104,6 +105,30 @@ var DocumentViewer = Widget.extend({ this._reset(); }, /** + * Get CSS transform property based on scale and angle + * + * @private + * @param {float} scale + * @param {float} angle + */ + _getTransform: function(scale, angle) { + return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)' + }, + /** + * Rotate image clockwise by provided angle + * + * @private + * @param {float} angle + */ + _rotate: function (angle) { + this._reset(); + var new_angle = (this.angle || 0) + angle; + this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle)); + this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%'); + this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%'); + this.angle = new_angle; + }, + /** * Zoom in/out image by provided scale * * @private @@ -111,7 +136,7 @@ var DocumentViewer = Widget.extend({ */ _zoom: function (scale) { if (scale > 0.5) { - this.$('.o_viewer_img').css('transform', 'scale3d(' + scale + ', ' + scale + ', 1)'); + this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0)); this.scale = scale; } }, @@ -284,6 +309,14 @@ var DocumentViewer = Widget.extend({ * @private * @param {MouseEvent} e */ + _onRotate: function (e) { + e.preventDefault(); + this._rotate(90); + }, + /** + * @private + * @param {MouseEvent} e + */ _onZoomIn: function (e) { e.preventDefault(); var scale = this.scale + ZOOM_STEP; diff --git a/addons/mail/static/src/less/composer.less b/addons/mail/static/src/less/composer.less index 9df25c895a3..e2fae8b106e 100644 --- a/addons/mail/static/src/less/composer.less +++ b/addons/mail/static/src/less/composer.less @@ -264,6 +264,7 @@ img { width: @o-mail-attachment-image-size; height: @o-mail-attachment-image-size; + image-orientation: from-image; // Only supported in Firefox } .o_image_overlay{ display: none; @@ -365,6 +366,9 @@ .o-flex-display(); justify-content: center; align-items: center; + img { + image-orientation: from-image; // Only supported in Firefox + } img, video { max-width: 100%; max-height: 100%; diff --git a/addons/mail/static/src/less/discuss.less b/addons/mail/static/src/less/discuss.less index 46dc6d41bfd..4e0ae26dccc 100644 --- a/addons/mail/static/src/less/discuss.less +++ b/addons/mail/static/src/less/discuss.less @@ -321,15 +321,11 @@ .o_mail_chat_content { .o-flex(1, 0, 0); - margin-left: 0; + margin-left: 10px; + margin-right: 10px; background-color: transparent; - .o_mail_no_content { - .o-position-absolute(@top: 30%); - text-align: center; - } } - .o_mail_chat_tab_pane { overflow-x: scroll; .o_mail_add_channel { diff --git a/addons/mail/static/src/less/thread.less b/addons/mail/static/src/less/thread.less index 8d8bc761829..81dee297011 100644 --- a/addons/mail/static/src/less/thread.less +++ b/addons/mail/static/src/less/thread.less @@ -208,11 +208,14 @@ .o_thread_title { margin-top: 20px; margin-bottom: 20px; - font-size: 20px; + font-weight: bold; + font-size: 125%; } .o_mail_no_content { - .o-webclient-padding(); + .o-position-absolute(30%, 0, 0, 0); + text-align: center; + font-size: 115%; } .o_thread_message .o_thread_message_core .o_mail_read_more { diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 4a7ba086ebf..26c093c3e84 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -51,6 +51,11 @@ <div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar"> <ul class="list-inline"> <li> + <a class="o_rotate" href="#"> + <i class="fa fa-2x fa-repeat"></i> + </a> + </li> + <li> <a class="o_zoom_out" href="#"> <i class="fa fa-2x fa-search-minus"></i> </a> @@ -105,18 +110,14 @@ <!-- When a channel is empty--> <div t-name="mail.EmptyChannel" class="o_mail_no_content"> - <div class="o_view_nocontent"> - <div class="o_nocontent_help"> - <t t-if="options.channel_id==='channel_inbox'"> - <p>Congratulations, your inbox is empty</p> - <p>New messages appear here.</p> - </t> - <t t-if="options.channel_id==='channel_starred'"> - <p>No starred message</p> - <p>You can mark any message as 'starred', and it shows up in this channel.</p> - </t> - </div> - </div> + <t t-if="options.channel_id==='channel_inbox'"> + <div class="o_thread_title">Congratulations, your inbox is empty</div> + <div>New messages appear here.</div> + </t> + <t t-if="options.channel_id==='channel_starred'"> + <div class="o_thread_title">No starred message</div> + <div>You can mark any message as 'starred', and it shows up in this channel.</div> + </t> </div> <t t-name="mail.ChatThread.Content"> @@ -266,7 +267,7 @@ </t> <t t-name="mail.Attachment"> - <t t-set="type" t-value="attachment.mimetype.split('/').shift()"/> + <t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/> <div t-attf-class="o_attachment #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name"> <t t-if="type == 'image'"> <div class="o_image_box"> diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 4a23f9b1d3b..a63e074ef4a 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -351,12 +351,12 @@ QUnit.test('kanban activity widget with an activity', function (assert) { // click on the activity button to re-open dropdown $record.find('.o_activity_btn').click(); - assert.strictEqual(rpcCount, 2, 'no RPC should be done as the activities are now in cache'); + assert.strictEqual(rpcCount, 3, 'should have reloaded the activities'); // mark activity as done $record.find('.o_mark_as_done').click(); $record = kanban.$('.o_kanban_record').first(); // the record widget has been reset - assert.strictEqual(rpcCount, 4, 'should have done an RPC to mark activity as done, and a read'); + assert.strictEqual(rpcCount, 5, 'should have done an RPC to mark activity as done, and a read'); assert.ok($record.find('.o_mail_activity .o_activity_color_default:not(.o_activity_color_today)').length, "activity widget should have been updated correctly"); assert.strictEqual($record.find('.o_mail_activity.open').length, 1, @@ -707,6 +707,128 @@ QUnit.test('chatter: Attachment viewer', function (assert) { form.destroy(); }); +QUnit.test('form activity widget: read RPCs', function (assert) { + // Records of model 'mail.activity' may be updated in business flows (e.g. + // the date of a 'Meeting' activity is updated when the associated meeting + // is dragged&dropped in the Calendar view). Because of that, the activities + // must be reloaded when the form is reloaded (the widget can't keep an + // internal cache). + assert.expect(6); + this.data.partner.records[0].activity_ids = [1]; + this.data.partner.records[0].activity_state = 'today'; + this.data['mail.activity'].records = [{ + id: 1, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + state: "today", + user_id: 2, + activity_type_id: 2, + }]; + + var nbReads = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<div class="oe_chatter">' + + '<field name="activity_ids" widget="mail_activity"/>' + + '</div>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'mail.activity') { + nbReads++; + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(nbReads, 1, "should have read the activities"); + assert.strictEqual(form.$('.o_mail_activity .o_thread_message').length, 1, + "should display an activity"); + assert.strictEqual(form.$('.o_mail_activity .o_thread_message .o_activity_date').text(), + 'Today', "the activity should be today"); + + form.$buttons.find('.o_form_button_edit').click(); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(nbReads, 1, "should not have re-read the activities"); + + // simulate a date change, and a reload of the form view + var tomorrow = moment().add(1, 'day').format("YYYY-MM-DD"); + this.data['mail.activity'].records[0].date_deadline = tomorrow; + form.reload(); + + assert.strictEqual(nbReads, 2, "should have re-read the activities"); + assert.strictEqual(form.$('.o_mail_activity .o_thread_message .o_activity_date').text(), + 'Tomorrow', "the activity should be tomorrow"); + + form.destroy(); +}); + +QUnit.test('form activity widget on a new record', function (assert) { + assert.expect(0); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<div class="oe_chatter">' + + '<field name="activity_ids" widget="mail_activity"/>' + + '</div>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'mail.activity') { + throw new Error("should not do a read on mail.activity"); + } + return this._super.apply(this, arguments); + }, + }); + + form.destroy(); +}); + +QUnit.test('form activity widget with another x2many field in view', function (assert) { + assert.expect(1); + + this.data.partner.fields.m2m = {string: "M2M", type: 'many2many', relation: 'partner'}; + + this.data.partner.records[0].m2m = [2]; + this.data.partner.records[0].activity_ids = [1]; + this.data.partner.records[0].activity_state = 'today'; + this.data['mail.activity'].records = [{ + id: 1, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + state: "today", + user_id: 2, + activity_type_id: 2, + }]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '<form string="Partners">' + + '<field name="m2m" widget="many2many_tags"/>' + + '<div class="oe_chatter">' + + '<field name="activity_ids" widget="mail_activity"/>' + + '</div>' + + '</form>', + res_id: 2, + }); + + assert.strictEqual(form.$('.o_mail_activity .o_thread_message').length, 1, + "should display an activity"); + + form.destroy(); +}); + QUnit.test('form activity widget: schedule next activity', function (assert) { assert.expect(5); this.data.partner.records[0].activity_ids = [1]; @@ -746,7 +868,7 @@ QUnit.test('form activity widget: schedule next activity', function (assert) { "the feedback should be sent correctly"); return $.when(); } - if (args.method === 'read' && checkReadArgs) { + if (args.method === 'read' && args.model === 'partner' && checkReadArgs) { assert.deepEqual(args.args[1], ['activity_ids', 'message_ids', 'display_name'], "should only read the mail fields"); } diff --git a/addons/mail/static/tests/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js index d17364b53ad..921436d4657 100644 --- a/addons/mail/static/tests/discuss_mobile_tests.js +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -65,6 +65,29 @@ QUnit.test('mobile basic rendering', function (assert) { }); }); +QUnit.test('on_{attach/detach}_callback', function (assert) { + assert.expect(2); + var done = assert.async(); + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + }).then(function (discuss) { + try { + discuss.on_attach_callback(); + assert.ok(true, 'should not crash on attach callback'); + discuss.on_detach_callback(); + assert.ok(true, 'should not crash on detach callback'); + } finally { + discuss.destroy(); + done(); + } + }); +}); + }); }); diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index 4338db1cfaf..6990a04efba 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -44,10 +44,9 @@ QUnit.test('basic rendering', function (assert) { assert.strictEqual($sidebar.length, 1, "should have rendered a sidebar"); - var $nocontent = discuss.$('.o_mail_chat_content'); - assert.strictEqual($nocontent.length, 1, + assert.strictEqual(discuss.$('.o_mail_chat_content').length, 1, "should have rendered the content"); - assert.strictEqual($nocontent.find('.o_mail_no_content').length, 1, + assert.strictEqual(discuss.$('.o_mail_no_content').length, 1, "should display no content message"); var $inbox = $sidebar.find('.o_mail_chat_channel_item[data-channel-id=channel_inbox]'); diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index 095da27be58..cd933bc2483 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -77,6 +77,15 @@ function createDiscuss(params) { var selector = params.debug ? 'body' : '#qunit-fixture'; discuss.appendTo($(selector)); + // override 'destroy' of discuss so that it calls 'destroy' on the parent + // instead, which is the parent of discuss and the mockServer. + discuss.destroy = function () { + // remove the override to properly destroy discuss and its children + // when it will be called the second time (by its parent) + delete discuss.destroy; + parent.destroy(); + }; + return discuss.call('chat_manager', 'isReady').then(function () { return discuss; }); diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index b2705050636..1c570d858cc 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -199,7 +199,7 @@ class MrpProductProduceLine(models.TransientModel): res = {} if self.product_id.tracking == 'serial': if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: - message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name + message = _('You can only process 1.0 %s of products with unique serial number.') % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res diff --git a/addons/note_pad/models/note.py b/addons/note_pad/models/note.py index 080e313a873..49536d79b88 100644 --- a/addons/note_pad/models/note.py +++ b/addons/note_pad/models/note.py @@ -11,4 +11,4 @@ class NotePad(models.Model): _pad_fields = ['note_pad'] - note_pad_url = fields.Char('Pad Url', pad_content_field='memo') + note_pad_url = fields.Char('Pad Url', pad_content_field='memo', copy=False) diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 0a90175bdc5..deb920ec69e 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -101,16 +101,19 @@ class PadCommon(models.AbstractModel): @api.multi def write(self, vals): - self._set_pad_value(vals) + self._set_field_to_pad(vals) + self._set_pad_to_field(vals) return super(PadCommon, self).write(vals) @api.model def create(self, vals): - self._set_pad_value(vals) + # Case of a regular creation: we receive the pad url, so we need to update the + # corresponding field + self._set_pad_to_field(vals) pad = super(PadCommon, self).create(vals) - # In case the pad is created programmatically, the content is not filled in yet since it is - # normally initialized by the JS layer + # Case of a programmatical creation (e.g. copy): we receive the field content, so we need + # to create the corresponding pad if self.env.context.get('pad_no_create', False): return pad for k, field in self._fields.items(): @@ -124,9 +127,7 @@ class PadCommon(models.AbstractModel): pad[k] = pad_info.get('url') return pad - # Set the pad content in vals - def _set_pad_value(self, vals): - + def _set_field_to_pad(self, vals): # Update the pad if the `pad_content_field` is modified for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: @@ -135,23 +136,9 @@ class PadCommon(models.AbstractModel): path = self[k].split('/p/')[1] myPad.setHtmlFallbackText(path, vals[field.pad_content_field]) + def _set_pad_to_field(self, vals): # Update the `pad_content_field` if the pad is modified for k, v in list(vals.items()): field = self._fields.get(k) if hasattr(field, 'pad_content_field'): vals[field.pad_content_field] = self.pad_get_content(v) - - @api.multi - @api.returns('self', lambda value: value.id) - def copy(self, default=None): - self.ensure_one() - if not default: - default = {} - for k, field in self._fields.items(): - if hasattr(field, 'pad_content_field'): - if self.env.context.get('pad_no_create', False): - default[k] = '' - else: - pad = self.pad_generate_url() - default[k] = pad.get('url') - return super(PadCommon, self).copy(default) diff --git a/addons/pad_project/models/project.py b/addons/pad_project/models/project.py index 6062135f7ea..f994b3a5ea7 100644 --- a/addons/pad_project/models/project.py +++ b/addons/pad_project/models/project.py @@ -8,7 +8,7 @@ class ProjectTask(models.Model): _name = "project.task" _inherit = ["project.task", 'pad.common'] - description_pad = fields.Char('Pad URL', pad_content_field='description') + description_pad = fields.Char('Pad URL', pad_content_field='description', copy=False) use_pad = fields.Boolean(related="project_id.use_pads", string="Use collaborative pad") @api.model @@ -19,13 +19,6 @@ class ProjectTask(models.Model): self = self.with_context(pad_no_create=True) return super(ProjectTask, self).create(vals) - @api.multi - @api.returns('self', lambda value: value.id) - def copy(self, default=None): - if not self.use_pad: - self = self.with_context(pad_no_create=True) - return super(ProjectTask, self).copy(default) - class ProjectProject(models.Model): _inherit = "project.project" diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 444d936429b..7e023e01c7a 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -73,7 +73,8 @@ class WebsitePayment(http.Controller): return tx - @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',], type='json', auth='public') + @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>', + '/website_payment/transaction/v2/<string:amount>/<string:currency_id>/<path:reference>',], type='json', auth='public') def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs): partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False acquirer = request.env['payment.acquirer'].browse(acquirer_id) @@ -100,7 +101,8 @@ class WebsitePayment(http.Controller): return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values) - @http.route(['/website_payment/token/<string:reference>/<float:amount>/<int:currency_id>'], type='http', auth='public', website=True) + @http.route(['/website_payment/token/<string:reference>/<string:amount>/<string:currency_id>', + '/website_payment/token/v2/<string:amount>/<string:currency_id>/<path:reference>'], type='http', auth='public', website=True) def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs): token = request.env['payment.token'].browse(int(pm_id)) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 879c7b60d08..d68df6f9934 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -78,8 +78,8 @@ class PaymentAcquirer(models.Model): help="Capture the amount from Odoo, when the delivery is completed.") # Formerly associated to `generate_and_pay_invoice` option from auto_confirm journal_id = fields.Many2one( - 'account.journal', 'Payment Journal', domain=[('type', '=', 'bank')], - default=lambda self: self.env['account.journal'].search([('type', '=', 'bank')], limit=1), + 'account.journal', 'Payment Journal', domain=[('type', 'in', ['bank', 'cash'])], + default=lambda self: self.env['account.journal'].search([('type', 'in', ['bank', 'cash'])], limit=1), help="""Payments will be registered into this journal. If you get paid straight on your bank account, select your bank account. If you get paid in batch for several transactions, create a specific payment journal for this payment acquirer to easily manage the bank reconciliation. You hold @@ -200,22 +200,13 @@ class PaymentAcquirer(models.Model): @api.model def create(self, vals): image_resize_images(vals) - vals = self._check_journal_id(vals) return super(PaymentAcquirer, self).create(vals) @api.multi def write(self, vals): image_resize_images(vals) - vals = self._check_journal_id(vals) return super(PaymentAcquirer, self).write(vals) - def _check_journal_id(self, vals): - if not vals.get('journal_id', False): - default_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1) - if default_journal: - vals.update({'journal_id': default_journal.id}) - return vals - @api.multi def toggle_website_published(self): self.write({'website_published': not self.website_published}) diff --git a/addons/payment/security/ir.model.access.csv b/addons/payment/security/ir.model.access.csv index 2982f1ab0c0..76d11991b88 100644 --- a/addons/payment/security/ir.model.access.csv +++ b/addons/payment/security/ir.model.access.csv @@ -1,6 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink payment_acquirer_all,payment.acquirer.all,model_payment_acquirer,,1,0,0,0 -payment_acquirer_user,payment.acquirer.user,model_payment_acquirer,base.group_user,1,1,1,0 payment_acquirer_system,payment.acquirer.system,model_payment_acquirer,base.group_system,1,1,1,1 payment_transaction_all,payment.transaction.all,model_payment_transaction,,1,0,0,0 payment_transaction_user,payment.transaction.user,model_payment_transaction,base.group_user,1,1,1,0 diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index 166cd429718..2224eeec179 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -55,8 +55,8 @@ <p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p> <t t-call="payment.payment_tokens_list" t-if="reference and amount and currency"> <t t-set="mode" t-value="'payment'"/> - <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> - <t t-set="form_action" t-value="'/website_payment/token/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> + <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference"/> + <t t-set="form_action" t-value="'/website_payment/token/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference"/> </t> <div t-if="not (s2s_acquirers or form_acquirers)" class="alert alert-danger"> <p>No payment acquirer found.</p> diff --git a/addons/payment/views/payment_views.xml b/addons/payment/views/payment_views.xml index 2a72f94978b..f3c8c0af16d 100644 --- a/addons/payment/views/payment_views.xml +++ b/addons/payment/views/payment_views.xml @@ -81,7 +81,7 @@ </page> <page string="Configuration"> <group name="acquirer_config"> - <field name="journal_id" context="{'default_type': 'bank'}"/> + <field name="journal_id" context="{'default_type': 'bank'}" required="1"/> <field name="capture_manually" attrs="{'invisible': [('authorize_implemented', '=', False)]}"/> <field name="save_token" widget="radio" attrs="{'invisible': ['|', ('token_implemented', '=', False), ('payment_flow', '=', 's2s')]}"/> <field name="fees_active" attrs="{'invisible': [('fees_implemented', '=', False)]}"/> diff --git a/addons/payment_ogone/controllers/main.py b/addons/payment_ogone/controllers/main.py index 8680e99fda5..1d03a688630 100644 --- a/addons/payment_ogone/controllers/main.py +++ b/addons/payment_ogone/controllers/main.py @@ -2,6 +2,7 @@ import logging import pprint import werkzeug +from werkzeug.urls import url_unquote_plus from odoo import http from odoo.http import request @@ -26,7 +27,7 @@ class OgoneController(http.Controller): """ Ogone contacts using GET, at least for accept """ _logger.info('Ogone: entering form_feedback with post data %s', pprint.pformat(post)) # debug request.env['payment.transaction'].sudo().form_feedback(post, 'ogone') - return werkzeug.utils.redirect(post.pop('return_url', '/')) + return werkzeug.utils.redirect(url_unquote_plus(post.pop('return_url', '/'))) @http.route(['/payment/ogone/s2s/create_json'], type='json', auth='public', csrf=False) def ogone_s2s_create_json(self, **kwargs): diff --git a/addons/payment_ogone/models/payment.py b/addons/payment_ogone/models/payment.py index 09fe0ef8a39..09df4b52bc4 100644 --- a/addons/payment_ogone/models/payment.py +++ b/addons/payment_ogone/models/payment.py @@ -15,9 +15,8 @@ from odoo import api, fields, models, _ from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment_ogone.controllers.main import OgoneController from odoo.addons.payment_ogone.data import ogone -from odoo.tools import float_round, DEFAULT_SERVER_DATE_FORMAT -from odoo.tools.float_utils import float_compare, float_repr -from odoo.tools.safe_eval import safe_eval +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, ustr +from odoo.tools.float_utils import float_compare, float_repr, float_round _logger = logging.getLogger(__name__) @@ -151,6 +150,9 @@ class PaymentAcquirerOgone(models.Model): def ogone_form_generate_values(self, values): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') ogone_tx_values = dict(values) + param_plus = { + 'return_url': ogone_tx_values.pop('return_url', False) + } temp_ogone_tx_values = { 'PSPID': self.ogone_pspid, 'ORDERID': values['reference'], @@ -168,7 +170,7 @@ class PaymentAcquirerOgone(models.Model): 'DECLINEURL': urls.url_join(base_url, OgoneController._decline_url), 'EXCEPTIONURL': urls.url_join(base_url, OgoneController._exception_url), 'CANCELURL': urls.url_join(base_url, OgoneController._cancel_url), - 'PARAMPLUS': 'return_url=%s' % ogone_tx_values.pop('return_url') if ogone_tx_values.get('return_url') else False, + 'PARAMPLUS': url_encode(param_plus), } if self.save_token in ['ask', 'always']: temp_ogone_tx_values.update({ @@ -211,8 +213,8 @@ class PaymentAcquirerOgone(models.Model): class PaymentTxOgone(models.Model): _inherit = 'payment.transaction' # ogone status - _ogone_valid_tx_status = [5, 9] - _ogone_wait_tx_status = [41, 50, 51, 52, 55, 56, 91, 92, 99] + _ogone_valid_tx_status = [5, 9, 8] + _ogone_wait_tx_status = [41, 50, 51, 52, 55, 56, 91, 92, 99, 81, 82] _ogone_pending_tx_status = [46] # 3DS HTML response _ogone_cancel_tx_status = [1] @@ -411,13 +413,12 @@ class PaymentTxOgone(models.Model): 'ORDERID': reference, 'AMOUNT': int(self.amount * 100), 'CURRENCY': self.currency_id.name, - 'OPERATION': 'RFD', - 'ECI': 2, # Recurring (from MOTO) - 'ALIAS': self.payment_token_id.acquirer_ref, - 'RTIMEOUT': 30, + 'OPERATION': 'RFS', + 'PAYID': self.acquirer_reference, } + data['SHASIGN'] = self.acquirer_id._ogone_generate_shasign('in', data) - direct_order_url = 'https://secure.ogone.com/ncol/%s/orderdirect.asp' % (self.acquirer_id.environment) + direct_order_url = 'https://secure.ogone.com/ncol/%s/maintenancedirect.asp' % (self.acquirer_id.environment) logged_data = data.copy() logged_data.pop('PSWD') @@ -476,7 +477,7 @@ class PaymentTxOgone(models.Model): self.write({ 'state': new_state, 'acquirer_reference': tree.get('PAYID'), - 'html_3ds': base64.b64decode(tree.HTML_ANSWER.decode('ascii')), + 'html_3ds': ustr(base64.b64decode(tree.HTML_ANSWER.text)), }) elif status in self._ogone_wait_tx_status and tries > 0: time.sleep(0.5) @@ -585,4 +586,4 @@ class PaymentToken(models.Model): 'acquirer_ref': alias, 'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name']) } - return {}
\ No newline at end of file + return {} diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index 7ecf25284f6..e4474fe72ce 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -109,8 +109,8 @@ odoo.define('payment_stripe.stripe', function(require) { if ($('.o_website_payment').length !== 0) { var invoice_num = $("input[name='invoice_num']").val(); - var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s", - invoice_num, amount, currency_id); + var url = _.str.sprintf("/website_payment/transaction/v2/%f/%s/%s", + amount, currency_id, invoice_num); var create_tx = ajax.jsonRpc(url, 'call', { acquirer_id: acquirer_id diff --git a/addons/point_of_sale/README.md b/addons/point_of_sale/README.md index d45c48a84ff..dc00e4a8f40 100644 --- a/addons/point_of_sale/README.md +++ b/addons/point_of_sale/README.md @@ -52,7 +52,7 @@ designed for modern retailer. ### Designed for Productivity -Whether it's for a restaurant or a shop, you can activate the multiple tickets +Whether it's for a restaurant or a shop, you can activate the multiple orders in parallel to not make your customers wait. ### Blazing fast search diff --git a/addons/point_of_sale/data/point_of_sale_demo.xml b/addons/point_of_sale/data/point_of_sale_demo.xml index 701b4ce2e13..7da2d82079b 100644 --- a/addons/point_of_sale/data/point_of_sale_demo.xml +++ b/addons/point_of_sale/data/point_of_sale_demo.xml @@ -51,6 +51,7 @@ <field name="available_in_pos">True</field> <field name="list_price">2.83</field> <field name="name">Orange Butterfly</field> + <field name="taxes_id">False</field> <field name="pos_categ_id" ref="fruits_vegetables"/> <field name="to_weight">True</field> <field name="uom_id" ref="uom.product_uom_kgm" /> diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index e4775d4a4e3..1b41565401d 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -64,7 +64,8 @@ class PosConfig(models.Model): return self.env['ir.qweb'].render('point_of_sale.customer_facing_display_html') name = fields.Char(string='Point of Sale Name', index=True, required=True, help="An internal identification of the point of sale.") - is_installed_account_accountant = fields.Boolean(compute="_compute_is_installed_account_accountant") + is_installed_account_accountant = fields.Boolean(string="Is the Full Accounting Installed", + compute="_compute_is_installed_account_accountant") journal_ids = fields.Many2many( 'account.journal', 'pos_config_journal_rel', 'pos_config_id', 'journal_id', string='Available Payment Methods', diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index f9604dbb1e6..d29dbadb5a1 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -160,13 +160,14 @@ class PosOrder(models.Model): """ Prepare the dict of values to create the new invoice for a pos order. """ + invoice_type = 'out_invoice' if self.amount_total >= 0 else 'out_refund' return { 'name': self.name, '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', + 'type': invoice_type, 'reference': self.name, 'partner_id': self.partner_id.id, 'comment': self.note or '', @@ -208,7 +209,7 @@ class PosOrder(models.Model): inv_line = { 'invoice_id': invoice_id, 'product_id': line.product_id.id, - 'quantity': line.qty, + 'quantity': line.qty if self.amount_total >= 0 else -line.qty, 'account_analytic_id': self._prepare_analytic_account(line), 'name': inv_name, } @@ -732,7 +733,7 @@ class PosOrder(models.Model): # when the pos.config has no picking_type_id set only the moves will be created if moves and not return_picking and not order_picking: moves._action_assign() - moves.filtered(lambda m: m.state in ['confirmed', 'waiting']).force_assign() + moves.filtered(lambda m: m.state in ['confirmed', 'waiting'])._force_assign() moves.filtered(lambda m: m.product_id.tracking == 'none')._action_done() return True @@ -792,7 +793,7 @@ class PosOrder(models.Model): 'location_dest_id': move.location_dest_id.id, 'lot_id': lot_id, }) - if not pack_lots: + if not pack_lots and not float_is_zero(qty_done, precision_rounding=move.product_uom.rounding): move.quantity_done = qty_done return has_wrong_lots diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index dba6b7fb0c9..591488c225c 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1305,6 +1305,7 @@ exports.Orderline = Backbone.Model.extend({ this.type = 'unit'; this.selected = false; this.id = orderline_id++; + this.price_manually_set = false; if (options.price) { this.set_unit_price(options.price); @@ -1345,6 +1346,7 @@ exports.Orderline = Backbone.Model.extend({ orderline.price = this.price; orderline.type = this.type; orderline.selected = false; + orderline.price_manually_set = this.price_manually_set; return orderline; }, set_product_lot: function(product){ @@ -1395,7 +1397,7 @@ exports.Orderline = Backbone.Model.extend({ } // just like in sale.order changing the quantity will recompute the unit price - if(! keep_price){ + if(! keep_price && ! this.price_manually_set){ this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity())); this.order.fix_tax_included_price(this); } @@ -2232,7 +2234,11 @@ exports.Order = Backbone.Model.extend({ set_pricelist: function (pricelist) { var self = this; this.pricelist = pricelist; - _.each(this.get_orderlines(), function (line) { + + var lines_to_recompute = _.filter(this.get_orderlines(), function (line) { + return ! line.price_manually_set; + }); + _.each(lines_to_recompute, function (line) { line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity())); self.fix_tax_included_price(line); }); @@ -2355,7 +2361,7 @@ exports.Order = Backbone.Model.extend({ this.assert_editable(); var newPaymentline = new exports.Paymentline({},{order: this, cashregister:cashregister, pos: this.pos}); if(cashregister.journal.type !== 'cash' || this.pos.config.iface_precompute_cash){ - newPaymentline.set_amount( Math.max(this.get_due(),0) ); + newPaymentline.set_amount( this.get_due() ); } this.paymentlines.add(newPaymentline); this.select_paymentline(newPaymentline); @@ -2519,10 +2525,10 @@ exports.Order = Backbone.Model.extend({ } } } - return round_pr(Math.max(0,due), this.pos.currency.rounding); + return round_pr(due, this.pos.currency.rounding); }, is_paid: function(){ - return this.get_due() === 0; + return this.get_due() <= 0; }, is_paid_with_cash: function(){ return !!this.paymentlines.find( function(pl){ diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index dadd9cffbb8..0b2d94dd71f 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -502,7 +502,9 @@ var OrderWidget = PosBaseWidget.extend({ }else if( mode === 'discount'){ order.get_selected_orderline().set_discount(val); }else if( mode === 'price'){ - order.get_selected_orderline().set_unit_price(val); + var selected_orderline = order.get_selected_orderline(); + selected_orderline.price_manually_set = true; + selected_orderline.set_unit_price(val); } } }, @@ -1973,17 +1975,6 @@ var PaymentScreenWidget = ScreenWidget.extend({ return false; } - var plines = order.get_paymentlines(); - for (var i = 0; i < plines.length; i++) { - if (plines[i].get_type() === 'bank' && plines[i].get_amount() < 0) { - this.gui.show_popup('error',{ - 'message': _t('Negative Bank Payment'), - 'comment': _t('You cannot have a negative amount in a Bank payment. Use a cash payment method to return money to the customer.'), - }); - return false; - } - } - if (!order.is_paid() || this.invoicing) { return false; } diff --git a/addons/point_of_sale/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js index 6c5c970f545..0d303df8c7e 100644 --- a/addons/point_of_sale/static/src/js/tests.js +++ b/addons/point_of_sale/static/src/js/tests.js @@ -167,14 +167,27 @@ odoo.define('point_of_sale.tour.pricelist', function (require) { content: "verify that unit price of oranges changed to $1", trigger: ".total > .value:contains('$ 2.00')", }, { + content: "order different oranges", + trigger: ".product:contains('Orange Butterfly')", + }, { + content: "change to price mode", + trigger: ".numpad button:contains('Price')", + }, { + content: "manually override the unit price of these oranges to $5", + trigger: ".numpad button.input-button:visible:contains('5')", + }, { + content: "change back to qty mode", + trigger: ".numpad button:contains('Qty')", + }, { content: "click pricelist button", trigger: ".control-button.o_pricelist_button", }, { content: "select public pricelist", trigger: ".selection-item:contains('Public Pricelist')", }, { - content: "verify that order has been recomputed", - trigger: ".total > .value:contains('$ 3.96')", + content: "verify that the boni oranges have been recomputed and the\ +butterfly oranges have not (their price was manually overriden)", + trigger: ".total > .value:contains('$ 8.96')", }, { content: "click pricelist button", trigger: ".control-button.o_pricelist_button", diff --git a/addons/point_of_sale/views/account_journal_view.xml b/addons/point_of_sale/views/account_journal_view.xml index 11cff71da4d..5d79164a30b 100644 --- a/addons/point_of_sale/views/account_journal_view.xml +++ b/addons/point_of_sale/views/account_journal_view.xml @@ -45,4 +45,13 @@ <field name="context">{'search_default_available_in_pos': 1, 'default_type': 'cash'}</field> </record> + + <!-- View Taxes --> + + <menuitem id="menu_action_tax_form_open" + action="account.action_tax_form" + parent="point_of_sale.menu_point_config_product" + groups="base.group_no_one" + sequence="40"/> + </odoo> diff --git a/addons/portal/controllers/mail.py b/addons/portal/controllers/mail.py index 3a59ecd70b3..b8ff6f8f2fd 100644 --- a/addons/portal/controllers/mail.py +++ b/addons/portal/controllers/mail.py @@ -5,6 +5,7 @@ from werkzeug.exceptions import NotFound, Forbidden from odoo import http from odoo.http import request +from odoo.osv import expression from odoo.tools import consteq @@ -95,6 +96,9 @@ class PortalChatter(http.Controller): access_as_sudo = _has_token_access(res_model, res_id, token=kw.get('token')) if not access_as_sudo: # if token is not correct, raise Forbidden raise Forbidden() + # Non-employee see only messages with not internal subtype (aka, no internal logs) + if not request.env['res.users'].has_group('base.group_user'): + domain = expression.AND([['&', '&', ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)], domain]) Message = request.env['mail.message'].sudo() return { 'messages': Message.search(domain, limit=limit, offset=offset).portal_message_format(), diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js index 66414d70b5d..c2f0e5fb49b 100644 --- a/addons/portal/static/src/js/portal_chatter.js +++ b/addons/portal/static/src/js/portal_chatter.js @@ -6,6 +6,7 @@ var ajax = require('web.ajax'); var core = require('web.core'); var Widget = require('web.Widget'); var rpc = require('web.rpc'); +var time = require('web.time'); var qweb = core.qweb; var _t = core._t; @@ -105,7 +106,7 @@ var PortalChatter = Widget.extend({ preprocessMessages: function(messages){ _.each(messages, function(m){ m['author_avatar_url'] = _.str.sprintf('/web/image/%s/%s/author_avatar/50x50', 'mail.message', m.id); - m['published_date_str'] = _.str.sprintf(_t('Published on %s'), moment(m.date).format('MMMM Do YYYY, h:mm:ss a')); + m['published_date_str'] = _.str.sprintf(_t('Published on %s'), moment(time.str_to_datetime(m.date)).format('MMMM Do YYYY, h:mm:ss a')); }); return messages; }, diff --git a/addons/pos_discount/models/pos_config.py b/addons/pos_discount/models/pos_config.py index 0c21d482f17..46272218f67 100644 --- a/addons/pos_discount/models/pos_config.py +++ b/addons/pos_discount/models/pos_config.py @@ -7,17 +7,18 @@ from odoo import api, fields, models class PosConfig(models.Model): _inherit = 'pos.config' - def _get_default_discount_product(self): - return self.env.ref('point_of_sale.product_product_consumable') - iface_discount = fields.Boolean(string='Order Discounts', help='Allow the cashier to give discounts on the whole order.') - discount_pc = fields.Float(string='Discount Percentage', default=10, help='The default discount percentage') - discount_product_id = fields.Many2one('product.product', string='Discount Product', domain="[('available_in_pos', '=', True)]", help='The product used to model the discount.', default=_get_default_discount_product) + discount_pc = fields.Float(string='Discount Percentage', help='The default discount percentage') + discount_product_id = fields.Many2one('product.product', string='Discount Product', + domain="[('available_in_pos', '=', True), ('sale_ok', '=', True)]", help='The product used to model the discount.') @api.onchange('module_pos_discount') def _onchange_module_pos_discount(self): if self.module_pos_discount: - self.discount_product_id = self.env['product.product'].search([('available_in_pos', '=', True)], limit=1) + self.discount_product_id = self.env.ref('point_of_sale.product_product_consumable', raise_if_not_found=False) + if not self.discount_product_id or not self.discount_product_id.available_in_pos or not self.discount_product_id.sale_ok: + domain = [('available_in_pos', '=', True), ('sale_ok', '=', True)] + self.discount_product_id = self.env['product.product'].search(domain, limit=1) self.discount_pc = 10.0 else: self.discount_product_id = False diff --git a/addons/pos_discount/static/src/js/discount.js b/addons/pos_discount/static/src/js/discount.js index e77e4559e0c..771992bb01b 100644 --- a/addons/pos_discount/static/src/js/discount.js +++ b/addons/pos_discount/static/src/js/discount.js @@ -23,6 +23,13 @@ var DiscountButton = screens.ActionButtonWidget.extend({ var order = this.pos.get_order(); var lines = order.get_orderlines(); var product = this.pos.db.get_product_by_id(this.pos.config.discount_product_id[0]); + if (product === undefined) { + this.gui.show_popup('error', { + title : _t("No discount product found"), + body : _t("The discount product seems misconfigured. Make sure it is flagged as 'Can be Sold' and 'Available in Point of Sale'."), + }); + return; + } // Remove existing discounts var i = 0; diff --git a/addons/pos_mercury/static/src/js/pos_mercury.js b/addons/pos_mercury/static/src/js/pos_mercury.js index 710420c62b4..e403b9bcf12 100644 --- a/addons/pos_mercury/static/src/js/pos_mercury.js +++ b/addons/pos_mercury/static/src/js/pos_mercury.js @@ -146,82 +146,82 @@ var lookUpCodeTransaction = { '000000': _t('Transaction approved'), }, 'TimeoutError': { - '001006': _t('Global API Not Initialized'), - '001007': _t('Timeout on Response'), - '003003': _t('Socket Error sending request'), - '003004': _t('Socket already open or in use'), - '003005': _t('Socket Creation Failed'), - '003006': _t('Socket Connection Failed'), - '003007': _t('Connection Lost'), - '003008': _t('TCP/IP Failed to Initialize'), - '003010': _t('Time Out waiting for server response'), - '003011': _t('Connect Canceled'), - '003053': _t('Initialize Failed'), - '009999': _t('Unknown Error'), + '001006': 'Global API Not Initialized', + '001007': 'Timeout on Response', + '003003': 'Socket Error sending request', + '003004': 'Socket already open or in use', + '003005': 'Socket Creation Failed', + '003006': 'Socket Connection Failed', + '003007': 'Connection Lost', + '003008': 'TCP/IP Failed to Initialize', + '003010': 'Time Out waiting for server response', + '003011': 'Connect Canceled', + '003053': 'Initialize Failed', + '009999': 'Unknown Error', }, 'FatalError': { - '-1': _t('Timeout error'), - '001001': _t('General Failure'), - '001003': _t('Invalid Command Format'), - '001004': _t('Insufficient Fields'), - '001011': _t('Empty Command String'), - '002000': _t('Password Verified'), - '002001': _t('Queue Full'), - '002002': _t('Password Failed – Disconnecting'), - '002003': _t('System Going Offline'), - '002004': _t('Disconnecting Socket'), - '002006': _t('Refused ‘Max Connections’'), - '002008': _t('Duplicate Serial Number Detected'), - '002009': _t('Password Failed (Client / Server)'), - '002010': _t('Password failed (Challenge / Response)'), - '002011': _t('Internal Server Error – Call Provider'), - '003002': _t('In Process with server'), - '003009': _t('Control failed to find branded serial (password lookup failed)'), - '003012': _t('128 bit CryptoAPI failed'), - '003014': _t('Threaded Auth Started Expect Response'), - '003017': _t('Failed to start Event Thread.'), - '003050': _t('XML Parse Error'), - '003051': _t('All Connections Failed'), - '003052': _t('Server Login Failed'), - '004001': _t('Global Response Length Error (Too Short)'), - '004002': _t('Unable to Parse Response from Global (Indistinguishable)'), - '004003': _t('Global String Error'), - '004004': _t('Weak Encryption Request Not Supported'), - '004005': _t('Clear Text Request Not Supported'), - '004010': _t('Unrecognized Request Format'), - '004011': _t('Error Occurred While Decrypting Request'), - '004017': _t('Invalid Check Digit'), - '004018': _t('Merchant ID Missing'), - '004019': _t('TStream Type Missing'), - '004020': _t('Could Not Encrypt Response- Call Provider'), - '100201': _t('Invalid Transaction Type'), - '100202': _t('Invalid Operator ID'), - '100203': _t('Invalid Memo'), - '100204': _t('Invalid Account Number'), - '100205': _t('Invalid Expiration Date'), - '100206': _t('Invalid Authorization Code'), - '100207': _t('Invalid Authorization Code'), - '100208': _t('Invalid Authorization Amount'), - '100209': _t('Invalid Cash Back Amount'), - '100210': _t('Invalid Gratuity Amount'), - '100211': _t('Invalid Purchase Amount'), - '100212': _t('Invalid Magnetic Stripe Data'), - '100213': _t('Invalid PIN Block Data'), - '100214': _t('Invalid Derived Key Data'), - '100215': _t('Invalid State Code'), - '100216': _t('Invalid Date of Birth'), - '100217': _t('Invalid Check Type'), - '100218': _t('Invalid Routing Number'), - '100219': _t('Invalid TranCode'), - '100220': _t('Invalid Merchant ID'), - '100221': _t('Invalid TStream Type'), - '100222': _t('Invalid Batch Number'), - '100223': _t('Invalid Batch Item Count'), - '100224': _t('Invalid MICR Input Type'), - '100225': _t('Invalid Driver’s License'), - '100226': _t('Invalid Sequence Number'), - '100227': _t('Invalid Pass Data'), - '100228': _t('Invalid Card Type'), + '-1': 'Timeout error', + '001001': 'General Failure', + '001003': 'Invalid Command Format', + '001004': 'Insufficient Fields', + '001011': 'Empty Command String', + '002000': 'Password Verified', + '002001': 'Queue Full', + '002002': 'Password Failed – Disconnecting', + '002003': 'System Going Offline', + '002004': 'Disconnecting Socket', + '002006': 'Refused ‘Max Connections’', + '002008': 'Duplicate Serial Number Detected', + '002009': 'Password Failed (Client / Server)', + '002010': 'Password failed (Challenge / Response)', + '002011': 'Internal Server Error – Call Provider', + '003002': 'In Process with server', + '003009': 'Control failed to find branded serial (password lookup failed)', + '003012': '128 bit CryptoAPI failed', + '003014': 'Threaded Auth Started Expect Response', + '003017': 'Failed to start Event Thread.', + '003050': 'XML Parse Error', + '003051': 'All Connections Failed', + '003052': 'Server Login Failed', + '004001': 'Global Response Length Error (Too Short)', + '004002': 'Unable to Parse Response from Global (Indistinguishable)', + '004003': 'Global String Error', + '004004': 'Weak Encryption Request Not Supported', + '004005': 'Clear Text Request Not Supported', + '004010': 'Unrecognized Request Format', + '004011': 'Error Occurred While Decrypting Request', + '004017': 'Invalid Check Digit', + '004018': 'Merchant ID Missing', + '004019': 'TStream Type Missing', + '004020': 'Could Not Encrypt Response- Call Provider', + '100201': 'Invalid Transaction Type', + '100202': 'Invalid Operator ID', + '100203': 'Invalid Memo', + '100204': 'Invalid Account Number', + '100205': 'Invalid Expiration Date', + '100206': 'Invalid Authorization Code', + '100207': 'Invalid Authorization Code', + '100208': 'Invalid Authorization Amount', + '100209': 'Invalid Cash Back Amount', + '100210': 'Invalid Gratuity Amount', + '100211': 'Invalid Purchase Amount', + '100212': 'Invalid Magnetic Stripe Data', + '100213': 'Invalid PIN Block Data', + '100214': 'Invalid Derived Key Data', + '100215': 'Invalid State Code', + '100216': 'Invalid Date of Birth', + '100217': 'Invalid Check Type', + '100218': 'Invalid Routing Number', + '100219': 'Invalid TranCode', + '100220': 'Invalid Merchant ID', + '100221': 'Invalid TStream Type', + '100222': 'Invalid Batch Number', + '100223': 'Invalid Batch Item Count', + '100224': 'Invalid MICR Input Type', + '100225': 'Invalid Driver’s License', + '100226': 'Invalid Sequence Number', + '100227': 'Invalid Pass Data', + '100228': 'Invalid Card Type', }, }; // Popup to show all transaction state for the payment. @@ -339,6 +339,15 @@ PaymentScreenWidget.include({ // Handler to manage the card reader string credit_code_transaction: function (parsed_result, old_deferred, retry_nr) { + var order = this.pos.get_order(); + if (order.get_due(order.selected_paymentline) < 0) { + this.gui.show_popup('error',{ + 'title': _t('Refunds not supported'), + 'body': _t('Credit card refunds are not supported. Instead select your credit card payment method, click \'Validate\' and refund the original charge manually through the Vantiv backend.'), + }); + return; + } + if(this.pos.getOnlinePaymentJournals().length === 0) { return; } @@ -643,9 +652,11 @@ PaymentScreenWidget.include({ }); } else { this._super(id); - order.selected_paymentline.mercury_swipe_pending = true; - this.render_paymentlines(); - order.trigger('change', order); // needed so that export_to_JSON gets triggered + if (order.get_due(order.selected_paymentline) > 0) { + order.selected_paymentline.mercury_swipe_pending = true; + this.render_paymentlines(); + order.trigger('change', order); // needed so that export_to_JSON gets triggered + } } } else { this._super(id); diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index b4ec38f05db..166a0a44506 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -428,7 +428,7 @@ class ProductTemplate(models.Model): ] # get the value (id) sets of existing variants - existing_variants = {frozenset(variant.attribute_value_ids.ids) for variant in tmpl_id.product_variant_ids} + existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant).ids) for variant in tmpl_id.product_variant_ids} # -> for each value set, create a recordset of values to create a # variant for if the value set isn't already a variant to_create_variants = [ diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py index 1a621d689cb..7917a0d4a2b 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -330,3 +330,23 @@ class TestVariantsNoCreate(common.TestProductCommon): {variant.attribute_value_ids for variant in template.product_variant_ids}, {self.prod_attr1_v1, self.prod_attr1_v2}, ) + + def test_update_variant_with_nocreate(self): + """ update variants with a 'nocreate' value on variant """ + template = self.env['product.template'].create({ + 'name': 'Sofax', + 'uom_id': self.uom_unit.id, + 'uom_po_id': self.uom_unit.id, + 'attribute_line_ids': [ + (0, 0, { # one variant for this one + 'attribute_id': self.prod_att_1.id, + 'value_ids': [(4, self.prod_attr1_v1.id)], + }), + ], + }) + self.assertEqual(len(template.product_variant_ids), 1) + + for variant_id in template.product_variant_ids: + variant_id.attribute_value_ids += self.size_S + template.attribute_line_ids += template.attribute_line_ids.browse() + self.assertEqual(len(template.product_variant_ids), 1) diff --git a/addons/product_expiry/models/product_product.py b/addons/product_expiry/models/product_product.py index 86094cfed16..a055f650286 100644 --- a/addons/product_expiry/models/product_product.py +++ b/addons/product_expiry/models/product_product.py @@ -10,7 +10,7 @@ class ProductTemplate(models.Model): life_time = fields.Integer(string='Product Life Time', help='Number of days before the goods may become dangerous and must not be consumed. It will be computed on the lot/serial number.') use_time = fields.Integer(string='Product Use Time', - help='Number of days before the goods starts deteriorating, without being dangerous yet. It will be computed on the lot/serial number.') + help='Number of days before the goods starts deteriorating, without being dangerous yet. It will be computed using the lot/serial number.') removal_time = fields.Integer(string='Product Removal Time', help='Number of days before the goods should be removed from the stock. It will be computed on the lot/serial number.') alert_time = fields.Integer(string='Product Alert Time', diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 85aea626c09..8a7ef37a487 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -435,7 +435,7 @@ class Task(models.Model): _date_name = "date_start" _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'rating.mixin'] _mail_post_access = 'read' - _order = "priority desc, sequence, date_start, name, id" + _order = "priority desc, sequence, id desc" @api.model def default_get(self, fields_list): @@ -492,7 +492,7 @@ class Task(models.Model): index=True, copy=False) date_end = fields.Datetime(string='Ending Date', index=True, copy=False) date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True) - date_deadline = fields.Date(string='Deadline', index=True, copy=False) + date_deadline = fields.Date(string='Deadline', index=True, copy=False, track_visibility='onchange') date_last_stage_update = fields.Datetime(string='Last Stage Update', default=fields.Datetime.now, index=True, @@ -505,8 +505,8 @@ class Task(models.Model): track_visibility='onchange', change_default=True) notes = fields.Text(string='Notes') - planned_hours = fields.Float("Planned Hours", help='It is the time planned to achieve the task. If this document has sub-tasks, it means the time needed to achieve this tasks and its childs.') - subtask_planned_hours = fields.Float("Subtask Planned Hours", compute='_compute_subtask_planned_hours', help="Computed using sum of hours planned of all subtasks created from main task. Usually these hours are less or equal to the Planned Hours (of main task).") + planned_hours = fields.Float("Planned Hours", help='It is the time planned to achieve the task. If this document has sub-tasks, it means the time needed to achieve this tasks and its childs.',track_visibility='onchange') + subtask_planned_hours = fields.Float("Subtasks", compute='_compute_subtask_planned_hours', help="Computed using sum of hours planned of all subtasks created from main task. Usually these hours are less or equal to the Planned Hours (of main task).") remaining_hours = fields.Float(string='Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task.") user_id = fields.Many2one('res.users', string='Assigned to', @@ -528,7 +528,7 @@ class Task(models.Model): legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) - parent_id = fields.Many2one('project.task', string='Parent Task') + parent_id = fields.Many2one('project.task', string='Parent Task', index=True) child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") subtask_count = fields.Integer("Sub-task count", compute='_compute_subtask_count') email_from = fields.Char(string='Email', help="These people will receive email.", index=True) @@ -539,6 +539,8 @@ class Task(models.Model): working_hours_close = fields.Float(compute='_compute_elapsed', string='Working hours to close', store=True, group_operator="avg") working_days_open = fields.Float(compute='_compute_elapsed', string='Working days to assign', store=True, group_operator="avg") working_days_close = fields.Float(compute='_compute_elapsed', string='Working days to close', store=True, group_operator="avg") + # customer portal: include comment and incoming emails in communication history + website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])]) _constraints = [(models.BaseModel._check_recursion, 'Circular references are not permitted between tasks and sub-tasks', ['parent_id'])] diff --git a/addons/project/static/src/less/project_dashboard.less b/addons/project/static/src/less/project_dashboard.less index 0b96966711d..9983bd435b1 100644 --- a/addons/project/static/src/less/project_dashboard.less +++ b/addons/project/static/src/less/project_dashboard.less @@ -23,10 +23,10 @@ font-weight: 800; } - > a { + > div { font-weight: 500; - &.o_needaction{ + button.o_needaction { font-size: small; font-weight: 400; margin-left: 4px; diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index a86176bd10c..c4c7261c893 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -139,8 +139,9 @@ <div class="table-responsive"><table class="table table-hover o_portal_my_doc_table"> <thead> <tr class="active"> - <th class="col-md-10">Task</th> - <th>Stage</th> + <th class="col-md-9">Task</th> + <th class="col-md-2">Stage</th> + <th>Ref</th> </tr> </thead> <tbody> @@ -152,6 +153,9 @@ <td> <span class="label label-info" title="Current stage of the task" t-esc="task.stage_id.name" /> </td> + <td> + <span t-esc="task.id"/> + </td> </tr> </t> </tbody> @@ -171,7 +175,7 @@ <div class="col-md-12"> <h4> <t t-call='portal.record_pager'/> - Task - <span t-field="task.name"/> + Task - <span t-field="task.name"/> (#<span t-esc="task.id"/>) <span t-field="task.stage_id.name" class="pull-right label label-info" title="Current stage of this task"/> <a class="btn btn-info" t-att-href="'/web#return_label=Website&model=project.task&id=%s&view_type=form' % (task.id)" groups="project.group_project_user">Edit Task</a> </h4> diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index ccafde64533..dc4930c9fb5 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -349,9 +349,9 @@ <div> <span class="o_value"><t t-esc="record.task_count.value"/></span> <span class="o_label"><t t-esc="record.label_tasks.value"/></span> - <a t-if="record.task_needaction_count.raw_value" class="o_needaction" name="%(act_project_project_2_project_task_all)d" type="action" context="{'search_default_message_needaction': 1}"> + <button t-if="record.task_needaction_count.raw_value" class="btn-link o_needaction" name="%(act_project_project_2_project_task_all)d" type="action" context="{'search_default_message_needaction': 1}"> <t t-esc="record.task_needaction_count.raw_value"/> - </a> + </button> </div> </a> </div> @@ -922,7 +922,7 @@ </p> </field> </record> - <menuitem action="project_tags_action" id="menu_project_tags_act" parent="menu_project_config" groups="base.group_no_one"/> + <menuitem action="project_tags_action" id="menu_project_tags_act" parent="menu_project_config"/> <!-- Reporting menus --> <menuitem id="menu_project_report" name="Reporting" diff --git a/addons/purchase/data/mail_template_data.xml b/addons/purchase/data/mail_template_data.xml index a5cc40f5696..dc51e377149 100644 --- a/addons/purchase/data/mail_template_data.xml +++ b/addons/purchase/data/mail_template_data.xml @@ -68,7 +68,7 @@ Please find in attachment a <strong>${object.state in ('draft', 'sent') and 'req % if object.origin: (RFQ origin: ${object.origin}) % endif -amounting <strong>${object.amount_total} ${object.currency_id.name}</strong> +amounting <strong>${format_amount(object.amount_total, object.currency_id)}</strong> from ${object.company_id.name}. </p> diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 0e8a070ff00..40e0ee89c34 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -352,7 +352,7 @@ class PurchaseOrder(models.Model): @api.multi def button_approve(self, force=False): - self.write({'state': 'purchase'}) + self.write({'state': 'purchase', 'date_approve': fields.Date.context_today(self)}) self._create_picking() if self.company_id.po_lock == 'lock': self.write({'state': 'done'}) @@ -919,14 +919,6 @@ class PurchaseOrderLine(models.Model): self.product_qty = 1.0 -class ProcurementGroup(models.Model): - _inherit = 'procurement.group' - - @api.model - def _get_exceptions_domain(self): - return super(ProcurementGroup, self)._get_exceptions_domain() + [('created_purchase_line_id', '=', False)] - - class ProcurementRule(models.Model): _inherit = 'procurement.rule' action = fields.Selection(selection_add=[('buy', 'Buy')]) diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py index bb5fd985e0b..fa38fa44bae 100644 --- a/addons/purchase/models/stock.py +++ b/addons/purchase/models/stock.py @@ -34,14 +34,14 @@ class StockMove(models.Model): @api.model def _prepare_merge_moves_distinct_fields(self): distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() - distinct_fields.append('purchase_line_id') + distinct_fields += ['purchase_line_id', 'created_purchase_line_id'] return distinct_fields @api.model def _prepare_merge_move_sort_method(self, move): move.ensure_one() keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) - keys_sorted.append(move.purchase_line_id.id) + keys_sorted += [move.purchase_line_id.id, move.created_purchase_line_id.id] return keys_sorted @api.multi @@ -181,3 +181,19 @@ class Orderpoint(models.Model): for poline in self.env['purchase.order.line'].search([('state','in',('draft','sent','to approve')),('orderpoint_id','in',self.ids)]): res[poline.orderpoint_id.id] += poline.product_uom._compute_quantity(poline.product_qty, poline.orderpoint_id.product_uom, round=False) return res + + def action_view_purchase(self): + """ This function returns an action that display existing + purchase orders of given orderpoint. + """ + action = self.env.ref('purchase.purchase_rfq') + result = action.read()[0] + + # Remvove the context since the action basically display RFQ and not PO. + result['context'] = {} + order_line_ids = self.env['purchase.order.line'].search([('orderpoint_id', '=', self.id)]) + purchase_ids = order_line_ids.mapped('order_id') + + result['domain'] = "[('id','in',%s)]" % (purchase_ids.ids) + + return result diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py index 49711eb32a9..c4c4e78e342 100644 --- a/addons/purchase/tests/test_create_picking.py +++ b/addons/purchase/tests/test_create_picking.py @@ -224,3 +224,92 @@ class TestCreatePicking(common.TestProductCommon): # check the delivered quantity self.assertEqual(po.order_line.qty_received, 3.0) + def test_04_mto_multiple_po(self): + """ Simulate a mto chain with 2 purchase order. + Create a move with qty 1, confirm the RFQ then create a new + move that will not be merged in the first one(simulate an increase + order quantity on a SO). It should generate a new RFQ, validate + and receipt the picking then try to reserve the delivery + picking. + """ + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') + # route buy should be there by default + partner = self.env['res.partner'].create({ + 'name': 'Jhon' + }) + + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + # A picking is require since only moves inside the same picking are merged. + customer_picking = self.env['stock.picking'].create({ + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': picking_type_out.id, + }) + + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 80.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move._action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check purchase order line data. + purchase_order_line = purchase_order.order_line + self.assertEqual(purchase_order_line.product_id, product, 'The product on the purchase order line is not correct.') + self.assertEqual(purchase_order_line.price_unit, seller.price, 'The purchase order line price should be the same than the seller.') + self.assertEqual(purchase_order_line.product_qty, customer_move.product_uom_qty, 'The purchase order line qty should be the same than the move.') + + purchase_order.button_confirm() + + customer_move_2 = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 20.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move_2._action_confirm() + + self.assertTrue(customer_move_2.exists(), 'The second customer move should not be merged in the first.') + self.assertEqual(sum(customer_picking.move_lines.mapped('product_uom_qty')), 100.0) + + purchase_order_2 = self.env['purchase.order'].search([('partner_id', '=', partner.id), ('state', '=', 'draft')]) + self.assertTrue(purchase_order_2, 'No purchase order created.') + + purchase_order_2.button_confirm() + + purchase_order.picking_ids.move_lines.quantity_done = 80.0 + purchase_order.picking_ids.button_validate() + + purchase_order_2.picking_ids.move_lines.quantity_done = 20.0 + purchase_order_2.picking_ids.button_validate() + + self.assertEqual(sum(customer_picking.move_lines.mapped('reserved_availability')), 100.0, 'The total quantity for the customer move should be available and reserved.') diff --git a/addons/purchase/tests/test_fifo_price.py b/addons/purchase/tests/test_fifo_price.py index a562a0fbfdb..8041ed12fd7 100644 --- a/addons/purchase/tests/test_fifo_price.py +++ b/addons/purchase/tests/test_fifo_price.py @@ -13,9 +13,6 @@ class TestFifoPrice(TestPurchase): self._load('account', 'test', 'account_minimal_test.xml') self._load('stock_account', 'test', 'stock_valuation_account.xml') - # Set the company currency as EURO for the sake of repeatibility - self.env.ref('base.main_company').currency_id = self.env.ref('base.EUR') - # Set a product as using fifo price product_icecream = self.env['product.product'].create({ 'default_code': 'FIFO', diff --git a/addons/purchase/views/account_invoice_views.xml b/addons/purchase/views/account_invoice_views.xml index ff14c42659f..f3eabb91534 100644 --- a/addons/purchase/views/account_invoice_views.xml +++ b/addons/purchase/views/account_invoice_views.xml @@ -58,7 +58,7 @@ <field name="inherit_id" ref="account.invoice_supplier_form"/> <field name="arch" type="xml"> <field name="reference" position="after" > - <field name="purchase_id" attrs="{'readonly': [('state','not in',['draft'])], 'invisible': [('state', '=', 'purchase')]}" class="oe_edit_only" + <field name="purchase_id" attrs="{'readonly': [('state','not in',['draft'])], 'invisible': ['|', ('state', '=', 'purchase'), ('type', '=', 'in_refund')]}" class="oe_edit_only" options="{'no_create': True}" context="{'show_total_amount': True}"/> </field> <xpath expr="//field[@name='invoice_line_ids']/tree/field[@name='company_id']" position="after"> diff --git a/addons/purchase/views/stock_views.xml b/addons/purchase/views/stock_views.xml index 80c9382ca1d..bc24cf74be0 100644 --- a/addons/purchase/views/stock_views.xml +++ b/addons/purchase/views/stock_views.xml @@ -30,9 +30,12 @@ <field name="inherit_id" ref="stock.view_warehouse_orderpoint_form"/> <field name="arch" type="xml"> <div name="button_box" position="inside"> - <button name="%(purchase.purchase_order_action_generic)d" type="action" - string="Purchases" class="oe_stat_button" icon="fa-refresh" - domain="[('orderpoint_id','=',active_id)]"/> + <button type="object" + name="action_view_purchase" + class="oe_stat_button" + string="Purchases" + icon="fa-refresh"> + </button> </div> </field> </record> diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py index 4b03b15fb3f..7aa518675f7 100644 --- a/addons/purchase_requisition/models/purchase_requisition.py +++ b/addons/purchase_requisition/models/purchase_requisition.py @@ -73,7 +73,6 @@ class PurchaseRequisition(models.Model): 'Status', track_visibility='onchange', required=True, copy=False, default='draft') state_blanket_order = fields.Selection(PURCHASE_REQUISITION_STATES, compute='_set_state') - account_analytic_id = fields.Many2one('account.analytic.account', 'Analytic Account') picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', required=True, default=_get_picking_in) is_quantity_copy = fields.Selection(related='type_id.quantity_copy', readonly=True) currency_id = fields.Many2one('res.currency', 'Currency', required=True, @@ -201,11 +200,12 @@ class PurchaseRequisitionLine(models.Model): requisition_id = fields.Many2one('purchase.requisition', required=True, string='Purchase Agreement', ondelete='cascade') company_id = fields.Many2one('res.company', related='requisition_id.company_id', string='Company', store=True, readonly=True, default= lambda self: self.env['res.company']._company_default_get('purchase.requisition.line')) account_analytic_id = fields.Many2one('account.analytic.account', string='Analytic Account') + analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') schedule_date = fields.Date(string='Scheduled Date') move_dest_id = fields.Many2one('stock.move', 'Downstream Move') supplier_info_ids = fields.One2many('product.supplierinfo', 'purchase_requisition_line_id') - @api.multi + @api.model def create(self,vals): res = super(PurchaseRequisitionLine, self).create(vals) if res.requisition_id.state not in ['draft', 'cancel', 'done'] and res.requisition_id.is_quantity_copy == 'none': @@ -265,8 +265,6 @@ class PurchaseRequisitionLine(models.Model): if self.product_id: self.product_uom_id = self.product_id.uom_id self.product_qty = 1.0 - if not self.account_analytic_id: - self.account_analytic_id = self.requisition_id.account_analytic_id if not self.schedule_date: self.schedule_date = self.requisition_id.schedule_date @@ -283,6 +281,7 @@ class PurchaseRequisitionLine(models.Model): 'taxes_id': [(6, 0, taxes_ids)], 'date_planned': requisition.schedule_date or fields.Date.today(), 'account_analytic_id': self.account_analytic_id.id, + 'analytic_tag_ids': self.analytic_tag_ids.ids, 'move_dest_ids': self.move_dest_id and [(4, self.move_dest_id.id)] or [] } diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml index d1a84d5b4a0..52953440ab1 100644 --- a/addons/purchase_requisition/views/purchase_requisition_views.xml +++ b/addons/purchase_requisition/views/purchase_requisition_views.xml @@ -173,7 +173,6 @@ <field name="type_id" attrs="{'readonly': [('state','!=','draft')]}" widget="selection"/> <field name="vendor_id" context="{'search_default_supplier':1, 'default_supplier':1, 'default_customer':0}" domain="[('supplier','=',True)]" attrs="{'required': [('is_quantity_copy', '=', 'none')], 'readonly': [('state', 'in', ['ongoing','done'])]}"/> <field name="currency_id" groups="base.group_multi_currency"/> - <field name="account_analytic_id" colspan="2" groups="analytic.group_analytic_accounting" attrs="{'readonly': [('state','not in',('draft'))]}"/> </group> <group> <field name="date_end" attrs="{'readonly': [('state','not in',('draft','in_progress','open','ongoing'))]}"/> @@ -194,6 +193,7 @@ <field name="product_uom_id" groups="uom.group_uom"/> <field name="schedule_date" groups="base.group_no_one"/> <field name="account_analytic_id" groups="analytic.group_analytic_accounting"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags"/> <field name="price_unit"/> </tree> <form string="Products"> diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 292d24c6a87..0742444b555 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -758,7 +758,7 @@ class ResourceCalendarLeaves(models.Model): date_from = fields.Datetime('Start Date', required=True) date_to = fields.Datetime('End Date', required=True) tz = fields.Selection( - _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'), + _tz_get, string='Timezone', default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC', help="Timezone used when encoding the leave. It is used to correctly " "localize leave hours when computing time intervals.") resource_id = fields.Many2one( diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index 20a641ede26..c08bd66d628 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -63,7 +63,8 @@ class ResourceMixin(models.AbstractModel): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) work_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) total_work_time += work_time - days_count += float_utils.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + if theoric_hours: + days_count += float_utils.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return { 'days': days_count, 'hours': total_work_time.total_seconds() / 3600, @@ -97,7 +98,8 @@ class ResourceMixin(models.AbstractModel): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) leave_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) total_leave_time += leave_time - days_count += float_utils.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + if theoric_hours: + days_count += float_utils.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return { 'days': days_count, 'hours': total_leave_time.total_seconds() / 3600, diff --git a/addons/sale/__init__.py b/addons/sale/__init__.py index ba3e6977f3c..17753815848 100644 --- a/addons/sale/__init__.py +++ b/addons/sale/__init__.py @@ -11,12 +11,13 @@ 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 'account.invoice.report' in env: + env['account.invoice.report'].init() if 'crm.team' in env: recs = env['crm.team'].search([]) for rec in recs: diff --git a/addons/sale/controllers/mail.py b/addons/sale/controllers/mail.py index cd0701e21db..d257bfa2736 100644 --- a/addons/sale/controllers/mail.py +++ b/addons/sale/controllers/mail.py @@ -11,6 +11,7 @@ from odoo.tools.misc import consteq class MailController(MailController): + @classmethod def _redirect_to_record(cls, model, res_id, access_token=None): # If the current user doesn't have access to the sales order, but provided # a valid access token, redirect him to the front-end view. diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py index 5f6e7ba2857..7a157566790 100644 --- a/addons/sale/models/__init__.py +++ b/addons/sale/models/__init__.py @@ -12,3 +12,4 @@ from . import sale from . import res_config_settings from . import sale_layout from . import sales_team +from . import ir_model_fields diff --git a/addons/sale/models/account_invoice.py b/addons/sale/models/account_invoice.py index 9878e75ec04..a03df305b16 100644 --- a/addons/sale/models/account_invoice.py +++ b/addons/sale/models/account_invoice.py @@ -96,15 +96,6 @@ class AccountInvoiceLine(models.Model): _inherit = 'account.invoice.line' _order = 'invoice_id, layout_category_id, sequence, id' - @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', - 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', - 'invoice_id.date_invoice') - def _compute_total_price(self): - for line in self: - price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.invoice_line_tax_ids.compute_all(price, line.invoice_id.currency_id, line.quantity, product=line.product_id, partner=line.invoice_id.partner_id) - line.price_total = taxes['total_included'] - sale_line_ids = fields.Many2many( 'sale.order.line', 'sale_order_line_invoice_rel', @@ -113,4 +104,3 @@ class AccountInvoiceLine(models.Model): layout_category_id = fields.Many2one('sale.layout_category', string='Section') layout_category_sequence = fields.Integer(string='Layout Sequence') # TODO: remove layout_category_sequence in master or make it work properly - price_total = fields.Monetary(compute='_compute_total_price', string='Total Amount', store=True) diff --git a/addons/sale/models/ir_model_fields.py b/addons/sale/models/ir_model_fields.py new file mode 100644 index 00000000000..828b9de8346 --- /dev/null +++ b/addons/sale/models/ir_model_fields.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details + +from odoo import models + + +# This is a nasty hack, targeted for V11 only +class IrModelFields(models.Model): + _inherit = 'ir.model.fields' + + def unlink(self): + # Prevent the deletion of some `shared` fields... -_- + self = self.filtered( + lambda rec: not ( + rec.model == 'res.config.settings' + and rec.name == 'auth_signup_uninvited' + ) + ) + return super(IrModelFields, self).unlink() diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 4c6223fd269..b26353142b6 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -10,6 +10,8 @@ class ProductProduct(models.Model): @api.multi def _sales_count(self): r = {} + if not self.user_has_groups('sales_team.group_sale_salesman'): + return r domain = [ ('state', 'in', ['sale', 'done']), ('product_id', 'in', self.ids), diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 911e2eb7ef9..1913f5859ed 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -501,7 +501,7 @@ class SaleOrder(models.Model): @api.returns('self', lambda value: value.id) def message_post(self, **kwargs): if self.env.context.get('mark_so_as_sent'): - self.filtered(lambda o: o.state == 'draft').write({'state': 'sent'}) + self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) return super(SaleOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) @api.multi diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index d134fa41179..b1686253c0e 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -644,7 +644,7 @@ <field name="arch" type="xml"> <button name="toggle_active" position="before"> <button class="oe_stat_button" name="%(action_product_sale_list)d" - type="action" icon="fa-usd"> + type="action" icon="fa-usd" groups="sales_team.group_sale_salesman"> <field string="Sales" name="sales_count" widget="statinfo" /> </button> </button> @@ -666,7 +666,8 @@ <field name="arch" type="xml"> <button name="toggle_active" position="before"> <button class="oe_stat_button" name="action_view_sales" - type="object" icon="fa-usd" attrs="{'invisible': [('sale_ok', '=', False)]}"> + type="object" icon="fa-usd" attrs="{'invisible': [('sale_ok', '=', False)]}" + groups="sales_team.group_sale_salesman"> <field string="Sales" name="sales_count" widget="statinfo" /> </button> </button> @@ -689,6 +690,10 @@ <group string="Invoicing" name="invoicing"> <field name="invoice_policy" widget="radio"/> <field name="service_type" widget="radio" invisible="True"/> + </group> + </group> + <group name="purchase" position="inside"> + <group string="Invoicing" name="invoicing"> <field name="expense_policy" widget="radio"/> </group> </group> diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py index e7c42f92fa3..90f6d6ad6c5 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -111,6 +111,7 @@ class SaleAdvancePaymentInv(models.TransientModel): 'product_id': self.product_id.id, 'sale_line_ids': [(6, 0, [so_line.id])], 'invoice_line_tax_ids': [(6, 0, tax_ids)], + 'analytic_tag_ids': [(6, 0, so_line.analytic_tag_ids.ids)], 'account_analytic_id': order.analytic_account_id.id or False, })], 'currency_id': order.pricelist_id.currency_id.id, @@ -157,6 +158,9 @@ class SaleAdvancePaymentInv(models.TransientModel): else: tax_ids = taxes.ids context = {'lang': order.partner_id.lang} + analytic_tag_ids = [] + for line in order.order_line: + analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in line.analytic_tag_ids] so_line = sale_line_obj.create({ 'name': _('Advance: %s') % (time.strftime('%m %Y'),), 'price_unit': amount, @@ -165,6 +169,7 @@ class SaleAdvancePaymentInv(models.TransientModel): 'discount': 0.0, 'product_uom': self.product_id.uom_id.id, 'product_id': self.product_id.id, + 'analytic_tag_ids': analytic_tag_ids, 'tax_id': [(6, 0, tax_ids)], 'is_downpayment': True, }) diff --git a/addons/sale_crm/models/crm_lead.py b/addons/sale_crm/models/crm_lead.py index 7409fdccaa5..b4eeb43f565 100644 --- a/addons/sale_crm/models/crm_lead.py +++ b/addons/sale_crm/models/crm_lead.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import date from dateutil.relativedelta import relativedelta from odoo import api, fields, models @@ -31,7 +30,7 @@ class CrmLead(models.Model): @api.model def retrieve_sales_dashboard(self): res = super(CrmLead, self).retrieve_sales_dashboard() - date_today = date.today() + date_today = fields.Date.from_string(fields.Date.context_today(self)) res['invoiced'] = { 'this_month': 0, diff --git a/addons/sale_management/__init__.py b/addons/sale_management/__init__.py index 1b2e5977577..086ab956265 100644 --- a/addons/sale_management/__init__.py +++ b/addons/sale_management/__init__.py @@ -11,3 +11,12 @@ def uninstall_hook(cr, registry): ('module', '=', 'sale') ]).mapped('res_id') env['ir.ui.menu'].browse(res_ids).update({'active': False}) + + +def post_init_hook(cr, registry): + env = Environment(cr, SUPERUSER_ID, {}) + res_ids = env['ir.model.data'].search([ + ('model', '=', 'ir.ui.menu'), + ('module', '=', 'sale'), + ]).mapped('res_id') + env['ir.ui.menu'].browse(res_ids).update({'active': True}) diff --git a/addons/sale_management/__manifest__.py b/addons/sale_management/__manifest__.py index 4202ac31f78..c4584b0acc9 100644 --- a/addons/sale_management/__manifest__.py +++ b/addons/sale_management/__manifest__.py @@ -48,4 +48,5 @@ The Dashboard for the Sales Manager will include ], 'application': True, 'uninstall_hook': 'uninstall_hook', + 'post_init_hook': 'post_init_hook', } diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py index 4b9212beb8f..3b4c60cc413 100644 --- a/addons/sale_stock/models/account_invoice.py +++ b/addons/sale_stock/models/account_invoice.py @@ -51,7 +51,9 @@ class AccountInvoiceLine(models.Model): qty_to_consider = invoiced_qty - qty_done qty_to_consider = min(qty_to_consider, quantity - qty_delivered) qty_delivered += qty_to_consider - average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered + # `move.price_unit` is negative if the move is out and positive if the move is + # dropshipped. Use its absolute value to compute the average price unit. + average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered if qty_delivered == quantity: break return average_price_unit diff --git a/addons/sale_stock/views/stock_views.xml b/addons/sale_stock/views/stock_views.xml index c9a0a76d054..196b8e8aa77 100644 --- a/addons/sale_stock/views/stock_views.xml +++ b/addons/sale_stock/views/stock_views.xml @@ -92,7 +92,8 @@ <field name="arch" type="xml"> <button name="action_view_orderpoints" position="after"> <button class="oe_stat_button" name="action_view_sales" - type="object" icon="fa-usd" attrs="{'invisible': [('sale_ok', '=', False)]}"> + type="object" icon="fa-usd" attrs="{'invisible': [('sale_ok', '=', False)]}" + groups="sales_team.group_sale_salesman"> <field string="Sales" name="sales_count" widget="statinfo" /> </button> </button> diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 00a4b708e2c..23a5b3a5f67 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -141,7 +141,7 @@ class AccountAnalyticLine(models.Model): values['timesheet_invoice_type'] = 'billable_fixed' # if the so line is already invoiced, and the delivered qty is still smaller than the ordered, then link the timesheet to the invoice if so_line.invoice_status == 'invoiced': - values['timesheet_invoice_id'] = so_line.invoice_lines[0].invoice_id.id + values['timesheet_invoice_id'] = so_line.invoice_lines and so_line.invoice_lines[0].invoice_id.id elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.service_type != 'timesheet': values['timesheet_invoice_type'] = 'billable_fixed' diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 335a6b657fe..2116722d9d0 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -137,8 +137,8 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) - task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item") - is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") + task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item") + is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) @api.multi diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 64e14f6a003..59327067366 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -60,9 +60,7 @@ <a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_groupby': ['timesheet_invoice_id'], 'pivot_column_groupby': ['date:month'],'pivot_measures': ['amount', 'timesheet_revenue']}">Profitability</a> </th> <td class="o_timesheet_plan_dashboard_cell"> - <t t-if="currency.position == 'before'" t-esc="currency.symbol"/> - <t t-esc="dashboard['money_amount']['invoiced']"/> - <t t-if="currency.position == 'after'" t-esc="currency.symbol"/> + <t t-esc="dashboard['money_amount']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/> </td> <td>Invoiced</td> </tr> @@ -76,9 +74,7 @@ </td> <td>Fixed</td> <td class="o_timesheet_plan_dashboard_cell"> - <t t-if="currency.position == 'before'" t-esc="currency.symbol"/> - <t t-esc="dashboard['money_amount']['to_invoice']"/> - <t t-if="currency.position == 'after'" t-esc="currency.symbol"/> + <t t-esc="dashboard['money_amount']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/> </td> <td>To invoice</td> </tr> @@ -92,9 +88,7 @@ </td> <td>No task found</td> <td class="o_timesheet_plan_dashboard_cell"> - <t t-if="currency.position == 'before'" t-esc="currency.symbol"/> - <t t-esc="dashboard['money_amount']['cost']"/> - <t t-if="currency.position == 'after'" t-esc="currency.symbol"/> + <t t-esc="dashboard['money_amount']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/> </td> <td>Cost</td> </tr> @@ -118,9 +112,7 @@ <td></td> <td></td> <td class="o_timesheet_plan_dashboard_total"> - <t t-if="currency.position == 'before'" t-esc="currency.symbol"/> - <t t-esc="dashboard['money_amount']['total']"/> - <t t-if="currency.position == 'after'" t-esc="currency.symbol"/> + <t t-esc="dashboard['money_amount']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/> </td> <td><b>Total</b></td> </tr> diff --git a/addons/stock/__init__.py b/addons/stock/__init__.py index 7039d459cb8..66322ee14f3 100644 --- a/addons/stock/__init__.py +++ b/addons/stock/__init__.py @@ -4,4 +4,16 @@ from . import controllers from . import models from . import report -from . import wizard
\ No newline at end of file +from . import wizard + + +from odoo import api, SUPERUSER_ID + + +# TODO: Apply proper fix & remove in master +def pre_init_hook(cr): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['ir.model.data'].search([ + ('model', 'like', '%stock%'), + ('module', '=', 'stock') + ]).unlink() diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index a5c03d60e51..e314dcbbde1 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -74,4 +74,5 @@ 'installable': True, 'application': True, 'auto_install': False, + 'pre_init_hook': 'pre_init_hook', } diff --git a/addons/stock/data/stock_demo2.xml b/addons/stock/data/stock_demo2.xml index dd10fa62160..079be6db035 100644 --- a/addons/stock/data/stock_demo2.xml +++ b/addons/stock/data/stock_demo2.xml @@ -419,13 +419,6 @@ obj().env.ref('stock.incomming_chicago_warehouse3').id]"/> </function> - <function model="stock.picking" name="force_assign"> - <value model="stock.picking" eval="[ - obj().env.ref('stock.outgoing_shipment_main_warehouse').id, - obj().env.ref('stock.outgoing_chicago_warehouse3').id, - obj().env.ref('stock.outgoing_shipment_main_warehouse2').id]"/> - </function> - <function model="stock.picking" name="action_done"> <value model="stock.picking" eval="[ obj().env.ref('stock.outgoing_shipment_main_warehouse1').id, diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py index 38b9af72a2b..ba047badef8 100644 --- a/addons/stock/models/procurement.py +++ b/addons/stock/models/procurement.py @@ -231,30 +231,17 @@ class ProcurementGroup(models.Model): ('product_id', '=', values['product_id'].id)] @api.model - def _get_exceptions_domain(self): - return [('procure_method', '=', 'make_to_order'), ('move_orig_ids', '=', False)] - - @api.model def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False): # Minimum stock rules self.sudo()._procure_orderpoint_confirm(use_new_cursor=use_new_cursor, company_id=company_id) # Search all confirmed stock_moves and try to assign them - confirmed_moves = self.env['stock.move'].search([('state', '=', 'confirmed')], limit=None, order='priority desc, date_expected asc') + confirmed_moves = self.env['stock.move'].search([('state', '=', 'confirmed'), ('product_uom_qty', '!=', 0.0)], limit=None, order='priority desc, date_expected asc') for moves_chunk in split_every(100, confirmed_moves.ids): self.env['stock.move'].browse(moves_chunk)._action_assign() if use_new_cursor: self._cr.commit() - exception_moves = self.env['stock.move'].search(self._get_exceptions_domain()) - for move in exception_moves: - values = move._prepare_procurement_values() - try: - with self._cr.savepoint(): - origin = (move.group_id and (move.group_id.name + ":") or "") + (move.rule_id and move.rule_id.name or move.origin or move.picking_id.name or "/") - self.run(move.product_id, move.product_uom_qty, move.product_uom, move.location_id, move.rule_id and move.rule_id.name or "/", origin, values) - except UserError as error: - self.env['procurement.rule']._log_next_activity(move.product_id, error.name) if use_new_cursor: self._cr.commit() diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index a75d7cbee24..92f3a9d1351 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -159,6 +159,7 @@ class StockMove(models.Model): is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable') is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable') reference = fields.Char(compute='_compute_reference', string="Reference", store=True) + has_move_lines = fields.Boolean(compute='_compute_has_move_lines') @api.depends('picking_id.is_locked') def _compute_is_locked(self): @@ -218,6 +219,8 @@ class StockMove(models.Model): move.is_quantity_done_editable = False elif move.show_details_visible: move.is_quantity_done_editable = False + elif move.show_operations: + move.is_quantity_done_editable = False else: move.is_quantity_done_editable = True @@ -226,6 +229,11 @@ class StockMove(models.Model): for move in self: move.reference = move.picking_id.name if move.picking_id else move.name + @api.depends('move_line_ids') + def _compute_has_move_lines(self): + for move in self: + move.has_move_lines = bool(move.move_line_ids) + @api.one @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_product_qty(self): diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index a66391b0427..9f4eacbd725 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -143,7 +143,7 @@ class StockMoveLine(models.Model): res = {} if self.product_id.tracking == 'serial': if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: - message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name + message = _('You can only process 1.0 %s of products with unique serial number.') % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res @@ -167,7 +167,7 @@ class StockMoveLine(models.Model): # If the move line is directly create on the picking view. # If this picking is already done we should generate an # associated done move. - if 'picking_id' in vals and 'move_id' not in vals: + if 'picking_id' in vals and not vals.get('move_id'): picking = self.env['stock.picking'].browse(vals['picking_id']) if picking.state == 'done': product = self.env['product.product'].browse(vals['product_id']) diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 948be5aa4d6..f812df8ea35 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -514,7 +514,18 @@ class Picking(models.Model): if after_vals: self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals) if vals.get('move_lines'): - self._autoconfirm_picking() + # Do not run autoconfirm if any of the moves has an initial demand. If an initial demand + # is present in any of the moves, it means the picking was created through the "planned + # transfer" mechanism. + pickings_to_not_autoconfirm = self.env['stock.picking'] + for picking in self: + if picking.state != 'draft': + continue + for move in picking.move_lines: + if not float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): + pickings_to_not_autoconfirm |= picking + break + (self - pickings_to_not_autoconfirm)._autoconfirm_picking() return res @api.multi diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 438d726dd95..11b34b51413 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -58,10 +58,14 @@ class StockQuant(models.Model): action = self.env.ref('stock.stock_move_line_action').read()[0] action['domain'] = [ ('product_id', '=', self.product_id.id), - '|', ('location_id', '=', self.location_id.id), - ('location_dest_id', '=', self.location_id.id), + '|', + ('location_id', '=', self.location_id.id), + ('location_dest_id', '=', self.location_id.id), ('lot_id', '=', self.lot_id.id), - ('package_id', '=', self.package_id.id)] + '|', + ('package_id', '=', self.package_id.id), + ('result_package_id', '=', self.package_id.id), + ] return action @api.constrains('product_id') diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index d9281233803..bb985547ede 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -11,10 +11,14 @@ class StockScrap(models.Model): _order = 'id desc' def _get_default_scrap_location_id(self): - return self.env['stock.location'].search([('scrap_location', '=', True)], limit=1).id + return self.env['stock.location'].search([('scrap_location', '=', True), ('company_id', 'in', [self.env.user.company_id.id, False])], limit=1).id def _get_default_location_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + company_user = self.env.user.company_id + warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_user.id)], limit=1) + if warehouse: + return warehouse.lot_stock_id.id + return None name = fields.Char( 'Reference', default=lambda self: _('New'), diff --git a/addons/stock/report/report_deliveryslip.xml b/addons/stock/report/report_deliveryslip.xml index 3d708d88024..60627d9f596 100644 --- a/addons/stock/report/report_deliveryslip.xml +++ b/addons/stock/report/report_deliveryslip.xml @@ -117,7 +117,7 @@ </td> <td class="text-right" t-if="move_line.state == 'done'"> <t t-if="move_line.ordered_qty != move_line.qty_done"> - <t t-set="backorder" t-value="True"/> + <t t-set="backorder" t-value="o.search([('backorder_id', '=', o.id), ('state', 'not in', ['cancel', 'done'])])"/> </t> <span t-field="move_line.qty_done"/> <span t-field="move_line.product_uom_id"/> diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 78835d1db9a..4589fb12a56 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -185,6 +185,51 @@ class TestPickShip(TestStockCommon): # the client picking should not be assigned anymore, as we returned partially what we took self.assertEqual(picking_client.state, 'confirmed') + def test_mto_resupply_cancel_ship(self): + """ This test simulates a pick pack ship with a resupply route + set. Pick and pack are validated, ship is cancelled. This test + ensure that new picking are not created from the cancelled + ship after the scheduler task. The supply route is only set in + order to make the scheduler run without mistakes (no next + activity). + """ + picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship() + stock_location = self.env['stock.location'].browse(self.stock_location) + warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse_1.write({'delivery_steps': 'pick_pack_ship'}) + warehouse_2 = self.env['stock.warehouse'].create({ + 'name': 'Small Warehouse', + 'code': 'SWH' + }) + warehouse_1.write({ + 'default_resupply_wh_id': warehouse_2.id, + 'resupply_wh_ids': [(6, 0, [warehouse_2.id])] + }) + resupply_route = self.env['stock.location.route'].search([('supplier_wh_id', '=', warehouse_2.id), ('supplied_wh_id', '=', warehouse_1.id)]) + self.assertTrue(resupply_route) + self.productA.write({'route_ids': [(4, resupply_route.id), (4, self.env.ref('stock.route_warehouse0_mto').id)]}) + + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0) + + picking_pick.action_assign() + picking_pick.move_lines[0].move_line_ids[0].qty_done = 10.0 + picking_pick.action_done() + + picking_pack.action_assign() + picking_pack.move_lines[0].move_line_ids[0].qty_done = 10.0 + picking_pack.action_done() + + picking_ship.action_cancel() + picking_ship.move_lines.write({'procure_method': 'make_to_order'}) + + self.env['procurement.group'].run_scheduler() + next_activity = self.env['mail.activity'].search([('res_model', '=', 'product.template'), ('res_id', '=', self.productA.product_tmpl_id.id)]) + self.assertEqual(picking_ship.state, 'cancel') + self.assertFalse(next_activity, 'If a next activity has been created if means that scheduler failed\ + and the end of this test do not have sense.') + self.assertEqual(len(picking_ship.move_lines.mapped('move_orig_ids')), 0, + 'Scheduler should not create picking pack and pick since ship has been manually cancelled.') + def test_no_backorder_1(self): """ Check the behavior of doing less than asked in the picking pick and chosing not to create a backorder. In this behavior, the second picking should obviously only be able to diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 3c69dd929f2..16bdbe44738 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -257,7 +257,8 @@ <page string="Detailed Operations" attrs="{'invisible': [('show_operations', '=', False)]}"> <field name="move_line_ids" attrs="{'readonly': [('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> <tree editable="bottom" decoration-muted="(state == 'done' and is_locked == True)" decoration-danger="qty_done>product_uom_qty and state!='done'" decoration-success="qty_done==product_uom_qty and state!='done' and not result_package_id"> - <field name="product_id" required="1"/> + <field name="product_id" required="1" attrs="{'readonly': ['|', ('state', '=', 'done'), ('move_id', '!=', False)]}"/> + <field name="move_id" invisible="1"/> <field name="in_entire_package" invisible="1"/> <field name="product_uom_id" force_save="1" attrs="{'readonly': [('state', '!=', 'draft')]}" groups="uom.group_uom"/> <field name="package_id" attrs="{'readonly': [('in_entire_package', '=', True)]}" groups="stock.group_tracking_lot" domain="[('location_id', '=', location_id)]"/> @@ -270,7 +271,7 @@ <field name="lot_id" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', True)], 'invisible': [('lots_visible', '=', False)], 'readonly': [('in_entire_package', '=', True)]}" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}"/> <field name="lot_name" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', False)], 'invisible': [('lots_visible', '=', False)], 'readonly': [('in_entire_package', '=', True)]}" context="{'default_product_id': product_id}"/> <field name="is_initial_demand_editable" invisible="1"/> - <field name="product_uom_qty" attrs="{'readonly': ['|', ('is_initial_demand_editable', '=', False), ('in_entire_package', '=', True)], 'required': [('product_id', '!=', False)]}"/> + <field name="product_uom_qty" readonly="1"/> <field name="is_locked" invisible="1"/> <field name="qty_done" attrs="{'readonly': ['|', ('state', 'in', ('done', 'cancel')), ('is_locked', '=', True), ('in_entire_package', '=', True)]}" force_save="1"/> </tree> @@ -280,7 +281,7 @@ </page> <page string="Operations"> <field name="id" invisible="1"/> - <field name="move_lines" attrs="{'readonly': ['|', ('show_operations', '=', True), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_picking_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> + <field name="move_lines" attrs="{'readonly': ['|', '&', ('show_operations', '=', True), '|', ('is_locked', '=', True), ('state', '=', 'done'), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_picking_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> <tree decoration-danger="state != 'done' and quantity_done > reserved_availability and show_reserved_availability" decoration-muted="scrapped == True or state == 'cancel' or (state == 'done' and is_locked == True)" string="Stock Moves" editable="bottom"> <field name="name" invisible="1"/> <field name="date_expected" invisible="1"/> @@ -295,8 +296,9 @@ <field name="show_reserved_availability" invisible="1"/> <field name="show_operations" invisible="1" readonly="1"/> <field name="additional" invisible="1"/> + <field name="has_move_lines" invisible="1"/> <field name="is_locked" invisible="1"/> - <field name="product_id" required="1" attrs="{'readonly': ['&', ('state', '!=', 'draft'), ('additional', '=', False)]}"/> + <field name="product_id" required="1" attrs="{'readonly': ['|', '&', ('state', '!=', 'draft'), ('additional', '=', False), ('has_move_lines', '=', True)]}"/> <field name="is_initial_demand_editable" invisible="1"/> <field name="is_quantity_done_editable" invisible="1"/> <field name="product_uom_qty" string="Initial Demand" attrs="{'readonly': [('is_initial_demand_editable', '=', False)]}"/> diff --git a/addons/stock/wizard/stock_immediate_transfer_views.xml b/addons/stock/wizard/stock_immediate_transfer_views.xml index 4987bd3cb17..e84fb1f3fea 100644 --- a/addons/stock/wizard/stock_immediate_transfer_views.xml +++ b/addons/stock/wizard/stock_immediate_transfer_views.xml @@ -7,7 +7,7 @@ <form string="Immediate transfer?"> <group> <p> - You haven't entered <i>done</i> quantities, by clicking on <i>apply</i> + You have not recorded <i>done</i> quantities yet, by clicking on <i>apply</i> Odoo will process all the <i>reserved</i> quantities. </p> </group> diff --git a/addons/stock_account/__init__.py b/addons/stock_account/__init__.py index 17a52b35e20..e6ecd5c5e46 100644 --- a/addons/stock_account/__init__.py +++ b/addons/stock_account/__init__.py @@ -66,13 +66,14 @@ def _configure_journals(cr, registry): 'company_id': company_id.id, 'fields_id': field_id, 'value': value, - 'res_id': 'product.category,'+str(env.ref('product.product_category_all').id), } properties = env['ir.property'].search([ ('name', '=', record), ('company_id', '=', company_id.id), - ('value_reference', '!=', False)]) - if not properties: + ]) + if properties: + properties.write(vals) + else: # create the property env['ir.property'].create(vals) diff --git a/addons/stock_account/models/res_config_settings.py b/addons/stock_account/models/res_config_settings.py index 3fa7481036f..0889e5e0983 100644 --- a/addons/stock_account/models/res_config_settings.py +++ b/addons/stock_account/models/res_config_settings.py @@ -8,4 +8,4 @@ class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' module_stock_landed_costs = fields.Boolean("Landed Costs", - help="Affect landed costs on receipt operations and split them among products to update their cost price.") + help="Affect landed costs on reception operations and split them among products to update their cost price.") diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 0c1fdf09cb3..a680bd4d8c1 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -200,6 +200,14 @@ class StockMove(models.Model): return True return False + def _is_dropshipped(self): + """ Check if the move should be considered as a dropshipping move so that the cost method + will be able to apply the correct logic. + + :return: True if the move is a dropshipping one else False + """ + return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer' + @api.model def _run_fifo(self, move, quantity=None): move.ensure_one() @@ -298,6 +306,21 @@ class StockMove(models.Model): 'value': value if quantity is None else self.value + value, 'price_unit': value / valued_quantity, }) + elif self._is_dropshipped(): + curr_rounding = self.company_id.currency_id.rounding + if self.product_id.cost_method in ['fifo']: + price_unit = self._get_price_unit() + # see test_dropship_fifo_perpetual_anglosaxon_ordered + self.product_id.standard_price = price_unit + else: + price_unit = self.product_id.standard_price + value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding) + # In move have a positive value, out move have a negative value, let's arbitrary say + # dropship are positive. + self.write({ + 'value': value, + 'price_unit': price_unit, + }) def _action_done(self): self.product_price_update_before_done() @@ -319,7 +342,7 @@ class StockMove(models.Model): if company_src and company_dst and company_src.id != company_dst.id: raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location.")) move._run_valuation() - for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out())): + for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out() or m._is_dropshipped())): move._account_entry_move() return res @@ -402,6 +425,13 @@ class StockMove(models.Model): if remaining_value_before_vacuum < 0: corrected_value += remaining_value_before_vacuum + # If `corrected_value` is 0, absolutely do *not* call `_account_entry_move`. We + # force the amount in the context, but in the case it is 0 it'll create an entry + # for the entire cost of the move. This case happens when the candidates moves + # entirely compensate the problematic move. + if not corrected_value: + continue + if move._is_in(): # If we just compensated an IN move that has a negative remaining # quantity, it means the move has returned more items than it received. @@ -582,7 +612,7 @@ class StockMove(models.Model): else: self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_dest, journal_id) - if self.company_id.anglo_saxon_accounting and self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer': + if self.company_id.anglo_saxon_accounting and self._is_dropshipped(): # Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687 journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation() self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id) diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index fa0c8e394af..bcd9cd5e21d 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -1363,7 +1363,8 @@ class TestStockValuation(TransactionCase): def test_fifo_negative_2(self): """ Receives 10 units, send more, the extra quantity should be valued at the last fifo - price, running the vacuum should not do anything. + price, running the vacuum should not do anything. Receive 2 units at the price the two + extra units were sent, check that no accounting entries are created. """ self.product1.product_tmpl_id.cost_method = 'fifo' @@ -1462,9 +1463,6 @@ class TestStockValuation(TransactionCase): self.assertEqual(len(move1.account_move_ids), 1) self.assertEqual(len(move2.account_move_ids), 1) - # --------------------------------------------------------------------- - # Ending - # --------------------------------------------------------------------- self.assertEqual(self.product1.qty_available, -2) self.assertEqual(self.product1.stock_value, -20) self.assertEqual(sum(self._get_stock_input_move_lines().mapped('debit')), 0) @@ -1474,6 +1472,64 @@ class TestStockValuation(TransactionCase): self.assertEqual(sum(self._get_stock_output_move_lines().mapped('debit')), 120) self.assertEqual(sum(self._get_stock_output_move_lines().mapped('credit')), 0) + # Now receive exactly the extra units at exactly the price sent, no + # accounting entries should be created after the vacuum. + # --------------------------------------------------------------------- + # Receive 2@10 + # --------------------------------------------------------------------- + move3 = self.env['stock.move'].create({ + 'name': '10 in', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 2.0, + 'price_unit': 10, + 'move_line_ids': [(0, 0, { + 'product_id': self.product1.id, + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_uom_id': self.uom_unit.id, + 'qty_done': 2.0, + })] + }) + move3._action_confirm() + move3._action_done() + + # --------------------------------------------------------------------- + # Run the vacuum + # --------------------------------------------------------------------- + self.env['stock.move']._run_fifo_vacuum() + + # --------------------------------------------------------------------- + # Ending + # --------------------------------------------------------------------- + self.assertEqual(move1.value, 100.0) + self.assertEqual(move1.remaining_qty, 0.0) + self.assertEqual(move1.price_unit, 10.0) + self.assertEqual(move1.remaining_value, 0.0) + self.assertEqual(move2.value, -120.0) + self.assertEqual(move2.remaining_qty, 0) + self.assertEqual(move2.remaining_value, 0) + self.assertEqual(move3.value, 20) + self.assertEqual(move3.remaining_qty, 0.0) + self.assertEqual(move3.price_unit, 10.0) + self.assertEqual(move3.remaining_value, 0.0) + + self.assertEqual(len(move1.account_move_ids), 1) + self.assertEqual(len(move2.account_move_ids), 1) + self.assertEqual(len(move3.account_move_ids), 1) # the created account move is due to the receipt + + # nothing should have changed in the accounting regarding the output + self.assertEqual(self.product1.qty_available, 0) + self.assertEqual(self.product1.stock_value, 0) + self.assertEqual(sum(self._get_stock_input_move_lines().mapped('debit')), 0) + self.assertEqual(sum(self._get_stock_input_move_lines().mapped('credit')), 120) + self.assertEqual(sum(self._get_stock_valuation_move_lines().mapped('debit')), 120) + self.assertEqual(sum(self._get_stock_valuation_move_lines().mapped('credit')), 120) + self.assertEqual(sum(self._get_stock_output_move_lines().mapped('debit')), 120) + self.assertEqual(sum(self._get_stock_output_move_lines().mapped('credit')), 0) + def test_fifo_negative_3(self): """ Receives 10 units, send 10 units, then send more: the extra quantity should be valued at the last fifo price, running the vacuum should not do anything. diff --git a/addons/stock_account/views/res_config_settings_views.xml b/addons/stock_account/views/res_config_settings_views.xml index 9453a8c763f..332e1307ba5 100644 --- a/addons/stock_account/views/res_config_settings_views.xml +++ b/addons/stock_account/views/res_config_settings_views.xml @@ -9,7 +9,7 @@ <div id="production_lot_info" position="after"> <h2>Costing</h2> <div class="row mt16 o_settings_container"> - <div class="col-xs-12 col-md-6 o_setting_box" title="Affect landed costs on receipt operations and split them among products to update their cost price."> + <div class="col-xs-12 col-md-6 o_setting_box" title="Affect landed costs on reception operations and split them among products to update their cost price."> <div class="o_setting_left_pane"> <field name="module_stock_landed_costs"/> </div> diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index bd9f7f523f8..70c1d04e9da 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -19,7 +19,7 @@ <field name="inherit_id" ref="stock.view_inventory_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='date']" position="after"> - <field name="accounting_date"/> + <field name="accounting_date" attrs="{'readonly':[('state','!=', 'draft')]}"/> </xpath> </field> </record> diff --git a/addons/stock_dropshipping/tests/__init__.py b/addons/stock_dropshipping/tests/__init__.py index f4159ef6e7f..c060ad7247b 100644 --- a/addons/stock_dropshipping/tests/__init__.py +++ b/addons/stock_dropshipping/tests/__init__.py @@ -5,3 +5,5 @@ from . import test_crossdock from . import test_dropship from . import test_lifo_price from . import test_procurement_exception +from . import test_stockvaluation + diff --git a/addons/stock_dropshipping/tests/test_lifo_price.py b/addons/stock_dropshipping/tests/test_lifo_price.py index 36574f186bc..5fc1864de1c 100644 --- a/addons/stock_dropshipping/tests/test_lifo_price.py +++ b/addons/stock_dropshipping/tests/test_lifo_price.py @@ -17,9 +17,6 @@ class TestLifoPrice(common.TransactionCase): self._load('account', 'test', 'account_minimal_test.xml') self._load('stock_account', 'test', 'stock_valuation_account.xml') - # Set the company currency as EURO for the sake of repeatibility - self.env.ref('base.main_company').write({'currency_id': self.env.ref('base.EUR').id}) - # Set product category removal strategy as LIFO product_category_001 = self.env['product.category'].create({ 'name': 'Lifo Category', diff --git a/addons/stock_dropshipping/tests/test_stockvaluation.py b/addons/stock_dropshipping/tests/test_stockvaluation.py new file mode 100644 index 00000000000..64bcbd0d77f --- /dev/null +++ b/addons/stock_dropshipping/tests/test_stockvaluation.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestStockValuation(AccountingTestCase): + def setUp(self): + super(TestStockValuation, self).setUp() + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.partner_id = self.env.ref('base.res_partner_1') + self.product1 = self.env.ref('product.product_product_8') + + self.acc_payable = self.env['account.account'].search([('name', '=', 'Account Payable')]).id + self.acc_expense = self.env['account.account'].search([('name', '=', 'Expenses')]).id + self.acc_receivable = self.env['account.account'].search([('name', '=', 'Account Receivable')]).id + self.acc_sale = self.env['account.account'].search([('name', '=', 'Product Sales')]).id + self.acc_stock_in = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Received)')]).id + self.acc_stock_out = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Delivered)')]).id + + def _dropship_product1(self): + # enable the dropship and MTO route on the product + dropshipping_route = self.env.ref('stock_dropshipping.route_drop_shipping') + mto_route = self.env.ref('stock.route_warehouse0_mto') + self.product1.write({'route_ids': [(6, 0, [dropshipping_route.id, mto_route.id])]}) + + # add a vendor + vendor1 = self.env['res.partner'].create({'name': 'vendor1'}) + seller1 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 8, + }) + self.product1.write({'seller_ids': [(6, 0, [seller1.id])]}) + + # sell one unit of this product + customer1 = self.env['res.partner'].create({'name': 'customer1'}) + sale_order1 = self.env['sale.order'].create({ + 'partner_id': customer1.id, + 'partner_invoice_id': customer1.id, + 'partner_shipping_id': customer1.id, + 'order_line': [(0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_uom_qty': 1, + 'product_uom': self.product1.uom_id.id, + 'price_unit': 12, + })], + 'pricelist_id': self.env.ref('product.list0').id, + 'picking_policy': 'direct', + }) + sale_order1.action_confirm() + + # confirm the purchase order + purchase_order1 = self.env['purchase.order'].search([('group_id', '=', sale_order1.procurement_group_id.id)]) + purchase_order1.button_confirm() + + # validate the dropshipping picking + self.assertEqual(len(sale_order1.picking_ids), 1) + self.assertEqual(sale_order1.picking_ids.move_lines._is_dropshipped(), True) + wizard = sale_order1.picking_ids.button_validate() + immediate_transfer = self.env[wizard['res_model']].browse(wizard['res_id']) + immediate_transfer.process() + self.assertEqual(sale_order1.picking_ids.state, 'done') + + # create the vendor bill + vendor_bill1 = self.env['account.invoice'].create({ + 'partner_id': vendor1.id, + 'purchase_id': purchase_order1.id, + 'account_id': vendor1.property_account_payable_id.id, + 'type': 'in_invoice', + }) + vendor_bill1.purchase_order_change() + vendor_bill1.action_invoice_open() + + # create the customer invoice + customer_invoice1_id = sale_order1.action_invoice_create() + customer_invoice1 = self.env['account.invoice'].browse(customer_invoice1_id) + customer_invoice1.action_invoice_open() + + all_amls = vendor_bill1.move_id.line_ids + customer_invoice1.move_id.line_ids + if sale_order1.picking_ids.move_lines.account_move_ids: + all_amls |= sale_order1.picking_ids.move_lines.account_move_ids.line_ids + return all_amls + + def _check_results(self, expected_aml, expected_aml_count, all_amls): + # Construct a dict similar to `expected_aml` with `all_amls` in order to + # compare them. + result_aml = {} + for aml in all_amls: + account_id = aml.account_id.id + if result_aml.get(account_id): + debit = result_aml[account_id][0] + credit = result_aml[account_id][1] + result_aml[account_id] = (debit + aml.debit, credit + aml.credit) + else: + result_aml[account_id] = (aml.debit, aml.credit) + + self.assertEqual(len(all_amls), expected_aml_count) + + for k, v in expected_aml.items(): + self.assertEqual(result_aml[k], v) + + # ------------------------------------------------------------------------- + # Continental + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_standard_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + # ------------------------------------------------------------------------- + # Anglosaxon + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_standard_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) + diff --git a/addons/stock_landed_costs/views/stock_landed_cost_views.xml b/addons/stock_landed_costs/views/stock_landed_cost_views.xml index 3b9c650227f..bf712eb6474 100644 --- a/addons/stock_landed_costs/views/stock_landed_cost_views.xml +++ b/addons/stock_landed_costs/views/stock_landed_cost_views.xml @@ -22,7 +22,7 @@ <group> <group> <field name="date"/> - <field name="picking_ids" widget="many2many_tags" options="{'no_create_edit': True}" domain="[('state', '=', 'done')]"/> + <field name="picking_ids" widget="many2many_tags" options="{'no_create_edit': True}" domain="[('state', '=', 'done'), ('picking_type_code', '!=', 'internal')]"/> </group> <group> <field name="account_journal_id"/> diff --git a/addons/survey/models/survey.py b/addons/survey/models/survey.py index 04ff7ccd558..7350d598ab0 100644 --- a/addons/survey/models/survey.py +++ b/addons/survey/models/survey.py @@ -680,6 +680,7 @@ class SurveyLabel(models.Model): value = fields.Char('Suggested value', translate=True, required=True) quizz_mark = fields.Float('Score for this choice', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer") + @api.one @api.constrains('question_id', 'question_id_2') def _check_question_not_empty(self): """Ensure that field question_id XOR field question_id_2 is not null""" diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index c51a0ec19dc..4b69f0da6d7 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -177,7 +177,7 @@ class TestAdvMailPerformance(TransactionCase): self.user_test.write({'notification_type': 'email'}) record = self.env['mail.test.track'].create({'name': 'Test'}) - with self.assertQueryCount(admin=92, emp=118): # test_mail only: 90 - 116 + with self.assertQueryCount(admin=92, emp=120): # test_mail only: 90 - 116 record.write({ 'user_id': self.user_test.id, }) @@ -455,7 +455,7 @@ class TestHeavyMailPerformance(TransactionCase): }) self.assertEqual(rec.message_partner_ids, self.partners | self.env.user.partner_id) - with self.assertQueryCount(admin=94, emp=122): # test_mail only: 92 - 120 + with self.assertQueryCount(admin=94, emp=123): # test_mail only: 92 - 120 rec.write({'user_id': self.user_portal.id}) self.assertEqual(rec.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) @@ -478,7 +478,7 @@ class TestHeavyMailPerformance(TransactionCase): customer_id = self.customer.id user_id = self.user_portal.id - with self.assertQueryCount(admin=326, emp=386): # test_mail only: 319 - 379 + with self.assertQueryCount(admin=326, emp=388): # test_mail only: 319 - 379 rec = self.env['mail.test.full'].create({ 'name': 'Test', 'umbrella_id': umbrella_id, diff --git a/addons/web/static/lib/qweb/qweb2.js b/addons/web/static/lib/qweb/qweb2.js index c4bb7128e9b..94a0afaa32a 100644 --- a/addons/web/static/lib/qweb/qweb2.js +++ b/addons/web/static/lib/qweb/qweb2.js @@ -358,7 +358,12 @@ QWeb2.Engine = (function() { req.open('GET', s, async); if (async) { req.addEventListener("load", function() { - if (req.status == 200) { + // 0, not being a valid HTTP status code, is used by browsers + // to indicate success for a non-http xhr response + // (for example, using the file:// protocol) + // https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest + // https://bugzilla.mozilla.org/show_bug.cgi?id=331610 + if (req.status == 200 || req.status == 0) { callback(null, self._parse_from_request(req)); } else { callback(new Error("Can't load template " + s + ", http status " + req.status)); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index b4b192c1ea9..9fe38adbde6 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -68,6 +68,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, { .load_filters(event.data.dataset, event.data.action_id) .then(event.data.on_success); }, + create_filter: '_onCreateFilter', push_state: '_onPushState', show_effect: '_onShowEffect', // session @@ -338,6 +339,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, { //-------------------------------------------------------------------------- /** + * @private + * @param {OdooEvent} e + * @param {Object} e.data.filter the filter description + * @param {function} e.data.on_success called when the RPC succeeds with its + * returned value as argument + */ + _onCreateFilter: function (e) { + data_manager + .create_filter(e.data.filter) + .then(e.data.on_success); + }, + /** * Displays a warning in a dialog of with the NotificationManager * * @private diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index e213b55189d..1e9c52238bc 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -136,9 +136,10 @@ var ActionManager = Widget.extend({ * is useful when we come from a loadState()) * @param {boolean} [options.replace_last_action=false] set to true to * replace last part of the breadcrumbs with the action - * @return {Deferred} resolved when the action is loaded and appended to the - * DOM ; rejected if the action can't be executed (e.g. if doAction has - * been called to execute another action before this one was complete). + * @return {$.Deferred<Object>} resolved with the action when the action is + * loaded and appended to the DOM ; rejected if the action can't be + * executed (e.g. if doAction has been called to execute another action + * before this one was complete). */ doAction: function (action, options) { var self = this; @@ -175,7 +176,9 @@ var ActionManager = Widget.extend({ self._preprocessAction(action, options); - return self._handleAction(action, options); + return self._handleAction(action, options).then(function () { + return action; + }); }); }, /** @@ -349,6 +352,11 @@ var ActionManager = Widget.extend({ // toggle the fullscreen mode for actions in target='fullscreen' self._toggleFullscreen(); + // now that the action has been executed, force its 'pushState' + // flag to 'true', as we don't want to prevent its controller + // from pushing its state if it changes in the future + action.pushState = true; + return action; }) .fail(function () { diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 9cfa99a9e7d..dfb512900e8 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -21,6 +21,7 @@ ActionManager.include({ custom_events: _.extend({}, ActionManager.prototype.custom_events, { env_updated: '_onEnvUpdated', execute_action: '_onExecuteAction', + get_controller_context: '_onGetControllerContext', search: '_onSearch', switch_view: '_onSwitchView', }), @@ -254,18 +255,26 @@ ActionManager.include({ action.env = self._generateActionEnv(action, options); action.controllers = {}; - // select the first view to display - var lazyLoadFirstView = false; + // select the first view to display, and optionally the main view + // which will be lazyloaded var firstView = options.viewType && _.findWhere(views, {type: options.viewType}); + var mainView; if (firstView) { if (!firstView.multiRecord && views[0].multiRecord) { - lazyLoadFirstView = true; + mainView = views[0]; } } else { firstView = views[0]; } - if (config.device.isMobile && !firstView.isMobileFriendly) { - firstView = _.findWhere(action.views, {isMobileFriendly: true}) || firstView; + + // use mobile-friendly view by default in mobile, if possible + if (config.device.isMobile) { + if (!firstView.isMobileFriendly) { + firstView = self._findMobileView(views, firstView.multiRecord) || firstView; + } + if (mainView && !mainView.isMobileFriendly) { + mainView = self._findMobileView(views, mainView.multiRecord) || mainView; + } } var def; @@ -279,8 +288,8 @@ ActionManager.include({ return $.when(def).then(function () { var defs = []; defs.push(self._createViewController(action, firstView.type)); - if (lazyLoadFirstView) { - defs.push(self._createViewController(action, views[0].type, {}, {lazy: true})); + if (mainView) { + defs.push(self._createViewController(action, mainView.type, {}, {lazy: true})); } return $.when.apply($, defs); }).then(function (controller, lazyLoadedController) { @@ -297,6 +306,22 @@ ActionManager.include({ }); }, /** + * Helper function to find the first mobile-friendly view, if any. + * + * @private + * @param {Array} views an array of views + * @param {boolean} multiRecord set to true iff we search for a multiRecord + * view + * @returns {Object|undefined} a mobile-friendly view of the requested + * multiRecord type, undefined if there is no such view + */ + _findMobileView: function (views, multiRecord) { + return _.findWhere(views, { + isMobileFriendly: true, + multiRecord: multiRecord, + }); + }, + /** * Generates the initial environment of a given action, which is a dict * containing information like the model name, the domain, the context... * That information is shared between the controllers of the corresponding @@ -716,6 +741,20 @@ ActionManager.include({ }); }, /** + * Handles a context request: provides to the caller the context of the + * current controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested context + */ + _onGetControllerContext: function (ev) { + ev.stopPropagation(); + var currentController = this.getCurrentController(); + var context = currentController && currentController.widget.getContext(); + ev.data.callback(context || {}); + }, + /** * Called when there is a change in the search view, so the current action's * environment needs to be updated with the new domain, context and groupby. * diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js index 16b22761581..03b9e756bb7 100644 --- a/addons/web/static/src/js/chrome/control_panel.js +++ b/addons/web/static/src/js/chrome/control_panel.js @@ -154,7 +154,9 @@ var ControlPanel = Widget.extend({ this._attach_content(new_cp_content); // Update the searchview and switch buttons - this._update_search_view(status.searchview, status.search_view_hidden, status.groupable); + if (status.searchview || options.clear) { + this._update_search_view(status.searchview, status.search_view_hidden, status.groupable); + } if (status.active_view_selector) { this._update_switch_buttons(status.active_view_selector); } diff --git a/addons/web/static/src/js/chrome/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js index 38f2bb7374c..d33cdce6500 100644 --- a/addons/web/static/src/js/chrome/search_menus.js +++ b/addons/web/static/src/js/chrome/search_menus.js @@ -97,10 +97,15 @@ return Widget.extend({ } var user_context = this.getSession().user_context; var search = this.searchview.build_search_data(); - var view_context = this.action.env.context; + var controllerContext; + this.trigger_up('get_controller_context', { + callback: function (ctx) { + controllerContext = ctx; + }, + }); var results = pyeval.eval_domains_and_contexts({ domains: search.domains, - contexts: [user_context].concat(search.contexts.concat(view_context || [])), + contexts: [user_context].concat(search.contexts.concat(controllerContext || [])), group_by_seq: search.groupbys || [], }); if (!_.isEmpty(results.group_by)) { @@ -123,7 +128,7 @@ return Widget.extend({ is_default: default_filter, action_id: this.action_id, }; - return data_manager.create_filter(filter).done(function (id) { + return this._createFilter(filter).then(function (id) { filter.id = id; self.toggle_save_menu(false); self.$save_name.find('input').val('').prop('checked', false); @@ -273,6 +278,26 @@ return Widget.extend({ } }); }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a new search view filter. + * + * @private + * @param {Object} filter the filter description + * @returns {$.Deferred} resolved with the RPC's result when it succeeds + */ + _createFilter: function (filter) { + var def = $.Deferred(); + this.trigger_up('create_filter', { + filter: filter, + on_success: def.resolve.bind(def), + }); + return def; + }, }); }); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 251cd2b508c..ffeb4a1de87 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -228,7 +228,7 @@ var utils = { * @returns {boolean} */ is_bin_size: function (v) { - return (/^\d+(\.\d*)? \w+$/).test(v); + return (/^\d+(\.\d*)? [^0-9]+$/).test(v); }, /** * @param {any} node diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 64783e09cdd..2a9a2906501 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1008,7 +1008,10 @@ var FieldX2Many = AbstractField.extend({ * @param {OdooEvent} ev */ _onDiscardChanges: function (ev) { - ev.data.fieldName = this.name; + if (ev.target !== this) { + ev.stopPropagation(); + this.trigger_up('discard_changes', _.extend({}, ev.data, {fieldName: this.name})); + } }, /** * Called when the renderer asks to edit a line, in that case simply tells diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index 9c7914eeebf..ccc7e089e65 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -103,7 +103,7 @@ var CrashManager = core.Class.extend({ if (!this.active) { return; } - new Dialog(this, { + return new Dialog(this, { size: 'medium', title: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"), subtitle: error.data.title, @@ -149,10 +149,10 @@ var CrashManager = core.Class.extend({ clipboard.destroy(); }); - dialog.open(); + return dialog.open(); }, show_message: function(exception) { - this.show_error({ + return this.show_error({ type: _t("Odoo Client Error"), message: exception, data: {debug: ""} 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 7998a6ca8f1..05031ca2463 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -272,6 +272,13 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @returns {Deferred} */ _confirmChange: function (id, fields, e) { + if (e.name === 'discard_changes' && e.target.reset) { + // the target of the discard event is a field widget. In that + // case, we simply want to reset the specific field widget, + // not the full view + return e.target.reset(this.model.get(e.target.dataPointID), e, true); + } + var state = this.model.get(this.handle); return this.renderer.confirmChange(state, id, fields, e); }, 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 2ef65cbd1b6..3013cb32f85 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -198,6 +198,90 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * Add and process default values for a given record. Those values are + * parsed and stored in the '_changes' key of the record. For relational + * fields, sub-dataPoints are created, and missing relational data is + * fetched. Also generate default values for fields with no given value. + * Typically, this function is called with the result of a 'default_get' + * RPC, to populate a newly created dataPoint. It may also be called when a + * one2many subrecord is open in a form view (dialog), to generate the + * default values for the fields displayed in the o2m form view, but not in + * the list or kanban (mainly to correctly create sub-dataPoints for + * relational fields). + * + * @param {string} recordID local id for a record + * @param {Object} values dict of default values for the given record + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @param {Array} [options.fieldNames] list of field names for which a + * default value must be generated (used to complete the values dict) + * @returns {Deferred} + */ + applyDefaultValues: function (recordID, values, options) { + options = options || {}; + var record = this.localData[recordID]; + var viewType = options.viewType || record.viewType; + var fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); + var field; + var fieldName; + record._changes = record._changes || {}; + + // ignore values for non requested fields (for instance, fields that are + // not in the view) + values = _.pick(values, fieldNames); + + // fill default values for missing fields + for (var i = 0; i < fieldNames.length; i++) { + fieldName = fieldNames[i]; + if (!(fieldName in values) && !(fieldName in record._changes)) { + field = record.fields[fieldName]; + if (field.type === 'float' || + field.type === 'integer' || + field.type === 'monetary') { + values[fieldName] = 0; + } else if (field.type === 'one2many' || field.type === 'many2many') { + values[fieldName] = []; + } else { + values[fieldName] = null; + } + } + } + + // parse each value and create dataPoints for relational fields + var defs = []; + for (fieldName in values) { + field = record.fields[fieldName]; + record.data[fieldName] = null; + var dp; + if (field.type === 'many2one' && values[fieldName]) { + dp = this._makeDataPoint({ + context: record.context, + data: {id: values[fieldName]}, + modelName: field.relation, + parentID: record.id, + }); + record._changes[fieldName] = dp.id; + } else if (field.type === 'reference' && values[fieldName]) { + var ref = values[fieldName].split(','); + dp = this._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: ref[0], + parentID: record.id, + }); + defs.push(this._fetchNameGet(dp)); + record._changes[fieldName] = dp.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + defs.push(this._processX2ManyCommands(record, fieldName, values[fieldName], options)); + } else { + record._changes[fieldName] = this._parseServerValue(field, values[fieldName]); + } + } + + return $.when.apply($, defs); + }, + /** * Onchange RPCs may return values for fields that are not in the current * view. Those fields might even be unknown when the onchange returns (e.g. * in x2manys, we only know the fields that are used in the inner view, but @@ -1414,6 +1498,7 @@ var BasicModel = AbstractModel.extend({ list = self._makeDataPoint({ fields: view ? view.fields : fieldInfo.relatedFields, fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + limit: fieldInfo.limit, modelName: field.relation, parentID: record.id, static: true, @@ -2980,7 +3065,7 @@ var BasicModel = AbstractModel.extend({ if (field.type === 'one2many' || field.type === 'many2many') { var ids; if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet - ids = context[fieldName] || []; + ids = context[fieldName] ? context[fieldName].slice(0) : []; } else { relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); ids = relDataPoint.res_ids.slice(0); @@ -3260,27 +3345,8 @@ var BasicModel = AbstractModel.extend({ context: params.context, }) .then(function (result) { - // fill default values for missing fields - for (var i = 0; i < fieldNames.length; i++) { - var fieldName = fieldNames[i]; - if (!(fieldName in result)) { - var field = params.fields[fieldName]; - if (field.type === 'float' || - field.type === 'integer' || - field.type === 'monetary') { - result[fieldName] = 0; - } else if (field.type === 'one2many' || field.type === 'many2many') { - result[fieldName] = []; - } else { - result[fieldName] = null; - } - } - } - - var data = {}; var record = self._makeDataPoint({ modelName: modelName, - data: data, fields: params.fields, fieldsInfo: params.fieldsInfo, context: params.context, @@ -3289,37 +3355,7 @@ var BasicModel = AbstractModel.extend({ viewType: params.viewType, }); - var defs = []; - _.each(fieldNames, function (name) { - var field = params.fields[name]; - data[name] = null; - record._changes = record._changes || {}; - var dp; - if (field.type === 'many2one' && result[name]) { - dp = self._makeDataPoint({ - context: record.context, - data: {id: result[name]}, - modelName: field.relation, - parentID: record.id, - }); - record._changes[name] = dp.id; - } else if (field.type === 'reference' && result[name]) { - var ref = result[name].split(','); - dp = self._makeDataPoint({ - context: record.context, - data: {id: parseInt(ref[1])}, - modelName: ref[0], - parentID: record.id, - }); - defs.push(self._fetchNameGet(dp)); - record._changes[name] = dp.id; - } else if (field.type === 'one2many' || field.type === 'many2many') { - defs.push(self._processX2ManyCommands(record, name, result[name])); - } else { - record._changes[name] = self._parseServerValue(field, result[name]); - } - }); - return $.when.apply($, defs) + return self.applyDefaultValues(record.id, result) .then(function () { var def = $.Deferred(); self._performOnChange(record, fields_key).always(function () { @@ -3502,13 +3538,17 @@ var BasicModel = AbstractModel.extend({ * @param {Object} record * @param {string} fieldName * @param {Array[Array]} commands + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {Deferred} */ - _processX2ManyCommands: function (record, fieldName, commands) { + _processX2ManyCommands: function (record, fieldName, commands, options) { var self = this; + options = options || {}; var defs = []; var field = record.fields[fieldName]; - var fieldInfo = record.fieldsInfo[record.viewType][fieldName]; + var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName]; var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; var fields = view ? view.fields : fieldInfo.relatedFields; @@ -3802,14 +3842,14 @@ var BasicModel = AbstractModel.extend({ // for multi-pages list datapoints, we might need to read the // order field first to apply the order on all pages - if (list.res_ids.length > list.limit) { - if (!list.orderedResIDs && list.orderedBy.length) { + if (list.res_ids.length > list.limit && list.orderedBy.length) { + if (!list.orderedResIDs) { var fieldNames = _.pluck(list.orderedBy, 'name'); def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames); - def.then(function () { - self._sortList(list); - }); } + def.then(function () { + self._sortList(list); + }); } return def.then(function () { var resIDs = []; diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index df9f9807bfd..b4df9c8e09e 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -45,7 +45,7 @@ var BasicView = AbstractView.extend({ this.rendererParams.viewType = this.viewType; this.controllerParams.confirmOnDelete = true; - this.controllerParams.archiveEnabled = 'active' in viewInfo.fields; + this.controllerParams.archiveEnabled = 'active' in this.fields; this.controllerParams.hasButtons = 'action_buttons' in params ? params.action_buttons : true; @@ -161,7 +161,12 @@ var BasicView = AbstractView.extend({ // (because those fields were unknow at that time). So we ask // the model to process them. def = this.model.applyRawChanges(record.id, viewType).then(function () { - if (!self.model.isNew(record.id)) { + if (self.model.isNew(record.id)) { + return self.model.applyDefaultValues(record.id, {}, { + fieldNames: fieldNames, + viewType: viewType, + }); + } else { return self.model.reload(record.id, { fieldNames: fieldNames, keepChanges: true, 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 2755c4c39fd..d49c7c33f5c 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -183,7 +183,7 @@ var CalendarController = AbstractController.extend({ // 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); + this.model.setDate(event.data.date); this.reload(); }, /** 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 ca28e90713f..5542ab06476 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -218,9 +218,9 @@ return AbstractModel.extend({ filters: params.filters, }; + this.setDate(params.initialDate); // Use mode attribute in xml file to specify zoom timeline (day,week,month) // by default month. - this.setDate(params.initialDate, true); this.setScale(params.mode); _.each(this.data.filters, function (filter) { @@ -260,9 +260,8 @@ return AbstractModel.extend({ }, /** * @param {Moment} start - * @param {boolean} highlight */ - setDate: function (start, highlight) { + setDate: function (start) { this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = start; this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); @@ -279,9 +278,6 @@ return AbstractModel.extend({ this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } - if (highlight) { - this.data.highlight_date = this.data.target_date; - } }, /** * @param {string} scale the scale to set 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 469130c723a..a8d5d07ed0d 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -467,11 +467,6 @@ return AbstractRenderer.extend({ this.target_date = this.state.target_date.toString(); } - var highlightDate = moment(this.state.highlight_date).format('YYYY-MM-DD'); - $calendar.find('.o_target_date').removeClass('o_target_date'); - $calendar.find('.fc-bg .fc-day[data-date="'+highlightDate+'"]') - .addClass('o_target_date'); - this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate()) .find('.o_selected_range') .removeClass('o_color o_selected_range'); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 731183e7efe..5f31c7103c7 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -126,6 +126,7 @@ var PivotController = AbstractController.extend({ var table = this.model.exportData(); if(table.measure_row.length + 1 > 256) { crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + framework.unblockUI(); return; } framework.blockUI(); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 99a5e4ee334..1c76c41ab71 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -135,7 +135,7 @@ var PivotModel = AbstractModel.extend({ exportData: function () { var measureNbr = this.data.measures.length; var headers = this._computeHeaders(); - var measureRow = measureNbr > 1 ? _.last(headers) : []; + var measureRow = measureNbr >= 1 ? _.last(headers) : []; var rows = this._computeRows(); var i, j, value; headers[0].splice(0,1); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index 4be84c23b71..1b74068d44d 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -260,6 +260,7 @@ var SelectCreateDialog = ViewDialog.extend({ var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); this.list_controller.reload(searchData); }, + get_controller_context: '_onGetControllerContext', }), /** @@ -416,6 +417,24 @@ var SelectCreateDialog = ViewDialog.extend({ dialog.on('closed', this, this.close); return dialog; }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles a context request: provides to the caller the context of the + * list controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested context + */ + _onGetControllerContext: function (ev) { + ev.stopPropagation(); + var context = this.list_controller && this.list_controller.getContext(); + ev.data.callback(context || {}); + } }); return { diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index 4b9f6a29a47..dd0096f269b 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -455,7 +455,7 @@ var DataExport = Dialog.extend({ Dialog.alert(this, _t("Please select fields to export...")); return; } - exported_fields.unshift({name: 'id', label: 'External ID'}); + exported_fields.unshift({name: 'id', label: _t('External ID')}); var export_format = this.$export_format_inputs.filter(':checked').val(); diff --git a/addons/web/static/src/js/widgets/domain_selector_dialog.js b/addons/web/static/src/js/widgets/domain_selector_dialog.js index 5f41973951d..46b54089c5f 100644 --- a/addons/web/static/src/js/widgets/domain_selector_dialog.js +++ b/addons/web/static/src/js/widgets/domain_selector_dialog.js @@ -40,7 +40,11 @@ return Dialog.extend({ this.domainSelector = new DomainSelector(this, model, domain, options); }, start: function () { - this.$el.css("overflow", "visible").closest(".modal-dialog").css("height", "auto"); // This restores default modal height (bootstrap) and allows field selector to overflow + var self = this; + this.opened().then(function () { + // this restores default modal height (bootstrap) and allows field selector to overflow + self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto'); + }); return $.when( this._super.apply(this, arguments), this.domainSelector.appendTo(this.$el) diff --git a/addons/web/static/src/js/widgets/notification.js b/addons/web/static/src/js/widgets/notification.js index 21c24c99e1b..d05a0be380c 100644 --- a/addons/web/static/src/js/widgets/notification.js +++ b/addons/web/static/src/js/widgets/notification.js @@ -34,7 +34,8 @@ var Notification = Widget.extend({ this.title = params.title; this.text = params.text; this.sticky = !!params.sticky; - if (params.type === 'warning') { + this.type = params.type; + if (this.type === 'warning') { this.template = 'Warning'; } }, @@ -43,6 +44,9 @@ var Notification = Widget.extend({ */ start: function () { var self = this; + if (this.type === 'warning') { + this.$el.addClass('o_error'); + } return this._super.apply(this, arguments).then(function () { self.$el.animate({opacity: 1.0}, 400, "swing", function () { if(!self.sticky) { diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index 78246df9e6c..96e8bd2ac97 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -165,7 +165,9 @@ .o-flex-flow(row, wrap); > .btn { - margin-right: 3px; + @o-statusbar-buttons-vmargin: 4px; + min-height: @odoo-statusbar-height - 2 * @o-statusbar-buttons-vmargin; + margin: @o-statusbar-buttons-vmargin 3px @o-statusbar-buttons-vmargin 0; padding-top: 2px; padding-bottom: 2px; } diff --git a/addons/web/static/src/less/views.less b/addons/web/static/src/less/views.less index 104d4e1b2a5..0c7936fc570 100644 --- a/addons/web/static/src/less/views.less +++ b/addons/web/static/src/less/views.less @@ -61,6 +61,10 @@ font-size: 125%; } + a { + cursor: pointer; + } + .o-init-image() { content: ""; display: block; diff --git a/addons/web/static/src/less/web_calendar.less b/addons/web/static/src/less/web_calendar.less index a3ead61f011..5179552b3b6 100644 --- a/addons/web/static/src/less/web_calendar.less +++ b/addons/web/static/src/less/web_calendar.less @@ -68,11 +68,6 @@ overflow-x: auto; } } - - .o_target_date:not(.fc-today) { - background-color: @odoo-brand-primary; - opacity: 0.1; - } } .o_calendar_sidebar_container { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 65b75fa00e0..748addc4b87 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -125,7 +125,6 @@ </div> </t> <t t-name="Warning" t-extend="Notification"> - <t t-jquery=".o_notification">$(this).addClass('o_error');</t> <t t-jquery=".o_icon" t-operation="replace"> <span class="o_icon fa fa-3x fa-exclamation"/> </t> diff --git a/addons/web/static/tests/chrome/action_manager_mobile_tests.js b/addons/web/static/tests/chrome/action_manager_mobile_tests.js index c23d7be19f8..491830578e6 100644 --- a/addons/web/static/tests/chrome/action_manager_mobile_tests.js +++ b/addons/web/static/tests/chrome/action_manager_mobile_tests.js @@ -80,6 +80,49 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + + QUnit.test('lazy load mobile-friendly view', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 1, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not have rendered a list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should not have rendered a kanban view either"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have rendered a form view"); + + // go back to lazy loaded view + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should not display the form view anymore"); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not display the list view either"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have lazy loaded the kanban view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'default_get', // default_get to open form view + '/web/dataset/search_read', // search read when coming back to Kanban + ]); + + actionManager.destroy(); + }); }); }); diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 4c4f6f9e338..9f7bacc1689 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -6,6 +6,7 @@ var ReportClientAction = require('report.client_action'); var AbstractAction = require('web.AbstractAction'); var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); +var ListController = require('web.ListController'); var ReportService = require('web.ReportService'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); @@ -760,7 +761,7 @@ QUnit.module('ActionManager', { }); QUnit.test('should not push a loaded state', function (assert) { - assert.expect(1); + assert.expect(3); var actionManager = createActionManager({ actions: this.actions, @@ -772,9 +773,14 @@ QUnit.module('ActionManager', { }, }, }); - actionManager.loadState({action: 1}); + actionManager.loadState({action: 3}); - assert.verifySteps([]); + assert.verifySteps([], "should not push the loaded state"); + + actionManager.$('tr.o_data_row:first').click(); + + assert.verifySteps(['push_state'], + "should push the state of it changes afterwards"); actionManager.destroy(); }); @@ -2350,6 +2356,70 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + QUnit.test('save current search', function (assert) { + assert.expect(4); + + testUtils.patch(ListController, { + getContext: function () { + return { + shouldBeInFilterContext: true, + }; + }, + }); + + _.extend(this.archs, { + 'partner,7,search': + '<search>' + + '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', 1)]"/>' + + '</search>', + }); + + this.actions.push({ + id: 33, + context: { + shouldNotBeInFilterContext: false, + }, + name: 'Partners', + res_model: 'partner', + search_view_id: [7, 'a custom search view'], + type: 'ir.actions.act_window', + views: [[false, 'list']], + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + create_filter: function (event) { + var filter = event.data.filter; + assert.deepEqual(filter.domain, [['bar', '=', 1]], + "should save the correct domain"); + assert.deepEqual(filter.context, {shouldBeInFilterContext: true}, + "should save the correct context"); + }, + }, + }); + actionManager.doAction(33); + + assert.strictEqual(actionManager.$('.o_data_row').length, 5, + "should contain 5 records"); + + // filter on bar + $('.o_control_panel .o_filters_menu a:contains(Bar)').click(); + + assert.strictEqual(actionManager.$('.o_data_row').length, 2, + "should contain 2 records"); + + // save filter + $('.o_control_panel .o_save_search a').click(); // toggle 'Save current search' + $('.o_control_panel .o_save_name input[type=text]').val('some name'); // name the filter + $('.o_control_panel .o_save_name button').click(); // click on 'Save' + + testUtils.unpatch(ListController); + actionManager.destroy(); + }); + QUnit.module('Actions in target="new"'); QUnit.test('can execute act_window actions in target="new"', function (assert) { @@ -2528,6 +2598,35 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + + QUnit.test('doAction resolved with an action', function (assert) { + assert.expect(4); + var done = assert.async(); + + this.actions.push({ + id: 21, + name: 'A Close Action', + type: 'ir.actions.act_window_close', + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + actionManager.doAction(21).then(function (action) { + assert.ok(action, "doAction should be resolved with an action"); + assert.strictEqual(action.id, 21, + "should be resolved with correct action id"); + assert.strictEqual(action.name, 'A Close Action', + "should be resolved with correct action name"); + assert.strictEqual(action.type, 'ir.actions.act_window_close', + "should be resolved with correct action type"); + actionManager.destroy(); + done(); + }); + }); }); }); diff --git a/addons/web/static/tests/core/util_tests.js b/addons/web/static/tests/core/util_tests.js index 204503117e6..c7158083dcb 100644 --- a/addons/web/static/tests/core/util_tests.js +++ b/addons/web/static/tests/core/util_tests.js @@ -44,6 +44,17 @@ QUnit.module('core', {}, function () { assert.strictEqual(intersperse("12345678", [3,0], '.'), '12.345.678'); }); + QUnit.test('is_bin_size', function (assert) { + assert.expect(3); + + var is_bin_size = utils.is_bin_size; + + assert.strictEqual(is_bin_size('Cg=='), false); + assert.strictEqual(is_bin_size('2.5 Mb'), true); + // should also work for non-latin languages (e.g. russian) + assert.strictEqual(is_bin_size('64.2 Кб'), true); + }); + }); });
\ No newline at end of file diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 2e40d7cc5ec..9b8df65ab29 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -368,10 +368,6 @@ QUnit.module('relational_fields', { "should have an open record button"); form.$('input.o_input').click(); - form.$('input.o_input').trigger($.Event('keyup', { - which: $.ui.keyCode.ESC, - keyCode: $.ui.keyCode.ESC, - })); assert.strictEqual(form.$('button.o_external_button:visible').length, 1, "should still have an open record button"); @@ -3064,6 +3060,108 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange on one2many containing x2many in form view', function (assert) { + assert.expect(16); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.turtles = [[0, false, {turtle_foo: 'new record'}]]; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<field name="foo"/>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '<form>' + + '<field name="partner_ids">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,search': '<search></search>', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "the onchange should have created one record in the relation"); + + // open the created o2m record in a form view, and add a m2m subrecord + // in its relation + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 0, + "there should be no record in the one2many in the dialog"); + + // add a many2many subrecord + $('.modal .o_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal').length, 2, + "should have opened a second dialog"); + + // select a many2many subrecord + $('.modal:nth(1) .o_list_view .o_data_cell:first').click(); + + assert.strictEqual($('.modal').length, 1, + "second dialog should be closed"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + assert.notOk($('.modal .o_x2m_control_panel .o_cp_pager div').is(':visible'), + 'm2m pager should be hidden'); + + // click on 'Save & Close' + $('.modal .modal-footer .btn-primary:first').click(); + + assert.strictEqual($('.modal').length, 0, "dialog should be closed"); + + // reopen o2m record, and another m2m subrecord in its relation, but + // discard the changes + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + + // add another m2m subrecord + $('.modal .o_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal').length, 2, + "should have opened a second dialog"); + + $('.modal:nth(1) .o_list_view .o_data_cell:first').click(); + + assert.strictEqual($('.modal').length, 1, + "second dialog should be closed"); + assert.strictEqual($('.modal .o_data_row').length, 2, + "there should be two records in the one2many in the dialog"); + + // click on 'Discard' + $('.modal .modal-footer .btn-default').click(); + + assert.strictEqual($('.modal').length, 0, "dialog should be closed"); + + // reopen o2m record to check that second changes have properly been discarded + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + + form.destroy(); + }); + QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) { var done = assert.async(); assert.expect(20); @@ -8017,6 +8115,80 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many with several pages, onchange and default order', function (assert) { + // This test reproduces a specific scenario where a one2many is displayed + // over several pages, and has a default order such that a record that + // would normally be on page 1 is actually on another page. Moreover, + // there is an onchange on that one2many which converts all commands 4 + // (LINK_TO) into commands 1 (UPDATE), which is standard in the ORM. + // This test ensures that the record displayed on page 2 is never fully + // read. + assert.expect(8); + + var data = this.data; + data.partner.records[0].turtles = [1, 2, 3]; + data.turtle.records[0].partner_ids = [1]; + data.partner.onchanges = { + turtles: function (obj) { + var res = _.map(obj.turtles, function (command) { + if (command[0] === 1) { // already an UPDATE command: do nothing + return command; + } + // convert LINK_TO commands to UPDATE commands + var id = command[1]; + var record = _.findWhere(data.turtle.records, {id: id}); + return [1, id, _.pick(record, ['turtle_int', 'turtle_foo', 'partner_ids'])]; + }); + obj.turtles = [[5]].concat(res); + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: data, + arch: '<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="top" limit="2" default_order="turtle_foo">' + + '<field name="turtle_int"/>' + + '<field name="turtle_foo" class="foo"/>' + + '<field name="partner_ids" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + var ids = args.method === 'read' ? ' [' + args.args[0] + ']' : ''; + assert.step(args.method + ids); + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa', + "should display two records out of three, in the correct order"); + + // edit turtle_int field of first row + form.$('.o_data_cell:first').click(); + form.$('.o_field_widget[name=turtle_int]').val(3).trigger('input'); + form.$el.click(); // leave edition + + assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa', + "should still display the same two records"); + + assert.verifySteps([ + 'read [1]', // main record + 'read [1,2,3]', // one2many (turtle_foo, all records) + 'read [2,3]', // one2many (all fields in view, records of first page) + 'read [2,4]', // many2many inside one2many (partner_ids), first page only + 'onchange', + ]); + + form.destroy(); + }); + QUnit.test('new record, with one2many with more default values than limit', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index fb5af01fdcd..99b85dcfa56 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -565,9 +565,13 @@ var MockServer = Class.extend({ return record.display_name.indexOf(str) !== -1; }); } - return _.map(records, function (record) { + var result = _.map(records, function (record) { return [record.id, record.display_name]; }); + if (args.limit) { + return result.slice(0, args.limit); + } + return result; }, /** * Simulate an 'onchange' rpc diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 492dfd4224c..bf5261c5b6e 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -669,6 +669,42 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get returning a non requested field', function (assert) { + // 'default_get' returns a default value for the fields given in + // arguments. It should not return a value for fields that have not be + // requested. However, it happens (e.g. res.users), and the webclient + // should not crash when this situation occurs (the field should simply + // be ignored). + assert.expect(2); + + this.params.context = {}; + this.params.fieldNames = ['category']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === 'default_get') { + result.product_ids = [[6, 0, [37, 41]]]; + } + return result; + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.ok('category' in record.data, + "should have processed 'category'"); + assert.notOk('product_ids' in record.data, + "should have ignored 'product_ids'"); + }); + + model.destroy(); + }); + QUnit.test('can make a default_record with default relational values', function (assert) { assert.expect(7); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 014435194f3..6ec4f50ab4b 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -1,8 +1,9 @@ odoo.define('web.view_dialogs_tests', function (require) { "use strict"; -var testUtils = require('web.test_utils'); var dialogs = require('web.view_dialogs'); +var ListController = require('web.ListController'); +var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); QUnit.module('Views', { @@ -66,6 +67,41 @@ QUnit.module('Views', { parent.destroy(); }); + QUnit.test('formviewdialog buttons in footer are not duplicated', function (assert) { + assert.expect(2); + this.data.partner.fields.poney_ids = {string: "Poneys", type: "one2many", relation: 'partner'}; + this.data.partner.records[0].poney_ids = []; + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<field name="poney_ids"><tree editable="top"><field name="display_name"/></tree></field>' + + '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' + + '</form>', + }, + }); + + new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + }).open(); + + assert.strictEqual($('div.modal button.btn-primary').length, 1, + "should have 1 buttons in modal"); + + $('.o_field_x2many_list_row_add a').click(); + $('input.o_input').trigger($.Event('keydown', { + which: $.ui.keyCode.ESCAPE, + keyCode: $.ui.keyCode.ESCAPE, + })); + + assert.strictEqual($('div.modal button.btn-primary').length, 1, + "should still have 1 buttons in modal"); + parent.destroy(); + }); + QUnit.test('SelectCreateDialog use domain, group_by and search default', function (assert) { assert.expect(3); @@ -206,6 +242,63 @@ QUnit.module('Views', { parent.destroy(); }); + QUnit.test('SelectCreateDialog: save current search', function (assert) { + assert.expect(4); + + testUtils.patch(ListController, { + getContext: function () { + return { + shouldBeInFilterContext: true, + }; + }, + }); + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree>' + + '<field name="display_name"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', True)]"/>' + + '</search>', + + }, + intercepts: { + create_filter: function (event) { + var filter = event.data.filter; + assert.deepEqual(filter.domain, [['bar', '=', true]], + "should save the correct domain"); + assert.deepEqual(filter.context, {shouldBeInFilterContext: true}, + "should save the correct context"); + }, + }, + }); + + var dialog = new dialogs.SelectCreateDialog(parent, { + context: {shouldNotBeInFilterContext: false}, + res_model: 'partner', + }).open(); + + assert.strictEqual(dialog.$('.o_data_row').length, 3, + "should contain 3 records"); + + // filter on bar + dialog.$('.o_filters_menu a:contains(Bar)').click(); + + assert.strictEqual(dialog.$('.o_data_row').length, 2, + "should contain 2 records"); + + // save filter + dialog.$('.o_save_search a').click(); // toggle 'Save current search' + dialog.$('.o_save_name input[type=text]').val('some name'); // name the filter + dialog.$('.o_save_name button').click(); // click on 'Save' + + testUtils.unpatch(ListController); + parent.destroy(); + }); }); }); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index 0d1860d236e..6939c90cefa 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -2167,7 +2167,6 @@ $.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color } // remove node without attributes (move content), and merge the same nodes - if (!dom.isImgFont(node)) { var className2, style, style2; for (i=0; i<nodes.length; i++) { node = nodes[i]; @@ -2208,7 +2207,6 @@ $.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color } } } - } range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select(); }; diff --git a/addons/web_settings_dashboard/static/src/js/dashboard.js b/addons/web_settings_dashboard/static/src/js/dashboard.js index 1cdd7e55178..06f24ab8735 100644 --- a/addons/web_settings_dashboard/static/src/js/dashboard.js +++ b/addons/web_settings_dashboard/static/src/js/dashboard.js @@ -194,29 +194,40 @@ var DashboardShare = Widget.extend({ 'click .li_share': 'share_linkedin', }, - init: function(parent, data){ + init: function (parent, data) { this.data = data; this.parent = parent; this.share_url = 'https://www.odoo.com'; this.share_text = encodeURIComponent("I am using #Odoo - Awesome open source business apps."); }, - share_twitter: function(){ + /** + * @param {MouseEvent} ev + */ + share_twitter: function (ev) { + ev.preventDefault(); var popup_url = _.str.sprintf( 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=%s %s',this.share_text,this.share_url); this.sharer(popup_url); }, - - share_facebook: function(){ + /** + * @param {MouseEvent} ev + */ + share_facebook: function (ev) { + ev.preventDefault(); var popup_url = _.str.sprintf('https://www.facebook.com/sharer/sharer.php?u=%s', encodeURIComponent(this.share_url)); this.sharer(popup_url); }, - share_linkedin: function(){ + /** + * @param {MouseEvent} ev + */ + share_linkedin: function (ev) { + ev.preventDefault(); var popup_url = _.str.sprintf('http://www.linkedin.com/shareArticle?mini=true&url=%s&title=I am using odoo&summary=%s&source=www.odoo.com', encodeURIComponent(this.share_url), this.share_text); this.sharer(popup_url); }, - sharer: function(popup_url){ + sharer: function (popup_url) { window.open( popup_url, 'Share Dialog', diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 40d3c32fc87..dad87420ecb 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -118,7 +118,7 @@ class Website(Home): def robots(self): return request.render('website.robots', {'url_root': request.httprequest.url_root}, mimetype='text/plain') - @http.route('/sitemap.xml', type='http', auth="public", website=True) + @http.route('/sitemap.xml', type='http', auth="public", website=True, multilang=False) def sitemap_xml_index(self, **kwargs): current_website = request.website Attachment = request.env['ir.attachment'].sudo() diff --git a/addons/website/models/ir_model_fields.py b/addons/website/models/ir_model_fields.py index 1e963bf650e..51f5c2a7dc5 100644 --- a/addons/website/models/ir_model_fields.py +++ b/addons/website/models/ir_model_fields.py @@ -1,17 +1,15 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details -from odoo import api, models +from odoo import models # This is a nasty hack, targeted for V11 only class IrModelFields(models.Model): _inherit = 'ir.model.fields' - @api.multi def unlink(self): - # Prevent the deletion of social_* fields defined in res.company if - # the other module is installed + # Prevent the deletion of some `shared` fields... -_- social = ( 'social_facebook', 'social_github', @@ -23,8 +21,9 @@ class IrModelFields(models.Model): self = self.filtered( lambda rec: not ( - rec.model == 'res.company' and - rec.name in social + (rec.model == 'res.company' and rec.name in social) + or (rec.model == 'res.config.settings' + and rec.name == 'auth_signup_uninvited') ) ) return super(IrModelFields, self).unlink() diff --git a/addons/website/models/website.py b/addons/website/models/website.py index ba5b168c9b6..f8e00eb4494 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -119,6 +119,10 @@ class Website(models.Model): 'arch': template_record.arch.replace(template, key), 'name': name, }) + + if view.arch_fs: + view.arch_fs = False + if ispage: page = self.env['website.page'].create({ 'url': page_url, @@ -711,7 +715,7 @@ class Page(models.Model): 'website_id': website.id, }) - return True + return url @api.multi @api.returns('self', lambda value: value.id) diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js index b6d72df3d2a..a931cc057ef 100644 --- a/addons/website/static/src/js/menu/content.js +++ b/addons/website/static/src/js/menu/content.js @@ -205,7 +205,7 @@ var PagePropertiesDialog = weWidgets.Dialog.extend({ method: 'save_page_info', args: [[context.website_id], params], context: context, - }).then(function () { + }).then(function (url) { // If from page manager: reload url, if from page itself: go to // (possibly) new url if (self._getMainObject().model === 'website.page') { diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py index 4115e397f64..b5f0c2d49c4 100644 --- a/addons/website_crm_partner_assign/models/crm_lead.py +++ b/addons/website_crm_partner_assign/models/crm_lead.py @@ -233,7 +233,7 @@ class CrmLead(models.Model): # will be modified by the portal form. If no activity exist we create a new one instead # that we assign to the portal user. - user_activity = lead.activity_ids.filtered(lambda activity: activity.user_id == self.env.user)[:1] + user_activity = lead.sudo().activity_ids.filtered(lambda activity: activity.user_id == self.env.user)[:1] if values['activity_date_deadline']: if user_activity: user_activity.sudo().write({ diff --git a/addons/website_crm_partner_assign/views/website_crm_partner_assign_templates.xml b/addons/website_crm_partner_assign/views/website_crm_partner_assign_templates.xml index 96346a3814d..35a5d8cf256 100644 --- a/addons/website_crm_partner_assign/views/website_crm_partner_assign_templates.xml +++ b/addons/website_crm_partner_assign/views/website_crm_partner_assign_templates.xml @@ -330,7 +330,7 @@ </td> <td><span t-field="opp.contact_name" /></td> <td> - <span class="label label-primary" title="Current stage of the opp" t-esc="opp.stage_id.name" /> + <span class="label label-primary" title="Current stage of the opportunity" t-esc="opp.stage_id.name" /> </td> </tr> </tbody> diff --git a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py index 5aa8073192f..3d85f0f4189 100644 --- a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py +++ b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py @@ -134,7 +134,7 @@ class CrmLeadAssignation(models.TransientModel): lead_location = fields.Char('Lead Location') partner_assigned_id = fields.Many2one('res.partner', 'Assigned Partner') partner_location = fields.Char('Partner Location') - lead_link = fields.Char('Lead Single Links') + lead_link = fields.Char('Link to Lead') @api.onchange('lead_id') def _onchange_lead_id(self): diff --git a/addons/website_customer/controllers/main.py b/addons/website_customer/controllers/main.py index 421a809b36d..063d4b9bb5f 100644 --- a/addons/website_customer/controllers/main.py +++ b/addons/website_customer/controllers/main.py @@ -24,7 +24,7 @@ class WebsiteCustomer(http.Controller): if not qs or qs.lower() in loc: yield {'loc': loc} - dom = [('website_published', '=', True), ('assigned_partner_id', '!=', False)] + dom = [('website_published', '=', True), ('assigned_partner_id', '!=', False), ('country_id', '!=', False)] dom += sitemap_qs2dom(qs, '/customers/country') countries = env['res.partner'].sudo().read_group(dom, ['id', 'country_id'], groupby='country_id') for country in countries: diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index f4fd9716aa2..b37ca97ea79 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -171,7 +171,8 @@ class WebsiteForm(http.Controller): return data def insert_record(self, request, model, values, custom, meta=None): - record = request.env[model.model].sudo().with_context(mail_create_nosubscribe=True).create(values) + model_name = model.sudo().model + record = request.env[model_name].sudo().with_context(mail_create_nosubscribe=True).create(values) if custom or meta: default_field = model.website_form_default_field_id @@ -183,13 +184,13 @@ class WebsiteForm(http.Controller): # If there is a default field configured for this model, use it. # If there isn't, put the custom data in a message instead if default_field.name: - if default_field.ttype == 'html' or model.model == 'mail.mail': + if default_field.ttype == 'html' or model_name == 'mail.mail': custom_content = nl2br(custom_content) record.update({default_field.name: custom_content}) else: values = { 'body': nl2br(custom_content), - 'model': model.model, + 'model': model_name, 'message_type': 'comment', 'no_auto_thread': False, 'res_id': record.id, @@ -201,7 +202,8 @@ class WebsiteForm(http.Controller): # Link all files attached on the form def insert_attachment(self, model, id_record, files): orphan_attachment_ids = [] - record = model.env[model.model].browse(id_record) + model_name = model.sudo().model + record = model.env[model_name].browse(id_record) authorized_fields = model.sudo()._get_form_writable_fields() for file in files: custom_field = file.field_name not in authorized_fields @@ -209,7 +211,7 @@ class WebsiteForm(http.Controller): 'name': file.filename, 'datas': base64.encodestring(file.read()), 'datas_fname': file.filename, - 'res_model': model.model, + 'res_model': model_name, 'res_id': record.id, } attachment_id = request.env['ir.attachment'].sudo().create(attachment_value) @@ -221,10 +223,10 @@ class WebsiteForm(http.Controller): # If some attachments didn't match a field on the model, # we create a mail.message to link them to the record if orphan_attachment_ids: - if model.model != 'mail.mail': + if model_name != 'mail.mail': values = { 'body': _('<p>Attached files : </p>'), - 'model': model.model, + 'model': model_name, 'message_type': 'comment', 'no_auto_thread': False, 'res_id': id_record, diff --git a/addons/website_form/static/src/js/website_form.js b/addons/website_form/static/src/js/website_form.js index 47ba66c244b..5318a84067d 100644 --- a/addons/website_form/static/src/js/website_form.js +++ b/addons/website_form/static/src/js/website_form.js @@ -58,7 +58,7 @@ odoo.define('website_form.animation', function (require) { send: function (e) { e.preventDefault(); // Prevent the default submit behavior - this.$target.find('.o_website_form_send').off(); // Prevent users from crazy clicking + this.$target.find('.o_website_form_send').off().addClass('disabled'); // Prevent users from crazy clicking var self = this; @@ -231,7 +231,7 @@ odoo.define('website_form.animation', function (require) { update_status: function (status) { var self = this; if (status !== 'success') { // Restore send button behavior if result is an error - this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); + this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}).removeClass('disabled'); } var $result = this.$('#o_website_form_result'); this.templates_loaded.done(function () { diff --git a/addons/website_mail_channel/views/website_mail_channel_templates.xml b/addons/website_mail_channel/views/website_mail_channel_templates.xml index 5c6b79d6e22..541683b7e8a 100644 --- a/addons/website_mail_channel/views/website_mail_channel_templates.xml +++ b/addons/website_mail_channel/views/website_mail_channel_templates.xml @@ -300,7 +300,7 @@ </div> </li> </ul> - <p t-if="messages and msg_more_count > 0 and thread_header" class="well well-sm"> + <p t-if="messages and (msg_more_count or 0) > 0 and thread_header" class="well well-sm"> <button class="fa btn-link o_mg_read_more" t-attf-data-href="/groups/#{slug(group)}/#{slug(thread_header)}/get_replies" t-attf-data-msg-id="#{messages[-1].id}"> diff --git a/addons/website_quote/controllers/main.py b/addons/website_quote/controllers/main.py index f7ba4bc160b..77306fc34de 100644 --- a/addons/website_quote/controllers/main.py +++ b/addons/website_quote/controllers/main.py @@ -45,8 +45,8 @@ class sale_quote(http.Controller): else: Order = request.env['sale.order'].search([('id', '=', order_id)]) # Log only once a day - if Order and request.session.get('view_quote') != now and request.env.user.share: - request.session['view_quote'] = now + if Order and request.session.get('view_quote_%s' % Order.id) != now and request.env.user.share: + request.session['view_quote_%s' % Order.id] = now body = _('Quotation viewed by customer') _message_post_helper(res_model='sale.order', res_id=Order.id, message=body, token=token, message_type='notification', subtype="mail.mt_note", partner_ids=Order.user_id.sudo().partner_id.ids) if not Order: diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py index cce60910618..294cce55d26 100644 --- a/addons/website_quote/models/sale_order.py +++ b/addons/website_quote/models/sale_order.py @@ -52,7 +52,15 @@ class SaleOrder(models.Model): return template and template.active and template or False def _get_default_online_payment(self): - return 1 if self.env['ir.config_parameter'].sudo().get_param('sale.sale_portal_confirmation_options', default='none') == 'pay' else 0 + default_template = self._get_default_template() + if self.template_id: + return self.template_id.require_payment + elif default_template: + return default_template.require_payment + elif self.env['ir.config_parameter'].sudo().get_param('sale.sale_portal_confirmation_options', default='none') == 'pay': + return 1 + else: + return 0 template_id = fields.Many2one( 'sale.quote.template', 'Quotation Template', @@ -70,16 +78,9 @@ class SaleOrder(models.Model): require_payment = fields.Selection([ (0, 'Online Signature'), (1, 'Online Payment')], default=_get_default_online_payment, string='Confirmation Mode', - compute='_compute_require_payment', store=True, help="Choose how you want to confirm an order to launch the delivery process. You can either " "request a digital signature or an upfront payment. With a digital signature, you can " "request the payment when issuing the invoice.") - @api.one - @api.depends('template_id') - def _compute_require_payment(self): - # Do not overwrite if the field is already set - if self.template_id and (self.require_payment is None or self.require_payment is False): - self.require_payment = self.template_id.require_payment @api.multi @api.returns('self', lambda value: value.id) diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index 5de0f524fc7..9f43a6791d8 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -14,7 +14,7 @@ class Website(models.Model): _inherit = 'website' pricelist_id = fields.Many2one('product.pricelist', compute='_compute_pricelist_id', string='Default Pricelist') - currency_id = fields.Many2one('res.currency', related='pricelist_id.currency_id', string='Default Currency') + currency_id = fields.Many2one('res.currency', related='pricelist_id.currency_id', related_sudo=False, string='Default Currency') salesperson_id = fields.Many2one('res.users', string='Salesperson') salesteam_id = fields.Many2one('crm.team', string='Sales Channel') pricelist_ids = fields.One2many('product.pricelist', compute="_compute_pricelist_ids", diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 4325d669fda..af6c2617d60 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -173,7 +173,7 @@ <t t-set="additional_title">Shop</t> <div id="wrap" class="js_sale"> <div class="oe_structure" /> - <div class="container oe_website_sale"> + <div t-attf-class="container oe_website_sale #{'oe_website_sale_search' if search else None}"> <div class="products_pager"> <div class="row" style="width: 100%;"> <t t-call="website_sale.search"> diff --git a/addons/website_sale_delivery/models/res_country.py b/addons/website_sale_delivery/models/res_country.py index 8b4648a7105..601ac731787 100644 --- a/addons/website_sale_delivery/models/res_country.py +++ b/addons/website_sale_delivery/models/res_country.py @@ -35,5 +35,7 @@ class ResCountry(models.Model): states = res break states |= carrier.state_ids + if not states: + states = states.search([('country_id', '=', self.id)]) res = res & states return res diff --git a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml index 63f877a0a52..3a39f64c27f 100644 --- a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml +++ b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml @@ -35,12 +35,12 @@ <label class="label-optional" t-field="delivery.name" t-att-for="'delivery_%i' % delivery.id"/> <t t-if="delivery.delivery_type == 'fixed'"> <span t-if="delivery.fixed_price > 0.0" class="badge pull-right" - t-field="delivery.fixed_price" t-options='{"widget": "monetary", "display_currency": website_sale_order.currency_id}'/> + t-field="delivery.fixed_price" t-options='{"widget": "monetary", + "from_currency": delivery.product_id.company_id.currency_id, "display_currency": website_sale_order.currency_id}'/> <span t-else="" class="badge pull-right">Free</span> </t> <t t-else=""> - <span class="badge pull-right hidden" t-field="delivery.fixed_price" t-options='{"widget": "monetary", - "display_currency": website_sale_order.currency_id}'/> + <span class="badge pull-right hidden" t-field="delivery.fixed_price" t-options='{"widget": "monetary", "from_currency": delivery.product_id.company_id.currency_id, "display_currency": website_sale_order.currency_id}'/> <span class="badge pull-right o_delivery_compute">Select to compute delivery rate</span> </t> <t t-if="delivery.website_description"> diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index d2e24ba52a8..d390570d448 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -348,7 +348,7 @@ class Slide(models.Model): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for record in self: if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']): - record.embed_code = '<iframe src="%s/slides/embed/%s?page=1" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % (base_url, record.id, 315, 420) + record.embed_code = '<iframe src="%s/slides/embed/%s?page=1" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % (base_url, record.id, 315, 420) elif record.slide_type == 'video' and record.document_id: if not record.mime_type: # embed youtube video diff --git a/addons/website_slides/static/src/js/slides.js b/addons/website_slides/static/src/js/slides.js index 7d945e9eb28..b331ebf366c 100644 --- a/addons/website_slides/static/src/js/slides.js +++ b/addons/website_slides/static/src/js/slides.js @@ -109,11 +109,12 @@ var page_widgets = {}; }, }); - $('iframe').ready(function() { + $('iframe.o_wslides_iframe_viewer').ready(function() { // TODO : make it work. For now, once the iframe is loaded, the value of #page_count is // still now set (the pdf is still loading) - var max_page = $('iframe').contents().find('#page_count').val(); - new SlideSocialEmbed($(this), max_page).setElement($('.oe_slide_js_embed_code_widget')); + var $iframe = $(this); + var max_page = $iframe.contents().find('#page_count').val(); + new SlideSocialEmbed($iframe, max_page).setElement($('.oe_slide_js_embed_code_widget')); }); diff --git a/addons/website_slides/static/src/js/slides_embed.js b/addons/website_slides/static/src/js/slides_embed.js index 4e7d3e71652..d3d1321e6c8 100644 --- a/addons/website_slides/static/src/js/slides_embed.js +++ b/addons/website_slides/static/src/js/slides_embed.js @@ -50,12 +50,12 @@ $(document).ready(function () { }, // page switching render_page: function (page_number) { - this.pdf_viewer.renderPage(page_number).then(this.on_rendered_page); + this.pdf_viewer.renderPage(page_number).then(this.on_rendered_page.bind(this)); }, change_page: function () { var page_asked = parseInt(this.$('#page_number').val(), 10); if(1 <= page_asked && page_asked <= this.pdf_viewer.pdf_page_total){ - this.pdf_viewer.changePage(page_asked).then(this.on_rendered_page); + this.pdf_viewer.changePage(page_asked).then(this.on_rendered_page.bind(this)); }else{ // if page number out of range, reset the page_counter to the actual page this.$('#page_number').val(this.pdf_viewer.pdf_page_current); |
