diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-06-14 15:20:01 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-06-14 15:20:01 +0200 |
| commit | 34a8754d4f0a6dca7cf612859d5702bcae1f90d6 (patch) | |
| tree | 757fd2bd012ff92968ea0cc0f27523fd0a1587cf | |
| parent | 856685d6408857678ca9fc25b09dfea468800f93 (diff) | |
| parent | 498b4b4350d5fdeab0fb58275ad3b8e4dd443190 (diff) | |
[MERGE] forward port branch saas-12.2 up to 498b4b4350d
113 files changed, 1029 insertions, 390 deletions
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 @@ <field name="is_email" /> <b><label for="is_email"/></b> </div> + <div class="text-left d-inline-block mr8" attrs="{'invisible': ['|', ('is_email','=', False), ('invoice_without_email', '=', False)]}"> + <field name="invoice_without_email" class="mr4"/> + </div> <div name="mail_form" attrs="{'invisible': [('is_email', '=', False)]}"> <!-- visible wizard --> <div attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}"> 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 @@ <div class="o_row"> <field name="end_type" attrs="{'required': [('recurrency', '=', True)]}"/> <field name="count" attrs="{'invisible': [('end_type', '!=', 'count')], 'required': [('recurrency', '=', True)]}"/> - <field name="final_date" attrs="{'invisible': [('end_type', '!=', 'end_date')], 'required': [('end_type', '=', 'end_date')]}"/> + <field name="final_date" attrs="{'invisible': [('end_type', '!=', 'end_date')], 'required': [('end_type', '=', 'end_date'), ('recurrency', '=', True)]}"/> </div> </group> <group attrs="{'invisible': [('rrule_type', '!=', 'weekly')]}" name="weekdays"> @@ -182,10 +182,10 @@ attrs="{'required': [('month_by', '=', 'date'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'date')]}"/> <field name="byday" string="The" - attrs="{'required': [('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], + attrs="{'required': [('recurrency', '=', True), ('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'day')]}"/> <field name="week_list" nolabel="1" - attrs="{'required': [('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], + attrs="{'required': [('recurrency', '=', True), ('month_by', '=', 'day'), ('rrule_type', '=', 'monthly')], 'invisible': [('month_by', '!=', 'day')]}"/> </div> </group> 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 @@ <record id="rank_doctor" model="gamification.karma.rank"> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div> + <div class="media-body">You have reached the last rank. Congratulations!</div> <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/> </div> </field> 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 @@ </group> <group> <field name="department_id"/> + <field name="job_id"/> <field name="company_id" groups="base.group_multi_company"/> </group> </group> 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 @@ <field name="work_location"/> </group> <group name="managers" string="Managers"> - <field name="parent_id"/> + <field name="employee_parent_id"/> <field name="coach_id"/> </group> </div> 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 @@ <field name="res_model">hr.employee</field> <field name="view_mode">kanban,tree,activity</field> <field name="view_id" ref="hr_employees_view_kanban"/> + <field name="target">fullscreen</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> 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 @@ </div> <field name="name" attrs="{'readonly': [('state', 'not in', ('draft', 'confirm'))]}"/> + <field name="user_id" invisible="1"/> <field name="out_of_office_message"/> </group> <group name="col_right"> 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) + 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 - return slug_str[:max_length] - -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 @@ <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> + <record id='account_tag_401_04_new' model='account.account.tag'> + <field name='name'>401.04 Ventas y/o servicios gravados realizados en zona fronteriza norte</field> + <field name='color'>4</field> + <field name='applicability'>accounts</field> + <field name='nature'>A</field> + </record> + <record id='account_tag_401_05_new' model='account.account.tag'> + <field name='name'>401.05 Ventas y/o servicios gravados realizados en zona fronteriza norte de contado</field> + <field name='color'>4</field> + <field name='applicability'>accounts</field> + <field name='nature'>A</field> + </record> + <record id='account_tag_401_06_new' model='account.account.tag'> + <field name='name'>401.06 Ventas y/o servicios gravados realizados en zona fronteriza norte a crédito</field> + <field name='color'>4</field> + <field name='applicability'>accounts</field> + <field name='nature'>A</field> + </record> <record id='account_tag_401_04' model='account.account.tag'> - <field name='name'>401.04 Ventas y/o servicios gravados al 0%</field> + <field name='name'>401.07 Ventas y/o servicios gravados al 0%</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_05' model='account.account.tag'> - <field name='name'>401.05 Ventas y/o servicios gravados al 0% de contado</field> + <field name='name'>401.08 Ventas y/o servicios gravados al 0% de contado</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_06' model='account.account.tag'> - <field name='name'>401.06 Ventas y/o servicios gravados al 0% a crédito</field> + <field name='name'>401.09 Ventas y/o servicios gravados al 0% a crédito</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_07' model='account.account.tag'> - <field name='name'>401.07 Ventas y/o servicios exentos</field> + <field name='name'>401.10 Ventas y/o servicios exentos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_08' model='account.account.tag'> - <field name='name'>401.08 Ventas y/o servicios exentos de contado</field> + <field name='name'>401.11 Ventas y/o servicios exentos de contado</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_09' model='account.account.tag'> - <field name='name'>401.09 Ventas y/o servicios exentos a crédito</field> + <field name='name'>401.12 Ventas y/o servicios exentos a crédito</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_10' model='account.account.tag'> - <field name='name'>401.10 Ventas y/o servicios gravados a la tasa general nacionales partes relacionadas</field> + <field name='name'>401.13 Ventas y/o servicios gravados a la tasa general nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_11' model='account.account.tag'> - <field name='name'>401.11 Ventas y/o servicios gravados a la tasa general extranjeros partes relacionadas</field> + <field name='name'>401.14 Ventas y/o servicios gravados a la tasa general extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_12' model='account.account.tag'> - <field name='name'>401.12 Ventas y/o servicios gravados al 0% nacionales partes relacionadas</field> + <field name='name'>401.15 Ventas y/o servicios gravados al 0% nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_13' model='account.account.tag'> - <field name='name'>401.13 Ventas y/o servicios gravados al 0% extranjeros partes relacionadas</field> + <field name='name'>401.16 Ventas y/o servicios gravados al 0% extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_14' model='account.account.tag'> - <field name='name'>401.14 Ventas y/o servicios exentos nacionales partes relacionadas</field> + <field name='name'>401.17 Ventas y/o servicios exentos nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_15' model='account.account.tag'> - <field name='name'>401.15 Ventas y/o servicios exentos extranjeros partes relacionadas</field> + <field name='name'>401.18 Ventas y/o servicios exentos extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_16' model='account.account.tag'> - <field name='name'>401.16 Ingresos por servicios administrativos</field> + <field name='name'>401.19 Ingresos por servicios administrativos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_17' model='account.account.tag'> - <field name='name'>401.17 Ingresos por servicios administrativos nacionales partes relacionadas</field> + <field name='name'>401.20 Ingresos por servicios administrativos nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_18' model='account.account.tag'> - <field name='name'>401.18 Ingresos por servicios administrativos extranjeros partes relacionadas</field> + <field name='name'>401.21 Ingresos por servicios administrativos extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_19' model='account.account.tag'> - <field name='name'>401.19 Ingresos por servicios profesionales</field> + <field name='name'>401.22 Ingresos por servicios profesionales</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_20' model='account.account.tag'> - <field name='name'>401.20 Ingresos por servicios profesionales nacionales partes relacionadas</field> + <field name='name'>401.23 Ingresos por servicios profesionales nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_21' model='account.account.tag'> - <field name='name'>401.21 Ingresos por servicios profesionales extranjeros partes relacionadas</field> + <field name='name'>401.24 Ingresos por servicios profesionales extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_22' model='account.account.tag'> - <field name='name'>401.22 Ingresos por arrendamiento</field> + <field name='name'>401.25 Ingresos por arrendamiento</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_23' model='account.account.tag'> - <field name='name'>401.23 Ingresos por arrendamiento nacionales partes relacionadas</field> + <field name='name'>401.26 Ingresos por arrendamiento nacionales partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_24' model='account.account.tag'> - <field name='name'>401.24 Ingresos por arrendamiento extranjeros partes relacionadas</field> + <field name='name'>401.27 Ingresos por arrendamiento extranjeros partes relacionadas</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_25' model='account.account.tag'> - <field name='name'>401.25 Ingresos por exportación</field> + <field name='name'>401.28 Ingresos por exportación</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_26' model='account.account.tag'> - <field name='name'>401.26 Ingresos por comisiones</field> + <field name='name'>401.29 Ingresos por comisiones</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_27' model='account.account.tag'> - <field name='name'>401.27 Ingresos por maquila</field> + <field name='name'>401.30 Ingresos por maquila</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_28' model='account.account.tag'> - <field name='name'>401.28 Ingresos por coordinados</field> + <field name='name'>401.31 Ingresos por coordinados</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_29' model='account.account.tag'> - <field name='name'>401.29 Ingresos por regalías</field> + <field name='name'>401.32 Ingresos por regalías</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_30' model='account.account.tag'> - <field name='name'>401.30 Ingresos por asistencia técnica</field> + <field name='name'>401.33 Ingresos por asistencia técnica</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_31' model='account.account.tag'> - <field name='name'>401.31 Ingresos por donativos</field> + <field name='name'>401.34 Ingresos por donativos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_32' model='account.account.tag'> - <field name='name'>401.32 Ingresos por intereses (actividad propia)</field> + <field name='name'>401.35 Ingresos por intereses (actividad propia)</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_33' model='account.account.tag'> - <field name='name'>401.33 Ingresos de copropiedad</field> + <field name='name'>401.36 Ingresos de copropiedad</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_34' model='account.account.tag'> - <field name='name'>401.34 Ingresos por fideicomisos</field> + <field name='name'>401.37 Ingresos por fideicomisos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_35' model='account.account.tag'> - <field name='name'>401.35 Ingresos por factoraje financiero</field> + <field name='name'>401.38 Ingresos por factoraje financiero</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_36' model='account.account.tag'> - <field name='name'>401.36 Ingresos por arrendamiento financiero</field> + <field name='name'>401.39 Ingresos por arrendamiento financiero</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_37' model='account.account.tag'> - <field name='name'>401.37 Ingresos de extranjeros con establecimiento en el país</field> + <field name='name'>401.40 Ingresos de extranjeros con establecimiento en el país</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> </record> <record id='account_tag_401_38' model='account.account.tag'> - <field name='name'>401.38 Otros ingresos propios</field> + <field name='name'>401.41 Otros ingresos propios</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>A</field> @@ -2293,20 +2311,26 @@ <field name='applicability'>accounts</field> <field name='nature'>D</field> </record> + <record id='account_tag_402_02_new' model='account.account.tag'> + <field name='name'>402.02 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios en zona fronteriza norte</field> + <field name='color'>4</field> + <field name='applicability'>accounts</field> + <field name='nature'>D</field> + </record> <record id='account_tag_402_02' model='account.account.tag'> - <field name='name'>402.02 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios al 0%</field> + <field name='name'>402.03 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios al 0%</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>D</field> </record> <record id='account_tag_402_03' model='account.account.tag'> - <field name='name'>402.03 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios exentos</field> + <field name='name'>402.04 Devoluciones, descuentos o bonificaciones sobre ventas y/o servicios exentos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>D</field> </record> <record id='account_tag_402_04' model='account.account.tag'> - <field name='name'>402.04 Devoluciones, descuentos o bonificaciones de otros ingresos</field> + <field name='name'>402.05 Devoluciones, descuentos o bonificaciones de otros ingresos</field> <field name='color'>4</field> <field name='applicability'>accounts</field> <field name='nature'>D</field> 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=" "/> <field name="product_qty"/> <field name="parent_product_tmpl_id" invisible="1" /> + <field name="valid_product_attribute_value_ids" invisible="1"/> <field name="product_uom_id" options="{'no_open':True,'no_create':True}" groups="uom.group_uom"/> - <field name="attribute_value_ids" widget="many2many_tags" options="{'no_create': True}" attrs="{'column_invisible': [('parent.product_id', '!=', False)]}" groups="product.group_product_variant"/> + <field name="attribute_value_ids" widget="many2many_tags" domain="[('id', 'in', valid_product_attribute_value_ids)]" options="{'no_create': True}" attrs="{'column_invisible': [('parent.product_id', '!=', False)]}" groups="product.group_product_variant"/> <field name="operation_id" groups="mrp.group_mrp_routings" attrs="{'column_invisible': [('parent.type','not in', ('normal', 'phantom'))]}" domain="[('routing_id', '=', parent.routing_id)]" options="{'no_quick_create':True,'no_create_edit':True}"/> </tree> </field> 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 @@ <field name="finished_lots_exist" invisible="1"/> </group> </group> - <field name="move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}"> + <field name="move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': ['|', ('is_locked', '=', True), ('state', '=', 'cancel')]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}"> <tree editable="bottom" decoration-success="product_uom_qty==qty_done" decoration-danger="(product_uom_qty > 0) and (qty_done>product_uom_qty)"> <field name="lot_id" attrs="{'column_invisible': [('parent.has_tracking', '=', 'none')]}" domain="[('product_id', '=', parent.product_id)]" context="{'default_product_id': parent.product_id}"/> <field name="lot_produced_ids" widget="many2many_tags" options="{'no_open': True, 'no_create': True}" domain="[('id', 'in', parent.order_finished_lot_ids)]" invisible="not context.get('final_lots')"/> 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 @@ <input type="checkbox" name="o_payment_form_save_token" data-remove-me=""/> Save my payment data </label> + <t t-if="acq.save_token == 'always'"> + <input type="checkbox" name="o_payment_form_save_token" checked="'checked'" class="o_hidden" data-remove-me=""/> + </t> </div> </t> <t t-else="acq.payment_flow == 's2s'"> 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 @@ <div class="row"> <div t-attf-class="col {{'col-lg-6' if not task.description else 'col-lg-12'}}"> <ul class="list-group"> - <a class="list-group-item list-group-item-action d-flex align-items-center oe_attachments py-1 px-2" t-foreach='task.attachment_ids' t-as='attachment' t-attf-href="/web/content/#{attachment.id}" target="_blank" data-no-post-process=""> + <a class="list-group-item list-group-item-action d-flex align-items-center oe_attachments py-1 px-2" t-foreach='task.attachment_ids' t-as='attachment' t-attf-href="/web/content/#{attachment.id}?download=true" target="_blank" data-no-post-process=""> <div class='oe_attachment_embedded o_image o_image_small mr-2 mr-lg-3' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-data-src="/web/image/#{attachment.id}/50x40"/> <div class='oe_attachment_name text-truncate'><t t-esc='attachment.name'/></div> </a> 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 @@ <field name="amount_total" sum="Total amount" widget="monetary"/> <field name="currency_id" invisible="1"/> <field name="state"/> - <field name="invoice_status" attrs="{'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))]}"/> + <field name="invoice_status" invisible="1"/> </tree> </field> </record> 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 @@ <page string="Repair Line"> <group> <group> + <field name="type"/> <field name="product_id"/> <field name="name"/> <label for="product_uom_qty"/> @@ -86,7 +87,6 @@ <field name="product_uom_qty"/> <field name="product_uom" groups="uom.group_uom"/> </div> - <field name="type" invisible="1"/> <field name="price_unit"/> <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/> <field name="invoiced" invisible="1"/> @@ -137,7 +137,7 @@ <field name="fees_lines"> <form string="Fees"> <group> - <field name="product_id"/> + <field name="product_id" domain="[('type','=','service')]" required="True"/> <field name="name"/> <label for="product_uom_qty"/> <div class="o_row"> diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 32321fedcf8..48b10e8a9c6 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1242,6 +1242,7 @@ class SaleOrderLine(models.Model): :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) """ result = {} + # avoid recomputation if no SO lines concerned if not self: return result diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 0ef68fd2ae1..bae766f63c8 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -447,6 +447,7 @@ name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags" + options="{'color_field': 'color'}" /> <field name="price_unit" diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 75c09e1db4a..5d1d240ea9a 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -92,10 +92,10 @@ class ProductTemplate(models.Model): @api.onchange('type') def _onchange_type(self): res = super(ProductTemplate, self)._onchange_type() - if self.type == 'service': + if self.type == 'service' and not self.invoice_policy: self.invoice_policy = 'order' self.service_type = 'timesheet' - elif self.type == 'consu' and self.service_policy == 'ordered_timesheet': + elif self.type == 'consu' and not self.invoice_policy and self.service_policy == 'ordered_timesheet': self.invoice_policy = 'order' return res diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index adec4d21015..7ee2d8b9969 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -129,8 +129,8 @@ class ProjectTask(models.Model): def _get_default_partner(self): partner = False if 'default_project_id' in self.env.context: # partner from SO line is prior on one from project - project = self.env['project.project'].browse(self.env.context['default_project_id']) - partner = project.sale_line_id.order_partner_id + project_sudo = self.env['project.project'].browse(self.env.context['default_project_id']).sudo() + partner = project_sudo.sale_line_id.order_partner_id if not partner: partner = super(ProjectTask, self)._get_default_partner() return partner @@ -213,7 +213,7 @@ class ProjectTask(models.Model): @api.multi @api.constrains('sale_line_id') def _check_sale_line_type(self): - for task in self: + for task in self.sudo(): if task.sale_line_id: if not task.sale_line_id.is_service or task.sale_line_id.is_expense: raise ValidationError(_('You cannot link the order item %s - %s to this task because it is a re-invoiced expense.' % (task.sale_line_id.order_id.id, task.sale_line_id.product_id.name))) diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index 0e12add5f27..19e39d753ca 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -82,12 +82,16 @@ class Project(models.Model): # # Time Repartition (per employee per billable types) # - user_ids = self.env['project.task'].sudo().search_read([('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id']) + user_ids = self.env['project.task'].sudo().read_group([('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id'], ['user_id']) user_ids = [user_id['user_id'][0] for user_id in user_ids] employee_ids = self.env['res.users'].sudo().search_read([('id', 'in', user_ids)], ['employee_ids']) # flatten the list of list employee_ids = list(itertools.chain.from_iterable([employee_id['employee_ids'] for employee_id in employee_ids])) - employees = self.env['hr.employee'].sudo().browse(employee_ids) | self.env['account.analytic.line'].search([('project_id', 'in', self.ids)]).mapped('employee_id') + + aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id']) + employee_ids.extend(list(map(lambda x: x['employee_id'][0], aal_employee_ids))) + + employees = self.env['hr.employee'].sudo().browse(employee_ids) repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type repartition_data = self.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) @@ -332,8 +336,14 @@ class Project(models.Model): def _plan_prepare_actions(self, values): actions = [] if len(self) == 1: + task_order_line_ids = [] + # retrieve all the sale order line that we will need later below + if self.env.user.has_group('sales_team.group_sale_salesman') or self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): + task_order_line_ids = self.env['project.task'].read_group([('project_id', '=', self.id), ('sale_line_id', '!=', False)], ['sale_line_id'], ['sale_line_id']) + task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids] + if self.env.user.has_group('sales_team.group_sale_salesman'): - if not self.sale_line_id and not self.tasks.mapped('sale_line_id'): + if not self.sale_line_id and not task_order_line_ids: actions.append({ 'label': _("Create a Sales Order"), 'type': 'action', @@ -342,14 +352,19 @@ class Project(models.Model): }) if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): to_invoice_amount = values['dashboard']['profit'].get('to_invoice', False) # plan project only takes services SO line with timesheet into account - sale_orders = self.tasks.mapped('sale_line_id.order_id').filtered(lambda so: so.invoice_status == 'to invoice') - if to_invoice_amount and sale_orders: - if len(sale_orders) == 1: + + sale_order_ids = self.env['sale.order.line'].read_group([('id', 'in', task_order_line_ids)], ['order_id'], ['order_id']) + sale_order_ids = [s['order_id'][0] for s in sale_order_ids] + sale_order_ids = self.env['sale.order'].search_read([('id', 'in', sale_order_ids), ('invoice_status', '=', 'to invoice')], ['id']) + sale_order_ids = list(map(lambda x: x['id'], sale_order_ids)) + + if to_invoice_amount and sale_order_ids: + if len(sale_order_ids) == 1: actions.append({ 'label': _("Create Invoice"), 'type': 'action', 'action_id': 'sale.action_view_sale_advance_payment_inv', - 'context': json.dumps({'active_ids': sale_orders.ids, 'active_model': 'project.project'}), + 'context': json.dumps({'active_ids': sale_order_ids, 'active_model': 'project.project'}), }) else: actions.append({ @@ -385,9 +400,13 @@ class Project(models.Model): # if only one project, add it in the context as default value tasks_domain = [('project_id', 'in', self.ids)] tasks_context = self.env.context - tasks_projects = self.env['project.task'].sudo().search(tasks_domain).mapped('project_id') - if len(tasks_projects) == 1: - tasks_context = {**tasks_context, 'default_project_id': tasks_projects.id} + + # filter out all the projects that have no tasks + task_projects_ids = self.env['project.task'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id']) + task_projects_ids = [p['project_id'][0] for p in task_projects_ids] + + if len(task_projects_ids) == 1: + tasks_context = {**tasks_context, 'default_project_id': task_projects_ids[0]} stat_buttons.append({ 'name': _('Tasks'), 'count': sum(self.mapped('task_count')), @@ -400,8 +419,13 @@ class Project(models.Model): }) if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): - sale_orders = self.mapped('sale_line_id.order_id') | self.mapped( - 'tasks.sale_order_id') + # read all the sale orders linked to the projects' tasks + task_so_ids = self.env['project.task'].search_read([ + ('project_id', 'in', self.ids), ('sale_order_id', '!=', False) + ], ['sale_order_id']) + task_so_ids = [o['sale_order_id'][0] for o in task_so_ids] + + sale_orders = self.mapped('sale_line_id.order_id') | self.env['sale.order'].browse(task_so_ids) if sale_orders: stat_buttons.append({ 'name': _('Sales Orders'), @@ -413,15 +437,20 @@ class Project(models.Model): context={'create': False, 'edit': False, 'delete': False} ) }) - invoices = sale_orders.mapped('invoice_ids').filtered(lambda inv: inv.type == 'out_invoice') - if invoices: + + invoice_ids = self.env['sale.order'].search_read([('id', 'in', sale_orders.ids)], ['invoice_ids']) + invoice_ids = list(itertools.chain(*[i['invoice_ids'] for i in invoice_ids])) + invoice_ids = self.env['account.invoice'].search_read([('id', 'in', invoice_ids), ('type', '=', 'out_invoice')], ['id']) + invoice_ids = list(map(lambda x: x['id'], invoice_ids)) + + if invoice_ids: stat_buttons.append({ 'name': _('Invoices'), - 'count': len(invoices), + 'count': len(invoice_ids), 'icon': 'fa fa-pencil-square-o', 'action': _to_action_data( action=self.env.ref('account.action_invoice_tree1'), - domain=[('id', 'in', invoices.ids), ('type', '=', 'out_invoice')], + domain=[('id', 'in', invoice_ids), ('type', '=', 'out_invoice')], context={'create': False, 'delete': False} ) }) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 203674c2d28..f0c34023b5e 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -365,6 +365,12 @@ class SaleOrderLine(models.Model): map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project else: map_so_project[so_line.order_id.id] = project + elif not project: + # Attach subsequent SO lines to the created project + so_line.project_id = ( + map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id)) + or map_so_project.get(so_line.order_id.id) + ) if so_line.product_id.service_tracking == 'task_in_project': if not project: if so_line.product_id.project_template_id: diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index eb1532d8744..e2591ba0268 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -363,8 +363,8 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): # check each line has or no generate something self.assertTrue(so_line1.project_id, "Line1 should have create a project based on template A") self.assertTrue(so_line2.project_id, "Line2 should have create an empty project") - self.assertFalse(so_line3.project_id, "Line3 should not have create a project, since line1 already create a project based on template A") - self.assertFalse(so_line4.project_id, "Line4 should not have create a project, since line1 already create an empty project") + self.assertEqual(so_line3.project_id, so_line1.project_id, "Line3 should reuse project of line1") + self.assertEqual(so_line4.project_id, so_line2.project_id, "Line4 should reuse project of line2") self.assertTrue(so_line4.task_id, "Line4 should have create a new task, even if no project created.") self.assertTrue(so_line5.project_id, "Line5 should have create a project based on template B") diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index 92c7e0e21cc..2831a293b17 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -18,12 +18,28 @@ </field> </record> + <record id="product_template_view_search_sale_timesheet" model="ir.ui.view"> + <field name="name">product.template.search.timesheet</field> + <field name="model">product.template</field> + <field name="inherit_id" ref="product.product_template_search_view"/> + <field name="mode">primary</field> + <field name="arch" type="xml"> + <xpath expr="//filter[@name='consumable']" position="after"> + <separator/> + <filter string="Time-based services" name="product_time_based" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')]"/> + <filter string="Fixed price services" name="product_service_fixed" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'order'), ('service_type', '=', 'timesheet')]"/> + <filter string="Milestone services" name="product_service_milestone" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'manual')]"/> + </xpath> + </field> + </record> + <record id="product_template_action_time_based" model="ir.actions.act_window"> <field name="name">Products</field> <field name="res_model">product.template</field> <field name="view_type">form</field> <field name="view_mode">tree,form</field> - <field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'timesheet'}</field> + <field name="search_view_id" ref="sale_timesheet.product_template_view_search_sale_timesheet"/> + <field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'timesheet', 'default_service_policy': 'delivered_timesheet'}</field> </record> <record id="product_template_action_fixed" model="ir.actions.act_window"> @@ -31,7 +47,8 @@ <field name="res_model">product.template</field> <field name="view_type">form</field> <field name="view_mode">tree,form</field> - <field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order', 'default_service_type': 'timesheet'}</field> + <field name="search_view_id" ref="sale_timesheet.product_template_view_search_sale_timesheet"/> + <field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order', 'default_service_type': 'timesheet', 'default_service_policy': 'ordered_timesheet'}</field> </record> <record id="product_template_action_milestone" model="ir.actions.act_window"> @@ -39,7 +56,8 @@ <field name="res_model">product.template</field> <field name="view_type">form</field> <field name="view_mode">tree,form</field> - <field name="context">{'search_default_product_service_milestone':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'manual'}</field> + <field name="search_view_id" ref="sale_timesheet.product_template_view_search_sale_timesheet"/> + <field name="context">{'search_default_product_service_milestone':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'manual', 'default_service_policy': 'delivered_manual'}</field> </record> </odoo> diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index d02defc8558..a1eb0003c66 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -61,20 +61,42 @@ <field name="groups_id" eval="[(4, ref('base.group_user'))]"/> <field name="inherit_id" ref="project.view_task_form2"/> <field name="arch" type="xml"> - <xpath expr="//button[@name='toggle_active']" position="before"> - <button type="object" name="action_view_so" - class="oe_stat_button" icon="fa-dollar" - attrs="{'invisible': [('sale_order_id', '=', False)]}" - string="Sales Order"/> - <field name="sale_order_id" invisible="1"/> - </xpath> <field name="partner_id" position="after"> - <field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': ['|', ('partner_id', '=', False)]}" options='{"no_create": True}' context="{'create': False, 'edit': False, 'delete': False}"/> + <field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': ['|', ('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}"/> <field name="billable_type" invisible="1"/> </field> </field> </record> + <record id="project_task_view_form_inherit_sale_line_editable" model="ir.ui.view"> + <field name="name">project.task.form.inherit.sale.line.editable.salesman</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="view_sale_service_inherit_form2"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='sale_line_id']" position="attributes"> + <attribute name="options">{"no_create": True}</attribute> + <attribute name="readonly">0</attribute> + </xpath> + </field> + <field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> + </record> + + <record id="project_task_view_form_sale_order" model="ir.ui.view"> + <field name="name">project.task.form.inherit.sale.order</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="project.view_task_form2"/> + <field name="arch" type="xml"> + <xpath expr="//button[@name='toggle_active']" position="before"> + <button type="object" name="action_view_so" + class="oe_stat_button" icon="fa-dollar" + attrs="{'invisible': [('sale_order_id', '=', False)]}" + string="Sales Order"/> + <field name="sale_order_id" invisible="1"/> + </xpath> + </field> + <field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> + </record> + <record id="project_task_view_search" model="ir.ui.view"> <field name="name">project.task.search.inherit</field> <field name="model">project.task</field> diff --git a/addons/snailmail/models/ir_actions_report.py b/addons/snailmail/models/ir_actions_report.py index 2a1ad2f9496..b441d55c0fb 100644 --- a/addons/snailmail/models/ir_actions_report.py +++ b/addons/snailmail/models/ir_actions_report.py @@ -6,6 +6,14 @@ from odoo import models, fields, api, _ class IrActionsReport(models.Model): _inherit = 'ir.actions.report' + @api.multi + def retrieve_attachment(self, record): + # Override this method in order to force to re-render the pdf in case of + # using snailmail + if self.env.context.get('snailmail_layout'): + return False + return super(IrActionsReport, self).retrieve_attachment(record) + @api.model def get_paperformat(self): # force the right format (euro/A4) when sending letters, only if we are not using the l10n_DE layout diff --git a/addons/snailmail_account/tests/test_pingen_send.py b/addons/snailmail_account/tests/test_pingen_send.py index ce73f388274..2cbda8ac9df 100644 --- a/addons/snailmail_account/tests/test_pingen_send.py +++ b/addons/snailmail_account/tests/test_pingen_send.py @@ -25,7 +25,7 @@ class TestPingenSend(AccountingTestCase): self.data = { 'data': json.dumps({ 'speed': 1, - 'color': 2, + 'color': 1, 'duplex': 0, 'send': True, }) diff --git a/addons/stock/data/stock_data.xml b/addons/stock/data/stock_data.xml index 34693a1c29f..199c6207c7b 100644 --- a/addons/stock/data/stock_data.xml +++ b/addons/stock/data/stock_data.xml @@ -14,6 +14,8 @@ <!-- Resource: stock.location --> + </data> + <data noupdate="1"> <record id="stock_location_locations" model="stock.location"> <field name="name">Physical Locations</field> <field name="usage">view</field> diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index f5b3edb17d1..890dd462d24 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -200,7 +200,7 @@ class Route(models.Model): @api.onchange('warehouse_selectable') def _onchange_warehouse_selectable(self): if not self.warehouse_selectable: - self.warehouse_ids = [] + self.warehouse_ids = [(5, 0, 0)] def toggle_active(self): for route in self: diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 07e064a3dea..4f47f83cf56 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1156,7 +1156,7 @@ class StockMove(models.Model): .filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1): if len(result_package.quant_ids.mapped('location_id')) > 1: raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.')) - picking = moves_todo and moves_todo[0].picking_id or False + picking = moves_todo.mapped('picking_id') moves_todo.write({'state': 'done', 'date': fields.Datetime.now()}) moves_todo.mapped('move_dest_ids')._action_assign() diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 66a7aa90b8c..f43374dad61 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -433,5 +433,5 @@ class QuantPackage(models.Model): for quant in self._get_contained_quants(): if quant.product_id not in res: res[quant.product_id] = 0 - res[quant.product_id] += quant.qty + res[quant.product_id] += quant.quantity return res diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 19cbed3599b..a82878fc893 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -380,7 +380,7 @@ <separator/> <filter name="late" string="Late" domain="[('scheduled_date','<', time.strftime('%%Y-%%m-%%d %%H:%%M:%%S')), ('state', 'in', ('assigned', 'waiting', 'confirmed'))]" help="Pickings that are late on scheduled time"/> <separator/> - <filter name="backorder" string="Backorders" domain="[('backorder_id','!=', False)]" help="Remaining parts of picking partially processed"/> + <filter name="backorder" string="Backorders" domain="[('backorder_id','!=', False), ('state', 'in', ('assigned', 'waiting', 'confirmed'))]" help="Remaining parts of picking partially processed"/> <separator/> <filter string="My Activities" name="activities_my" domain="[('activity_ids.user_id', '=', uid)]"/> @@ -566,7 +566,6 @@ action = action_values 'default_picking_type_id': active_id, 'contact_display': 'partner_address', 'search_default_late': 1, - 'search_default_confirmed': 1, } </field> <field name="search_view_id" ref="view_picking_internal_search"/> @@ -589,7 +588,6 @@ action = action_values 'default_picking_type_id': active_id, 'contact_display': 'partner_address', 'search_default_backorder': 1, - 'search_default_confirmed': 1, } </field> <field name="search_view_id" ref="view_picking_internal_search"/> diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index 02d81a6d34a..e7a5eeed161 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -17,6 +17,7 @@ <link href="/survey/static/src/css/survey_print.css" rel="stylesheet" type="text/css"/> <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link> + <link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_templates.scss"/> </template> <template id="survey_web_assets" name="Survey Web Assets"> @@ -32,7 +33,6 @@ <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fonts.scss" /> <!-- Some helper classes (like mt- mb-) are required for survey templates --> <link rel="stylesheet" type="text/less" href="/web_editor/static/src/scss/web_editor.common.scss"/> - <link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_templates.scss"/> </template> <template id="survey_report_assets_pdf" inherit_id="web.report_assets_pdf"> diff --git a/addons/uom/models/uom_uom.py b/addons/uom/models/uom_uom.py index f98b0dc67bb..04368b3624c 100644 --- a/addons/uom/models/uom_uom.py +++ b/addons/uom/models/uom_uom.py @@ -83,9 +83,8 @@ class UoM(models.Model): self._cr.execute(""" SELECT C.id AS category_id, count(U.id) AS uom_count FROM uom_category C - LEFT JOIN uom_uom U ON C.id = U.category_id AND uom_type = 'reference' + LEFT JOIN uom_uom U ON C.id = U.category_id AND uom_type = 'reference' AND U.active = 't' WHERE C.id IN %s - AND U.active = 't' GROUP BY C.id """, (tuple(category_ids),)) for uom_data in self._cr.dictfetchall(): diff --git a/addons/uom/tests/test_uom.py b/addons/uom/tests/test_uom.py index 66207639f77..c6fe98dd81e 100644 --- a/addons/uom/tests/test_uom.py +++ b/addons/uom/tests/test_uom.py @@ -68,6 +68,16 @@ class TestUom(TransactionCase): 'name': 'Custom UoM category', }) + # at first we can not create a non reference in custom category + with self.assertRaises(ValidationError): + self.env['uom.uom'].create({ + 'name': 'Bigger UoM of my category', + 'factor_inv': 42, + 'uom_type': 'bigger', + 'rounding': 0.5, + 'category_id': category.id + }) + # create the reference self.env['uom.uom'].create({ 'name': 'Reference UoM of my category', 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 5667e2d1441..28c7d96b5d8 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 @@ -372,7 +372,7 @@ ActionManager.include({ Widget: View, }); } else if (config.debug === 'assets') { - console.error("View type '" + viewType + "' is not present in the view registry."); + console.log("View type '" + viewType + "' is not present in the view registry."); } }); return views; diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index e546eb02be0..546831a11ce 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1510,6 +1510,7 @@ var AbstractFieldBinary = AbstractField.extend({ */ _clearFile: function (){ var self = this; + this.$('.o_input_file').val(''); this.set_filename(''); if (!this.isDestroyed()) { this._setValue(false).then(function() { diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js index 9e79ecf19d2..45c7340d5c1 100644 --- a/addons/web/static/src/js/views/qweb/qweb_view.js +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -65,8 +65,9 @@ var Model = AbstractModel.extend({ /** * reload */ - reload: function (_id, _params) { - // FIXME: update this._state or something? + reload: function (_id, params) { + _.extend(this._state, _.pick(params, ['domain', 'context'])); + return this._fetch(); } }); diff --git a/addons/web/static/src/scss/layout_boxed.scss b/addons/web/static/src/scss/layout_boxed.scss index 2129f583b21..bc4f5a6c2fb 100644 --- a/addons/web/static/src/scss/layout_boxed.scss +++ b/addons/web/static/src/scss/layout_boxed.scss @@ -1,5 +1,5 @@ .o_boxed_footer, .o_boxed_header, .o_report_layout_boxed { - font-family: 'Lato' 'Noto'; + font-family: 'Lato', 'Noto'; color: gray('700'); font-size: 12px; } diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 091b0baccd2..92600eed10e 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1998,6 +1998,36 @@ QUnit.module('basic_fields', { list.destroy(); }); + QUnit.test("binary fields input value is empty whean clearing after uploading", async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="document" filename="foo"/>' + + '<field name="foo"/>' + + '</form>', + res_id: 1, + }); + + await testUtils.form.clickEdit(form); + + // // We need to convert the input type since we can't programmatically set the value of a file input + form.$('.o_input_file').attr('type', 'text').val('coucou.txt'); + + assert.strictEqual(form.$('.o_input_file').val(), 'coucou.txt', + "input value should be changed to \"coucou.txt\""); + + await testUtils.dom.click(form.$('.o_field_binary_file > .o_clear_file_button')); + + assert.strictEqual(form.$('.o_input_file').val(), '', + "input value should be empty"); + + form.destroy(); + }); + QUnit.test('field text in editable list view', async function (assert) { assert.expect(1); diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index d322366f359..5e7e39f9dfc 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -99,7 +99,7 @@ class Website(Home): if request.env['res.users'].browse(request.uid).has_group('base.group_user'): redirect = b'/web?' + request.httprequest.query_string else: - redirect = '/' + redirect = '/my' return http.redirect_with_hash(redirect) return response @@ -467,3 +467,8 @@ class WebsiteBinary(http.Controller): if unique: kw['unique'] = unique return Binary().content_image(**kw) + + @http.route(['/favicon.ico'], type='http', auth='public', website=True) + def favicon(self, **kw): + # when opening a pdf in chrome, chrome tries to open the default favicon url + return self.content_image(model='website', id=str(request.website.id), field='favicon', **kw) diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 9701610dde3..37cd73125e4 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -1,27 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <data noupdate="1"> - <!-- Default Menu to store module menus for new website --> - <record id="main_menu" model="website.menu"> - <field name="name">Default Main Menu</field> - <field name="url">/default-main-menu</field> - </record> - <record id="menu_home" model="website.menu"> - <field name="name">Home</field> - <field name="url">/</field> - <field name="parent_id" ref="website.main_menu"/> - <field name="sequence" type="int">10</field> - </record> - <record id="menu_contactus" model="website.menu"> - <field name="name">Contact us</field> - <field name="url">/contactus</field> - <field name="parent_id" ref="website.main_menu"/> - <field name="sequence" type="int">60</field> - </record> - + <data> <!-- split between ir_ui_view & website_page record to keep external_id on ir_ui_view --> - - <!-- Homepage --> <record id="homepage" model="ir.ui.view"> <field name="name">Home</field> <field name="type">qweb</field> @@ -35,13 +15,7 @@ </t> </field> </record> - <record id="homepage_page" model="website.page"> - <field name="website_published">True</field> - <field name="url">/</field> - <field name="view_id" ref="homepage"/> - </record> - <!-- Contact Us page --> <record id="contactus" model="ir.ui.view"> <field name="name">Contact Us</field> <field name="type">qweb</field> @@ -93,13 +67,7 @@ </t> </field> </record> - <record id="contactus_page" model="website.page"> - <field name="url">/contactus</field> - <field name="website_published">True</field> - <field name="view_id" ref="contactus"/> - </record> - <!-- About Us page --> <record id="aboutus" model="ir.ui.view"> <field name="name">About us</field> <field name="type">qweb</field> @@ -145,12 +113,43 @@ </t> </field> </record> + </data> + + <data noupdate="1"> + <record id="homepage_page" model="website.page"> + <field name="website_published">True</field> + <field name="url">/</field> + <field name="view_id" ref="homepage"/> + </record> + <record id="contactus_page" model="website.page"> + <field name="url">/contactus</field> + <field name="website_published">True</field> + <field name="view_id" ref="contactus"/> + </record> <record id="aboutus_page" model="website.page"> <field name="website_published">True</field> <field name="url">/aboutus</field> <field name="view_id" ref="aboutus"/> </record> + <!-- Default Menu to store module menus for new website --> + <record id="main_menu" model="website.menu"> + <field name="name">Default Main Menu</field> + <field name="url">/default-main-menu</field> + </record> + <record id="menu_home" model="website.menu"> + <field name="name">Home</field> + <field name="url">/</field> + <field name="parent_id" ref="website.main_menu"/> + <field name="sequence" type="int">10</field> + </record> + <record id="menu_contactus" model="website.menu"> + <field name="name">Contact us</field> + <field name="url">/contactus</field> + <field name="parent_id" ref="website.main_menu"/> + <field name="sequence" type="int">60</field> + </record> + <record id="default_website" model="website"> <field name="name">My Website</field> <field name="domain"></field> diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index a396b676940..946ed9fe7f8 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -82,7 +82,8 @@ class View(models.Model): # original tree. Indeed, the order of children 'id' fields # must remain the same so that the inheritance is applied # in the same order in the copied tree. - inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key}) + child = inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key}) + inherit_child.inherit_children_ids.write({'inherit_id': child.id}) inherit_child.unlink() else: # Trigger COW on inheriting views diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 0c63ee55f41..82e4f36107f 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -141,7 +141,7 @@ class Website(models.Model): public_user_to_change_websites = self.env['website'] self._get_languages.clear_cache(self) if 'company_id' in values and 'user_id' not in values: - public_user_to_change_websites = self.filtered(lambda w: w.user_id.company_id.id != values['company_id']) + public_user_to_change_websites = self.filtered(lambda w: w.sudo().user_id.company_id.id != values['company_id']) if public_user_to_change_websites: company = self.env['res.company'].browse(values['company_id']) super(Website, public_user_to_change_websites).write(dict(values, user_id=company._get_public_user().id)) diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index bb847ba9f6a..0d24a2517ad 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -797,6 +797,54 @@ class TestCowViewSaving(common.TransactionCase): all_title_updated = specific_view.website_meta_title == self.base_view.website_meta_title == "A bug got fixed by updating this field" self.assertEqual(all_title_updated, True, "Update on top level generic views should also be applied on specific views") + def test_multiple_inherit_level(self): + """ Test multi-level inheritance: + Base + | + ---> Extension (Website-specific) + | + ---> Extension 2 (Website-specific) + """ + View = self.env['ir.ui.view'] + + self.inherit_view.website_id = 1 + inherit_view_2 = View.create({ + 'name': 'Extension 2', + 'mode': 'extension', + 'inherit_id': self.inherit_view.id, + 'arch': '<div position="inside">, extended content 2</div>', + 'key': 'website.extension_view_2', + 'website_id': 1, + }) + + total_views = View.search_count([]) + + # id | name | content | website_id | inherit | key + # -------------------------------------------------------------------------------------------- + # 1 | Base | base content | / | / | website.base_view + # 2 | Extension | , extended content | 1 | 1 | website.extension_view + # 3 | Extension 2 | , extended content 2 | 1 | 2 | website.extension_view_2 + + self.base_view.with_context(website_id=1).write({'arch': '<div>modified content</div>'}) + + # 2 views are created, one is deleted + self.assertEqual(View.search_count([]), total_views + 1) + self.assertFalse(self.inherit_view.exists()) + self.assertTrue(inherit_view_2.exists()) + + # Verify the inheritance + base_specific = View.search([('key', '=', self.base_view.key), ('website_id', '=', 1)]).with_context(load_all_views=True) + extend_specific = View.search([('key', '=', 'website.extension_view'), ('website_id', '=', 1)]) + self.assertEqual(extend_specific.inherit_id, base_specific) + self.assertEqual(inherit_view_2.inherit_id, extend_specific) + + # id | name | content | website_id | inherit | key + # -------------------------------------------------------------------------------------------- + # 1 | Base | base content | / | / | website.base_view + # 4 | Base | modified content | 1 | / | website.base_view + # 5 | Extension | , extended content | 1 | 4 | website.extension_view + # 3 | Extension 2 | , extended content 2 | 1 | 5 | website.extension_view_2 + class Crawler(HttpCase): def setUp(self): diff --git a/addons/website_event/views/event_templates.xml b/addons/website_event/views/event_templates.xml index 03589b6f9fc..ca118e38bc4 100644 --- a/addons/website_event/views/event_templates.xml +++ b/addons/website_event/views/event_templates.xml @@ -218,6 +218,10 @@ </div> </div> <t t-raw="0"/> + <div class="alert alert-info alert-dismissable mt16 css_non_editable_mode_hidden o_not_editable" t-ignore="True" role="status"> + <button type="button" class="close" data-dismiss="alert" aria-label="Close">&times;</button> + Following content will appear on all events + </div> <div class="oe_structure" id="oe_structure_website_event_layout_1"/> </div> </t> diff --git a/addons/website_mail/models/mail_message.py b/addons/website_mail/models/mail_message.py index 683990e1e67..911571787cf 100644 --- a/addons/website_mail/models/mail_message.py +++ b/addons/website_mail/models/mail_message.py @@ -59,7 +59,10 @@ class MailMessage(models.Model): if self.user_has_groups('base.group_public'): self.env.cr.execute('SELECT id FROM "%s" WHERE website_published IS FALSE AND id = ANY (%%s)' % (self._table), (self.ids,)) if self.env.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)) + 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) + + ' - ({} {}, {} {})'.format(_('Records:'), self.ids[:6], _('User:'), self._uid) + ) return super(MailMessage, self).check_access_rule(operation=operation) @api.multi diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index d03428d9287..8ea869d5455 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -258,8 +258,8 @@ <button class="btn btn-sm d-md-none bg-white border" type="button" data-toggle="collapse" data-target="#o_wprofile_sidebar_collapse" aria-expanded="false" aria-controls="o_wprofile_sidebar_collapse">More info</button> </div> <div class="collapse d-md-block" id="o_wprofile_sidebar_collapse"> - <div t-if="user.next_rank_id" class="mb-3"> - <small class="font-weight-bold mt-1">Next rank:</small> + <div t-if="user.rank_id" class="mb-3"> + <small t-if="user.next_rank_id" class="font-weight-bold mt-1">Next rank:</small> <t t-call="website_profile.profile_next_rank_card"> <t t-set="img_max_width">40%</t> </t> @@ -313,13 +313,18 @@ </template> <template id="profile_next_rank_card" name="Profile Next Rank Card"> - <div t-if="user.next_rank_id" class="o_wprofile_progress_circle"> + <div class="o_wprofile_progress_circle"> <svg viewBox="0 0 36 36" class="o_pc_circular_chart"> - <t t-if="(user.next_rank_id.karma_min - user.rank_id.karma_min) > 0"> - <t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))"/> + <t t-if="user.next_rank_id"> + <t t-if="(user.next_rank_id.karma_min - user.rank_id.karma_min) > 0"> + <t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))"/> + </t> + <t t-else=""> + <t t-set="user_points" t-value="0"/> + </t> </t> <t t-else=""> - <t t-set="user_points" t-value="0"/> + <t t-set="user_points" t-value="100"/> </t> <path class="o_pc_circle_bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> <path class="o_pc_circle" t-attf-stroke-dasharray="#{user_points}, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" stroke="url(#gradient)" mask="url(#mask)"/> @@ -333,12 +338,20 @@ </svg> <div class="o_pc_overlay d-flex flex-column align-items-center justify-content-center"> <img class="img-fluid" - t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id}/image_medium" - t-att-alt="user.next_rank_id.name + ' badge'" + t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id if user.next_rank_id else user.rank_id.id}/image_medium" + t-att-alt="(user.next_rank_id.name if user.next_rank_id else user.rank_id.name) + ' badge'" t-att-style="'max-width: ' + (img_max_width if img_max_width else '50%;')"/> - <h4 class=" mb-0">"<span t-field="user.next_rank_id.name"/>"</h4> + <h4 class=" mb-0"> + " + <span t-if="user.next_rank_id" t-field="user.next_rank_id.name"/> + <span t-else="" t-field="user.rank_id.name"/> + " + </h4> <small> - <span class="font-weight-bold text-primary" t-field="user.karma"/>/<span class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> xp + <span class="font-weight-bold text-primary" t-field="user.karma"/>/ + <span t-if="user.next_rank_id" class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> + <span t-else="" class="font-weight-bold" t-field="user.rank_id.karma_min"/> + xp </small> </div> </div> diff --git a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js new file mode 100644 index 00000000000..a1093ca98ee --- /dev/null +++ b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js @@ -0,0 +1,34 @@ +odoo.define('website_sale_delivery.tour', function (require) { +'use strict'; + +var tour = require("web_tour.tour"); + +tour.register('check_free_delivery', { + test: true, + url: '/shop?search=conference chair', +}, + [ + { + content: "select conference chair", + trigger: '.oe_product_cart:first a:contains("Conference Chair")', + }, + { + content: "click on add to cart", + extra_trigger: 'label:contains(Steel) input:propChecked', + trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary', + }, + { + content: "click in modal on 'Proceed to checkout' button", + trigger: 'button:contains("Proceed to Checkout")', + }, + { + content: "go to checkout", + extra_trigger: '#cart_products input.js_quantity:propValue(1)', + trigger: 'a[href*="/shop/checkout"]', + }, + { + content: "Check Free Delivery value to be zero", + trigger: "#delivery_carrier span:contains('0.0')" + }, + ]); +}); diff --git a/addons/website_sale_delivery/tests/__init__.py b/addons/website_sale_delivery/tests/__init__.py new file mode 100644 index 00000000000..2235dde4188 --- /dev/null +++ b/addons/website_sale_delivery/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_ui diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py new file mode 100644 index 00000000000..1c61c00b9fb --- /dev/null +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -0,0 +1,14 @@ +import odoo.tests +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +@odoo.tests.tagged('post_install', '-at_install') +class TestUi(odoo.tests.HttpCase): + + def test_01_free_delivery_when_exceed_threshold(self): + self.env.ref("delivery.free_delivery_carrier").write({ + 'fixed_price': 2, + 'free_over': True, + 'amount': 10, + }) + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('check_free_delivery')", "odoo.__DEBUG__.services['web_tour.tour'].tours.check_free_delivery.ready", login="admin") 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 573acdf0bc4..873bc681458 100644 --- a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml +++ b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml @@ -19,6 +19,7 @@ <xpath expr="." position="inside"> <script type="text/javascript" src="/website_sale_delivery/static/src/js/website_sale_delivery.js"></script> <link rel="stylesheet" type="text/scss" href="/website_sale_delivery/static/src/scss/website_sale_delivery.scss"/> + <script type="text/javascript" src="/website_sale_delivery/static/src/js/website_free_delivery_tour.js"></script> </xpath> </template> @@ -28,11 +29,9 @@ <t t-set='badge_class' t-value="(delivery_nb != 1 and 'float-right ' or '') + 'badge badge-secondary'" /> <t t-if="delivery.delivery_type == 'fixed'"> <span t-if="delivery.fixed_price > 0.0" t-att-class="badge_class"> - <t t-set="monetary_options" t-value='{"widget": "monetary", - "from_currency": delivery.product_id.company_id.currency_id, - "display_currency": website_sale_order.currency_id}'/> - <t t-if="delivery.free_over" t-esc="delivery.rate_shipment(website_sale_order)['price']" t-options='monetary_options'/> - <t t-else="" t-esc="delivery.fixed_price" t-options='monetary_options'/> + <t t-esc="delivery.rate_shipment(website_sale_order)['price'] if delivery.free_over else delivery.fixed_price" + t-options='{"widget": "monetary", "from_currency": delivery.product_id.company_id.currency_id, + "display_currency": website_sale_order.currency_id}'/> </span> <span t-else="" t-att-class="badge_class">Free</span> </t> diff --git a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js index 6ba3ed8b172..8348c76ad7f 100644 --- a/addons/website_sale_product_configurator/static/src/js/website_sale_options.js +++ b/addons/website_sale_product_configurator/static/src/js/website_sale_options.js @@ -40,11 +40,20 @@ publicWidget.registry.WebsiteSale.include({ * @param {integer} quantity */ _onOptionsUpdateQuantity: function (quantity) { - this.$form + var $qtyInput = this.$form .find('.js_main_product input[name="add_qty"]') - .first() - .val(quantity) - .trigger('change'); + .first(); + + if ($qtyInput.length) { + $qtyInput.val(quantity).trigger('change'); + } else { + // This handles the case when the "Select Quantity" customize show + // is disabled, and therefore the above selector does not find an + // element. + // To avoid duplicating all RPC, only trigger the variant change if + // it is not already done from the above trigger. + this.optionalProductsModal.triggerVariantChange(this.optionalProductsModal.$el); + } }, /** diff --git a/addons/website_sale_wishlist/models/product_wishlist.py b/addons/website_sale_wishlist/models/product_wishlist.py index 7467991f3a7..1bcbe57baef 100644 --- a/addons/website_sale_wishlist/models/product_wishlist.py +++ b/addons/website_sale_wishlist/models/product_wishlist.py @@ -7,7 +7,7 @@ from odoo.http import request class ProductWishlist(models.Model): _name = 'product.wishlist' _description = 'Product Wishlist' - _sql_constrains = [ + _sql_constraints = [ ("product_unique_partner_id", "UNIQUE(product_id, partner_id)", "Duplicated wishlisted product for this partner."), diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index fb972988dc6..f4a02f8937a 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -268,6 +268,7 @@ class WebsiteSlides(WebsiteProfile): 'channels_newest': channels_newest, 'achievements': achievements, 'users': users, + 'top3_users': self._get_top3_users(), 'challenges': challenges, 'challenges_done': challenges_done, }) @@ -311,6 +312,7 @@ class WebsiteSlides(WebsiteProfile): 'search_my': my, 'search_tags': search_tags, 'search_channel_tag_id': post.get('channel_tag_id'), + 'top3_users': self._get_top3_users(), }) return request.render('website_slides.courses_all', values) @@ -318,6 +320,11 @@ class WebsiteSlides(WebsiteProfile): def _prepare_additional_channel_values(self, values, **kwargs): return values + def _get_top3_users(self): + return request.env['res.users'].sudo().search_read([ + ('karma', '>', 0), + ('image', '!=', False)], ['id'], limit=3, order='karma desc') + @http.route([ '/slides/<model("slide.channel"):channel>', '/slides/<model("slide.channel"):channel>/page/<int:page>', @@ -374,6 +381,7 @@ class WebsiteSlides(WebsiteProfile): values = { 'channel': channel, + 'active_tab': kw.get('active_tab', 'home'), # search 'search_category': category, 'search_tag': tag, diff --git a/addons/website_slides/static/src/xml/slide_quiz.xml b/addons/website_slides/static/src/xml/slide_quiz.xml index 0f1f3626c7e..645ecae94cd 100644 --- a/addons/website_slides/static/src/xml/slide_quiz.xml +++ b/addons/website_slides/static/src/xml/slide_quiz.xml @@ -8,7 +8,7 @@ </div> <div t-foreach="widget.quiz.questions" t-as="question" - t-attf-class="o_wslides_js_lesson_quiz_question my-3 #{widget.readonly ? 'disabled' : ''} #{widget.slide.completed ? 'completed-disabled' : ''}" + t-attf-class="o_wslides_js_lesson_quiz_question mt-3 mb-4 #{widget.readonly ? 'disabled' : ''} #{widget.slide.completed ? 'completed-disabled' : ''}" t-att-data-question-id="question.id" t-att-data-title="question.question"> <div class="h4"> <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question.question"/> diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index 7d5f666bafb..5be3e4fb380 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -163,20 +163,20 @@ <div class="col-12 col-md-8 col-lg-9"> <ul class="nav nav-tabs o_wslides_nav_tabs flex-nowrap" role="tablist" id="profile_extra_info_tablist"> <li class="nav-item o_wslides_course_header_nav_home"> - <a t-att-class="'nav-link %s' % ('' if search_slide_type else 'active')" id="home-tab" data-toggle="pill" href="#home" role="tab" aria-controls="home" aria-selected="true"> + <a t-att-class="'nav-link %s' % ('active' if active_tab == 'home' else '')" id="home-tab" data-toggle="pill" href="#home" role="tab" aria-controls="home" t-att-aria-selected="'true' if active_tab == 'home' else 'false'"> <i class="fa fa-home"/> Course </a> </li> <li t-if="channel.allow_comment" class="nav-item"> - <a class="nav-link" id="review-tab" data-toggle="pill" href="#review" role="tab" aria-controls="review" aria-selected="false">Review</a> + <a t-att-class="'nav-link %s' % ('active' if active_tab == 'review' else '')" id="review-tab" data-toggle="pill" href="#review" role="tab" aria-controls="review" t-att-aria-selected="'true' if active_tab == 'review' else 'false'">Review</a> </li> </ul> <div class="tab-content py-4 o_wslides_tabs_content mb-4" id="courseMainTabContent"> - <div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab"> + <div t-att-class="'tab-pane fade %s' % ('show active' if active_tab == 'home' else '')" id="home" role="tabpanel" aria-labelledby="home-tab"> <t t-if="channel.channel_type == 'training'" t-call="website_slides.course_slides_list"/> </div> - <div t-if="channel.allow_comment" class="tab-pane fade" id="review" role="tabpanel" aria-labelledby="review-tab"> + <div t-if="channel.allow_comment" t-att-class="'tab-pane fade %s' % ('show active' if active_tab == 'review' else '')" id="review" role="tabpanel" aria-labelledby="review-tab"> <t t-call="portal.message_thread"> <t t-set="object" t-value="channel"/> <t t-set="hash" t-value="message_post_hash"/> diff --git a/addons/website_slides/views/website_slides_templates_homepage.xml b/addons/website_slides/views/website_slides_templates_homepage.xml index 432a9dc3a13..1b822e16037 100644 --- a/addons/website_slides/views/website_slides_templates_homepage.xml +++ b/addons/website_slides/views/website_slides_templates_homepage.xml @@ -1,11 +1,34 @@ <?xml version="1.0" ?> <odoo><data> +<template id='courses_footer'> + <!-- 'users': 3 top users with picture (skip users without picture) --> + <section class="s_banner bg-900" style="background-image: url(/website_slides/static/src/img/banner_heroes_default.svg); background-size: cover; background-position: right bottom"> + <div class="container py-5"> + <div class="row align-items-center"> + <div t-attf-class="col-12 #{'col-md' if top3_users else 'text-center'}"> + <h2 class="my-4 text-white">Meet The Heros Who Transformed Their Company!</h2> + <a href="/profile/users" class="btn btn-primary btn-lg">Go meet them</a> + </div> + <div class="col-12 col-md-5" t-if="top3_users"> + <div class="ml-4"> + <t t-foreach="top3_users" t-as="user"> + <img class="rounded-circle img-fluid shadow ml-n4" + style="border: 6px solid white" + t-attf-src="/web/image/res.users/#{user['id']}/image_medium"/> + </t> + </div> + </div> + </div> + </div> + </section> +</template> + <!-- Channels home template --> <template id='courses_home' name="Odoo Courses Homepage"> <t t-set="body_classname" t-value="'o_wslides_body'"/> <t t-call="website.layout"> - <div class="wrap o_wslides_wrap"> + <div id="wrap" class="wrap o_wslides_wrap"> <section class="s_banner overflow-hidden bg-900" style="background-image: url("/website_slides/static/src/img/banner_default.svg"); background-size: cover; background-position: 55% 65%"> <div class="container align-items-center d-flex mb-5 mt-lg-5 pt-lg-4 pb-lg-1"> <div> @@ -146,18 +169,7 @@ </div> </div> </div> - <section class="s_banner" style="background-image: url(/website_slides/static/src/img/banner_heroes_default.svg); background-size: cover; background-position: right bottom"> - <div class="container py-5"> - <div class="row align-items-center"> - <div class="col-12 col-md"> - <h2 class="my-4 text-white">Meet the people who transformed their company</h2> - </div> - <div class="col-12 col-md-4"> - <a href="/profile/users" class="btn btn-primary btn-lg">Go meet them</a> - </div> - </div> - </div> - </section> + <t t-call="website_slides.courses_footer"></t> </div> </t> </template> @@ -264,18 +276,7 @@ </div> </div> - <section class="s_banner bg-900" style="background-image: url(/website_slides/static/src/img/banner_heroes_default.svg); background-size: cover; background-position: right bottom"> - <div class="container py-5"> - <div class="row align-items-center"> - <div class="col-12 col-md"> - <h2 class="my-4">Meet the people who transformed their company</h2> - </div> - <div class="col-12 col-md-4"> - <a href="/profile/users" class="btn btn-primary btn-lg">Go meet them</a> - </div> - </div> - </div> - </section> + <t t-call="website_slides.courses_footer"></t> </div> </t> </template> @@ -395,13 +396,13 @@ <img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/> <a href="/profile/ranks_badges" t-field="user.rank_id"/> </div> - <t t-if="user.next_rank_id"> - <div class="font-weight-bold text-muted mt-1">Next rank:</div> + <t t-if="user.rank_id"> + <div t-if="user.next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div> <t t-call="website_profile.profile_next_rank_card"> <t t-set="bg_class">bg-200</t> <t t-set="img_max_width">50%</t> </t> - <div t-field="user.next_rank_id.description_motivational"/> + <div t-field="user.rank_id.description_motivational"/> </t> </div> </template> diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml index 6676cc4932f..0853c8dccb9 100644 --- a/addons/website_slides/views/website_slides_templates_lesson.xml +++ b/addons/website_slides/views/website_slides_templates_lesson.xml @@ -6,7 +6,7 @@ <t t-set="body_classname" t-value="'o_wslides_body'"/> <t t-call="website.layout"> <t t-set="main_object" t-value="slide"/> - <div class="wrap o_wslides_wrap"> + <div id="wrap" class="wrap o_wslides_wrap"> <div class="o_wslides_lesson_header o_wslides_gradient position-relative text-white pb-0 pt-2 pt-md-5"> <t t-call="website_slides.course_nav"> <t t-set="channel" t-value="slide.channel_id"/> @@ -185,12 +185,12 @@ <div class="row align-items-center my-3"> <div class="col-12 col-md order-2 order-md-1 d-flex"> <div class="d-flex align-items-center"> - <h4 class="my-0"> + <h1 class="h5 my-0"> <t t-call="website_slides.slide_icon"> <t t-set="icon_class">mr-1</t> </t> <span t-field="slide.name"/> - </h4> + </h1> <span t-if="slide.question_ids" t-att-class="'ml-2 badge %s' % ('badge-success' if channel_progress[slide.id].get('completed') else 'badge-info')"> <span t-if="channel_progress[slide.id].get('completed')"> @@ -427,7 +427,7 @@ t-att-data-has-next="1 if next_slide else 0" t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"> <div t-foreach="slide_questions" t-as="question" - t-att-class="'o_wslides_js_lesson_quiz_question mt-3 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']"> + t-att-class="'o_wslides_js_lesson_quiz_question mt-3 mb-4 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']"> <div class="h4"> <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question['question']"/> </div> diff --git a/addons/website_slides_forum/views/website_slides_forum_templates.xml b/addons/website_slides_forum/views/website_slides_forum_templates.xml index 82f7b8e9182..c9fcd28a1ee 100644 --- a/addons/website_slides_forum/views/website_slides_forum_templates.xml +++ b/addons/website_slides_forum/views/website_slides_forum_templates.xml @@ -9,13 +9,13 @@ <li class="nav-item"> <a t-att-href="'/slides/%s%s?%s' % (slug(forum.slide_channel_id), '/%s' % slug(category) if category else '', keep_query('debug'))" t-att-class="'nav-link o_wprofile_navlink'" style="border-left: 0px"> - <i class="fa fa-home"/> Home</a> + <i class="fa fa-home"/> Course</a> </li> <li class="nav-item"> <a href="#" t-att-class="'nav-link active o_wprofile_navlink'" style="border-left: 0px">Forum</a> </li> <li class="nav-item"> - <a t-att-href="'/slides/%s' % (slug(forum.slide_channel_id))" + <a t-att-href="'/slides/%s?active_tab=review' % (slug(forum.slide_channel_id))" t-att-class="'nav-link o_wprofile_navlink'" style="border-left: 0px">Review</a> </li> </ul> 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 @@ </rng:optional> <rng:optional><rng:attribute name="precision"/></rng:optional> - <rng:optional> - <rng:element name="templates"> - <rng:oneOrMore> - <rng:ref name="any"/> - </rng:oneOrMore> - </rng:element> - </rng:optional> <rng:optional><rng:attribute name="color"/></rng:optional> @@ -54,11 +47,17 @@ <rng:optional><rng:attribute name="decoration-warning"/></rng:optional> <rng:optional><rng:attribute name="decoration-danger"/></rng:optional> - <rng:zeroOrMore> - <rng:choice> - <rng:ref name="field"/> - </rng:choice> - </rng:zeroOrMore> + <rng:interleave> + <rng:zeroOrMore><rng:ref name="field"/></rng:zeroOrMore> + <rng:optional> + <rng:element name="templates"> + <rng:oneOrMore> + <rng:ref name="any"/> + </rng:oneOrMore> + </rng:element> + </rng:optional> + </rng:interleave> + <rng:empty/> </rng:element> </rng:define> 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 @@ </group> <group string="Advanced Properties" groups="base.group_no_one"> <field name="related"/> - <field name="depends"/> + <field name="depends" attrs="{'required': [('compute', 'not in', [False, ''])]}"/> <field name="compute" widget="ace" options="{'mode': 'python'}"/> </group> <div groups="base.group_no_one"> @@ -295,7 +295,7 @@ </group> <group string="Advanced Properties" groups="base.group_no_one"> <field name="related"/> - <field name="depends" attrs="{'required': [('compute', '!=', False)] }"/> + <field name="depends" attrs="{'required': [('compute', 'not in', [False, ''])]}"/> <field name="compute" widget="ace" options="{'mode': 'python'}"/> </group> <div groups="base.group_no_one"> 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) - - self.env['ir.attachment']._patch_method('create', create) - self.addCleanup(self.env['ir.attachment']._revert_method, 'create') + return origin_unlink(self) - 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 index 5648ac52219..5648ac52219 100644 --- a/odoo/addons/test_pylint/tests/_odoo_checkers.py +++ b/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 index 8e5e69fa351..8e5e69fa351 100644 --- a/odoo/addons/test_pylint/tests/test_pylint.py +++ b/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 |
