diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 487477dee6a..d509f4f88a9 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -446,6 +446,7 @@ class AccountBankStatementLine(models.Model): """ Prepare the dict of values to balance the move. :param recordset move: the account.move to link the move line + :param dict move: a dict of vals of a account.move which will be created later :param float amount: the amount of transaction that wasn't already reconciled """ company_currency = self.journal_id.company_id.currency_id @@ -453,19 +454,23 @@ class AccountBankStatementLine(models.Model): st_line_currency = self.currency_id or statement_currency amount_currency = False st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + if isinstance(move, dict): + amount_sum = sum(x[2].get('amount_currency', 0) for x in move['line_ids']) + else: + amount_sum = sum(x.amount_currency for x in move.line_ids) # We have several use case here to compare the currency and amount currency of counterpart line to balance the move: if st_line_currency != company_currency and st_line_currency == statement_currency: # company in currency A, statement in currency B and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and statement_currency == company_currency: # company in currency A, statement in currency A and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and st_line_currency != statement_currency: # company in currency A, statement in currency B and transaction in currency C # counterpart line must have currency B and use rate between B and C to compute correct amount - amount_currency = -sum([x.amount_currency for x in move.line_ids])/st_line_currency_rate + amount_currency = -amount_sum/st_line_currency_rate elif st_line_currency == company_currency and statement_currency != company_currency: # company in currency A, statement in currency B and transaction in currency A # counterpart line must have currency B and amount is computed using the rate between A and B @@ -474,9 +479,8 @@ class AccountBankStatementLine(models.Model): # last case is company in currency A, statement in currency A and transaction in currency A # and in this case counterpart line does not need any second currency nor amount_currency - return { + aml_dict = { 'name': self.name, - 'move_id': move.id, 'partner_id': self.partner_id and self.partner_id.id or False, 'account_id': amount >= 0 \ and self.statement_id.journal_id.default_credit_account_id.id \ @@ -487,22 +491,72 @@ class AccountBankStatementLine(models.Model): 'currency_id': statement_currency != company_currency and statement_currency.id or (st_line_currency != company_currency and st_line_currency.id or False), 'amount_currency': amount_currency, } + if isinstance(move, self.env['account.move'].__class__): + aml_dict['move_id'] = move.id + return aml_dict + @api.multi def fast_counterpart_creation(self): """This function is called when confirming a bank statement and will allow to automatically process lines without going in the bank reconciliation widget. By setting an account_id on bank statement lines, it will create a journal entry using that account to counterpart the bank account """ + payment_list = [] + move_list = [] + account_type_receivable = self.env.ref('account.data_account_type_receivable') + already_done_stmt_line_ids = [a['statement_line_id'][0] for a in self.env['account.move.line'].read_group([('statement_line_id', 'in', self.ids)], ['statement_line_id'], ['statement_line_id'])] + managed_st_line = [] for st_line in self: # Technical functionality to automatically reconcile by creating a new move line - if st_line.account_id and not st_line.journal_entry_ids.ids: - vals = { + if st_line.account_id and not st_line.id in already_done_stmt_line_ids: + managed_st_line.append(st_line.id) + # Create payment vals + total = st_line.amount + payment_methods = (total > 0) and st_line.journal_id.inbound_payment_method_ids or st_line.journal_id.outbound_payment_method_ids + currency = st_line.journal_id.currency_id or st_line.company_id.currency_id + partner_type = 'customer' if st_line.account_id.user_type_id == account_type_receivable else 'supplier' + payment_list.append({ + 'payment_method_id': payment_methods and payment_methods[0].id or False, + 'payment_type': total > 0 and 'inbound' or 'outbound', + 'partner_id': st_line.partner_id.id, + 'partner_type': partner_type, + 'journal_id': st_line.statement_id.journal_id.id, + 'payment_date': st_line.date, + 'state': 'reconciled', + 'currency_id': currency.id, + 'amount': abs(total), + 'communication': st_line._get_communication(payment_methods[0] if payment_methods else False), + 'name': st_line.statement_id.name or _("Bank Statement %s") % st_line.date, + }) + + # Create move and move line vals + move_vals = st_line._prepare_reconciliation_move(st_line.statement_id.name) + aml_dict = { 'name': st_line.name, 'debit': st_line.amount < 0 and -st_line.amount or 0.0, 'credit': st_line.amount > 0 and st_line.amount or 0.0, 'account_id': st_line.account_id.id, + 'partner_id': st_line.partner_id.id, + 'statement_line_id': st_line.id, } - st_line.process_reconciliation(new_aml_dicts=[vals]) + st_line._prepare_move_line_for_currency(aml_dict, st_line.date or fields.Date.context_today()) + move_vals['line_ids'] = [(0, 0, aml_dict)] + balance_line = self._prepare_reconciliation_move_line( + move_vals, -aml_dict['debit'] if st_line.amount < 0 else aml_dict['credit']) + move_vals['line_ids'].append((0, 0, balance_line)) + move_list.append(move_vals) + + # Creates + payment_ids = self.env['account.payment'].create(payment_list) + for payment_id, move_vals in zip(payment_ids, move_list): + for line in move_vals['line_ids']: + line[2]['payment_id'] = payment_id.id + move_ids = self.env['account.move'].create(move_list) + move_ids.post() + + for move, st_line, payment in zip(move_ids, self.browse(managed_st_line), payment_ids): + st_line.write({'move_name': move.name}) + payment.write({'payment_reference': move.name}) def _get_communication(self, payment_method_id): return self.name or '' @@ -630,8 +684,6 @@ class AccountBankStatementLine(models.Model): # Create move line(s). Either matching an existing journal entry (eg. invoice), in which # case we reconcile the existing and the new move lines together, or being a write-off. if counterpart_aml_dicts or new_aml_dicts: - st_line_currency = self.currency_id or statement_currency - st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False # Create the move self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 @@ -654,32 +706,12 @@ class AccountBankStatementLine(models.Model): # Complete dicts to create both counterpart move lines and write-offs to_create = (counterpart_aml_dicts + new_aml_dicts) - company = self.company_id date = self.date or fields.Date.today() for aml_dict in to_create: aml_dict['move_id'] = move.id aml_dict['partner_id'] = self.partner_id.id aml_dict['statement_line_id'] = self.id - if st_line_currency.id != company_currency.id: - aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] - aml_dict['currency_id'] = st_line_currency.id - if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: - # Statement is in company currency but the transaction is in foreign currency - aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) - aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) - elif self.currency_id and st_line_currency_rate: - # Statement is in foreign currency and the transaction is in another one - aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) - aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) - else: - # Statement is in foreign currency and no extra currency is given for the transaction - aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) - aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) - elif statement_currency.id != company_currency.id: - # Statement is in foreign currency but the transaction is in company currency - prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency - aml_dict['amount_currency'] = prorata_factor * self.amount - aml_dict['currency_id'] = statement_currency.id + self._prepare_move_line_for_currency(aml_dict, date) # Create write-offs for aml_dict in new_aml_dicts: @@ -729,6 +761,36 @@ class AccountBankStatementLine(models.Model): counterpart_moves.assert_balanced() return counterpart_moves + @api.multi + def _prepare_move_line_for_currency(self, aml_dict, date): + self.ensure_one() + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + company = self.company_id + + if st_line_currency.id != company_currency.id: + aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] + aml_dict['currency_id'] = st_line_currency.id + if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: + # Statement is in company currency but the transaction is in foreign currency + aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) + aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) + elif self.currency_id and st_line_currency_rate: + # Statement is in foreign currency and the transaction is in another one + aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) + aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) + else: + # Statement is in foreign currency and no extra currency is given for the transaction + aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) + aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) + elif statement_currency.id != company_currency.id: + # Statement is in foreign currency but the transaction is in company currency + prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency + aml_dict['amount_currency'] = prorata_factor * self.amount + aml_dict['currency_id'] = statement_currency.id + def _check_invoice_state(self, invoice): if invoice.state == 'in_payment' and all([payment.state == 'reconciled' for payment in invoice.mapped('payment_move_line_ids.payment_id')]): invoice.write({'state': 'paid'}) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 2e325c760fe..e711f4a0dfd 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -93,11 +93,12 @@ class AccountInvoice(models.Model): ('type', 'in', [TYPE2JOURNAL[ty] for ty in inv_types if ty in TYPE2JOURNAL]), ('company_id', '=', company_id), ] - journal_with_currency = False - if self._context.get('default_currency_id'): - currency_clause = [('currency_id', '=', self._context.get('default_currency_id'))] - journal_with_currency = self.env['account.journal'].search(domain + currency_clause, limit=1) - return journal_with_currency or self.env['account.journal'].search(domain, limit=1) + company_currency_id = self.env['res.company'].browse(company_id).currency_id.id + currency_id = self._context.get('default_currency_id') or company_currency_id + currency_clause = [('currency_id', '=', currency_id)] + if currency_id == company_currency_id: + currency_clause = ['|', ('currency_id', '=', False)] + currency_clause + return self.env['account.journal'].search(domain + currency_clause, limit=1) @api.model def _default_currency(self): diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index e5f4920cc3c..4e6b6adbb90 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -87,12 +87,14 @@ class AccountMove(models.Model): if line.account_id.user_type_id.type in ('receivable', 'payable'): amount = abs(line.debit - line.credit) total_amount += amount - for partial_line in (line.matched_debit_ids + line.matched_credit_ids): - total_reconciled += partial_line.amount precision_currency = move.currency_id or move.company_id.currency_id if float_is_zero(total_amount, precision_rounding=precision_currency.rounding): move.matched_percentage = 1.0 else: + for line in move.line_ids: + if line.account_id.user_type_id.type in ('receivable', 'payable'): + for partial_line in (line.matched_debit_ids + line.matched_credit_ids): + total_reconciled += partial_line.amount move.matched_percentage = total_reconciled / total_amount @api.multi @@ -432,6 +434,7 @@ class AccountMove(models.Model): @api.multi def _reverse_move(self, date=None, journal_id=None): self.ensure_one() + date = date or fields.Date.today() with self.env.norecompute(): reversed_move = self.copy(default={ 'date': date, @@ -692,8 +695,8 @@ class AccountMoveLine(models.Model): tax_audit = fields.Char(string="Tax Audit String", compute="_compute_tax_audit", store=True, help="Computed field, listing the tax grids impacted by this line, and the amount it applies to each of them.") _sql_constraints = [ - ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry !'), - ('credit_debit2', 'CHECK (credit+debit>=0)', 'Wrong credit or debit value in accounting entry !'), + ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry! Credit or debit should be zero.'), + ('credit_debit2', 'CHECK (credit+debit>=0)', 'Wrong credit or debit value in accounting entry! Credit and debit should be positive.'), ] @api.model @@ -827,22 +830,21 @@ class AccountMoveLine(models.Model): In case of full reconciliation, all moves belonging to the reconciliation will belong to the same account_full_reconcile object. """ # Get first all aml involved - part_recs = self.env['account.partial.reconcile'].search(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)]) - amls = self - todo = set(part_recs) + todo = self.env['account.partial.reconcile'].search_read(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)], ['debit_move_id', 'credit_move_id']) + amls = set(self.ids) seen = set() while todo: - partial_rec = todo.pop() - seen.add(partial_rec) - for aml in [partial_rec.debit_move_id, partial_rec.credit_move_id]: - if aml not in amls: - amls += aml - for x in aml.matched_debit_ids | aml.matched_credit_ids: - if x not in seen: - todo.add(x) - partial_rec_ids = [x.id for x in seen] + aml_ids = [rec['debit_move_id'][0] for rec in todo if rec['debit_move_id']] + [rec['credit_move_id'][0] for rec in todo if rec['credit_move_id']] + amls |= set(aml_ids) + seen |= set([rec['id'] for rec in todo]) + todo = self.env['account.partial.reconcile'].search_read(['&', '|', ('credit_move_id', 'in', aml_ids), ('debit_move_id', 'in', aml_ids), '!', ('id', 'in', list(seen))], ['debit_move_id', 'credit_move_id']) + + partial_rec_ids = list(seen) if not amls: return + else: + amls = self.browse(list(amls)) + # If we have multiple currency, we can only base ourselve on debit-credit to see if it is fully reconciled currency = set([a.currency_id for a in amls if a.currency_id.id != False]) multiple_currency = False diff --git a/addons/account/report/account_invoice_report.py b/addons/account/report/account_invoice_report.py index abc17743e61..614aba000ab 100644 --- a/addons/account/report/account_invoice_report.py +++ b/addons/account/report/account_invoice_report.py @@ -96,7 +96,7 @@ class AccountInvoiceReport(models.Model): sub.payment_term_id, sub.uom_name, sub.currency_id, sub.journal_id, sub.fiscal_position_id, sub.user_id, sub.company_id, sub.nbr, sub.invoice_id, sub.type, sub.state, sub.categ_id, sub.date_due, sub.account_id, sub.account_line_id, sub.partner_bank_id, - sub.product_qty, sub.price_total as price_total, sub.price_average as price_average, sub.amount_total as amount_total, + sub.product_qty, sub.price_total as price_total, sub.price_average as price_average, sub.amount_total / COALESCE(cr.rate, 1) as amount_total, COALESCE(cr.rate, 1) as currency_rate, sub.residual as residual, sub.commercial_partner_id as commercial_partner_id """ return select_str @@ -114,8 +114,7 @@ class AccountInvoiceReport(models.Model): ai.partner_bank_id, SUM ((invoice_type.sign_qty * ail.quantity) / COALESCE(u.factor,1) * COALESCE(u2.factor,1)) AS product_qty, SUM(ail.price_subtotal_signed * invoice_type.sign) AS price_total, - ai.amount_total_signed / (SELECT count(*) FROM account_invoice_line l where invoice_id = ai.id) * - count(*) * invoice_type.sign AS amount_total, + SUM(ail.price_total * invoice_type.sign_qty) AS amount_total, SUM(ABS(ail.price_subtotal_signed)) / CASE WHEN SUM(ail.quantity / COALESCE(u.factor,1) * COALESCE(u2.factor,1)) <> 0::numeric THEN SUM(ail.quantity / COALESCE(u.factor,1) * COALESCE(u2.factor,1)) diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index d4dce5581ca..2846e2d2e2f 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -281,7 +281,6 @@ var StatementModel = BasicModel.extend({ if (last && !this._isValid(last)) { return Promise.resolve(false); } - prop = this._formatQuickCreate(line); line.reconciliation_proposition.push(prop); line.createForm = _.pick(prop, this.quickCreateFields); @@ -493,13 +492,6 @@ var StatementModel = BasicModel.extend({ this._blurProposition(handle); var focus = this._formatQuickCreate(line, _.pick(reconcileModel, fields)); focus.reconcileModelId = reconcileModelId; - if (!line.reconciliation_proposition.every(function(prop) {return prop.to_check == focus.to_check;})) { - new CrashManager().show_warning({data: { - exception_type: _t("Incorrect Operation"), - message: _t("You cannot mix items with and without the 'To Check' checkbox ticked.") - }}); - return Promise.resolve(); - } line.reconciliation_proposition.push(focus); if (reconcileModel.has_second_line) { var second = {}; @@ -619,19 +611,16 @@ var StatementModel = BasicModel.extend({ var self = this; var line = this.getLine(handle); var prop = _.last(_.filter(line.reconciliation_proposition, '__focus')); + if ('to_check' in values && values.to_check === false) { + // check if we have another line with to_check and if yes don't change value of this proposition + prop.to_check = line.reconciliation_proposition.some(function(rec_prop, index) { + return rec_prop.id !== prop.id && rec_prop.to_check; + }); + } if (!prop) { prop = this._formatQuickCreate(line); line.reconciliation_proposition.push(prop); } - if ('to_check' in values && !line.reconciliation_proposition.slice(0,-1).every(function(prop) {return prop.to_check == values.to_check;})) { - new CrashManager().show_warning({data: { - exception_type: _t("Incorrect Operation"), - message: _t("You cannot mix items with and without the 'To Check' checkbox ticked.") - }}); - // FIXME: model should not be tied to the DOM ! - $('.create_to_check input:visible').prop('checked', !values.to_check).change(); - return Promise.resolve(); - } _.each(values, function (value, fieldName) { if (fieldName === 'analytic_tag_ids') { switch (value.operation) { @@ -683,7 +672,6 @@ var StatementModel = BasicModel.extend({ prop.__tax_to_recompute = true; } line.createForm = _.pick(prop, this.quickCreateFields); - // If you check/uncheck the force_tax_included box, reset the createForm amount. if(prop.base_amount) line.createForm.amount = prop.base_amount; @@ -744,16 +732,8 @@ var StatementModel = BasicModel.extend({ "new_aml_dicts": _.map(_.filter(props, function (prop) { return isNaN(prop.id) && prop.display; }), self._formatToProcessReconciliation.bind(self, line)), + "to_check": line.to_check, }; - line.reconciliation_proposition.some(function(prop) { - if (prop.to_check) { - values_dict['to_check'] = true; - return true; - } - }); - if (line.reconciliation_proposition[0].to_check) { - values_dict['to_check'] = true; - } // If the lines are not fully balanced, create an unreconciled amount. // line.st_line.currency_id is never false here because its equivalent to @@ -870,7 +850,12 @@ var StatementModel = BasicModel.extend({ var formatOptions = { currency_id: line.st_line.currency_id, }; + line.to_check = false; _.each(line.reconciliation_proposition, function (prop) { + if (prop.to_check) { + // If one of the proposition is to_check, set the global to_check flag to true + line.to_check = true; + } if (prop.is_tax) { if (!_.find(line.reconciliation_proposition, {'id': prop.link}).__tax_to_recompute) { reconciliation_proposition.push(prop); @@ -1025,6 +1010,7 @@ var StatementModel = BasicModel.extend({ prop.label = prop.name; prop.account_id = self._formatNameGet(prop.account_id || line.account_id); prop.is_partially_reconciled = prop.amount_str !== prop.total_amount_str; + prop.to_check = !!prop.to_check; }); } }, @@ -1160,7 +1146,7 @@ var StatementModel = BasicModel.extend({ 'link': values.link, 'display': true, 'invalid': true, - 'to_check': values.to_check, + 'to_check': !!values.to_check, '__tax_to_recompute': true, 'is_tax': values.is_tax, '__focus': '__focus' in values ? values.__focus : true, @@ -1406,11 +1392,8 @@ var ManualModel = StatementModel.extend({ return self.loadData(lines); }); default: - var partner_ids = context.partner_ids; - var account_ids = context.account_ids || self.account_ids; - if (partner_ids && !account_ids) account_ids = []; - if (!partner_ids && account_ids) partner_ids = []; - account_ids = null; // TOFIX: REMOVE ME + var partner_ids = context.partner_ids || null; + var account_ids = context.account_ids || self.account_ids || null; return self._rpc({ model: 'account.reconciliation.widget', method: 'get_all_data_for_manual_reconciliation', @@ -1627,6 +1610,7 @@ var ManualModel = StatementModel.extend({ prop.debit = prop.debit !== 0 ? 0 : tmp_value; prop.amount = -prop.amount; prop.journal_id = self._formatNameGet(prop.journal_id || line.journal_id); + prop.to_check = !!prop.to_check; }); } }, diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 53cd0b76598..01e18865480 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -346,7 +346,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { update: function (state) { var self = this; // isValid - var to_check_checked = !!(state.reconciliation_proposition[0] && state.reconciliation_proposition[0].to_check); + var to_check_checked = !!(state.to_check); this.$('caption .o_buttons button.o_validate').toggleClass('d-none', !!state.balance.type && !to_check_checked); this.$('caption .o_buttons button.o_reconcile').toggleClass('d-none', state.balance.type <= 0 || to_check_checked); this.$('caption .o_buttons .o_no_valid').toggleClass('d-none', state.balance.type >= 0); @@ -476,9 +476,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { } } }); + if (state.to_check) { + // Set the to_check field to true if global to_check is set + this.$('.create_to_check input').prop('checked', state.to_check).change(); + } return true; }); - }); + }); } this.$('.create .add_line').toggle(!!state.balance.amount_currency); }, diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 0248d0cec7e..84f7d40e9a6 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -477,7 +477,7 @@ var auto_reconciliation = { }; var data_for_manual_reconciliation_widget = { - '[[],null]': { + '[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]': { 'customers': [ {'account_id': 287, 'partner_name': "Agrolait", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-14 12:30:31", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 8, 'account_name': "101200 Account Receivable", 'mode': "customers"}, {'account_id': 7, 'partner_name': "Camptocamp", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-13 14:24:55", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 12, 'account_name': "101200 Account Receivable", 'mode': "customers"} @@ -744,7 +744,7 @@ QUnit.module('account', { "name": "INV/2017/0003", "analytic_tag_ids": [[6, null, []]] }], - payment_aml_ids: [], new_aml_dicts: []}]], + payment_aml_ids: [], new_aml_dicts: [], to_check: false}]], "Should call process_bank_statement_line with ids"); var def = testUtils.makeTestPromise(); def.abort = function () {}; @@ -782,8 +782,10 @@ QUnit.module('account', { // line is send back to server. testUtils.mock.intercept(clientAction, 'call_service', function (event) { assert.deepEqual(event.data.args[1].args, - [[5],[{partner_id: 8, counterpart_aml_dicts: [], - payment_aml_ids: [], new_aml_dicts: [{ + [[5],[{partner_id: 8, to_check: false, counterpart_aml_dicts: [], + payment_aml_ids: [], + to_check: false, + new_aml_dicts: [{ account_id: 287, credit: 1175, debit: 0, @@ -834,7 +836,7 @@ QUnit.module('account', { // We also create a line which is the open balance. testUtils.mock.intercept(clientAction, 'call_service', function (event) { assert.deepEqual(event.data.args[1].args, - [[5],[{partner_id: 8, + [[5],[{partner_id: 8, to_check: false, counterpart_aml_dicts: [{ counterpart_aml_id: 109, credit: 650, @@ -884,6 +886,7 @@ QUnit.module('account', { }], payment_aml_ids: [], new_aml_dicts: [], + to_check: false, }] ], "should call process_bank_statement_line with partial reconcile values"); } @@ -1408,6 +1411,7 @@ QUnit.module('account', { payment_aml_ids: [], partner_id: 8, + to_check: false, new_aml_dicts: []}] ], "should call process_bank_statement_line with partial reconcile values"); } @@ -1500,6 +1504,7 @@ QUnit.module('account', { payment_aml_ids: [], partner_id: 8, + to_check: false, new_aml_dicts: [ {account_id: 282, credit: 0, @@ -1609,6 +1614,7 @@ QUnit.module('account', { payment_aml_ids: [], partner_id: 8, + to_check: false, new_aml_dicts: [] }] ], "should call process_bank_statement_line with correct counterpart_aml_dicts"); @@ -1662,7 +1668,7 @@ QUnit.module('account', { assert.expect(5); // tweak the data to fit our needs - this.params.data_for_manual_reconciliation_widget['[283, null, "", 0, 6]'] = _.extend({}, this.params.data_for_manual_reconciliation_widget['[[],null]']); + this.params.data_for_manual_reconciliation_widget['[283, null, "", 0, 6]'] = _.extend({}, this.params.data_for_manual_reconciliation_widget['[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]']); this.params.data_for_manual_reconciliation_widget['[283, null, "", 0, 6]'].accounts[0].reconciliation_proposition = [ {account_id: 283, account_type: "other", amount_currency_str: "", currency_id: false, date_maturity: "2017-03-18", date: "2017-02-16", total_amount_str: "$ 500.00", partner_id: 8, account_name: "101000 Current Assets", name: "INV/2017/0987", partner_name: "Agrolait", @@ -1772,7 +1778,7 @@ QUnit.module('account', { QUnit.test('Tax on account receivable', async function(assert){ assert.expect(21); - this.params.data_for_manual_reconciliation_widget['[[],null]'].accounts = []; + this.params.data_for_manual_reconciliation_widget['[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]'].accounts = []; var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options); testUtils.mock.addMockEnvironment(clientAction, { data: this.params.data, diff --git a/addons/account/tests/test_bank_statement_reconciliation.py b/addons/account/tests/test_bank_statement_reconciliation.py index 0311f278a4b..7957be0b77d 100644 --- a/addons/account/tests/test_bank_statement_reconciliation.py +++ b/addons/account/tests/test_bank_statement_reconciliation.py @@ -13,6 +13,8 @@ class TestBankStatementReconciliation(AccountingTestCase): self.bsl_model = self.env['account.bank.statement.line'] self.reconciliation_widget = self.env['account.reconciliation.widget'] self.partner = self.env['res.partner'].create({'name': 'test'}) + self.currency_usd_id = self.env.ref("base.USD").id + self.currency_euro_id = self.env.ref("base.EUR").id def test_reconciliation_proposition(self): rcv_mv_line = self.create_invoice(100) @@ -108,3 +110,31 @@ class TestBankStatementReconciliation(AccountingTestCase): }) return bank_stmt_line + + def test_confirm_statement_usd(self): + 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.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'currency_id': self.currency_usd_id, + 'rate': 2.0, + 'name': '2001-01-01', + }) + bank_journal_usd = self.env['account.journal'].create({ + 'name': 'Bank US', + 'type': 'bank', + 'code': 'BNK68', + 'currency_id': self.currency_usd_id, + }) + statement = self.bs_model.create({ + 'journal_id': bank_journal_usd.id, + 'balance_end_real': 100, + 'line_ids': [(0, 0, { + 'name': '_', + 'partner_id': self.partner.id, + 'amount': 100, + 'account_id': bank_journal_usd.default_debit_account_id.id, + })], + }) + statement.button_open() + statement.button_confirm_bank() diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index 54fbd7ff4be..e65b5f0ee89 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -11,6 +11,7 @@ class AccountInvoiceSend(models.TransientModel): _description = 'Account Invoice Send' is_email = fields.Boolean('Email', default=lambda self: self.env.user.company_id.invoice_is_email) + invoice_without_email = fields.Text(compute='_compute_invoice_without_email', string='invoice(s) that will not be sent') is_print = fields.Boolean('Print', default=lambda self: self.env.user.company_id.invoice_is_print) printed = fields.Boolean('Is Printed', default=False) invoice_ids = fields.Many2many('account.invoice', 'account_invoice_account_invoice_send_rel', string='Invoices') @@ -45,6 +46,22 @@ class AccountInvoiceSend(models.TransientModel): self.composer_id.template_id = self.template_id.id self.composer_id.onchange_template_id_wrapper() + @api.onchange('is_email') + def _compute_invoice_without_email(self): + for wizard in self: + if wizard.is_email and len(wizard.invoice_ids) > 1: + invoices = self.env['account.invoice'].search([ + ('id', 'in', self.env.context.get('active_ids')), + ('partner_id.email', '=', False) + ]) + if invoices: + wizard.invoice_without_email = "%s\n%s" % ( + _("The following invoice(s) will not be sent by email, because the customers don't have email address."), + "\n".join([i.reference for i in invoices]) + ) + else: + wizard.invoice_without_email = False + @api.multi def _send_email(self): if self.is_email: diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml index 6052c1438d9..648dcdec65d 100644 --- a/addons/account/wizard/account_invoice_send_views.xml +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -23,6 +23,9 @@ +
+ +
diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index e106534bdac..9b5f54d6b1d 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -162,7 +162,7 @@
- +
@@ -182,10 +182,10 @@ attrs="{'required': [('month_by', '=', 'date'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'date')]}"/>
diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 8a49ae9d74d..06a7d8ba397 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -253,7 +253,7 @@ class Lead(models.Model): return {} if user_id and self._context.get('team_id'): team = self.env['crm.team'].browse(self._context['team_id']) - if user_id in team.member_ids.ids: + if user_id in team.member_ids.ids or user_id == team.user_id.id: return {} team_id = self.env['crm.team']._get_default_team_id(user_id=user_id) return {'team_id': team_id} diff --git a/addons/gamification/data/gamification_karma_rank_demo.xml b/addons/gamification/data/gamification_karma_rank_demo.xml index c2d81ebbcc7..6250ee227f6 100644 --- a/addons/gamification/data/gamification_karma_rank_demo.xml +++ b/addons/gamification/data/gamification_karma_rank_demo.xml @@ -39,7 +39,7 @@
-
Reach the next rank and gain a week-end with a unicorn !
+
You have reached the last rank. Congratulations!
diff --git a/addons/google_calendar/models/google_calendar.py b/addons/google_calendar/models/google_calendar.py index 9155cf9a0fa..bf9010f3023 100644 --- a/addons/google_calendar/models/google_calendar.py +++ b/addons/google_calendar/models/google_calendar.py @@ -81,7 +81,7 @@ class SyncEvent(object): self.OP = Delete((self.OE.status and "OE") or (self.GG.status and "GG"), 'The event has been deleted from one side, we delete on other side !') #If event is not deleted ! - elif self.OE.status and (self.GG.status or not is_owner): + elif self.OE.status and (self.GG.status or (not is_owner and self.GG.update)): if abs(self.OE.update - self.GG.update) > timedelta(seconds=1): if self.OE.update < self.GG.update: tmpSrc = 'GG' diff --git a/addons/google_spreadsheet/models/google_drive.py b/addons/google_spreadsheet/models/google_drive.py index 32402ffe626..f3c04608e72 100644 --- a/addons/google_spreadsheet/models/google_drive.py +++ b/addons/google_spreadsheet/models/google_drive.py @@ -35,7 +35,7 @@ class GoogleDrive(models.Model): if not modifiers.get('invisible') and not modifiers.get('column_invisible'): display_fields.append(node.get('name')) fields = " ".join(display_fields) - domain = domain.replace("'", r"\'").replace('"', "'") + domain = domain.replace("'", r"\'").replace('"', "'").replace('True', 'true').replace('False', 'false') if groupbys: fields = "%s %s" % (groupbys, fields) formula = '=oe_read_group("%s";"%s";"%s";"%s")' % (model, fields, groupbys, domain) diff --git a/addons/hr/models/res_users.py b/addons/hr/models/res_users.py index a74088498b7..499586befcf 100644 --- a/addons/hr/models/res_users.py +++ b/addons/hr/models/res_users.py @@ -21,7 +21,7 @@ class User(models.Model): department_id = fields.Many2one(related='employee_id.department_id', readonly=False, related_sudo=False) address_id = fields.Many2one(related='employee_id.address_id', readonly=False, related_sudo=False) work_location = fields.Char(related='employee_id.work_location', readonly=False, related_sudo=False) - parent_id = fields.Many2one(related='employee_id.parent_id', related_sudo=False) + employee_parent_id = fields.Many2one(related='employee_id.parent_id', related_sudo=False) coach_id = fields.Many2one(related='employee_id.coach_id', readonly=False, related_sudo=False) address_home_id = fields.Many2one(related='employee_id.address_home_id', readonly=False, related_sudo=False) is_address_home_a_company = fields.Boolean(related='employee_id.is_address_home_a_company', readonly=False, related_sudo=False) @@ -59,7 +59,7 @@ class User(models.Model): 'child_ids', 'employee_id', 'employee_ids', - 'parent_id', + 'employee_parent_id', ] hr_writable_fields = [ @@ -87,7 +87,7 @@ class User(models.Model): 'marital', 'mobile_phone', 'notes', - 'parent_id', + 'employee_parent_id', 'passport_id', 'permit_no', 'employee_phone', diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index c60b8613788..8e8d5327749 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -171,6 +171,7 @@ + diff --git a/addons/hr/views/res_users.xml b/addons/hr/views/res_users.xml index de407838721..9ebb3d5c1ce 100644 --- a/addons/hr/views/res_users.xml +++ b/addons/hr/views/res_users.xml @@ -62,7 +62,7 @@ - +
diff --git a/addons/hr_attendance/views/hr_employee_view.xml b/addons/hr_attendance/views/hr_employee_view.xml index f9403ff847a..6e6b6d7df56 100644 --- a/addons/hr_attendance/views/hr_employee_view.xml +++ b/addons/hr_attendance/views/hr_employee_view.xml @@ -162,6 +162,7 @@ hr.employee kanban,tree,activity + fullscreen

Create a new employee diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 4e5207a385a..37e250779ec 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -800,12 +800,12 @@ class HrExpenseSheet(models.Model): return self.employee_id.parent_id.user_id elif self.employee_id.department_id.manager_id.user_id: return self.employee_id.department_id.manager_id.user_id - return self.env.user + return self.env['res.users'] def activity_update(self): for expense_report in self.filtered(lambda hol: hol.state == 'submit'): self.activity_schedule( 'hr_expense.mail_act_expense_approval', - user_id=expense_report.sudo()._get_responsible_for_approval().id) + user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id) self.filtered(lambda hol: hol.state == 'approve').activity_feedback(['hr_expense.mail_act_expense_approval']) self.filtered(lambda hol: hol.state == 'cancel').activity_unlink(['hr_expense.mail_act_expense_approval']) diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 58f9bc70284..5d3353c889b 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -266,6 +266,11 @@ class HolidaysRequest(models.Model): self.request_unit_hours = False self.request_unit_custom = False + @api.onchange('user_id') + def _onchange_user_id(self): + if not self.out_of_office_message: + self.out_of_office_message = self.user_id.out_of_office_message + @api.onchange('request_date_from_period', 'request_hour_from', 'request_hour_to', 'request_date_from', 'request_date_to', 'employee_id') @@ -340,7 +345,7 @@ class HolidaysRequest(models.Model): def _onchange_type(self): if self.holiday_type == 'employee': if not self.employee_id: - self.employee_id = self.env.user.employee_ids[:1].id + self.employee_id = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1).id self.mode_company_id = False self.category_id = False elif self.holiday_type == 'company': @@ -353,7 +358,7 @@ class HolidaysRequest(models.Model): self.mode_company_id = False self.category_id = False if not self.department_id: - self.department_id = self.env.user.employee_ids[:1].department_id.id + self.department_id = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1).department_id.id elif self.holiday_type == 'category': self.employee_id = False self.mode_company_id = False @@ -384,11 +389,15 @@ class HolidaysRequest(models.Model): for holiday in self: holiday.number_of_days_display = holiday.number_of_days + def _get_calendar(self): + self.ensure_one() + return self.employee_id.resource_calendar_id or self.env.user.company_id.resource_calendar_id + @api.multi @api.depends('number_of_days') def _compute_number_of_hours_display(self): for holiday in self: - calendar = holiday.employee_id.resource_calendar_id or self.env.user.company_id.resource_calendar_id + calendar = holiday._get_calendar() if holiday.date_from and holiday.date_to: number_of_hours = calendar.get_work_hours_count(holiday.date_from, holiday.date_to) holiday.number_of_hours_display = number_of_hours or (holiday.number_of_days * HOURS_PER_DAY) @@ -572,6 +581,10 @@ class HolidaysRequest(models.Model): @api.multi def write(self, values): + # Allow an employee to always write his own out of office message + if len(self) == 1 and values.keys() == {'out_of_office_message'} and self.employee_id.user_id == self.env.user: + return super(HolidaysRequest, self.sudo()).write(values) + employee_id = values.get('employee_id', False) if not self.env.context.get('leave_fast_create') and values.get('state'): self._check_approval_update(values['state']) @@ -823,7 +836,7 @@ class HolidaysRequest(models.Model): def _get_responsible_for_approval(self): self.ensure_one() - responsible = self.env.user + responsible = self.env['res.users'] if self.validation_type == 'hr' or (self.validation_type == 'both' and self.state == 'validate1'): if self.holiday_status_id.responsible_id: @@ -846,12 +859,12 @@ class HolidaysRequest(models.Model): elif holiday.state == 'confirm': holiday.activity_schedule( 'hr_holidays.mail_act_leave_approval', - user_id=holiday.sudo()._get_responsible_for_approval().id) + user_id=holiday.sudo()._get_responsible_for_approval().id or self.env.user.id) elif holiday.state == 'validate1': holiday.activity_feedback(['hr_holidays.mail_act_leave_approval']) holiday.activity_schedule( 'hr_holidays.mail_act_leave_second_approval', - user_id=holiday.sudo()._get_responsible_for_approval().id) + user_id=holiday.sudo()._get_responsible_for_approval().id or self.env.user.id) elif holiday.state == 'validate': to_do |= holiday elif holiday.state == 'refuse': diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index 352765d45cc..91bf5658093 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -550,7 +550,7 @@ class HolidaysAllocation(models.Model): def _get_responsible_for_approval(self): self.ensure_one() - responsible = self.env.user + responsible = self.env['res.users'] if self.validation_type == 'hr' or (self.validation_type == 'both' and self.state == 'validate1'): if self.holiday_status_id.responsible_id: @@ -570,12 +570,12 @@ class HolidaysAllocation(models.Model): elif allocation.state == 'confirm': allocation.activity_schedule( 'hr_holidays.mail_act_leave_allocation_approval', - user_id=allocation.sudo()._get_responsible_for_approval().id) + user_id=allocation.sudo()._get_responsible_for_approval().id or self.env.user.id) elif allocation.state == 'validate1': allocation.activity_feedback(['hr_holidays.mail_act_leave_allocation_approval']) allocation.activity_schedule( 'hr_holidays.mail_act_leave_allocation_second_approval', - user_id=allocation.sudo()._get_responsible_for_approval().id) + user_id=allocation.sudo()._get_responsible_for_approval().id or self.env.user.id) elif allocation.state == 'validate': to_do |= allocation elif allocation.state == 'refuse': diff --git a/addons/hr_holidays/tests/test_out_of_office.py b/addons/hr_holidays/tests/test_out_of_office.py index 05e3e480cc0..4c97f465ce1 100644 --- a/addons/hr_holidays/tests/test_out_of_office.py +++ b/addons/hr_holidays/tests/test_out_of_office.py @@ -4,26 +4,29 @@ from datetime import datetime from dateutil.relativedelta import relativedelta -from odoo.tests.common import tagged, users, warmup +from odoo.tests.common import tagged, users, warmup, Form from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase @tagged('out_of_office') class TestOutOfOffice(TestHrHolidaysBase): - def test_leave_ooo(self): - leave_type = self.env['hr.leave.type'].create({ + def setUp(self): + super().setUp() + self.leave_type = self.env['hr.leave.type'].create({ 'name': 'Legal Leaves', 'time_type': 'leave', 'allocation_type': 'no', }) + + def test_leave_ooo(self): self.assertNotEqual(self.employee_hruser.user_id.im_status, 'leave_offline', 'user should not be on leave') self.assertNotEqual(self.employee_hruser.user_id.partner_id.im_status, 'leave_offline', 'user should not be on leave') leave_date_end = (datetime.today() + relativedelta(days=3)) leave = self.env['hr.leave'].create({ 'name': 'Christmas', 'employee_id': self.employee_hruser.id, - 'holiday_status_id': leave_type.id, + 'holiday_status_id': self.leave_type.id, 'date_from': (datetime.today() - relativedelta(days=1)), 'date_to': leave_date_end, 'out_of_office_message': 'contact tde in case of problems', @@ -51,6 +54,29 @@ class TestOutOfOffice(TestHrHolidaysBase): self.assertEqual(infos[0]['direct_partner'][0]['out_of_office_date_end'], leave_date_end) self.assertEqual(infos[0]['direct_partner'][0]['out_of_office_message'], 'contact tde in case of problems') + def test_leave_ooo_use_default(self): + """ Out of office message from Preferences should be used as default value """ + self.user_hruser.out_of_office_message = 'contact xdo in case of problems' + + leave_form = Form(self.env['hr.leave'].sudo(self.user_hruser), view='hr_holidays.hr_leave_view_form') + leave_form.date_from = datetime.today() - relativedelta(days=1) + leave_form.date_to = datetime.today() + leave_form.holiday_status_id = self.leave_type + leave = leave_form.save() + self.assertEqual(leave.out_of_office_message, 'contact xdo in case of problems') + + def test_leave_ooo_overwrite_default(self): + """ Out of office message default value should be overwrittable """ + self.user_hruser.out_of_office_message = 'contact xdo in case of problems' + + leave_form = Form(self.env['hr.leave'].sudo(self.user_hruser), view='hr_holidays.hr_leave_view_form') + leave_form.date_from = datetime.today() - relativedelta(days=1) + leave_form.date_to = datetime.today() + leave_form.holiday_status_id = self.leave_type + leave_form.out_of_office_message = 'contact tde in case of problems' + leave = leave_form.save() + self.assertEqual(leave.out_of_office_message, 'contact tde in case of problems') + @tagged('out_of_office') class TestOutOfOfficePerformance(TestHrHolidaysBase): diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index e137648e758..cb8d6ec5420 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -246,6 +246,7 @@ + diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 12c81e9d620..4f5bd4abb0b 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -44,7 +44,7 @@ def _guess_mimetype(ext=False, default='text/html'): return ext is not False and exts.get(ext, default) or exts -def slugify_one(s, max_length=None): +def slugify_one(s, max_length=0): """ Transform a string to a slug that can be used in a url path. This method will first try to do the job with python-slugify if present. Otherwise it will process string by stripping leading and ending spaces, @@ -62,13 +62,12 @@ def slugify_one(s, max_length=None): except TypeError: pass uni = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') - slug_str = re.sub('[\W_]', ' ', uni).strip().lower() - slug_str = re.sub('[-\s]+', '-', slug_str) - - return slug_str[:max_length] + slug_str = re.sub(r'[\W_]', ' ', uni).strip().lower() + slug_str = re.sub(r'[-\s]+', '-', slug_str) + return slug_str[:max_length] if max_length > 0 else slug_str -def slugify(s, max_length=None, path=False): +def slugify(s, max_length=0, path=False): if not path: return slugify_one(s, max_length=max_length) else: diff --git a/addons/l10n_mx/data/account_tag_data.xml b/addons/l10n_mx/data/account_tag_data.xml index 1e8b23dacda..a025c4dd4e2 100644 --- a/addons/l10n_mx/data/account_tag_data.xml +++ b/addons/l10n_mx/data/account_tag_data.xml @@ -2077,212 +2077,230 @@ accounts A + + 401.04 Ventas y/o servicios gravados realizados en zona fronteriza norte + 4 + accounts + A + + + 401.05 Ventas y/o servicios gravados realizados en zona fronteriza norte de contado + 4 + accounts + A + + + 401.06 Ventas y/o servicios gravados realizados en zona fronteriza norte a crédito + 4 + accounts + A + - 401.04 Ventas y/o servicios gravados al 0% + 401.07 Ventas y/o servicios gravados al 0% 4 accounts A - 401.05 Ventas y/o servicios gravados al 0% de contado + 401.08 Ventas y/o servicios gravados al 0% de contado 4 accounts A - 401.06 Ventas y/o servicios gravados al 0% a crédito + 401.09 Ventas y/o servicios gravados al 0% a crédito 4 accounts A - 401.07 Ventas y/o servicios exentos + 401.10 Ventas y/o servicios exentos 4 accounts A - 401.08 Ventas y/o servicios exentos de contado + 401.11 Ventas y/o servicios exentos de contado 4 accounts A - 401.09 Ventas y/o servicios exentos a crédito + 401.12 Ventas y/o servicios exentos a crédito 4 accounts A - 401.10 Ventas y/o servicios gravados a la tasa general nacionales partes relacionadas + 401.13 Ventas y/o servicios gravados a la tasa general nacionales partes relacionadas 4 accounts A - 401.11 Ventas y/o servicios gravados a la tasa general extranjeros partes relacionadas + 401.14 Ventas y/o servicios gravados a la tasa general extranjeros partes relacionadas 4 accounts A - 401.12 Ventas y/o servicios gravados al 0% nacionales partes relacionadas + 401.15 Ventas y/o servicios gravados al 0% nacionales partes relacionadas 4 accounts A - 401.13 Ventas y/o servicios gravados al 0% extranjeros partes relacionadas + 401.16 Ventas y/o servicios gravados al 0% extranjeros partes relacionadas 4 accounts A - 401.14 Ventas y/o servicios exentos nacionales partes relacionadas + 401.17 Ventas y/o servicios exentos nacionales partes relacionadas 4 accounts A - 401.15 Ventas y/o servicios exentos extranjeros partes relacionadas + 401.18 Ventas y/o servicios exentos extranjeros partes relacionadas 4 accounts A - 401.16 Ingresos por servicios administrativos + 401.19 Ingresos por servicios administrativos 4 accounts A - 401.17 Ingresos por servicios administrativos nacionales partes relacionadas + 401.20 Ingresos por servicios administrativos nacionales partes relacionadas 4 accounts A - 401.18 Ingresos por servicios administrativos extranjeros partes relacionadas + 401.21 Ingresos por servicios administrativos extranjeros partes relacionadas 4 accounts A - 401.19 Ingresos por servicios profesionales + 401.22 Ingresos por servicios profesionales 4 accounts A - 401.20 Ingresos por servicios profesionales nacionales partes relacionadas + 401.23 Ingresos por servicios profesionales nacionales partes relacionadas 4 accounts A - 401.21 Ingresos por servicios profesionales extranjeros partes relacionadas + 401.24 Ingresos por servicios profesionales extranjeros partes relacionadas 4 accounts A - 401.22 Ingresos por arrendamiento + 401.25 Ingresos por arrendamiento 4 accounts A - 401.23 Ingresos por arrendamiento nacionales partes relacionadas + 401.26 Ingresos por arrendamiento nacionales partes relacionadas 4 accounts A - 401.24 Ingresos por arrendamiento extranjeros partes relacionadas + 401.27 Ingresos por arrendamiento extranjeros partes relacionadas 4 accounts A - 401.25 Ingresos por exportación + 401.28 Ingresos por exportación 4 accounts A - 401.26 Ingresos por comisiones + 401.29 Ingresos por comisiones 4 accounts A - 401.27 Ingresos por maquila + 401.30 Ingresos por maquila 4 accounts A - 401.28 Ingresos por coordinados + 401.31 Ingresos por coordinados 4 accounts A - 401.29 Ingresos por regalías + 401.32 Ingresos por regalías 4 accounts A - 401.30 Ingresos por asistencia técnica + 401.33 Ingresos por asistencia técnica 4 accounts A - 401.31 Ingresos por donativos + 401.34 Ingresos por donativos 4 accounts A - 401.32 Ingresos por intereses (actividad propia) + 401.35 Ingresos por intereses (actividad propia) 4 accounts A - 401.33 Ingresos de copropiedad + 401.36 Ingresos de copropiedad 4 accounts A - 401.34 Ingresos por fideicomisos + 401.37 Ingresos por fideicomisos 4 accounts A - 401.35 Ingresos por factoraje financiero + 401.38 Ingresos por factoraje financiero 4 accounts A - 401.36 Ingresos por arrendamiento financiero + 401.39 Ingresos por arrendamiento financiero 4 accounts A - 401.37 Ingresos de extranjeros con establecimiento en el país + 401.40 Ingresos de extranjeros con establecimiento en el país 4 accounts A - 401.38 Otros ingresos propios + 401.41 Otros ingresos propios 4 accounts A @@ -2293,20 +2311,26 @@ accounts D + + 402.02 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios en zona fronteriza norte + 4 + accounts + D + - 402.02 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios al 0% + 402.03 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios al 0% 4 accounts D - 402.03 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios exentos + 402.04 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios exentos 4 accounts D - 402.04 Devoluciones, descuentos o bonificaciones de otros ingresos + 402.05 Devoluciones, descuentos o bonificaciones de otros ingresos 4 accounts D diff --git a/addons/lunch/report/lunch_product_report.py b/addons/lunch/report/lunch_product_report.py index 2615cb827ee..3a30fc426d3 100644 --- a/addons/lunch/report/lunch_product_report.py +++ b/addons/lunch/report/lunch_product_report.py @@ -69,7 +69,7 @@ class LunchProductReport(models.Model): self._cr.execute(""" CREATE or REPLACE view %s AS ( SELECT - row_number() over () AS id, + row_number() over (ORDER BY user_id,product.id) AS id, product.id AS product_id, product.name, product.category_id, diff --git a/addons/lunch/static/src/js/lunch_kanban_model.js b/addons/lunch/static/src/js/lunch_kanban_model.js index ddd11f9a9be..c2386c7ace8 100644 --- a/addons/lunch/static/src/js/lunch_kanban_model.js +++ b/addons/lunch/static/src/js/lunch_kanban_model.js @@ -40,7 +40,7 @@ var LunchKanbanModel = KanbanModel.extend({ }); }, reload: function (id, options) { - var domain = options.domain || this.localData[id].domain; + var domain = options && options.domain || this.localData[id].domain; this._addOrUpdate(domain, ['is_available_at', 'in', [this.locationId]]); options = _.extend(options, {domain: domain}); diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index a2ba820656f..5875ab42ee2 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -172,6 +172,8 @@ class MailMail(models.Model): messages._notify_failure_update() # notify user that we have a failure (notifications - failed).sudo().write({ 'email_status': 'sent', + 'failure_type': '', + 'failure_reason': '', }) if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail. mail_to_delete_ids = [mail.id for mail in self if mail.auto_delete] diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 39510206641..17baaf3c8fc 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -730,8 +730,9 @@ class Message(models.Model): WHERE message.message_type = %%s AND (message.subtype_id IS NULL OR subtype.internal IS TRUE) AND message.id = ANY (%%s)''' % (self._table), ('comment', self.ids,)) if self._cr.fetchall(): raise AccessError( - _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % - (self._description, operation)) + _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % (self._description, operation) + + ' - ({} {}, {} {})'.format(_('Records:'), self.ids[:6], _('User:'), self._uid) + ) # Read mail_message.ids to have their values message_values = dict((res_id, {}) for res_id in self.ids) @@ -900,8 +901,9 @@ class Message(models.Model): if not (other_ids and self.browse(other_ids).exists()): return raise AccessError( - _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % - (self._description, operation)) + _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)') % (self._description, operation) + + ' - ({} {}, {} {})'.format(_('Records:'), list(other_ids)[:6], _('User:'), self._uid) + ) @api.model def _get_record_name(self, values): diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index 72936152ae5..0658c3c6820 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -537,6 +537,7 @@ var BasicComposer = Widget.extend({ files: ev.currentTarget.files, submitForm: true }); + ev.target.value = ""; }, /** * @private diff --git a/addons/membership/models/partner.py b/addons/membership/models/partner.py index 3a13a1e1d33..907efb32b88 100644 --- a/addons/membership/models/partner.py +++ b/addons/membership/models/partner.py @@ -44,12 +44,19 @@ class Partner(models.Model): 'member_lines.account_invoice_line.invoice_id.partner_id', 'free_member', 'member_lines.date_to', 'member_lines.date_from', - 'associate_member.membership_state') + 'associate_member') def _compute_membership_state(self): values = self._membership_state() for partner in self: partner.membership_state = values[partner.id] + # Do not depend directly on "associate_member.membership_state" or we might end up in an + # infinite loop. Since we still need this dependency somehow, we explicitly search for the + # "parent members" and trigger a recompute. + parent_members = self.search([('associate_member', 'in', self.ids)]) - self + if parent_members: + parent_members._recompute_todo(self._fields['membership_state']) + @api.depends('member_lines.account_invoice_line.invoice_id.state', 'member_lines.account_invoice_line.invoice_id.invoice_line_ids', 'member_lines.account_invoice_line.invoice_id.payment_ids', diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 28aee57a29b..84573dffed7 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -247,6 +247,7 @@ class MrpBomLine(models.Model): 'mrp.bom', 'Parent BoM', index=True, ondelete='cascade', required=True) parent_product_tmpl_id = fields.Many2one('product.template', 'Parent Product Template', related='bom_id.product_tmpl_id') + valid_product_attribute_value_ids = fields.Many2many('product.attribute.value', related='bom_id.product_tmpl_id.valid_product_attribute_value_ids') attribute_value_ids = fields.Many2many( 'product.attribute.value', string='Apply on Variants', help="BOM Product Variants needed form apply this line.") diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 858b6705d1c..8b30be3da44 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -597,7 +597,7 @@ class MrpProduction(models.Model): move_raw.write({ 'group_id': production.procurement_group_id.id, 'unit_factor': move_raw.product_uom_qty / production.product_qty, - 'reference': self.name, # set reference when MO name is different than 'New' + 'reference': production.name, # set reference when MO name is different than 'New' }) production._generate_finished_moves() production.move_raw_ids._adjust_procure_method() @@ -849,8 +849,10 @@ class MrpProduction(models.Model): raise UserError(_('Work order %s is still running') % wo.name) self._check_lots() self.post_inventory() - moves_to_cancel = (self.move_raw_ids | self.move_finished_ids).filtered(lambda x: x.state not in ('done', 'cancel')) - moves_to_cancel._action_cancel() + # Moves without quantity done are not posted => set them as done instead of canceling. In + # case the user edits the MO later on and sets some consumed quantity on those, we do not + # want the move lines to be canceled. + (self.move_raw_ids | self.move_finished_ids).filtered(lambda x: x.state not in ('done', 'cancel')).write({'state': 'done'}) self.write({'date_finished': fields.Datetime.now()}) return True diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index 0dcfcdaf9a0..28e8bb2c768 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -63,8 +63,9 @@ string=" "/> + - + diff --git a/addons/mrp/views/stock_move_views.xml b/addons/mrp/views/stock_move_views.xml index aff0e661120..f1b7c8228ef 100644 --- a/addons/mrp/views/stock_move_views.xml +++ b/addons/mrp/views/stock_move_views.xml @@ -49,7 +49,7 @@ - + diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml index 550939e21b7..64afb0b016f 100644 --- a/addons/payment/views/payment_templates.xml +++ b/addons/payment/views/payment_templates.xml @@ -87,6 +87,9 @@ Save my payment data + + + diff --git a/addons/payment_adyen/models/payment.py b/addons/payment_adyen/models/payment.py index 818de84dc20..7cb1020870e 100644 --- a/addons/payment_adyen/models/payment.py +++ b/addons/payment_adyen/models/payment.py @@ -170,7 +170,7 @@ class AcquirerAdyen(models.Model): 'sessionValidity': tmp_date.isoformat('T')[:19] + "Z", 'resURL': urls.url_join(base_url, AdyenController._return_url), 'merchantReturnData': json.dumps({'return_url': '%s' % values.pop('return_url')}) if values.get('return_url', '') else False, - 'shopperEmail': values.get('partner_email', ''), + 'shopperEmail': values.get('partner_email') or values.get('billing_partner_email') or '', }) values['merchantSig'] = self._adyen_generate_merchant_sig_sha256('in', values) diff --git a/addons/payment_paypal/models/payment.py b/addons/payment_paypal/models/payment.py index fb12cabdc50..f83129749a9 100644 --- a/addons/payment_paypal/models/payment.py +++ b/addons/payment_paypal/models/payment.py @@ -86,7 +86,7 @@ class AcquirerPaypal(models.Model): else: percentage = self.fees_int_var fixed = self.fees_int_fixed - fees = (percentage / 100.0 * amount + fixed) / (1 - percentage / 100.0) + fees = (percentage / 100.0 * amount) + fixed / (1 - percentage / 100.0) return fees @api.multi diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 18dc7ca5227..0f3e676f4ed 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -214,7 +214,8 @@ class PosSession(models.Model): st_values = { 'journal_id': journal.id, 'user_id': self.env.user.id, - 'name': pos_name + 'name': pos_name, + 'balance_start': self.env["account.bank.statement"]._get_opening_balance(journal.id) } statements.append(ABS.with_context(ctx).sudo(uid).create(st_values).id) diff --git a/addons/project/static/src/css/project.css b/addons/project/static/src/css/project.css index 9522588f358..e3c9656063c 100644 --- a/addons/project/static/src/css/project.css +++ b/addons/project/static/src/css/project.css @@ -38,3 +38,16 @@ margin-top: 6px; margin-right: 8px; } + +.o_form_project_tasks.o_form_editable .oe_title { + max-width: initial; +} + +.o_form_project_tasks.o_form_editable .oe_title .o_task_name { + /* + * should be (coming from addons/web/static/src/scss/form_view.scss): + * max-width: map-get($container-max-widths, md) - (2 * $o-horizontal-padding); + */ + max-width: 688px; + margin-right: auto; +} diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index 14498944913..1bb202f8fd8 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -238,7 +238,7 @@

    - +
    diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index e73d64b280d..2723f670c01 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -95,9 +95,9 @@ class AccountInvoice(models.Model): self.partner_id = self.purchase_id.partner_id.id vendor_ref = self.purchase_id.partner_ref - if vendor_ref: - self.reference = ", ".join([self.reference, vendor_ref]) if ( - self.reference and vendor_ref not in self.reference) else vendor_ref + if vendor_ref and (not self.reference or ( + vendor_ref + ", " not in self.reference and not self.reference.endswith(vendor_ref))): + self.reference = ", ".join([self.reference, vendor_ref]) if self.reference else vendor_ref if not self.invoice_line_ids: #as there's no invoice line yet, we keep the currency of the PO diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 981e4e60830..5f50195f636 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -380,7 +380,7 @@ - + diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index e8873375ba9..90d7476546f 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -185,11 +185,15 @@ class StockRule(models.Model): price_unit = seller.currency_id._convert( price_unit, line.order_id.currency_id, line.order_id.company_id, fields.Date.today()) - return { + res = { 'product_qty': line.product_qty + procurement_uom_po_qty, 'price_unit': price_unit, 'move_dest_ids': [(4, x.id) for x in values.get('move_dest_ids', [])] } + orderpoint_id = values.get('orderpoint_id') + if orderpoint_id: + res['orderpoint_id'] = orderpoint_id.id + return res @api.model def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, po): diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index f96f4e472c2..729adc06dba 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -79,6 +79,7 @@ +
    - @@ -137,7 +137,7 @@
    - +
diff --git a/doc/cla/corporate/jankaritech.md b/doc/cla/corporate/jankaritech.md new file mode 100644 index 00000000000..4379bf42dbe --- /dev/null +++ b/doc/cla/corporate/jankaritech.md @@ -0,0 +1,23 @@ +Nepal, 21.05.2019 + +JankariTech Pvt. Ltd. agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Artur Neumann artur@jankaritech.com https://github.com/individual-it + +List of contributors: + +Artur Neumann artur@jankaritech.com https://github.com/individual-it + +Phil Davis phil@jankaritech.com https://github.com/phil-davis + +Paurakh Sharma Humagain paurakh011@gmail.com https://github.com/paurakhsharma + +Saugat Pachhai suagatchhetri@outlook.com https://github.com/skshetry + +Dipak Acharya dpakach@gmail.com https://github.com/dpakach diff --git a/doc/cla/individual/luisiniguezh.md b/doc/cla/individual/luisiniguezh.md new file mode 100644 index 00000000000..11a1613066b --- /dev/null +++ b/doc/cla/individual/luisiniguezh.md @@ -0,0 +1,11 @@ +Mexico, 2018-12-05 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Luis Arturo Íñiguez Hernández luis.iniguezh@gmail.com https://github.com/luisiniguezh diff --git a/doc/cla/individual/michelezak.md b/doc/cla/individual/michelezak.md new file mode 100644 index 00000000000..d3bf2ff1b6f --- /dev/null +++ b/doc/cla/individual/michelezak.md @@ -0,0 +1,11 @@ +Italy, 2019-06-04 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Michele Zaccheddu michelezak93@gmail.com https://github.com/MicheleZak diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 0071cb701a0..b750de481b4 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -787,3 +787,11 @@ class IrActionsActClient(models.Model): for record in self: params = record.params record.params_store = repr(params) if isinstance(params, dict) else params + + def _get_default_form_view(self): + doc = super(IrActionsActClient, self)._get_default_form_view() + params = doc.find(".//field[@name='params']") + params.getparent().remove(params) + params_store = doc.find(".//field[@name='params_store']") + params_store.getparent().remove(params_store) + return doc diff --git a/odoo/addons/base/models/ir_filters.py b/odoo/addons/base/models/ir_filters.py index 190e581e8dd..547302be38b 100644 --- a/odoo/addons/base/models/ir_filters.py +++ b/odoo/addons/base/models/ir_filters.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import ast +import datetime from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval class IrFilters(models.Model): @@ -41,7 +42,10 @@ class IrFilters(models.Model): @api.multi def _get_eval_domain(self): self.ensure_one() - return ast.literal_eval(self.domain) + return safe_eval(self.domain, { + 'datetime': datetime, + 'context_today': datetime.datetime.now, + }) @api.model def _get_action_domain(self, action_id=None): diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index 54a778cc8bd..adabe84ec08 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -751,7 +751,7 @@ class IrModelFields(models.Model): else: if column_rename: raise UserError(_('Can only rename one field at a time!')) - column_rename = (obj._table, item.name, vals['name'], item.index) + column_rename = (obj._table, item.name, vals['name'], item.index, item.store) # We don't check the 'state', because it might come from the context # (thus be set for multiple fields) and will be ignored anyway. @@ -769,10 +769,11 @@ class IrModelFields(models.Model): if column_rename: # rename column in database, and its corresponding index if present - table, oldname, newname, index = column_rename - self._cr.execute('ALTER TABLE "%s" RENAME COLUMN "%s" TO "%s"' % (table, oldname, newname)) - if index: - self._cr.execute('ALTER INDEX "%s_%s_index" RENAME TO "%s_%s_index"' % (table, oldname, table, newname)) + table, oldname, newname, index, stored = column_rename + if stored: + self._cr.execute('ALTER TABLE "%s" RENAME COLUMN "%s" TO "%s"' % (table, oldname, newname)) + if index: + self._cr.execute('ALTER INDEX "%s_%s_index" RENAME TO "%s_%s_index"' % (table, oldname, table, newname)) if column_rename or patched_models: # setup models, this will reload all manual fields in registry @@ -1201,13 +1202,17 @@ class IrModelAccess(models.Model): :rtype: list """ assert access_mode in ('read', 'write', 'create', 'unlink'), 'Invalid access mode' - self._cr.execute("""SELECT c.name, g.name - FROM ir_model_access a - JOIN ir_model m ON (a.model_id=m.id) - JOIN res_groups g ON (a.group_id=g.id) - LEFT JOIN ir_module_category c ON (c.id=g.category_id) - WHERE m.model=%s AND a.active IS TRUE AND a.perm_""" + access_mode, - (model_name,)) + self._cr.execute(""" + SELECT c.name, g.name + FROM ir_model_access a + JOIN ir_model m ON (a.model_id = m.id) + JOIN res_groups g ON (a.group_id = g.id) + LEFT JOIN ir_module_category c ON (c.id = g.category_id) + WHERE m.model = %%s + AND a.active = TRUE + AND a.perm_%s = TRUE + ORDER BY c.name, g.name NULLS LAST + """ % access_mode, [model_name]) return [('%s/%s' % x) if x[0] else x[1] for x in self._cr.fetchall()] # The context parameter is useful when the method translates error messages. diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py index 092641e7f0d..e0ee6587b7c 100644 --- a/odoo/addons/base/models/ir_rule.py +++ b/odoo/addons/base/models/ir_rule.py @@ -209,7 +209,7 @@ class IrRule(models.Model): 'document_model': model, 'rules_list': '\n'.join('- %s' % rule.name for rule in rules), 'multi_company_warning': ('\n' + _('Note: this might be a multi-company issue.') + '\n') if any( - 'company_id' in r.domain_force for r in rules) else '', + 'company_id' in (r.domain_force or []) for r in rules) else '', 'example_records': list(records.ids[:6]), 'user_id': self.env.user.id, }) diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index 691aebb3279..815ec50a072 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -246,7 +246,13 @@ class IrTranslation(models.Model): continue if not callable(field.translate): # Pass context without lang, need to read real stored field, not translation - result = model.browse(record.res_id).with_context(lang=None).read([field_name]) + try: + result = model.browse(record.res_id).with_context(lang=None).read([field_name]) + except AccessError: + # because we can read self but not the record, + # that means we would get an access error when accessing the translations + # so instead we defer the access right to the "check" method + result = [{field_name: _("Cannot be translated; record not accessible.")}] record.source = result[0][field_name] if result else False def _inverse_source(self): @@ -538,7 +544,7 @@ class IrTranslation(models.Model): """ Check access rights of operation ``mode`` on ``self`` for the current user. Raise an AccessError in case conditions are not met. """ - if self.env.user._is_admin(): + if self.env.user._is_superuser(): return # collect translated field records (model_ids) and other translations diff --git a/odoo/addons/base/rng/gantt_view.rng b/odoo/addons/base/rng/gantt_view.rng index 08e86b5bc87..dbc216df10c 100644 --- a/odoo/addons/base/rng/gantt_view.rng +++ b/odoo/addons/base/rng/gantt_view.rng @@ -38,13 +38,6 @@ - - - - - - - @@ -54,11 +47,17 @@ - - - - - + + + + + + + + + + + diff --git a/odoo/addons/base/views/ir_model_views.xml b/odoo/addons/base/views/ir_model_views.xml index 116eddc9912..d6862ca48f8 100644 --- a/odoo/addons/base/views/ir_model_views.xml +++ b/odoo/addons/base/views/ir_model_views.xml @@ -109,7 +109,7 @@ - +
@@ -295,7 +295,7 @@ - +
diff --git a/odoo/addons/test_access_rights/tests/test_feedback.py b/odoo/addons/test_access_rights/tests/test_feedback.py index 660dd068199..d9ba8be24d2 100644 --- a/odoo/addons/test_access_rights/tests/test_feedback.py +++ b/odoo/addons/test_access_rights/tests/test_feedback.py @@ -57,15 +57,12 @@ class TestACLFeedback(Feedback): }) self.assertEqual( ctx.exception.args[0], - """Sorry, you are not allowed to create documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups: - - Group 0""" + """Sorry, you are not allowed to create documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0""" ) def test_two_groups(self): r = self.record.sudo(self.user) - expected = """Sorry, you are not allowed to access documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups: - - Group 0 - - Group 1""" + expected = """Sorry, you are not allowed to access documents of type 'Object For Test Access Right' (test_access_right.some_obj). This operation is allowed for the groups:\n\t- Group 0\n\t- Group 1""" with self.assertRaises(AccessError) as ctx: # noinspection PyStatementEffect r.val @@ -266,8 +263,10 @@ class TestFieldGroupFeedback(Feedback): Document type: Object For Test Access Right (test_access_right.some_obj) Operation: read +User: %s Fields: - forbidden (allowed for groups 'User types / Internal User', 'Test Group'; forbidden for groups 'Extra Rights / Technical Features', 'User types / Public')""" + % self.user.id ) def test_write(self): @@ -283,7 +282,9 @@ Fields: Document type: Object For Test Access Right (test_access_right.some_obj) Operation: write +User: %s Fields: - forbidden (allowed for groups 'User types / Internal User', 'Test Group'; forbidden for groups 'Extra Rights / Technical Features', 'User types / Public') - forbidden2 (allowed for groups 'Test Group')""" + % self.user.id ) diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index c315f76a6f1..ec5b6078168 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -9,6 +9,7 @@ import time from odoo import api from odoo.addons.base.models.assetsbundle import AssetsBundle +from odoo.addons.base.models.ir_attachment import IrAttachment from odoo.modules.module import get_resource_path from odoo.tests import HttpCase from odoo.tests.common import TransactionCase @@ -544,21 +545,21 @@ class TestAssetsBundleWithIRAMock(TransactionCase): self.counter = counter = Counter() # patch methods 'create' and 'unlink' of model 'ir.attachment' + origin_create = IrAttachment.create + origin_unlink = IrAttachment.unlink + @api.model def create(self, vals): counter.update(['create']) - return create.origin(self, vals) + return origin_create(self, vals) @api.multi def unlink(self): counter.update(['unlink']) - return unlink.origin(self) + return origin_unlink(self) - self.env['ir.attachment']._patch_method('create', create) - self.addCleanup(self.env['ir.attachment']._revert_method, 'create') - - self.env['ir.attachment']._patch_method('unlink', unlink) - self.addCleanup(self.env['ir.attachment']._revert_method, 'unlink') + self.patch(IrAttachment, 'create', create) + self.patch(IrAttachment, 'unlink', unlink) def _get_asset(self): files, remains = self.env['ir.qweb']._get_asset_content(self.stylebundle_xmlid, {}) diff --git a/odoo/addons/test_lint/__init__.py b/odoo/addons/test_lint/__init__.py new file mode 100644 index 00000000000..0e68430dabe --- /dev/null +++ b/odoo/addons/test_lint/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, SUPERUSER_ID + + +# TO REMOVE in master +def uninstall_test_pylint(cr): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['ir.module.module'].search([ + ('name', '=', 'test_pylint'), + ('state', '=', 'installed') + ]).write({'state': 'uninstalled'}) diff --git a/odoo/addons/test_lint/__manifest__.py b/odoo/addons/test_lint/__manifest__.py new file mode 100644 index 00000000000..554e20ba3b2 --- /dev/null +++ b/odoo/addons/test_lint/__manifest__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'test-lint', + 'version': '0.1', + 'category': 'Tests', + 'description': """A module to test Odoo code with various linters.""", + 'maintainer': 'Odoo SA', + 'depends': ['base'], + 'installable': True, + 'auto_install': False, + 'pre_init_hook': 'uninstall_test_pylint' +} diff --git a/odoo/addons/test_lint/tests/__init__.py b/odoo/addons/test_lint/tests/__init__.py new file mode 100644 index 00000000000..d085060f6bf --- /dev/null +++ b/odoo/addons/test_lint/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_pylint +from . import test_ecmascript diff --git a/odoo/addons/test_pylint/tests/_odoo_checkers.py b/odoo/addons/test_lint/tests/_odoo_checkers.py similarity index 100% rename from odoo/addons/test_pylint/tests/_odoo_checkers.py rename to odoo/addons/test_lint/tests/_odoo_checkers.py diff --git a/odoo/addons/test_lint/tests/test_ecmascript.py b/odoo/addons/test_lint/tests/test_ecmascript.py new file mode 100644 index 00000000000..46008b0587d --- /dev/null +++ b/odoo/addons/test_lint/tests/test_ecmascript.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import os +import re +import subprocess +from unittest import skipIf +from odoo.tests.common import TransactionCase +from odoo import tools +from odoo.modules import get_modules, get_module_path + +MAX_ES_VERSION = 'es5' + +_logger = logging.getLogger(__name__) + +try: + es_check = tools.misc.find_in_path('es-check') +except IOError: + es_check = None + + +@skipIf(es_check is None, "es-check tool not found on this system") +class TestECMAScriptVersion(TransactionCase): + + longMessage = True + + def test_ecmascript_version(self): + """ Test that there is no unsupported ecmascript in javascript files """ + + black_re = re.compile(r'summernote.+(intro\.js|outro.js)$') + + mod_paths = [get_module_path(m) for m in get_modules()] + files_to_check = [] + for p in mod_paths: + for dp, _, file_names in os.walk(p): + if 'static/test' in dp: + continue + for fn in file_names: + fullpath_name = os.path.join(dp, fn) + if fullpath_name.endswith('.js') and not black_re.search(fullpath_name): + files_to_check.append(fullpath_name) + + _logger.info('Testing %s js files', len(files_to_check)) + cmd = [es_check, MAX_ES_VERSION] + files_to_check + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = process.communicate() + self.assertEqual(process.returncode, 0, msg=out) diff --git a/odoo/addons/test_pylint/tests/test_pylint.py b/odoo/addons/test_lint/tests/test_pylint.py similarity index 100% rename from odoo/addons/test_pylint/tests/test_pylint.py rename to odoo/addons/test_lint/tests/test_pylint.py diff --git a/odoo/addons/test_new_api/__manifest__.py b/odoo/addons/test_new_api/__manifest__.py index e238f37f779..ec4bdae82bc 100644 --- a/odoo/addons/test_new_api/__manifest__.py +++ b/odoo/addons/test_new_api/__manifest__.py @@ -10,7 +10,9 @@ 'data': [ 'ir.model.access.csv', 'views.xml', - 'demo_data.xml', 'template.xml', ], + 'demo': [ + 'demo_data.xml', + ], } diff --git a/odoo/addons/test_pylint/__manifest__.py b/odoo/addons/test_pylint/__manifest__.py index 66f801454a5..42e9d621f71 100644 --- a/odoo/addons/test_pylint/__manifest__.py +++ b/odoo/addons/test_pylint/__manifest__.py @@ -3,7 +3,7 @@ 'name': 'test-eval', 'version': '0.1', 'category': 'Tests', - 'description': """A module to test Odoo with pylint.""", + 'description': """Deprecated empty module in favor of test-lint.""", 'maintainer': 'Odoo SA', 'depends': ['base'], 'installable': True, diff --git a/odoo/addons/test_pylint/tests/__init__.py b/odoo/addons/test_pylint/tests/__init__.py deleted file mode 100644 index 66bc70c522e..00000000000 --- a/odoo/addons/test_pylint/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test_pylint diff --git a/odoo/http.py b/odoo/http.py index 886fecc48ba..217720f6a72 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -280,13 +280,15 @@ class WebRequest(object): _request_stack.pop() if self._cr: - if exc_type is None and not self._failed: - self._cr.commit() - if self.registry: - self.registry.signal_changes() - elif self.registry: - self.registry.reset_changes() - self._cr.close() + try: + if exc_type is None and not self._failed: + self._cr.commit() + if self.registry: + self.registry.signal_changes() + elif self.registry: + self.registry.reset_changes() + finally: + self._cr.close() # just to be sure no one tries to re-use the request self.disable_db = True self.uid = None diff --git a/odoo/models.py b/odoo/models.py index 12c03f020fe..6693dac4c55 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2776,11 +2776,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): Document type: %(document_kind)s (%(document_model)s) Operation: %(operation)s +User: %(user)s Fields: %(fields_list)s""") % { 'document_model': self._name, 'document_kind': description or self._name, 'operation': operation, + 'user': self._uid, 'fields_list': '\n'.join( '- %s (%s)' % (f, format_groups(self._fields[f])) for f in sorted(invalid_fields) @@ -3100,7 +3102,13 @@ Fields: # errors for non-transactional search/read sequences coming from clients. return _logger.info('Failed operation on deleted record(s): %s, uid: %s, model: %s', operation, self._uid, self._name) - raise MissingError(_('Missing document(s)') + ':' + _('One of the documents you are trying to access has been deleted, please try again after refreshing.')) + raise MissingError( + _('Missing document(s)') + ':' + _('One of the documents you are trying to access has been deleted, please try again after refreshing.') + + '\n\n({} {}, {} {}, {} {}, {} {})'.format( + _('Document type:'), self._name, _('Operation:'), operation, + _('Records:'), invalid.ids[:6], _('User:'), self._uid, + ) + ) def _filter_access_rules(self, operation): """ Return the subset of ``self`` for which ``operation`` is allowed. """ @@ -3458,7 +3466,10 @@ Fields: for sub_ids in cr.split_for_in_conditions(set(self.ids)): cr.execute(query, params + [sub_ids]) if cr.rowcount != len(sub_ids): - raise MissingError(_('One of the records you are trying to modify has already been deleted (Document type: %s).') % self._description) + raise MissingError( + _('One of the records you are trying to modify has already been deleted (Document type: %s).') % self._description + + '\n\n({} {}, {} {})'.format(_('Records:'), sub_ids[:6], _('User:'), self._uid) + ) for name in updated: field = self._fields[name] @@ -4360,7 +4371,10 @@ Fields: existing = self.browse(ids + new_ids) if len(existing) < len(self): # mark missing records in cache with a failed value - exc = MissingError(_("Record does not exist or has been deleted.")) + exc = MissingError( + _("Record does not exist or has been deleted.") + + '\n\n({} {}, {} {})'.format(_('Records:'), (self - existing).ids[:6], _('User:'), self._uid) + ) self.env.cache.set_failed(self - existing, self._fields.values(), exc) return existing