diff options
| author | Martin Trigaux <mat@odoo.com> | 2019-07-15 07:07:48 +0000 |
|---|---|---|
| committer | Martin Trigaux <mat@odoo.com> | 2019-07-15 08:12:26 +0000 |
| commit | fd15ca19183a765e1307c4d197bf0dda30b73ab7 (patch) | |
| tree | 896042073ebc752e8bcf535f21696320c69bbe13 /addons | |
| parent | 6b8a0beda65dce688ecd0ee34828097b67a13dab (diff) | |
| parent | 1f5a4649a67908ecb6acfec37673c8149de0471f (diff) | |
[MERGE] Forward port of saas-12.4 to master up to 1f5a4649a67908
closes odoo/odoo#34850
Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
Diffstat (limited to 'addons')
93 files changed, 938 insertions, 398 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 9f6c7071950..c83c5350431 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1358,7 +1358,7 @@ class AccountTax(models.Model): base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 store_included_tax_total = True - if tax.price_include: + if tax.price_include or self._context.get('force_price_include'): if tax.amount_type == 'percent': incl_percent_amount += tax.amount elif tax.amount_type == 'division': @@ -1387,7 +1387,7 @@ class AccountTax(models.Model): cumulated_tax_included_amount = 0 for tax in taxes: #compute the tax_amount - if tax.price_include and total_included_checkpoints.get(i): + if (self._context.get('force_price_include') or tax.price_include) and total_included_checkpoints.get(i): # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount) cumulated_tax_included_amount = 0 @@ -1426,7 +1426,7 @@ class AccountTax(models.Model): 'sequence': tax.sequence, 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id, 'analytic': tax.analytic, - 'price_include': tax.price_include, + 'price_include': tax.price_include or self._context.get('force_price_include'), 'tax_exigibility': tax.tax_exigibility, 'tax_repartition_line_id': repartition_line.id, 'tag_ids': (repartition_line.tag_ids + subsequent_tags).ids, diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 1309145169c..91f9fc8a38f 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1024,7 +1024,7 @@ class AccountMove(models.Model): vendor_display_name = _('From: ') + move.invoice_source_email move.invoice_vendor_icon = '@' else: - vendor_display_name = ('Created by: ') + move.create_uid.name + vendor_display_name = ('Created by: ') + move.sudo().create_uid.name move.invoice_vendor_icon = '#' move.invoice_vendor_display_name = vendor_display_name @@ -1877,6 +1877,9 @@ class AccountMove(models.Model): for move in self: if move.auto_post and move.date > fields.Date.today(): raise UserError(_("This move is configured to be auto-posted on {}".format(move.date.strftime(self.env['res.lang']._lang_get(self.env.user.lang).date_format)))) + + move.message_subscribe([p.id for p in [move.partner_id, move.commercial_partner_id] if p not in move.message_partner_ids]) + to_write = {'state': 'posted'} if move.name == '/': @@ -2091,6 +2094,18 @@ class AccountMove(models.Model): ]) records.post() + # offer the possibility to duplicate thanks to a button instead of a hidden menu, which is more visible + @api.multi + def action_duplicate(self): + self.ensure_one() + action = self.env.ref('account.action_move_journal_line').read()[0] + action['context'] = dict(self.env.context) + action['context']['form_view_initial_mode'] = 'edit' + action['context']['view_no_maturity'] = False + action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] + action['res_id'] = self.copy().id + return action + class AccountMoveLine(models.Model): _name = "account.move.line" @@ -3536,19 +3551,6 @@ class AccountMoveLine(models.Model): tables, where_clause, where_clause_params = query.get_sql() return tables, where_clause, where_clause_params - # FIXME: Clarify me and change me in master - @api.multi - def action_duplicate(self): - self.ensure_one() - action = self.env.ref('account.action_move_journal_line').read()[0] - action['target'] = 'inline' - action['context'] = dict(self.env.context) - action['context']['form_view_initial_mode'] = 'edit' - action['context']['view_no_maturity'] = False - action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] - action['res_id'] = self.copy().id - return action - def _reconciled_lines(self): ids = [] for aml in self.filtered('account_id.reconcile'): diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 67523d4e562..2834791ec38 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -174,19 +174,23 @@ class AccountReconcileModel(models.Model): tax = self.env['account.tax'].browse(tax_res['id']) new_aml_dicts.append({ - 'account_id': tax.account_id and tax.account_id.id or base_line_dict['account_id'], - 'name': tax.name, + 'account_id': tax_res['account_id'] or base_line_dict['account_id'], + 'name': tax_res['name'], 'partner_id': base_line_dict.get('partner_id'), 'debit': tax_res['amount'] > 0 and tax_res['amount'] or 0, 'credit': tax_res['amount'] < 0 and -tax_res['amount'] or 0, 'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'], 'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'], - 'tax_exigible': tax.tax_exigibility == 'on_payment', + 'tax_exigible': tax_res['tax_exigibility'], + 'tax_repartition_line_id': tax_res['tax_repartition_line_id'], + 'tax_ids': tax_res['tax_ids'], + 'tag_ids': tax_res['tag_ids'] }) # Handle price included taxes. base_line_dict['debit'] = tax_res['base'] > 0 and tax_res['base'] or base_line_dict['debit'] base_line_dict['credit'] = tax_res['base'] < 0 and -tax_res['base'] or base_line_dict['credit'] + base_line_dict['tag_ids'] = [(6, 0, res['base_tags'])] return new_aml_dicts @api.multi diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index 42e8da47d36..483e1142fab 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -262,9 +262,9 @@ class ResPartner(models.Model): LEFT JOIN account_move_line aml ON aml.partner_id = partner.id RIGHT JOIN account_account acc ON aml.account_id = acc.id WHERE acc.internal_type = %s - AND NOT acc.deprecated + AND NOT acc.deprecated AND acc.company_id = %s GROUP BY partner.id - HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, sign, operand)) + HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand)) res = self._cr.fetchall() if not res: return [('id', '=', '0')] @@ -282,7 +282,6 @@ class ResPartner(models.Model): def _invoice_total(self): account_invoice_report = self.env['account.invoice.report'] if not self.ids: - self.total_invoiced = 0.0 return True user_currency_id = self.env.company.currency_id.id diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 85a6f4bfcfb..b58d0c6376f 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -215,7 +215,8 @@ class ReportAgedPartnerBalance(models.AbstractModel): total[(i + 1)] += values['total'] values['partner_id'] = partner['partner_id'] if partner['partner_id']: - browsed_partner = self.env['res.partner'].browse(partner['partner_id']) + #browse the partner name and trust field in sudo, as we may not have full access to the record (but we still have to see it in the report) + browsed_partner = self.env['res.partner'].sudo().browse(partner['partner_id']) values['name'] = browsed_partner.name and len(browsed_partner.name) >= 45 and browsed_partner.name[0:40] + '...' or browsed_partner.name values['trust'] = browsed_partner.trust else: diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index b9e9e7d465d..f6b79a26280 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -94,6 +94,12 @@ class TestReconciliationMatchingRules(AccountingTestCase): 'sequence': 1, }) + self.tax21 = self.env['account.tax'].create({ + 'name': '21%', + 'type_tax_use': 'purchase', + 'amount': 21, + }) + def test_matching_fields(self): ''' Test all fields used to restrict the rules's applicability.''' @@ -300,6 +306,34 @@ class TestReconciliationMatchingRules(AccountingTestCase): {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0}, ]) + def test_auto_reconcile_with_tax(self): + ''' Test auto reconciliation with a tax amount included in the bank statement line''' + + self.rule_1.write({ + 'auto_reconcile': True, + 'force_tax_included': True, + 'tax_ids': [(6, 0, self.tax21.ids)], + 'rule_type': 'writeoff_suggestion', + }) + + self.bank_line_2.unlink() + self.bank_line_1.amount = -121 + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled'}, + }, + self.bank_st + ) + + # Check first line has been well reconciled. + self.assertRecordValues(self.bank_line_1.journal_entry_ids, [ + {'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id]}, + {'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_line_id': self.tax21.id}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0}, + ]) + def test_reverted_move_matching(self): AccountMove = self.env['account.move'] move = AccountMove.create({ diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 7d3cd61e1e1..0a2bf22c90d 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -761,6 +761,26 @@ <field name="exclude_from_invoice_tab" invisible="1"/> <field name="account_internal_type" invisible="1"/> </tree> + <!-- Form view to cover mobile use --> + <form> + <group> + <field name="tax_line_id" invisible="1"/> + <field name="account_id" domain="[('company_id', '=', parent.company_id), ('deprecated', '=', False)]"/> + <field name="partner_id" domain="['|', ('parent_id', '=', False), ('is_company', '=', True)]"/> + <field name="name"/> + <field name="analytic_account_id" groups="analytic.group_analytic_accounting"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags"/> + <field name="amount_currency" groups="base.group_multi_currency"/> + <field name="company_currency_id" invisible="1"/> + <field name="company_id" invisible="1"/> + <field name="currency_id" options="{'no_create': True}" groups="base.group_multi_currency"/> + <field name="debit" sum="Total Debit"/> + <field name="credit" sum="Total Credit"/> + <field name="tax_ids" string="Taxes Applied" widget="many2many_tags" options="{'no_create': True}"/> + <field name="date_maturity" required="0" invisible="context.get('view_no_maturity', False)"/> + <field name="recompute_tax_line" invisible="1" readonly="1"/> + </group> + </form> </field> </page> <page id="other_tab" string="Other Info" name="other_info" @@ -929,7 +949,8 @@ view_id="account.view_move_line_tax_audit_tree"/> <record id="action_account_moves_all_a" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_sales':1, 'name_groupby':1}</field> <field name="name">Journal Items</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_sales':1, 'name_groupby':1}</field> + <field name="name">Journal Items</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> <field name="view_id" ref="view_move_line_tree_grouped"/> @@ -937,7 +958,7 @@ </record> <record id="action_account_moves_journal_sales" model="ir.actions.act_window"> - <field name="context">{'journal_type':'sales', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_sales':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'sales', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_sales':1, 'name_groupby':1}</field> <field name="name">Sales</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -946,7 +967,7 @@ </record> <record id="action_account_moves_journal_purchase" model="ir.actions.act_window"> - <field name="context">{'journal_type':'purchase', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_purchases':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'purchase', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_purchases':1, 'name_groupby':1}</field> <field name="name">Purchase</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -955,7 +976,7 @@ </record> <record id="action_account_moves_journal_bank_cash" model="ir.actions.act_window"> - <field name="context">{'journal_type':'bank', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_bank':1, 'search_default_cash':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'bank', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_bank':1, 'search_default_cash':1, 'name_groupby':1}</field> <field name="name">Bank and Cash</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -964,7 +985,7 @@ </record> <record id="action_account_moves_journal_misc" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_misc_filter':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_misc_filter':1, 'name_groupby':1}</field> <field name="name">Miscellaneous</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -973,7 +994,7 @@ </record> <record id="action_account_moves_ledger_general" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_account': 1, 'group_by':'account_id', 'search_default_posted':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_account': 1, 'search_default_posted':1}</field> <field name="name">General Ledger</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -982,7 +1003,7 @@ </record> <record id="action_account_moves_ledger_partner" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_partner': 1, 'group_by':'partner_id', 'search_default_posted':1, 'search_default_payable':1, 'search_default_receivable':1, 'search_default_unreconciled':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_partner': 1, 'search_default_posted':1, 'search_default_payable':1, 'search_default_receivable':1, 'search_default_unreconciled':1}</field> <field name="name">Partner Ledger</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -991,7 +1012,7 @@ </record> <record id="action_account_moves_journal_bank_cash" model="ir.actions.act_window"> - <field name="context">{'journal_type':'bank', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_bank':1, 'search_default_cash':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'bank', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_bank':1, 'search_default_cash':1, 'name_groupby':1}</field> <field name="name">Bank and Cash</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -1000,7 +1021,7 @@ </record> <record id="action_account_moves_journal_misc" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'group_by':'move_id', 'search_default_posted':1, 'search_default_misc_filter':1, 'name_groupby':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_move': 1, 'search_default_posted':1, 'search_default_misc_filter':1, 'name_groupby':1}</field> <field name="name">Miscellaneous</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -1009,7 +1030,7 @@ </record> <record id="action_account_moves_ledger_general" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_account': 1, 'group_by':'account_id', 'search_default_posted':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_account': 1, 'search_default_posted':1}</field> <field name="name">General Ledger</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> @@ -1018,7 +1039,7 @@ </record> <record id="action_account_moves_ledger_partner" model="ir.actions.act_window"> - <field name="context">{'journal_type':'general', 'search_default_group_by_partner': 1, 'group_by':'partner_id', 'search_default_posted':1, 'search_default_payable':1, 'search_default_receivable':1, 'search_default_unreconciled':1}</field> + <field name="context">{'journal_type':'general', 'search_default_group_by_partner': 1, 'search_default_posted':1, 'search_default_payable':1, 'search_default_receivable':1, 'search_default_unreconciled':1}</field> <field name="name">Partner Ledger</field> <field name="res_model">account.move.line</field> <field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field> diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml index 3ed9afdabea..28d4f03de25 100644 --- a/addons/account/wizard/account_invoice_send_views.xml +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -13,6 +13,7 @@ <field name="composer_id" invisible="1"/> <field name="invoice_ids" invisible="1"/> <field name="email_from" invisible="1" /> + <field name="mail_server_id" invisible="1"/> <div name="option_print"> <field name="is_print" /> <b><label for="is_print"/></b> diff --git a/addons/account_payment/models/payment.py b/addons/account_payment/models/payment.py index 02d52d25eb3..8dd98f98285 100644 --- a/addons/account_payment/models/payment.py +++ b/addons/account_payment/models/payment.py @@ -20,7 +20,7 @@ class PaymentTransaction(models.Model): values.update(render_values) return self.acquirer_id.with_context(submit_class='btn btn-primary', submit_txt=submit_txt or _('Pay Now')).sudo().render( self.reference, - invoice.residual_signed, + invoice.amount_residual_signed, invoice.currency_id.id, values=values, ) diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml index bdc9c9da488..4f8c00964d6 100644 --- a/addons/account_payment/views/account_portal_templates.xml +++ b/addons/account_payment/views/account_portal_templates.xml @@ -7,7 +7,7 @@ <td class="text-center"> <t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/> <t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.acquirer_id.provider in ('transfer', 'manual'))"/> - <a t-if="invoice.state == 'open' and invoice.amount_total and invoice.type == 'out_invoice' and (pending_manual_txs or not tx_ids)" + <a t-if="invoice.state == 'posted' and invoice.invoice_payment_state == 'not_paid' and invoice.amount_total and invoice.type == 'out_invoice' and (pending_manual_txs or not tx_ids)" t-att-href="invoice.get_portal_url(anchor='portal_pay')" title="Pay now" aria-label="Pay now" class="btn btn-sm btn-primary" role="button"> <i class="fa fa-arrow-circle-right"/><span class='d-none d-md-inline'> Pay Now</span> </a> @@ -16,16 +16,16 @@ <xpath expr="//t[@t-foreach='invoices']/tr/td[hasclass('tx_status')]" position="replace"> <t t-set="last_tx" t-value="invoice.get_portal_last_transaction()"/> <td class="tx_status text-center"> - <t t-if="invoice.state == 'open' and (last_tx.state not in ['pending', 'authorized', 'done', 'cancel'] or (last_tx.state == 'pending' and last_tx.acquirer_id.provider in ('transfer', 'manual')))"> + <t t-if="invoice.state == 'posted' and invoice.invoice_payment_state == 'not_paid' and (last_tx.state not in ['pending', 'authorized', 'done', 'cancel'] or (last_tx.state == 'pending' and last_tx.acquirer_id.provider in ('transfer', 'manual')))"> <span class="badge badge-pill badge-info"><i class="fa fa-fw fa-clock-o"></i><span class="d-none d-md-inline"> Waiting for Payment</span></span> </t> - <t t-if="invoice.state == 'open' and last_tx.state == 'authorized'"> + <t t-if="invoice.state == 'posted' and last_tx.state == 'authorized'"> <span class="badge badge-pill badge-primary"><i class="fa fa-fw fa-check"/><span class="d-none d-md-inline"> Authorized</span></span> </t> - <t t-if="invoice.state == 'open' and last_tx.state == 'pending' and last_tx.acquirer_id.provider not in ('transfer', 'manual')"> + <t t-if="invoice.state == 'posted' and last_tx.state == 'pending' and last_tx.acquirer_id.provider not in ('transfer', 'manual')"> <span class="badge badge-pill badge-warning"><span class="d-none d-md-inline"> Pending</span></span> </t> - <t t-if="invoice.state == 'paid' or last_tx.state == 'done'"> + <t t-if="invoice.state == 'posted' and invoice.invoice_payment_state == 'paid' or last_tx.state == 'done'"> <span class="badge badge-pill badge-success"><i class="fa fa-fw fa-check"></i><span class="d-none d-md-inline"> Paid</span></span> </t> <t t-if="invoice.state == 'cancel'"> @@ -36,7 +36,7 @@ </template> <template id="portal_invoice_payment" name="Invoice Payment"> - <div class="row" t-if="not tx_ids and invoice.state == 'open' and invoice.amount_total" id="portal_pay"> + <div class="row" t-if="not tx_ids and invoice.state == 'posted' and invoice.invoice_payment_state == 'not_paid' and invoice.amount_total" id="portal_pay"> <div class="modal fade" id="pay_with" role="dialog"> <div class="modal-dialog modal-content"> <div class="modal-content"> @@ -71,14 +71,14 @@ <t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/> <t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.acquirer_id.provider in ('transfer', 'manual'))"/> <div> - <a href="#" t-if="invoice.state == 'open' and invoice.amount_total and invoice.type == 'out_invoice' and (pending_manual_txs or not tx_ids)" + <a href="#" t-if="invoice.state == 'posted' and invoice.invoice_payment_state == 'not_paid' and invoice.amount_total and invoice.type == 'out_invoice' and (pending_manual_txs or not tx_ids)" class="btn btn-primary btn-block mb-2" data-toggle="modal" data-target="#pay_with"> <i class="fa fa-fw fa-arrow-circle-right"/> Pay Now </a> - <div t-if="tx_ids and not pending_manual_txs and invoice.state != 'paid'" class="alert alert-info py-1 mb-2" > + <div t-if="tx_ids and not pending_manual_txs and invoice.invoice_payment_state != 'paid'" class="alert alert-info py-1 mb-2" > <i class="fa fa-fw fa-check-circle"/> Pending </div> - <div t-if="invoice.state == 'paid'" class="alert alert-success py-1 mb-2" > + <div t-if="invoice.invoice_payment_state == 'paid'" class="alert alert-success py-1 mb-2" > <i class="fa fa-fw fa-check-circle"/> Paid </div> </div> @@ -87,11 +87,11 @@ <div t-if="invoice.transaction_ids and invoice.amount_total and not success and not error" class="o_account_payment_tx_status" t-att-data-invoice-id="invoice.id"> <t t-call="payment.payment_confirmation_status"> <t t-set="payment_tx_id" t-value="invoice.get_portal_last_transaction()"/> - <t t-set="reference" t-value="invoice.reference"/> + <t t-set="reference" t-value="invoice.invoice_payment_ref"/> </t> </div> <t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('authorized', 'done'))"/> - <div t-if="not tx_ids and invoice.state == 'open' and invoice.amount_total" id="portal_pay"> + <div t-if="not tx_ids and invoice.state == 'posted' and invoice.invoice_payment_state == 'not_paid' and invoice.amount_total" id="portal_pay"> <div t-if="pms or acquirers" id="payment_method"> <t t-call="account_payment.portal_invoice_payment"/> </div> @@ -133,11 +133,11 @@ <t t-set="payment_tx_id" t-value="invoice.get_portal_last_transaction()"/> <span t-if='payment_tx_id.acquirer_id.done_msg' t-raw="payment_tx_id.acquirer_id.done_msg"/> <span t-if='payment_tx_id.acquirer_id.post_msg' t-raw="payment_tx_id.acquirer_id.post_msg"/> - <div t-if="payment_tx_id.acquirer_id.post_msg and payment_tx_id.acquirer_id.provider == 'transfer' and invoice.reference"> - <b>Communication: </b><span t-esc='invoice.reference'/> + <div t-if="payment_tx_id.acquirer_id.post_msg and payment_tx_id.acquirer_id.provider == 'transfer' and invoice.ref"> + <b>Communication: </b><span t-esc='invoice.ref'/> </div> </t> - <t t-if="success == 'pay_invoice' and invoice.state == 'paid'"> + <t t-if="success == 'pay_invoice' and invoice.invoice_payment_state == 'paid'"> Done, your online payment has been successfully processed. Thank you for your order. </t> </xpath> diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py index 17a10f7e0e5..5c2cee79cca 100644 --- a/addons/base_geolocalize/models/base_geocoder.py +++ b/addons/base_geolocalize/models/base_geocoder.py @@ -55,7 +55,7 @@ class GeoCoder(models.AbstractModel): return self._geo_query_address_default(street=street, zip=zip, city=city, state=state, country=country) @api.model - def geo_find(self, addr): + def geo_find(self, addr, **kw): """Use a location provider API to convert an address string into a latitude, longitude tuple. Here we use Openstreetmap Nominatim by default. :param addr: Address string passed to API @@ -64,7 +64,7 @@ class GeoCoder(models.AbstractModel): provider = self._get_provider().tech_name try: service = getattr(self, '_call_' + provider) - result = service(addr) + result = service(addr, **kw) except AttributeError: raise UserError(_( 'Provider %s is not implemented for geolocation service.' @@ -77,7 +77,7 @@ class GeoCoder(models.AbstractModel): return result @api.model - def _call_openstreetmap(self, addr): + def _call_openstreetmap(self, addr, **kw): """ Use Openstreemap Nominatim service to retrieve location :return: (latitude, longitude) or None if not found @@ -95,7 +95,7 @@ class GeoCoder(models.AbstractModel): return float(geo['lat']), float(geo['lon']) @api.model - def _call_googlemap(self, addr): + def _call_googlemap(self, addr, **kw): """ Use google maps API. It won't work without a valid API key. :return: (latitude, longitude) or None if not found """ @@ -106,8 +106,11 @@ class GeoCoder(models.AbstractModel): "Visit https://developers.google.com/maps/documentation/geocoding/get-api-key for more information." )) url = "https://maps.googleapis.com/maps/api/geocode/json" + params = {'sensor': 'false', 'address': addr, 'key': apikey} + if kw.get('force_country'): + params['components'] = 'country:%s' % kw['force_country'] try: - result = requests.get(url, params={'sensor': 'false', 'address': addr, 'key': apikey}).json() + result = requests.get(url, params).json() except Exception as e: self._raise_query_error(e) @@ -151,4 +154,3 @@ class GeoCoder(models.AbstractModel): def _raise_query_error(self, error): raise UserError(_('Error with geolocation server:') + ' %s' % error) - diff --git a/addons/base_geolocalize/models/res_partner.py b/addons/base_geolocalize/models/res_partner.py index a92c7af7df4..a0c887a1adb 100644 --- a/addons/base_geolocalize/models/res_partner.py +++ b/addons/base_geolocalize/models/res_partner.py @@ -10,10 +10,10 @@ class ResPartner(models.Model): def _geo_localize(self, street='', zip='', city='', state='', country=''): geo_obj = self.env['base.geocoder'] search = geo_obj.geo_query_address(street=street, zip=zip, city=city, state=state, country=country) - result = geo_obj.geo_find(search) + result = geo_obj.geo_find(search, force_country=country) if result is None: search = geo_obj.geo_query_address(city=city, state=state, country=country) - result = geo_obj.geo_find(search) + result = geo_obj.geo_find(search, force_country=country) return result @api.multi diff --git a/addons/crm_livechat/__manifest__.py b/addons/crm_livechat/__manifest__.py index bbded070830..1ff8aef366e 100644 --- a/addons/crm_livechat/__manifest__.py +++ b/addons/crm_livechat/__manifest__.py @@ -5,6 +5,9 @@ 'name': 'CRM Livechat', 'category': 'crm', 'summary': 'Create lead from livechat conversation', + 'data': [ + 'data/utm_data.xml', + ], 'depends': [ 'crm', 'im_livechat' diff --git a/addons/crm_livechat/data/utm_data.xml b/addons/crm_livechat/data/utm_data.xml new file mode 100644 index 00000000000..0474945948b --- /dev/null +++ b/addons/crm_livechat/data/utm_data.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record model="utm.source" id="utm_source_livechat"> + <field name="name">Livechat</field> + </record> +</odoo> diff --git a/addons/crm_livechat/models/mail_channel.py b/addons/crm_livechat/models/mail_channel.py index 9db2cfd2ea4..9b3765942c8 100644 --- a/addons/crm_livechat/models/mail_channel.py +++ b/addons/crm_livechat/models/mail_channel.py @@ -4,6 +4,7 @@ from odoo import models, _ from odoo.tools import html2plaintext + class MailChannel(models.Model): _inherit = 'mail.channel' @@ -24,13 +25,15 @@ class MailChannel(models.Model): '%s: %s\n' % (message.author_id.name or self.anonymous_name, message.body) for message in self.channel_message_ids.sorted('id') ) + utm_source = self.env.ref('crm_livechat.utm_source_livechat', raise_if_not_found=False) lead = self.env['crm.lead'].create({ 'name': html2plaintext(key[5:]), 'partner_id': channel_partners.partner_id.id, 'user_id': None, 'team_id': None, 'description': html2plaintext(description), - 'referred': partner.name + 'referred': partner.name, + 'source_id': utm_source and utm_source.id, }) lead._onchange_partner_id() msg = _('Created a new lead: <a href="#" data-oe-id="%s" data-oe-model="crm.lead">%s</a>') % (lead.id, lead.name) diff --git a/addons/gamification/data/gamification_karma_rank_data.xml b/addons/gamification/data/gamification_karma_rank_data.xml index f86b827cceb..5eba07b1a3d 100644 --- a/addons/gamification/data/gamification_karma_rank_data.xml +++ b/addons/gamification/data/gamification_karma_rank_data.xml @@ -14,8 +14,8 @@ <field name="description" type="html"><p>You just began the adventure! Welcome!</p></field> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">Reach the next rank to show the rest of the world you exist!</div> - <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/> + <div class="media-body">Earn your first points and join the adventure !</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_newbie_badge.svg"/> </div> </field> <field name="karma_min">1</field> @@ -27,8 +27,8 @@ <field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">Reach the next rank to improve your status.</div> - <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/> + <div class="media-body">Reach the next rank to show the rest of the world you exist.</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/> </div> </field> <field name="karma_min">100</field> @@ -40,8 +40,8 @@ <field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">Reach the next rank and become a Master!</div> - <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/> + <div class="media-body">Reach the next rank to improve your status!</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/> </div> </field> <field name="karma_min">500</field> @@ -53,8 +53,8 @@ <field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">Reach the next rank and become a powerful user!</div> - <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/> + <div class="media-body">Reach the next rank and become a Master!</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/> </div> </field> <field name="karma_min">2000</field> @@ -66,7 +66,8 @@ <field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field> <field name="description_motivational" type="html"> <div class="media align-items-center"> - <div class="media-body">You have reached the last rank. Congratulations!</div> + <div class="media-body">Reach the next rank and become a powerful user!</div> + <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/> </div> </field> <field name="karma_min">10000</field> diff --git a/addons/gamification/data/gamification_karma_rank_demo.xml b/addons/gamification/data/gamification_karma_rank_demo.xml index 6250ee227f6..839e8750047 100644 --- a/addons/gamification/data/gamification_karma_rank_demo.xml +++ b/addons/gamification/data/gamification_karma_rank_demo.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<odoo><data noupdate="0"> +<odoo><data noupdate="1"> <!-- Set demo karma for test tour as demo user--> <record id="base.user_demo" model="res.users"> <field name="karma">20</field> @@ -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">You have reached the last rank. Congratulations!</div> + <div class="media-body">Reach the next rank and gain a very nice unicorn !</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/gamification/models/res_users.py b/addons/gamification/models/res_users.py index daa9d4bc1e1..a0ca65ced45 100644 --- a/addons/gamification/models/res_users.py +++ b/addons/gamification/models/res_users.py @@ -96,6 +96,14 @@ class Users(models.Model): if old_rank != user.rank_id: user._rank_changed() + def _get_next_rank(self): + """ For fresh users with 0 karma that don't have a rank_id and next_rank_id yet + this method returns the first karma rank (by karma ascending). This acts as a + default value in related views. + + TDE FIXME in post-12.4: make next_rank_id a non-stored computed field correctly computed """ + return self.next_rank_id or (not self.rank_id and self.env['gamification.karma.rank'].search([], order="karma_min ASC", limit=1)) + def get_gamification_redirection_data(self): """ Hook for other modules to add redirect button(s) in new rank reached mail diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index aa3040d7947..1797dd66bea 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime -from pprint import pformat import json import logging @@ -184,29 +183,14 @@ class GoogleService(models.TransientModel): except: pass except requests.HTTPError as error: - # https://developers.google.com/calendar/v3/errors if error.response.status_code in (204, 404): status = error.response.status_code response = "" else: - try: - exception_req = pformat(json.loads(error.request.body)) - except: - exception_req = error.request.body - - try: - exception_res = pformat(error.response.json()) - error_msg = _("Error while requesting Google Services: %s") % res['error']['message'] - warning_msg = _("Something went wrong with your request to google: %s") % res['error']['message'] - except: - exception_res = error.response.text - error_msg = _("Error while requesting Google Services") - warning_msg = _("Something went wrong with your request to google") - - _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", exception_req, exception_res) + _logger.exception("Bad google request : %s !", error.response.content) if error.response.status_code in (400, 401, 410): - raise UserError(error_msg) - raise self.env['res.config.settings'].get_config_warning(warning_msg) + raise error + raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google")) return (status, response, ask_time) # TODO : remove me, it is only used in google calendar. Make google_calendar use the constants diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index c8d566aa3a8..887d23bd904 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -144,6 +144,12 @@ class HrEmployeePrivate(models.Model): return self.env['hr.employee.public'].browse(self.ids).read(fields, load=load) @api.model + def load_views(self, views, options=None): + if self.check_access_rights('read', raise_exception=False): + return super(HrEmployeePrivate, self).load_views(views, options=options) + return self.env['hr.employee.public'].load_views(views, options=options) + + @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ We override the _search because it is the method that checks the access rights diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index f353c4d72bf..d7e52389246 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -146,6 +146,11 @@ <field name="permit_no"/> <field name="visa_expire"/> </group> + <group string="Education"> + <field name="certificate"/> + <field name="study_field"/> + <field name="study_school"/> + </group> </group> <group> <field name="google_drive_link" widget="url"/> diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 3a5c490ec70..439fa07148e 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -100,5 +100,6 @@ parent="menu_config_plan" groups="group_hr_manager" sequence="100"/> + </data> </odoo> diff --git a/addons/hr_contract/models/hr_contract.py b/addons/hr_contract/models/hr_contract.py index 343479f3bdb..9195bf138d6 100644 --- a/addons/hr_contract/models/hr_contract.py +++ b/addons/hr_contract/models/hr_contract.py @@ -16,7 +16,7 @@ class Contract(models.Model): name = fields.Char('Contract Reference', required=True) active = fields.Boolean(default=True) - employee_id = fields.Many2one('hr.employee', string='Employee') + employee_id = fields.Many2one('hr.employee', string='Employee', tracking=True) department_id = fields.Many2one('hr.department', string="Department") job_id = fields.Many2one('hr.job', string='Job Position') date_start = fields.Date('Start Date', required=True, default=fields.Date.today, diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 12c44f26cb4..ec194dcb1b3 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -600,7 +600,7 @@ class HolidaysRequest(models.Model): return current_employee = self.env['hr.employee'].sudo().search([('user_id', '=', self.env.uid)], limit=1) for record in self: - emp_id = record._cache.get('employee_id', [False])[0] + emp_id = record._cache.get('employee_id', False) and record._cache.get('employee_id')[0] if emp_id != current_employee.id: try: record._cache['name'] diff --git a/addons/hr_holidays/report/hr_holidays_reports.xml b/addons/hr_holidays/report/hr_holidays_reports.xml index 59030b52b99..26386e7933b 100644 --- a/addons/hr_holidays/report/hr_holidays_reports.xml +++ b/addons/hr_holidays/report/hr_holidays_reports.xml @@ -4,7 +4,7 @@ <report id="action_report_holidayssummary" string="Time Off Summary" - model="hr.leave" + model="hr.holidays.summary.dept" report_type="qweb-pdf" name="hr_holidays.report_holidayssummary" file="hr_holidays.report_holidayssummary" diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index ec1261db52a..829cbb587c4 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -643,6 +643,18 @@ </field> </record> + <record model="ir.actions.server" id="hr_applicant_resumes_server"> + <field name="name">hr.applicant.resumes.server</field> + <field name="model_id" ref="hr_recruitment.model_hr_applicant"/> + <field name="state">code</field> + <field name="code"> +act = env.ref('hr_recruitment.hr_applicant_resumes').read()[0] +act['domain'] = [('res_model', '=', 'hr.applicant'), '|', ('company_id', '=', False), ('company_id', '=', env.user.company_id.id)] +action = act + </field> + </record> + + <!-- Stage Tree View --> <record model="ir.ui.view" id="hr_recruitment_stage_tree"> <field name="name">hr.recruitment.stage.tree</field> diff --git a/addons/l10n_br/data/account_tax_template_data.xml b/addons/l10n_br/data/account_tax_template_data.xml index 72f61cd9716..5515bb686ff 100644 --- a/addons/l10n_br/data/account_tax_template_data.xml +++ b/addons/l10n_br/data/account_tax_template_data.xml @@ -2296,7 +2296,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2309,7 +2309,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2333,7 +2333,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2346,7 +2346,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2405,7 +2405,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2418,7 +2418,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2442,7 +2442,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2455,7 +2455,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2479,7 +2479,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2492,7 +2492,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2516,7 +2516,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_201010302'), + 'account_id': ref('account_template_101050505'), 'minus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> @@ -2529,7 +2529,7 @@ (0,0, { 'factor_percent': 100, 'repartition_type': 'tax', - 'account_id': ref('account_template_101050505'), + 'account_id': ref('account_template_201010302'), 'plus_report_line_ids': [ref('tax_report_icms_1')], }), ]"/> diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index 14b89d8c7fa..09665c73540 100644 --- a/addons/l10n_fr_fec/wizard/account_fr_fec.py +++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py @@ -307,7 +307,7 @@ class AccountFrFec(models.TransientModel): TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/' - ELSE replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', '') END AS EcritureLib, + ELSE replace(replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', ''), '\r', '') END AS EcritureLib, replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit, CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet, diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index d92a8a5ecb0..e657e146837 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -502,6 +502,7 @@ class MailTemplate(models.Model): 'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)), 'model_description': self.env['ir.model']._get(record._name).display_name, 'company': 'company_id' in record and record['company_id'] or self.env.company, + 'record': record, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) values['body_html'] = self.env['mail.thread']._replace_local_links(body) diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index e84d7588b1b..2a496faf86d 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -438,10 +438,27 @@ var BasicComposer = Widget.extend({ * @param {boolean} params.submitForm [optional] */ _processAttachmentChange: function (params) { - var self = this, - attachments = this.get('attachment_ids'), - files = params.files, - submitForm = params.submitForm; + var self = this; + var attachments = this.get('attachment_ids'); + var files = params.files; + var submitForm = params.submitForm; + var $form = this.$('form.o_form_binary_form'); + + /** + * makes a new formData as formData.delete() is not supported by IE or Safari Mobile. + * + * @return {FormData} + */ + function makeFormDataWithoutUfile() { + var newFormData = new window.FormData(); + $form.find('input').each(function (index, input) { + if (input.name !== 'ufile') { + newFormData.append(input.name, input.value); + } + }); + return newFormData; + } + _.each(files, function (file) { var attachment = _.findWhere(attachments, { name: file.name, @@ -453,24 +470,20 @@ var BasicComposer = Widget.extend({ attachments = _.without(attachments, attachment); } }); - var $form = this.$('form.o_form_binary_form'); if (submitForm) { $form.submit(); this._$attachmentButton.prop('disabled', true); } else { - var data = new FormData($form[0]); _.each(files, function (file) { - // removing existing key with blank data and appending again with file info - // In safari, existing key will not be updated when append with new file. - data.delete("ufile"); - data.append("ufile", file, file.name); + var formData = makeFormDataWithoutUfile(); + formData.append("ufile", file, file.name); $.ajax({ url: $form.attr("action"), type: "POST", enctype: 'multipart/form-data', processData: false, contentType: false, - data: data, + data: formData, success: function (result) { var $el = $(result); $.globalEval($el.contents().text()); diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 5d0647d9762..6eeeaecf7c3 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -167,9 +167,9 @@ var RenameConversationDialog = Dialog.extend({ kwargs: { name: name, } - }).then(function (updatedName) { + }).then(function () { var channel = self.call('mail_service', 'getThread', self._channelID); - channel.setName(updatedName); + channel.setName(name); self._callback(); }); }, diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js index b5b2e068cbf..f93209a913f 100644 --- a/addons/mail/static/src/js/models/messages/message.js +++ b/addons/mail/static/src/js/models/messages/message.js @@ -801,6 +801,7 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin _warnMessageModerated: function () { var mailBus = this.call('mail_service', 'getMailBus'); if (this.needsModerationByUser()) { + this._setModeratedByUser(false); var moderationBox = this.call('mail_service', 'getMailbox', 'moderation'); moderationBox.decrementMailboxCounter(); moderationBox.removeMessage(this.getID()); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js index 01397f6ab27..6c7cefe42e3 100644 --- a/addons/mail/static/src/js/services/mail_manager.js +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -94,9 +94,7 @@ var MailManager = AbstractService.extend({ additionalThreadIDs: data.channel_ids }); } - if (options.domain && options.domain !== []) { - this._addMessageToThreads(message, options); - } + this._addMessageToThreads(message, options); } return prom; }, diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index daf34b4a5be..8c65266a392 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -1425,7 +1425,7 @@ QUnit.test('rename DM conversation', async function (assert) { assert.step(args.method); assert.strictEqual(args.args[0], 1); assert.strictEqual(args.kwargs.name, "Demo"); - return Promise.resolve("Demo"); + return Promise.resolve(); } return this._super.apply(this, arguments); }, diff --git a/addons/mass_mailing/i18n/mass_mailing.pot b/addons/mass_mailing/i18n/mass_mailing.pot index 5e2624c624d..9d3a4b33523 100644 --- a/addons/mass_mailing/i18n/mass_mailing.pot +++ b/addons/mass_mailing/i18n/mass_mailing.pot @@ -2320,9 +2320,9 @@ msgstr "" #. module: mass_mailing #. openerp-web -#: code:addons/mass_mailing/static/src/js/unsubscribe.js:49 +#: code:addons/mass_mailing/static/src/js/unsubscribe.js:50 #, python-format -msgid "You have been <strong>successfully unsubscribed from </strong>." +msgid "You have been <strong>successfully unsubscribed from %s</strong>." msgstr "" #. module: mass_mailing diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 6d718a9ffbf..34ef0472c7e 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -95,7 +95,7 @@ class MailMail(models.Model): return res @api.multi - def _postprocess_sent_message(self, failure_type=None, **kwargs): + def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed for mail in self: if mail.mailing_id: @@ -103,5 +103,4 @@ class MailMail(models.Model): mail.statistics_ids.write({'sent': fields.Datetime.now(), 'exception': False}) elif mail_sent is False and mail.statistics_ids: mail.statistics_ids.write({'exception': fields.Datetime.now()}) - return super(MailMail, self)._postprocess_sent_message(failure_type=failure_type, **kwargs) -
\ No newline at end of file + return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type) diff --git a/addons/mass_mailing/static/src/js/unsubscribe.js b/addons/mass_mailing/static/src/js/unsubscribe.js index ae1c34f864b..f43aefcd976 100644 --- a/addons/mass_mailing/static/src/js/unsubscribe.js +++ b/addons/mass_mailing/static/src/js/unsubscribe.js @@ -46,7 +46,10 @@ odoo.define('mass_mailing.unsubscribe', function (require) { var unsubscribed_list = $("input[name='unsubscribed_list']").val(); if (unsubscribed_list){ - $('#subscription_info').html(_t('You have been <strong>successfully unsubscribed from ' + unsubscribed_list + "</strong>.")); + $('#subscription_info').html(_.str.sprintf( + _t("You have been <strong>successfully unsubscribed from %s</strong>."), + unsubscribed_list + )); } else{ $('#subscription_info').html(_t('You have been <strong>successfully unsubscribed</strong>.')); diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml index 793bb61034d..575824aedcc 100644 --- a/addons/mass_mailing/views/unsubscribe_templates.xml +++ b/addons/mass_mailing/views/unsubscribe_templates.xml @@ -121,7 +121,7 @@ <header> <div><title>Odoo</title></div> <div class="text-center"> - <img t-attf-src="/web/binary/company_logo"/> + <img t-attf-src="/web/binary/company_logo?company={{ res_company.id }}"/> </div> </header> <div id="wrap" class="oe_structure oe_empty"/> diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 90f21112012..cb6575673ff 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -828,8 +828,6 @@ class MrpProduction(models.Model): moves_to_do = order.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) for move in moves_to_do.filtered(lambda m: m.product_qty == 0.0 and m.quantity_done > 0): move.product_uom_qty = move.quantity_done - for move in moves_to_do.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding)): - move._action_cancel() # MRP do not merge move, catch the result of _action_done in order # to get extra moves. moves_to_do = moves_to_do._action_done() @@ -860,6 +858,11 @@ class MrpProduction(models.Model): if wo.time_ids.filtered(lambda x: (not x.date_end) and (x.loss_type in ('productive', 'performance'))): raise UserError(_('Work order %s is still running') % wo.name) self._check_lots() + + # Cancel unfinished move + move_to_cancel = self.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel') and float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding)) + move_to_cancel._action_cancel() + self.post_inventory() # 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 diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index a6adb02778d..7b747de0ce6 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -825,8 +825,8 @@ class PaymentTransaction(models.Model): @api.model def _compute_reference_prefix(self, values): if values and values.get('invoice_ids'): - many_list = self.resolve_2many_commands('invoice_ids', values['invoice_ids'], fields=['number']) - return ','.join(dic['number'] for dic in many_list) + many_list = self.resolve_2many_commands('invoice_ids', values['invoice_ids'], fields=['name']) + return ','.join(dic['name'] for dic in many_list) return None @api.model diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py index 946f031484d..6361a211f16 100644 --- a/addons/payment_authorize/models/payment.py +++ b/addons/payment_authorize/models/payment.py @@ -62,7 +62,8 @@ class PaymentAcquirerAuthorize(models.Model): # The signature key is now '128-character hexadecimal format', while the # transaction key was only 16-character. if len(values['x_trans_key']) == 128: - return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper() + key = bytes.fromhex(values['x_trans_key']) + return hmac.new(key, data, hashlib.sha512).hexdigest().upper() else: return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest() @@ -315,13 +316,14 @@ class TxAuthorize(models.Model): 'acquirer_reference': tree.get('x_trans_id'), 'date': fields.Datetime.now(), }) - if init_state != 'authorized': - self.execute_callback() if self.payment_token_id: self.payment_token_id.verified = True self._set_transaction_done() + + if init_state != 'authorized': + self.execute_callback() if tree.get('x_type').lower() == 'auth_only': self.write({'acquirer_reference': tree.get('x_trans_id')}) self._set_transaction_authorized() diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 9a173995152..f4d56fad0cb 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1738,8 +1738,11 @@ exports.Orderline = Backbone.Model.extend({ else var price_include = !price_exclude; if (tax.amount_type === 'fixed') { - var sign_base_amount = base_amount >= 0 ? 1 : -1; - return (Math.abs(tax.amount) * sign_base_amount) * quantity; + var sign_base_amount = Math.sign(base_amount) || 1; + // Since base amount has been computed with quantity + // we take the abs of quantity + // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce + return tax.amount * sign_base_amount * Math.abs(quantity); } if (tax.amount_type === 'percent' && !price_include){ return base_amount * tax.amount / 100; diff --git a/addons/point_of_sale/wizard/pos_payment.py b/addons/point_of_sale/wizard/pos_payment.py index 4c46927d1af..ca366987f4b 100644 --- a/addons/point_of_sale/wizard/pos_payment.py +++ b/addons/point_of_sale/wizard/pos_payment.py @@ -33,7 +33,7 @@ class PosMakePayment(models.TransientModel): journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, default=_default_journal) amount = fields.Float(digits=0, required=True, default=_default_amount) payment_name = fields.Char(string='Payment Reference') - payment_date = fields.Date(string='Payment Date', required=True, default=lambda *a: fields.Date.today()) + payment_date = fields.Date(string='Payment Date', required=True, default=lambda self: fields.Date.context_today(self)) @api.onchange('session_id') def _on_change_session(self): diff --git a/addons/pos_cache/static/src/js/pos_cache.js b/addons/pos_cache/static/src/js/pos_cache.js index 6a26143cc6e..1b59761bc26 100644 --- a/addons/pos_cache/static/src/js/pos_cache.js +++ b/addons/pos_cache/static/src/js/pos_cache.js @@ -15,22 +15,21 @@ models.PosModel = models.PosModel.extend({ return model.model === "product.product"; }); - // Give both the fields and domain to pos_cache in the - // backend. This way we don't have to hardcode these - // values in the backend and they automatically stay in - // sync with whatever is defined (and maybe extended by - // other modules) in js. - var product_model = this.models[product_index]; - var product_fields = product_model.fields; - var product_domain = product_model.domain; + var product_model = self.models[product_index]; // We don't want to load product.product the normal // uncached way, so get rid of it. if (product_index !== -1) { this.models.splice(product_index, 1); } - return posmodel_super.load_server_data.apply(this, arguments).then(function () { + // Give both the fields and domain to pos_cache in the + // backend. This way we don't have to hardcode these + // values in the backend and they automatically stay in + // sync with whatever is defined (and maybe extended by + // other modules) in js. + var product_fields = typeof product_model.fields === 'function' ? product_model.fields(self) : product_model.fields; + var product_domain = typeof product_model.domain === 'function' ? product_model.domain(self) : product_model.domain; var records = rpc.query({ model: 'pos.config', method: 'get_products_from_cache', diff --git a/addons/pos_hr/static/src/js/screens.js b/addons/pos_hr/static/src/js/screens.js index bef83516f0f..84551507148 100644 --- a/addons/pos_hr/static/src/js/screens.js +++ b/addons/pos_hr/static/src/js/screens.js @@ -16,10 +16,11 @@ ScreenWidget.include({ barcode_cashier_action: function(code){ var self = this; var employees = this.pos.employees; + var prom; for(var i = 0, len = employees.length; i < len; i++){ if(employees[i].barcode === Sha1.hash(code.code)){ if (employees[i].id !== this.pos.get_cashier().id && employees[i].pin) { - return this.gui.ask_password(employees[i].pin).then(function(){ + prom = this.gui.ask_password(employees[i].pin).then(function(){ self.pos.set_cashier(employees[i]); self.chrome.widget.username.renderElement(); return true; @@ -27,12 +28,18 @@ ScreenWidget.include({ } else { this.pos.set_cashier(employees[i]); this.chrome.widget.username.renderElement(); - return true; + prom = Promise.resolve(true); } + break; } } - this.barcode_error_action(code); - return false; + if (!prom){ + this.barcode_error_action(code); + return Promise.resolve(false); + } + else { + return prom + } }, show: function() { this._super(); @@ -73,8 +80,10 @@ var LoginScreenWidget = ScreenWidget.extend({ * @override */ barcode_cashier_action: function(code) { - this._super(code); - this.unlock_screen(); + var self = this; + return this._super(code).then(function () { + self.unlock_screen(); + }); }, unlock_screen: function() { diff --git a/addons/pos_mercury/__manifest__.py b/addons/pos_mercury/__manifest__.py index 4727dbafdea..50778cac342 100644 --- a/addons/pos_mercury/__manifest__.py +++ b/addons/pos_mercury/__manifest__.py @@ -29,6 +29,7 @@ following: 'views/pos_mercury_views.xml', 'views/pos_mercury_transaction_templates.xml', 'views/pos_config_setting_views.xml', + 'views/pos_config_views.xml', ], 'demo': [ 'data/pos_mercury_demo.xml', diff --git a/addons/pos_mercury/static/src/js/pos_mercury.js b/addons/pos_mercury/static/src/js/pos_mercury.js index 818a17db300..9dbe66a5a8d 100644 --- a/addons/pos_mercury/static/src/js/pos_mercury.js +++ b/addons/pos_mercury/static/src/js/pos_mercury.js @@ -509,7 +509,7 @@ PaymentScreenWidget.include({ self.credit_code_transaction(parsed_result); } else { // this is for supporting another payment system like mercury this.gui.show_popup('selection',{ - title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ', + title: _t('Pay with: '), list: online_payment_journals, confirm: function (item) { parsed_result.journal_id = item; diff --git a/addons/pos_mercury/views/pos_config_views.xml b/addons/pos_mercury/views/pos_config_views.xml new file mode 100644 index 00000000000..8da1d16bd8a --- /dev/null +++ b/addons/pos_mercury/views/pos_config_views.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view"> + <field name="name">pos.config.form.view.inherit.mercury</field> + <field name="model">pos.config</field> + <field name="inherit_id" ref="point_of_sale.pos_config_view_form"/> + <field name="arch" type="xml"> + <label for="iface_scan_via_proxy" position="attributes"> + <attribute name="string">Barcode Scanner/Card Reader</attribute> + </label> + </field> + </record> +</odoo> diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index d3ecb8a9d71..3ab10e2d3b2 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -606,12 +606,7 @@ class PurchaseOrderLine(models.Model): if product_lang.description_purchase: self.name += '\n' + product_lang.description_purchase - fpos = self.order_id.fiscal_position_id - if self.env.uid == SUPERUSER_ID: - company_id = self.env.company.id - self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id.filtered(lambda r: r.company_id.id == company_id)) - else: - self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id) + self._compute_tax_id() self._suggest_quantity() self._onchange_quantity() @@ -681,9 +676,8 @@ class PurchaseOrderLine(models.Model): ''' if not self.product_id: return - seller_min_qty = self.product_id.seller_ids\ - .filtered(lambda r: r.name == self.order_id.partner_id)\ + .filtered(lambda r: r.name == self.order_id.partner_id and (not r.product_id or r.product_id == self.product_id))\ .sorted(key=lambda r: r.min_qty) if seller_min_qty: self.product_qty = seller_min_qty[0].min_qty or 1.0 diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index fdb03cf3289..9a1e06fe644 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -45,8 +45,12 @@ class StockMove(models.Model): if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: + # The date must be today, and not the date of the move since the move move is still + # in assigned state. However, the move date is the scheduled date until move is + # done, then date of actual move processing. See: + # https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36 price_unit = order.currency_id._convert( - price_unit, order.company_id.currency_id, order.company_id, self.date, round=False) + price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False) return price_unit return super(StockMove, self)._get_price_unit() diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index 07cf6272ca9..9490d218f17 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -822,12 +822,12 @@ class TestStockValuationWithCOA(AccountingTestCase): 'company_id': company.id, }) - # To allow testing validation of PO + # To allow testing validation of PO and Delivery + today = date_po def _today(*args, **kwargs): - return datetime.strptime(date_po, "%Y-%m-%d").date() - # To allow testing validation of Delivery + return datetime.strptime(today, "%Y-%m-%d").date() def _now(*args, **kwargs): - return datetime.strptime(date_delivery + ' 01:00:00', "%Y-%m-%d %H:%M:%S") + return datetime.strptime(today + ' 01:00:00', "%Y-%m-%d %H:%M:%S") patchers = [ patch('odoo.fields.Date.context_today', _today), @@ -856,13 +856,17 @@ class TestStockValuationWithCOA(AccountingTestCase): line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + today = date_delivery picking = po.picking_ids (picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) .write({'quantity_done': 1.0})) picking.button_validate() + # 5 Units received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + today = date_invoice inv = self.env['account.move'].with_context(default_type='in_invoice').create({ 'type': 'in_invoice', 'invoice_date': date_invoice, @@ -944,7 +948,7 @@ class TestStockValuationWithCOA(AccountingTestCase): product_avg = self.product1.copy({ 'purchase_method': 'purchase', 'name': 'AVG', - 'standard_price': 60, + 'standard_price': 0, 'property_account_creditor_price_difference': self.price_diff_account.id }) product_avg.invoice_policy = 'order' @@ -991,13 +995,12 @@ class TestStockValuationWithCOA(AccountingTestCase): 'company_id': company.id, }) - # To allow testing validation of PO + # To allow testing validation of PO and Delivery + today = date_po def _today(*args, **kwargs): - return datetime.strptime(date_po, "%Y-%m-%d").date() - # To allow testing validation of Delivery - delivery_now = date_delivery + return datetime.strptime(today, "%Y-%m-%d").date() def _now(*args, **kwargs): - return datetime.strptime(delivery_now + ' 01:00:00', "%Y-%m-%d %H:%M:%S") + return datetime.strptime(today + ' 01:00:00', "%Y-%m-%d %H:%M:%S") patchers = [ patch('odoo.fields.Date.context_today', _today), @@ -1027,6 +1030,7 @@ class TestStockValuationWithCOA(AccountingTestCase): line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + today = date_delivery picking = po.picking_ids (picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) @@ -1034,7 +1038,10 @@ class TestStockValuationWithCOA(AccountingTestCase): picking.button_validate() picking.action_done() # Create Backorder + # 5 Units received at rate 0.7 = 42.86 + self.assertAlmostEqual(product_avg.standard_price, 42.86) + today = date_invoice inv = self.env['account.move'].with_context(default_type='in_invoice').create({ 'type': 'in_invoice', 'invoice_date': date_invoice, @@ -1055,13 +1062,16 @@ class TestStockValuationWithCOA(AccountingTestCase): inv.post() + today = date_delivery1 backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) - delivery_now = date_delivery1 (backorder_picking.move_lines .filtered(lambda l: l.purchase_line_id == line_product_avg) .write({'quantity_done': 5.0})) backorder_picking.button_validate() + # 5 Units received at rate 0.7 (42.86) + 5 Units received at rate 0.8 (37.50) = 40.18 + self.assertAlmostEqual(product_avg.standard_price, 40.18) + today = date_invoice1 inv1 = self.env['account.move'].with_context(default_type='in_invoice').create({ 'type': 'in_invoice', 'invoice_date': date_invoice1, diff --git a/addons/sale_expense/security/ir.model.access.csv b/addons/sale_expense/security/ir.model.access.csv index f9e8f32a752..228b1262f58 100644 --- a/addons/sale_expense/security/ir.model.access.csv +++ b/addons/sale_expense/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_sale_order_employee,sale.order.employee.expense,sale.model_sale_order,base.group_user,1,0,0,0
\ No newline at end of file +access_sale_order_employee,sale.order.employee.expense,sale.model_sale_order,base.group_user,0,0,0,0
\ No newline at end of file diff --git a/addons/sale_expense/security/sale_expense_security.xml b/addons/sale_expense/security/sale_expense_security.xml index 4b40a737f7a..c8087457e9d 100644 --- a/addons/sale_expense/security/sale_expense_security.xml +++ b/addons/sale_expense/security/sale_expense_security.xml @@ -13,6 +13,7 @@ <field name="perm_write" eval="False"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> + <field name="active" eval="False"/> <!-- opw-2027005: this rules breaks sale "see own document" --> </record> </data> diff --git a/addons/sale_expense/views/hr_expense_views.xml b/addons/sale_expense/views/hr_expense_views.xml index c86de76cad0..ca46f6a58f7 100644 --- a/addons/sale_expense/views/hr_expense_views.xml +++ b/addons/sale_expense/views/hr_expense_views.xml @@ -6,7 +6,7 @@ <field name="inherit_id" ref="hr_expense.hr_expense_view_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='employee_id']" position="after"> - <field name="sale_order_id" context="{'sale_show_partner_name': True}"/> + <field name="sale_order_id" context="{'sale_show_partner_name': True}" groups="sales_team.group_sale_salesman"/> </xpath> </field> </record> @@ -17,7 +17,7 @@ <field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="after"> - <field name="sale_order_id" context="{'sale_show_partner_name': True}"/> + <field name="sale_order_id" context="{'sale_show_partner_name': True}" groups="sales_team.group_sale_salesman"/> </xpath> </field> </record> diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 35a106fa9ea..edeff617fd9 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -183,7 +183,7 @@ class ProjectTask(models.Model): @api.depends('project_id.sale_line_employee_ids') def _compute_is_project_map_empty(self): for task in self: - task.is_project_map_empty = not bool(task.project_id.sale_line_employee_ids) + task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids) @api.onchange('project_id') def _onchange_project(self): diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 17b0bc2799f..2fbe2d2dc35 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -354,7 +354,7 @@ <a t-attf-class="progress-bar o_progress_{{key}}" t-attf-style="width: {{amount / total * 100}}%" type="action" data-model="account.analytic.line" - t-att-data-domain="json.dumps(employee['__domain_' + key])" + t-att-data-domain="employee['__domain_' + key]" > <span t-att-title="title" style="font-size: 0px; width: 100%; height: 100%;"> <t t-esc="label" /> diff --git a/addons/sale_timesheet_purchase/models/project_overview.py b/addons/sale_timesheet_purchase/models/project_overview.py index 600bf28094d..6af3cbef830 100644 --- a/addons/sale_timesheet_purchase/models/project_overview.py +++ b/addons/sale_timesheet_purchase/models/project_overview.py @@ -22,8 +22,8 @@ class Project(models.Model): context={'create': False, 'edit': False, 'delete': False} ) }) - account_invoice_lines = self.env['account.invoice.line'].search([('account_analytic_id', 'in', accounts)]) - account_invoices = account_invoice_lines.mapped('invoice_id') + account_invoice_lines = self.env['account.move.line'].search([('analytic_account_id', 'in', accounts)]) + account_invoices = account_invoice_lines.mapped('move_id') if account_invoices: stat_buttons.append({ 'name': _('Vendor Bills'), diff --git a/addons/web/static/lib/jSignature/jSignatureCustom.js b/addons/web/static/lib/jSignature/jSignatureCustom.js index ae51103da3d..6ccd2af79a3 100644 --- a/addons/web/static/lib/jSignature/jSignatureCustom.js +++ b/addons/web/static/lib/jSignature/jSignatureCustom.js @@ -760,7 +760,7 @@ function jSignatureClass(parent, options, instanceExtensions) { // these, when enabled, will hover above the sig area. Hence we append them to DOM before canvas. this.$controlbarUpper = (function(){ var controlbarstyle = 'padding:0 !important; margin:0 !important;'+ - 'width: 100% !important; height: 0 !important; -ms-touch-action: none;'+ + 'width: 100% !important; height: 0 !important; -ms-touch-action: none; touch-action: none;'+ 'margin-top:-1em !important; margin-bottom:1em !important;'; return $('<div style="'+controlbarstyle+'"></div>').appendTo($parent); })(); @@ -771,7 +771,7 @@ function jSignatureClass(parent, options, instanceExtensions) { this.$controlbarLower = (function(){ var controlbarstyle = 'padding:0 !important; margin:0 !important;'+ - 'width: 100% !important; height: 0 !important; -ms-touch-action: none;'+ + 'width: 100% !important; height: 0 !important; -ms-touch-action: none; touch-action: none;'+ 'margin-top:-1.5em !important; margin-bottom:1.5em !important; position: relative;'; return $('<div style="'+controlbarstyle+'"></div>').appendTo($parent); })(); @@ -876,7 +876,7 @@ function jSignatureClass(parent, options, instanceExtensions) { $canvas.bind('mouseup.'+apinamespace, drawEndHandler); $canvas.bind('mousedown.'+apinamespace, drawStartHandler); } else { - canvas.ontouchstart = function(e) { + canvas.addEventListener('touchstart', function(e) { canvas.onmousedown = canvas.onmouseup = canvas.onmousemove = undef; this.fatFingerCompensation = ( @@ -886,19 +886,19 @@ function jSignatureClass(parent, options, instanceExtensions) { drawStartHandler(e); - canvas.ontouchend = drawEndHandler; - canvas.ontouchstart = drawStartHandler; - canvas.ontouchmove = drawMoveHandler; - }; - canvas.onmousedown = function(e) { + canvas.addEventListener('touchend', drawEndHandler); + canvas.addEventListener('touchstart', drawStartHandler); + canvas.addEventListener('touchmove', drawMoveHandler); + }); + canvas.addEventListener('mousedown', function(e) { canvas.ontouchstart = canvas.ontouchend = canvas.ontouchmove = undef; drawStartHandler(e); - canvas.onmousedown = drawStartHandler; - canvas.onmouseup = drawEndHandler; - canvas.onmousemove = drawMoveHandler; - } + canvas.addEventListener('mousedown', drawStartHandler); + canvas.addEventListener('mouseup', drawEndHandler); + canvas.addEventListener('mousemove', drawMoveHandler); + }); if (window.navigator.msPointerEnabled) { canvas.onmspointerdown = drawStartHandler; canvas.onmspointerup = drawEndHandler; @@ -1084,28 +1084,16 @@ jSignatureClass.prototype.initializeCanvas = function(settings) { settings.width = '100%'; } - $canvas.css( - 'margin' - , 0 - ).css( - 'padding' - , 0 - ).css( - 'border' - , 'none' - ).css( - 'height' - , settings.height === 'ratio' || !settings.height ? 1 : settings.height.toString(10) - ).css( - 'width' - , settings.width === 'ratio' || !settings.width ? 1 : settings.width.toString(10) - ).css( - '-ms-touch-action' - , 'none' - ).css( - 'background-color', - settings['background-color'] - ); + $canvas.css({ + 'margin': 0, + 'padding': 0, + 'border': 'none', + 'height': settings.height === 'ratio' || !settings.height ? 1 : settings.height.toString(10), + 'width': settings.width === 'ratio' || !settings.width ? 1 : settings.width.toString(10), + '-ms-touch-action': 'none', + 'touch-action': 'none', + 'background-color': settings['background-color'], + }); $canvas.appendTo(this.$parent); diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index b17a3f501e6..bc5250ab620 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -2,6 +2,7 @@ odoo.define('web.ajax', function (require) { "use strict"; var config = require('web.config'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var time = require('web.time'); var download = require('web.download'); @@ -9,6 +10,10 @@ var contentdisposition = require('web.contentdisposition'); var _t = core._t; +// Create the final object containing all the functions first to allow monkey +// patching them correctly if ever needed. +var ajax = {}; + function _genericJsonRpc (fct_name, params, settings, fct) { var shadow = settings.shadow || false; delete settings.shadow; @@ -469,44 +474,79 @@ var loadAsset = (function () { })(); /** - * Loads the given js and css libraries. Note that the ajax loadJS and loadCSS methods - * don't do anything if the given file is already loaded. + * Loads the given js/css libraries and asset bundles. Note that no library or + * asset will be loaded if it was already done before. * * @param {Object} libs - * @Param {Array | Array<Array>} [libs.jsLibs=[]] The list of JS files that we want to - * load. The list may contain strings (the files to load), or lists of strings. The - * first level is loaded sequentially, and files listed in inner lists are loaded in - * parallel. - * @param {Array<string>} [libs.cssLibs=[]] A list of css files, to be loaded in - * parallel - * @param {Array<string>} [libs.assetLibs=[]] A list of xmlId. The loaded template - * contains the script and link to be loaded + * @param {Array<string|string[]>} [libs.assetLibs=[]] + * The list of assets to load. Each list item may be a string (the xmlID + * of the asset to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the assets in + * inner lists are loaded in parallel (use this for efficiency but only + * if the order does not matter, should rarely be the case for assets). + * @param {string[]} [libs.cssLibs=[]] + * The list of CSS files to load. They will all be loaded in parallel but + * put in the DOM in the given order (only the order in the DOM is used + * to determine priority of CSS rules, not loaded time). + * @param {Array<string|string[]>} [libs.jsLibs=[]] + * The list of JS files to load. Each list item may be a string (the URL + * of the file to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the files in inner + * lists are loaded in parallel (use this for efficiency but only + * if the order does not matter). + * @param {string[]} [libs.cssContents=[]] + * List of inline styles to add after loading the CSS files. + * @param {string[]} [libs.jsContents=[]] + * List of inline scripts to add after loading the JS files. * * @returns {Promise} */ -function loadLibs (libs) { - var defs = []; - (libs.jsLibs || []).forEach(function (urls) { - defs.push(Promise.all(defs).then(function () { - if (typeof(urls) === 'string') { - return ajax.loadJS(urls); - } else { - return Promise.all(urls.map(ajax.loadJS)); +function loadLibs(libs) { + var mutex = new concurrency.Mutex(); + mutex.exec(function () { + var defs = []; + var cssLibs = [libs.cssLibs || []]; // Force loading in parallel + defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () { + if (libs.cssContents && libs.cssContents.length) { + $('head').append($('<style/>', { + html: libs.cssContents.join('\n'), + })); } })); - }); - (libs.cssLibs || []).forEach(function (url) { - defs.push(ajax.loadCSS(url)); - }); - _.each(libs.assetLibs || [], function (xmlId) { - defs.push(loadAsset(xmlId).then(function (asset) { - return loadLibs(asset); + defs.push(_loadArray(libs.jsLibs || [], ajax.loadJS).then(function () { + if (libs.jsContents && libs.jsContents.length) { + $('head').append($('<script/>', { + html: libs.jsContents.join('\n'), + })); + } })); + return Promise.all(defs); + }); + mutex.exec(function () { + return _loadArray(libs.assetLibs || [], function (xmlID) { + return ajax.loadAsset(xmlID).then(function (asset) { + return ajax.loadLibs(asset); + }); + }); }); - return Promise.all(defs); + + function _loadArray(array, loadCallback) { + var _mutex = new concurrency.Mutex(); + array.forEach(function (urlData) { + _mutex.exec(function () { + if (typeof urlData === 'string') { + return loadCallback(urlData); + } + return Promise.all(urlData.map(loadCallback)); + }); + }); + return _mutex.getUnlockedDef(); + } + + return mutex.getUnlockedDef(); } -var ajax = { +_.extend(ajax, { jsonRpc: jsonRpc, rpc: rpc, loadCSS: loadCSS, @@ -516,7 +556,7 @@ var ajax = { loadLibs: loadLibs, get_file: get_file, post: post, -}; +}); return ajax; diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index ca0c7829a2c..b9a7d26e470 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -155,6 +155,10 @@ var BasicModel = AbstractModel.extend({ // save is performed. this.mutex = new concurrency.Mutex(); + // this array is used to accumulate RPC requests done in the same call + // stack, so that they can be batched in the minimum number of RPCs + this.batchedRPCsRequests = []; + this.localData = Object.create(null); this._super.apply(this, arguments); }, @@ -4071,6 +4075,86 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * This function accumulates RPC requests done in the same call stack, and + * performs them in the next micro task tick so that similar requests can be + * batched in a single RPC. + * + * For now, only 'read' calls are supported. + * + * @private + * @param {Object} params + * @returns {Promise} + */ + _performRPC: function (params) { + var self = this; + + // save the RPC request + var request = _.extend({}, params); + var prom = new Promise(function (resolve, reject) { + request.resolve = resolve; + request.reject = reject; + }); + this.batchedRPCsRequests.push(request); + + // empty the pool of RPC requests in the next micro tick + Promise.resolve().then(function () { + if (!self.batchedRPCsRequests.length) { + // pool has already been processed + return; + } + + // reset pool of RPC requests + var batchedRPCsRequests = self.batchedRPCsRequests; + self.batchedRPCsRequests = []; + + // batch similar requests + var batches = {}; + var key; + for (var i = 0; i < batchedRPCsRequests.length; i++) { + var request = batchedRPCsRequests[i]; + key = request.model + ',' + JSON.stringify(request.context); + if (!batches[key]) { + batches[key] = _.extend({}, request, {requests: [request]}); + } else { + batches[key].ids = _.uniq(batches[key].ids.concat(request.ids)); + batches[key].fieldNames = _.uniq(batches[key].fieldNames.concat(request.fieldNames)); + batches[key].requests.push(request); + } + } + + // perform batched RPCs + function onSuccess(batch, results) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + var fieldNames = request.fieldNames.concat(['id']); + var filteredResults = results.filter(function (record) { + return request.ids.indexOf(record.id) >= 0; + }).map(function (record) { + return _.pick(record, fieldNames); + }); + request.resolve(filteredResults); + } + } + function onFailure(batch, error) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + request.reject(error); + } + } + for (key in batches) { + var batch = batches[key]; + self._rpc({ + model: batch.model, + method: 'read', + args: [batch.ids, batch.fieldNames], + context: batch.context, + }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch)); + } + }); + + return prom; + }, + /** * Once a record is created and some data has been fetched, we need to do * quite a lot of computations to determine what needs to be fetched. This * method is doing that. @@ -4277,11 +4361,12 @@ var BasicModel = AbstractModel.extend({ var def; if (missingIDs.length && fieldNames.length) { - def = self._rpc({ - model: list.model, - method: 'read', - args: [missingIDs, fieldNames], + def = self._performRPC({ context: list.getContext(), + fieldNames: fieldNames, + ids: missingIDs, + method: 'read', + model: list.model, }); } else { def = Promise.resolve(_.map(missingIDs, function (id) { diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 6ffe35574bd..f73209a0330 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -651,8 +651,8 @@ var BasicRenderer = AbstractRenderer.extend({ // this.renderFieldWidget(...).addClass(...), the class is added on // the temporary div and not on the actual element that will be // rendered. As we do not return a promise and some callers cannot - // wait for this.defs, we copy those attributes to the final element. - widget.$el.attr($el.getAttributes()); + // wait for this.defs, we copy those classnames to the final element. + widget.$el.addClass($el.attr('class')); $el.replaceWith(widget.$el); self._registerModifiers(node, record, widget, { diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 23a26bb6d73..ad1a95cd14f 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -405,7 +405,8 @@ var ListRenderer = BasicRenderer.extend({ isPassword: 'password' in node.attrs, }); this._handleAttributes($td, node); - return $td.html(formattedValue).attr('title', formattedValue); + var title = field.type !== 'boolean' ? formattedValue : ''; + return $td.html(formattedValue).attr('title', title); }, /** * Renders the button element associated to the given node and record. diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss index 55559546e74..120896dceee 100644 --- a/addons/web/static/src/scss/fields.scss +++ b/addons/web/static/src/scss/fields.scss @@ -88,6 +88,7 @@ &.o_field_many2manytags { flex-flow: row wrap; align-items: baseline; + max-width: 100%; > .o_field_widget { flex: 1 0 100px; @@ -101,6 +102,8 @@ font-size: 12px; cursor: pointer; user-select: none; + display: flex; + max-width: 100%; a { color: inherit; @@ -115,6 +118,7 @@ .o_delete { color: inherit; cursor: pointer; + padding-left: 4px; } } diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index 508ce38a961..4f5958f65cd 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -8422,6 +8422,57 @@ QUnit.module('fields', {}, function () { form.destroy(); }); + QUnit.test('many2manys inside a one2many are fetched in batch after onchange', async function (assert) { + assert.expect(7); + + this.data.partner.onchanges = { + turtles: function (obj) { + obj.turtles = [ + [5], + [1, 1, { + turtle_foo: "leonardo", + partner_ids: [[4, 2]], + }], + [1, 2, { + turtle_foo: "donatello", + partner_ids: [[4, 2], [4, 4]], + }], + ]; + }, + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="turtle_foo"/>' + + '<field name="partner_ids" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</form>', + enableBasicModelBachedRPCs: true, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'read') { + assert.deepEqual(args.args[0], [2, 4], + 'should read the partner_ids once, batched'); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(form, '.o_data_row', 2); + assert.strictEqual(form.$('.o_field_widget[name="partner_ids"]').text().replace(/\s/g, ''), + "secondrecordsecondrecordaaa"); + + assert.verifySteps(['default_get', 'onchange', 'read']); + + form.destroy(); + }); + QUnit.test('two one2many fields with same relation and onchanges', async function (assert) { // this test simulates the presence of two one2many fields with onchanges, such that // changes to the first o2m are repercuted on the second one diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 282e93bdc60..c1ffac7bd6f 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -414,6 +414,39 @@ QUnit.module('Views', { form.destroy(); }); + + QUnit.test('group containing both a field and a group', async function (assert) { + // The purpose of this test is to check that classnames defined in a + // field widget and those added by the form renderer are correctly + // combined. For instance, the renderer adds className 'o_group_col_x' + // on outer group's children (an outer group being a group that contains + // at least a group). + assert.expect(4); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<group>' + + '<field name="foo"/>' + + '<group>' + + '<field name="int_field"/>' + + '</group>' + + '</group>' + + '</form>', + res_id: 1, + }); + + assert.containsOnce(form, '.o_group .o_field_widget[name=foo]'); + assert.containsOnce(form, '.o_group .o_inner_group .o_field_widget[name=int_field]'); + + assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_field_char'); + assert.hasClass(form.$('.o_field_widget[name=foo]'), 'o_group_col_6'); + + form.destroy(); + }); + QUnit.test('Form and subview with _view_ref contexts', async function (assert) { assert.expect(2); @@ -485,6 +518,7 @@ QUnit.module('Views', { form.destroy(); actionManager.destroy(); }); + QUnit.test('invisible fields are properly hidden', async function (assert) { assert.expect(4); @@ -6862,7 +6896,6 @@ QUnit.module('Views', { delete widgetRegistry.map.test; }); - QUnit.test('bounce edit button in readonly mode', async function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 2e6969bab48..0513253507a 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -271,6 +271,19 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('boolean field has no title', async function (assert) { + assert.expect(1); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="bar"/></tree>', + }); + assert.equal(list.$('tbody tr:first td:eq(1)').attr('title'), ""); + list.destroy(); + }); + QUnit.test('record-depending invisible lines are correctly aligned', async function (assert) { assert.expect(4); diff --git a/addons/web/views/database_manager.html b/addons/web/views/database_manager.html index d39a77ef4e7..4649366dd61 100644 --- a/addons/web/views/database_manager.html +++ b/addons/web/views/database_manager.html @@ -41,6 +41,10 @@ }); // close modal on submit $('.modal').on('submit', 'form', function (ev) { + var form = $(this).closest('form')[0]; + if (form && form.checkValidity && !form.checkValidity()) { + return; + } var modal = $(this).parentsUntil('body', '.modal'); if (modal.hasClass('o_database_backup')) { $(modal).modal('hide'); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index 3f808964beb..b196552709d 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -1108,7 +1108,7 @@ var VideoWidget = MediaWidget.extend({ return {errorCode: 0}; } - var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0'; + var autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0'; if (ytMatch && ytMatch[2].length === 11) { $video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay); @@ -1137,7 +1137,8 @@ var VideoWidget = MediaWidget.extend({ $video.attr('src', $video.attr('src') + '&rel=0'); } if (options.loop && (ytMatch || vimMatch)) { - $video.attr('src', $video.attr('src') + '&loop=1'); + var videoSrc = _.str.sprintf('%s&loop=1', $video.attr('src')); + $video.attr('src', ytMatch ? _.str.sprintf('%s&playlist=%s', videoSrc, ytMatch[2]) : videoSrc); } if (options.hide_controls && (ytMatch || dmMatch)) { $video.attr('src', $video.attr('src') + '&controls=0'); diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 9ed3d2010da..baff05e2356 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -318,7 +318,11 @@ </div> <div class="o_video_dialog_options d-none mt-4"> <div class="o_yt_option o_vim_option o_dm_option"> - <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label> + <label class="o_switch mb0"> + <input id="o_video_autoplay" type="checkbox"/><span/> + <span style="margin-right: 8px;">Autoplay</span> + <span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span> + </label> </div> <div class="o_yt_option o_vim_option"> <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label> diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 96e7bc39b2b..3a5b773f81d 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -53,7 +53,7 @@ class Website(models.Model): domain = fields.Char('Website Domain') country_group_ids = fields.Many2many('res.country.group', 'website_country_group_rel', 'website_id', 'country_group_id', string='Country Groups', help='Used when multiple websites have the same domain.') - company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.ref('base.main_company').id, required=True) + company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.company, required=True) language_ids = fields.Many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages', default=_active_languages) default_lang_id = fields.Many2one('res.lang', string="Default Language", default=_default_language, required=True) default_lang_code = fields.Char("Default language code", related='default_lang_id.code', store=True, readonly=False) diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index 33e1e01ebcc..ab7f4218a5b 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -76,7 +76,7 @@ publicWidget.Widget.include({ this.editableMode = this.options.editableMode || false; var extraEvents = this.editableMode ? this.edit_events : this.read_events; if (extraEvents) { - this.events = _.extend(this.events || {}, extraEvents); + this.events = _.extend({}, this.events || {}, extraEvents); } }, }); diff --git a/addons/website/static/tests/tours/rte.js b/addons/website/static/tests/tours/rte.js index 716562389e7..7b2c8511fc3 100644 --- a/addons/website/static/tests/tours/rte.js +++ b/addons/website/static/tests/tours/rte.js @@ -176,7 +176,7 @@ tour.register('rte_translator', { }, { content: "check bis: content is translated", trigger: '#wrap p font:first:contains(translated french text)', - extra_trigger: 'body:not(:has(button[data-action=save]))', + extra_trigger: 'html[lang*="fr"] body:not(:has(button[data-action=save]))', }, { content: "check bis: placeholder translation", trigger: 'input[placeholder="test french placeholder"]', diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py index 5ca9344aa32..8e2eb382a40 100644 --- a/addons/website/tests/test_qweb.py +++ b/addons/website/tests/test_qweb.py @@ -2,10 +2,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import re -import werkzeug -import odoo from odoo import tools +from odoo.addons.website.tools import MockRequest from odoo.modules.module import get_module_resource from odoo.tests.common import TransactionCase @@ -68,45 +67,6 @@ class TestQweb(TransactionCase): "alt": "Marc Demo", }).encode('utf8')) -class MockObject(object): - _log_call = [] - def __init__(self, *args, **kwargs): - self.__dict__ = kwargs - def __call__(self, *args, **kwargs): - self._log_call.append((args, kwargs)) - return self - def __getitem__(self, index): - return self - -def werkzeugRaiseNotFound(*args, **kwargs): - raise werkzeug.exceptions.NotFound() - -class MockRequest(object): - """ Class with context manager mocking odoo.http.request for tests """ - def __init__(self, env, website=None, context=None, multilang=True, routing=True): - app = MockObject(routing={ - 'type': 'http', - 'website': True, - 'multilang': multilang, - }) - app.get_db_router = app.bind = app.match = app - if not routing: - app.match = werkzeugRaiseNotFound - self.request = MockObject( - env=env, context=context or {}, db=None, - session=MockObject( - debug=False - ), - website=website, httprequest=MockObject( - path='/hello/', - app=app - ) - ) - odoo.http._request_stack.push(self.request) - def __enter__(self): - return self.request - def __exit__(self, exc_type, exc_value, traceback): - odoo.http._request_stack.pop() class TestQwebProcessAtt(TransactionCase): def setUp(self): @@ -134,7 +94,7 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/a', {'href': '/a'}) def test_process_att_no_website(self): - with MockRequest(self.env) as request: + with MockRequest(self.env): # no website so URL rewriting self._test_att('/', {'href': '/'}) self._test_att('/en_US/', {'href': '/en_US/'}) @@ -143,7 +103,7 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/a', {'href': '/a'}) def test_process_att_monolang_route(self): - with MockRequest(self.env, website=self.website, multilang=False) as request: + with MockRequest(self.env, website=self.website, multilang=False): # lang not changed in URL but CDN enabled self._test_att('/a', {'href': 'http://test.cdn/a'}) self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'}) @@ -151,19 +111,19 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/en_US/b', {'href': '/en_US/b'}) def test_process_att_no_request_lang(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website): self._test_att('/', {'href': '/'}) self._test_att('/en_US/', {'href': '/'}) self._test_att('/fr_FR/', {'href': '/fr_FR/'}) def test_process_att_with_request_lang(self): - with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request: + with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}): self._test_att('/', {'href': '/fr_FR/'}) self._test_att('/en_US/', {'href': '/'}) self._test_att('/fr_FR/', {'href': '/fr_FR/'}) def test_process_att_matching_cdn_and_lang(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website): # lang prefix is added before CDN self._test_att('/a', {'href': 'http://test.cdn/a'}) self._test_att('/en_US/a', {'href': 'http://test.cdn/a'}) @@ -173,13 +133,13 @@ class TestQwebProcessAtt(TransactionCase): self._test_att('/fr_FR/b', {'href': '/fr_FR/b'}) def test_process_att_no_route(self): - with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request: + with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False): # default on multilang=True if route is not /{module}/static/ self._test_att('/web/static/hi', {'href': '/web/static/hi'}) self._test_att('/my-page', {'href': '/fr_FR/my-page'}) def test_process_att_url_crap(self): - with MockRequest(self.env, self.website) as request: + with MockRequest(self.env, website=self.website) as request: # #{fragment} is stripped from URL when testing route self._test_att('/x#y?z', {'href': '/x#y?z'}) self.assertEqual( diff --git a/addons/website/tools.py b/addons/website/tools.py index 2abe896afff..e3bb897ec52 100644 --- a/addons/website/tools.py +++ b/addons/website/tools.py @@ -1,7 +1,11 @@ # -*- encoding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import odoo import re +import werkzeug + +from odoo.tools import DotDict def get_video_embed_code(video_url): @@ -49,3 +53,59 @@ def get_video_embed_code(video_url): # We directly use the provided URL as it is embedUrl = video_url return '<iframe class="embed-responsive-item" src="%s" allowFullScreen="true" frameborder="0"></iframe>' % embedUrl + + +class MockObject(object): + _log_call = [] + + def __init__(self, *args, **kwargs): + self.__dict__ = kwargs + + def __call__(self, *args, **kwargs): + self._log_call.append((args, kwargs)) + return self + + def __getitem__(self, index): + return self + + +def werkzeugRaiseNotFound(*args, **kwargs): + raise werkzeug.exceptions.NotFound() + + +class MockRequest(object): + """ Class with context manager mocking odoo.http.request for tests """ + def __init__(self, env, **kw): + app = MockObject(routing={ + 'type': 'http', + 'website': True, + 'multilang': kw.get('multilang', True), + }) + app.get_db_router = app.bind = app.match = app + if not kw.get('routing', True): + app.match = werkzeugRaiseNotFound + self.request = DotDict({ + 'context': kw.get('context', {}), + 'db': None, + 'env': env, + 'httprequest': { + 'path': '/hello/', + 'app': app, + }, + 'redirect': werkzeug.utils.redirect, + 'session': { + 'geoip': { + 'country_code': kw.get('country_code'), + }, + 'debug': False, + 'sale_order_id': kw.get('sale_order_id'), + }, + 'website': kw.get('website'), + }) + odoo.http._request_stack.push(self.request) + + def __enter__(self): + return self.request + + def __exit__(self, exc_type, exc_value, traceback): + odoo.http._request_stack.pop() diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml index f2a8d0b3e02..90fe854dbae 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -26,8 +26,19 @@ </div> </div> <h2>Website <button name="action_website_create_new" type="object" string="New" class="ml-2 btn btn-link" icon="fa-plus" groups="!website.group_multi_website"/></h2> - <div class="row mt16 o_settings_container" id="website_settings"> - <div class="col-12 o_setting_box" id="website_action_setting"> + <div class="row mt16 o_settings_container" id="website_settings_placeholder" attrs="{'invisible': [('website_id', '!=', False)]}"> + <div class="col-12 o_setting_box"> + <div class="text-muted" groups="website.group_multi_website"> + Select a website to load its settings. + </div> + <div class="text-muted" groups="!website.group_multi_website"> + There is no website available for this company. You could create a new one. + </div> + </div> + </div> + <!-- !! Every fields inside this container should be website specific (related to website record) !! --> + <div class="row mt16 o_settings_container" id="website_settings" attrs="{'invisible': [('website_id', '=', False)]}"> + <div class="col-12 o_setting_box" id="website_action_setting"> <button name="website_go_to" type="object" string="Go to Website" class="btn btn-primary" icon="fa-globe"/> <button name="%(website.action_website_add_features)d" type="action" string="Add features" class="ml-2 btn btn-secondary" icon="fa-plus"/> </div> @@ -41,7 +52,7 @@ <div class="content-group"> <div class="row mt16"> <label class="col-lg-3 o_light_label" string="Name" for="website_name"/> - <field name="website_name" attrs="{'required': [('website_id', '!=', False)] }"/> + <field name="website_name" attrs="{'required': [('website_id', '!=', False)]}"/> </div> <div class="row"> <label class="col-lg-3 o_light_label" for="favicon" /> diff --git a/addons/website_crm_partner_assign/controllers/main.py b/addons/website_crm_partner_assign/controllers/main.py index 3acffdacd50..99422b03160 100644 --- a/addons/website_crm_partner_assign/controllers/main.py +++ b/addons/website_crm_partner_assign/controllers/main.py @@ -192,7 +192,8 @@ class WebsiteCrmPartnerAssign(WebsitePartnerPage): if not qs or qs.lower() in loc: yield {'loc': loc} - partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), ('grade_id.website_published', '=', True)] + partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), + ('grade_id.website_published', '=', True), ('country_id', '!=', False)] dom += sitemap_qs2dom(qs=qs, route='/partners/country/') countries = env['res.partner'].sudo().read_group(partners_dom, fields=['id', 'country_id'], groupby='country_id') for country in countries: diff --git a/addons/website_crm_partner_assign/tests/test_partner_assign.py b/addons/website_crm_partner_assign/tests/test_partner_assign.py index 773eb86beff..98ad2d451c6 100644 --- a/addons/website_crm_partner_assign/tests/test_partner_assign.py +++ b/addons/website_crm_partner_assign/tests/test_partner_assign.py @@ -29,7 +29,7 @@ class TestPartnerAssign(TransactionCase): 'partner_id': self.customer_uk.id }) - def geo_find(addr): + def geo_find(addr, **kw): return { 'Wavre, Belgium': (50.7158956, 4.6128075), 'Cannon Hill Park, B46 3AG Birmingham, United Kingdom': (52.45216, -1.898578), diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index 1785766def7..e80dd1ffe07 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -128,6 +128,8 @@ class WebsiteForm(http.Controller): # If it's not, we'll use attachments instead if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary': data['record'][field_name] = base64.b64encode(field_value.read()) + if authorized_fields[field_name]['manual']: + data['record'][field_name + "_filename"] = field_value.filename else: field_value.field_name = field_name data['attachments'].append(field_value) diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index e25be65ba48..9528a9b5e73 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -18,9 +18,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ 'click .karma_required': '_onKarmaRequiredClick', 'mouseenter .o_js_forum_tag_follow': '_onTagFollowBoxMouseEnter', 'mouseleave .o_js_forum_tag_follow': '_onTagFollowBoxMouseLeave', - 'click .o_forum_profile_pic_edit': '_onEditProfilePicClick', - 'change .o_forum_file_upload': '_onFileUploadChange', - 'click .o_forum_profile_pic_clear': '_onProfilePicClearClick', 'mouseenter .o_forum_user_info': '_onUserInfoMouseEnter', 'mouseleave .o_forum_user_info': '_onUserInfoMouseLeave', 'mouseleave .o_forum_user_bio_expand': '_onUserBioExpandMouseLeave', @@ -218,41 +215,6 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ * @private * @param {Event} ev */ - _onEditProfilePicClick: function (ev) { - ev.preventDefault(); - $(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click'); - }, - /** - * @private - * @param {Event} ev - */ - _onFileUploadChange: function (ev) { - if (!ev.currentTarget.files.length) { - return; - } - var $form = $(ev.currentTarget).closest('form'); - utils.getDataURLFromFile(ev.currentTarget.files[0]).then(function (result) { - $form.find('.o_forum_avatar_img').attr('src', result); - }); - $form.find('#forum_clear_image').remove(); - }, - /** - * @private - * @param {Event} ev - */ - _onProfilePicClearClick: function (ev) { - var $form = $(ev.currentTarget).closest('form'); - $form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png'); - $form.append($('<input/>', { - name: 'clear_image', - id: 'forum_clear_image', - type: 'hidden', - })); - }, - /** - * @private - * @param {Event} ev - */ _onUserInfoMouseEnter: function (ev) { $(ev.currentTarget).parent().find('.o_forum_user_bio_expand').delay(500).toggle('fast'); }, diff --git a/addons/website_profile/static/src/js/website_profile.js b/addons/website_profile/static/src/js/website_profile.js index caccf454f1a..b78d2ed9f06 100644 --- a/addons/website_profile/static/src/js/website_profile.js +++ b/addons/website_profile/static/src/js/website_profile.js @@ -44,6 +44,9 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ selector: '.o_wprofile_editor_form', read_events: { + 'click .o_forum_profile_pic_edit': '_onEditProfilePicClick', + 'change .o_forum_file_upload': '_onFileUploadChange', + 'click .o_forum_profile_pic_clear': '_onProfilePicClearClick', 'click .o_wprofile_submit_btn': '_onSubmitClick', }, @@ -76,6 +79,43 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ /** * @private + * @param {Event} ev + */ + _onEditProfilePicClick: function (ev) { + ev.preventDefault(); + $(ev.currentTarget).closest('form').find('.o_forum_file_upload').trigger('click'); + }, + /** + * @private + * @param {Event} ev + */ + _onFileUploadChange: function (ev) { + if (!ev.currentTarget.files.length) { + return; + } + var $form = $(ev.currentTarget).closest('form'); + var reader = new window.FileReader(); + reader.readAsDataURL(ev.currentTarget.files[0]); + reader.onload = function (ev) { + $form.find('.o_forum_avatar_img').attr('src', ev.target.result); + }; + $form.find('#forum_clear_image').remove(); + }, + /** + * @private + * @param {Event} ev + */ + _onProfilePicClearClick: function (ev) { + var $form = $(ev.currentTarget).closest('form'); + $form.find('.o_forum_avatar_img').attr('src', '/web/static/src/img/placeholder.png'); + $form.append($('<input/>', { + name: 'clear_image', + id: 'forum_clear_image', + type: 'hidden', + })); + }, + /** + * @private */ _onSubmitClick: function () { this.$('textarea.load_editor').data('wysiwyg').save(); diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index 3060431e87f..c28712f06ac 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -97,20 +97,20 @@ <template id="user_profile_edit_content" name="Edit Profile"> <h1 class="o_page_header">Edit Profile</h1> <div> - <form t-attf-action="/profile/user/save" method="post" role="form" class="js_website_submit_form row" enctype="multipart/form-data"> + <form t-attf-action="/profile/user/save" method="post" role="form" class="o_wprofile_editor_form js_website_submit_form row" enctype="multipart/form-data"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="file" class="d-none o_forum_file_upload" name="ufile" accept="image/*"/> <input type="hidden" name="url_param" t-att-value="request.params.get('url_param')"/> <div class="col-3"> <div class="card o_card_people"> <div class="card-body"> - <img class="w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/> + <img class="o_forum_avatar_img w-100 mb-3" t-attf-src="/web/image/res.users/#{user.id}/image_medium"/> <div class="text-center"> <a href="#" class="o_forum_profile_pic_edit btn btn-primary" aria-label="Edit"> <i class="fa fa-pencil fa-1g float-sm-none float-md-left" title="Edit"></i> </a> - <a href="#" title="Clear" aria-label="Clear" class="btn border-primary"> - <i class="fa fa-trash-o float-sm-none float-md-right o_forum_profile_pic_clear"></i> + <a href="#" title="Clear" aria-label="Clear" class="btn border-primary o_forum_profile_pic_clear"> + <i class="fa fa-trash-o float-sm-none float-md-right"></i> </a> </div> <div class="form-group mt-3 mb-0 pt-2 border-top"> @@ -125,7 +125,7 @@ </div> <div class="col-9 mb-3"> <div class="card"> - <div class="card-body o_wprofile_editor_form"> + <div class="card-body"> <div class="row"> <input name="user_id" t-att-value="user.id" type="hidden"/> <div class="form-group col-12"> @@ -258,12 +258,11 @@ <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.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> - </div> + <t t-set="next_rank_id" t-value="user._get_next_rank()"/> + <small t-if="next_rank_id" class="font-weight-bold mt-1">Next rank:</small> + <t t-if="next_rank_id or user.rank_id" t-call="website_profile.profile_next_rank_card"> + <t t-set="img_max_width">40%</t> + </t> <table class="table table-sm w-100" id="o_wprofile_sidebar_table"> <tbody> @@ -315,17 +314,21 @@ <template id="profile_next_rank_card" name="Profile Next Rank Card"> <div class="o_wprofile_progress_circle"> <svg viewBox="0 0 36 36" class="o_pc_circular_chart"> - <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-set="next_rank_id" t-value="next_rank_id or user._get_next_rank()"/> + <t t-if="next_rank_id and user.rank_id"> + <t t-if="(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)/(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-elif="user.rank_id"> <t t-set="user_points" t-value="100"/> </t> + <t t-else=""> + <t t-set="user_points" t-value="0"/> + </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)"/> <mask id="mask"> @@ -338,18 +341,18 @@ </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 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-attf-src="/web/image/gamification.karma.rank/#{next_rank_id.id if next_rank_id else user.rank_id.id}/image_medium" + t-att-alt="(next_rank_id.name if 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-if="user.next_rank_id" t-field="user.next_rank_id.name"/> + <span t-if="next_rank_id" t-field="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 t-if="user.next_rank_id" class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> + <span t-if="next_rank_id" class="font-weight-bold" t-field="next_rank_id.karma_min"/> <span t-else="" class="font-weight-bold" t-field="user.rank_id.karma_min"/> xp </small> @@ -510,7 +513,7 @@ <div class="card-body"> <div class="d-inline-block position-relative"> <img class="rounded-circle img-fluid" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&res_model=%s&res_id=%s' % (record._name, record.id) if record else '')"/> - <img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user['position']}.svg" alt="User rank" style="bottom: 0; right: -10px"/> + <img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user_index + 1}.svg" alt="User rank" style="bottom: 0; right: -10px"/> </div> <h3 class="mt-2 mb-0" t-esc="user['name']"></h3> <span class="badge badge-danger font-weight-normal px-2" t-if="not user['website_published']">Unpublished</span> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 0748f9dc6e4..c47b5b2ab57 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -640,10 +640,11 @@ class WebsiteSale(http.Controller): new_values['customer'] = True new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id new_values['user_id'] = request.website.salesperson_id and request.website.salesperson_id.id - new_values['website_id'] = request.website.id - if order.partner_id.id == request.website.user_id.sudo().partner_id.id: - # If the partner is public, we assign the company of the website + if request.website.specific_user_account: + new_values['website_id'] = request.website.id + + if mode[0] == 'new': new_values['company_id'] = request.website.company_id.id lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None diff --git a/addons/website_sale/tests/test_sale_process.py b/addons/website_sale/tests/test_sale_process.py index 3234653c687..f2fe650022e 100644 --- a/addons/website_sale/tests/test_sale_process.py +++ b/addons/website_sale/tests/test_sale_process.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + import odoo.tests +from odoo import api +from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.addons.website.tools import MockRequest + @odoo.tests.tagged('post_install', '-at_install') class TestUi(odoo.tests.HttpCase): @@ -33,4 +38,114 @@ class TestUi(odoo.tests.HttpCase): self.start_tour("/", 'website_sale_tour') - # TO DO - add public test with new address when convert to web.tour format. + +@odoo.tests.tagged('post_install', '-at_install') +class TestWebsiteSaleCheckoutAddress(odoo.tests.TransactionCase): + ''' The goal of this method class is to test the address management on + the checkout (new/edit billing/shipping, company_id, website_id..). + ''' + def setUp(self): + super(TestWebsiteSaleCheckoutAddress, self).setUp() + self.website = self.env['website'].browse(1) + self.country_id = self.env['res.country'].search([], limit=1).id + self.WebsiteSaleController = WebsiteSale() + self.default_address_values = { + 'name': 'a res.partner address', 'email': 'email@email.email', 'street': 'ooo', + 'city': 'ooo', 'country_id': self.country_id, 'submitted': 1, + } + + def _create_so(self, partner_id=None): + return self.env['sale.order'].create({ + 'partner_id': partner_id, + 'website_id': self.website.id, + 'order_line': [(0, 0, { + 'product_id': self.env['product.product'].create({'name': 'Product A', 'list_price': 100}).id, + 'name': 'Product A', + })] + }) + + def _get_last_address(self, partner): + ''' Useful to retrieve the last created shipping address ''' + return partner.child_ids.sorted('id', reverse=True)[0] + + # TEST WEBSITE + def test_01_create_shipping_address_specific_user_account(self): + ''' Ensure `website_id` is correctly set (specific_user_account) ''' + p = self.env.user.partner_id + so = self._create_so(p.id) + + with MockRequest(self.env, website=self.website, sale_order_id=so.id): + self.WebsiteSaleController.address(**self.default_address_values) + self.assertFalse(self._get_last_address(p).website_id, "New shipping address should not have a website set on it (no specific_user_account).") + + self.website.specific_user_account = True + + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(self._get_last_address(p).website_id, self.website, "New shipping address should have a website set on it (specific_user_account).") + + # TEST COMPANY + def _setUp_multicompany_env(self): + ''' Have 2 companies A & B. + Have 1 website 1 which company is B + Have admin on company A + ''' + self.company_a = self.env['res.company'].create({ + 'name': 'Company A', + }) + self.company_b = self.env['res.company'].create({ + 'name': 'Company B', + }) + self.company_c = self.env['res.company'].create({ + 'name': 'Company C', + }) + self.website.company_id = self.company_b + self.env.user.company_id = self.company_a + + self.demo_user = self.env.ref('base.user_demo') + self.demo_user.company_ids += self.company_c + self.demo_user.company_id = self.company_c + self.demo_partner = self.demo_user.partner_id + + def test_02_demo_address_and_company(self): + ''' This test ensure that the company_id of the address (partner) is + correctly set and also, is not wrongly changed. + eg: new shipping should use the company of the website and not the + one from the admin, and editing a billing should not change its + company. + ''' + self._setUp_multicompany_env() + so = self._create_so(self.demo_partner.id) + + env = api.Environment(self.env.cr, self.demo_user.id, {}) + # change also website env for `sale_get_order` to not change order partner_id + with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id): + # 1. Logged in user, new shipping + self.WebsiteSaleController.address(**self.default_address_values) + new_shipping = self._get_last_address(self.demo_partner) + self.assertTrue(new_shipping.company_id != self.env.user.company_id, "Logged in user new shipping should not get the company of the sudo() neither the one from it's partner..") + self.assertEqual(new_shipping.company_id, self.website.company_id, ".. but the one from the website.") + + # 2. Logged in user, edit billing + self.default_address_values['partner_id'] = self.demo_partner.id + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(self.demo_partner.company_id, self.company_c, "Logged in user edited billing (the partner itself) should not get its company modified.") + + def test_03_public_user_address_and_company(self): + ''' Same as test_02 but with public user ''' + self._setUp_multicompany_env() + so = self._create_so(self.website.user_id.partner_id.id) + + env = api.Environment(self.env.cr, self.website.user_id.id, {}) + # change also website env for `sale_get_order` to not change order partner_id + with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id): + # 1. Public user, new billing + self.default_address_values['partner_id'] = -1 + self.WebsiteSaleController.address(**self.default_address_values) + new_partner = so.partner_id + self.assertNotEqual(new_partner, self.website.user_id.partner_id, "New billing should have created a new partner and assign it on the SO") + self.assertEqual(new_partner.company_id, self.website.company_id, "The new partner should get the company of the website") + + # 2. Public user, edit billing + self.default_address_values['partner_id'] = new_partner.id + self.WebsiteSaleController.address(**self.default_address_values) + self.assertEqual(new_partner.company_id, self.website.company_id, "Public user edited billing (the partner itself) should not get its company modified.") diff --git a/addons/website_sale/views/sale_order_views.xml b/addons/website_sale/views/sale_order_views.xml index 0d87cabfb0d..c1f72eac0ec 100644 --- a/addons/website_sale/views/sale_order_views.xml +++ b/addons/website_sale/views/sale_order_views.xml @@ -59,6 +59,7 @@ <field name="name">Orders</field> <field name="res_model">sale.order</field> <field name="view_mode">tree,form,kanban,activity</field> + <field name="domain">[]</field> <field name="context">{'show_sale': True, 'search_default_order_confirmed': 1, 'search_default_from_website': 1}</field> <field name="search_view_id" ref="view_sales_order_filter_ecommerce"/> <field name="help" type="html"> diff --git a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js index 4ac148854f9..4ac148854f9 100644 --- a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js +++ b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py index 1bf4ce3d3bf..34dcf086a48 100644 --- a/addons/website_sale_delivery/tests/test_ui.py +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -12,4 +12,4 @@ class TestUi(odoo.tests.HttpCase): '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") + self.start_tour("/", 'check_free_delivery', 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 201fe46d843..079f79bf183 100644 --- a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml +++ b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml @@ -19,10 +19,15 @@ <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> + <template id="assets_tests" name="Website Sale Delivery Assets Tests" inherit_id="web.assets_tests"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/website_sale_delivery/static/tests/tours/website_free_delivery.js"></script> + </xpath> + </template> + <template id="payment_delivery_methods"> <input t-att-value="delivery.id" t-att-id="'delivery_%i' % delivery.id" type="radio" name="delivery_type" t-att-checked="order.carrier_id and order.carrier_id.id == delivery.id and 'checked' or False" t-att-class="'d-none' if delivery_nb == 1 else ''"/> <label class="label-optional" t-field="delivery.name"/> diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 7bb67444493..d8d7afa5ce9 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -257,7 +257,7 @@ class WebsiteSlides(WebsiteProfile): if request.env.user._is_public(): users = request.env['res.users'].sudo().search([ ('karma', '>', 0), - ('website_published', '=', True)], limit=5, order='create_date desc') + ('website_published', '=', True)], limit=5, order='karma desc') else: users = None @@ -323,6 +323,7 @@ class WebsiteSlides(WebsiteProfile): def _get_top3_users(self): return request.env['res.users'].sudo().search_read([ ('karma', '>', 0), + ('website_published', '=', True), ('image', '!=', False)], ['id'], limit=3, order='karma desc') @http.route([ diff --git a/addons/website_slides/data/mail_data.xml b/addons/website_slides/data/mail_data.xml index 2cf45c4e0c5..226e2111e33 100644 --- a/addons/website_slides/data/mail_data.xml +++ b/addons/website_slides/data/mail_data.xml @@ -29,14 +29,14 @@ <record id="slide_template_shared" model="mail.template"> <field name="name">Slide Shared</field> <field name="model_id" ref="model_slide_slide"/> - <field name="subject">${user.name} shared a ${object.slide_type} with you!</field> + <field name="subject">${ctx['user'].name} shared a ${object.slide_type} with you!</field> <field name="email_from">${user.email_formatted | safe}</field> <field name="email_to">${ctx['email']}</field> <field name="body_html" type="html"> <div style="margin: 0px; padding: 0px;"> <p style="margin: 0px; padding: 0px; font-size: 13px;"> Hello<br/><br/> - ${user.name} shared the ${object.slide_type} <strong>${object.name}</strong> with you! + ${ctx['user'].name} shared the ${object.slide_type} <strong>${object.name}</strong> with you! <div style="margin: 16px 8px 16px 8px; text-align: center;"> <a href="${object.website_url}"> <img alt="${object.name}" src="${ctx['base_url']}/web/image/slide.slide/${object.id}/image" style="height:auto; width:150px; margin: 16px;"/> diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index d3614c4a845..e858fad831c 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -269,12 +269,18 @@ class Channel(models.Model): return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses") @api.multi - @api.depends('name') + def get_base_url(self): + self.ensure_one() + icp = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + return self.website_id and self.website_id._get_http_domain() or icp + + @api.multi + @api.depends('name', 'website_id.domain') def _compute_website_url(self): super(Channel, self)._compute_website_url() - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for channel in self: if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange. + base_url = channel.get_base_url() channel.website_url = '%s/slides/%s' % (base_url, slug(channel)) @api.multi diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index 1a4e7ac6e1c..dbf065ad7a1 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -242,13 +242,13 @@ class Slide(models.Model): self[key] = value @api.multi - @api.depends('name') + @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): # TDE FIXME: clena this link.tracker strange stuff super(Slide, self)._compute_website_url() - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for slide in self: if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange. + base_url = slide.channel_id.get_base_url() # link_tracker is not in dependencies, so use it to shorten url only if installed. if self.env.registry.get('link.tracker'): url = self.env['link.tracker'].sudo().create({ @@ -379,7 +379,10 @@ class Slide(models.Model): mail_ids = [] base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for record in self: - mail_ids.append(self.channel_id.share_template_id.with_context(email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light')) + if self.env.user.has_group('base.group_portal'): + mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).sudo().send_mail(record.id, notif_layout='mail.mail_notification_light', email_values={'email_from': self.env['res.company'].catchall or self.env['res.company'].email})) + else: + mail_ids.append(self.channel_id.share_template_id.with_context(user=self.env.user, email=email, base_url=base_url).send_mail(record.id, notif_layout='mail.mail_notification_light')) return mail_ids def action_like(self): diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index 8ca2108c368..f5b56297fe8 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -57,13 +57,11 @@ odoo.define('website_slides.quiz', function (require) { * @override */ willStart: function () { - var def = new Promise(function () {}); - if (this.quiz) { - def.resolve(); - } else { - def = this._fetchQuiz(); + var defs = [this._super.apply(this, arguments)]; + if (!this.quiz) { + defs.push(this._fetchQuiz()); } - return Promise.all([this._super.apply(this, arguments), def]); + return Promise.all(defs); }, /** @@ -329,7 +327,7 @@ odoo.define('website_slides.quiz', function (require) { _extractChannelData: function (slideData){ return { id: slideData.channelId, - channelEnroll: slideData.enroll, + channelEnroll: slideData.channelEnroll, signupAllowed: slideData.signupAllowed }; }, diff --git a/addons/website_slides/views/website_slides_templates_homepage.xml b/addons/website_slides/views/website_slides_templates_homepage.xml index d65299942b8..9cc2cba4de4 100644 --- a/addons/website_slides/views/website_slides_templates_homepage.xml +++ b/addons/website_slides/views/website_slides_templates_homepage.xml @@ -355,7 +355,7 @@ <div class="row o_wslides_home_aside_title"> <div class="col"> <a href="/profile/users" class="float-right">View all</a> - <h5 class="m-0">Leaderboard of the week</h5> + <h5 class="m-0">Leaderboard</h5> <hr class="mt-2 pt-2"/> </div> </div> @@ -396,14 +396,14 @@ <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.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.rank_id.description_motivational"/> + <t t-set="next_rank_id" t-value="user._get_next_rank()"/> + <div t-if="next_rank_id" class="font-weight-bold text-muted mt-1">Next rank:</div> + <t t-if="next_rank_id or user.rank_id" 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-if="next_rank_id" t-field="next_rank_id.description_motivational"/> + <div t-else="">Congratulations, you have reached the last rank!</div> </div> </template> |
