diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 459b4dc0778..6520a567b35 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- + +from ast import literal_eval + from odoo import api, Command, fields, models, _ from odoo.exceptions import UserError, ValidationError from odoo.addons.base.models.res_bank import sanitize_account_number @@ -9,13 +12,6 @@ import re _logger = logging.getLogger(__name__) -def is_encodable_as_ascii(string): - try: - remove_accents(string).encode('ascii') - except UnicodeEncodeError: - return False - return True - class AccountJournalGroup(models.Model): _name = 'account.journal.group' @@ -37,7 +33,11 @@ class AccountJournal(models.Model): _name = "account.journal" _description = "Journal" _order = 'sequence, type, code' - _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin'] + _inherit = ['portal.mixin', + 'mail.alias.mixin.optional', + 'mail.thread', + 'mail.activity.mixin', + ] _check_company_auto = True _check_company_domain = models.check_company_domain_parent_of _rec_names_search = ['name', 'code'] @@ -81,7 +81,6 @@ class AccountJournal(models.Model): ('bank', 'Bank'), ('general', 'Miscellaneous'), ], required=True, - inverse='_inverse_type', help="Select 'Sale' for customer invoices journals.\n"\ "Select 'Purchase' for vendor bills journals.\n"\ "Select 'Cash' or 'Bank' for journals that are used in customer or vendor payments.\n"\ @@ -186,11 +185,9 @@ class AccountJournal(models.Model): sale_activity_note = fields.Text('Activity Summary') # alias configuration for journals - alias_id = fields.Many2one('mail.alias', string='Email Alias', help="Send one separate email for each invoice.\n\n" - "Any file extension will be accepted.\n\n" - "Only PDF and XML files will be interpreted by Odoo", copy=False) - alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain') - alias_name = fields.Char('Alias Name', copy=False, compute='_compute_alias_name', inverse='_inverse_type', help="It creates draft invoices and bills by sending an email.") + alias_id = fields.Many2one(help="Send one separate email for each invoice.\n\n" + "Any file extension will be accepted.\n\n" + "Only PDF and XML files will be interpreted by Odoo") journal_group_ids = fields.Many2many('account.journal.group', check_company=True, @@ -358,52 +355,14 @@ class AccountJournal(models.Model): else: journal.suspense_account_id = False - def _inverse_type(self): - # Create an alias for purchase/sales journals - for journal in self: - if journal.type not in ('purchase', 'sale'): - if journal.alias_id: - journal.alias_id.sudo().unlink() - continue - - alias_name = next(string for string in ( - journal.alias_name, - journal.name, - journal.code, - journal.type, - ) if string and is_encodable_as_ascii(string)) - - if journal.company_id != self.env.ref('base.main_company'): - if is_encodable_as_ascii(journal.company_id.name): - alias_name = f"{alias_name}-{journal.company_id.name}" - else: - alias_name = f"{alias_name}-{journal.company_id.id}" - - alias_values = { - 'alias_defaults': { - 'move_type': 'in_invoice' if journal.type == 'purchase' else 'out_invoice', - 'company_id': journal.company_id.id, - 'journal_id': journal.id, - }, - 'alias_parent_thread_id': journal.id, - 'alias_name': alias_name, - } - if journal.alias_id: - journal.alias_id.sudo().write(alias_values) - else: - alias_values['alias_model_id'] = self.env['ir.model']._get('account.move').id - alias_values['alias_parent_model_id'] = self.env['ir.model']._get('account.journal').id - journal.alias_id = self.env['mail.alias'].sudo().create(alias_values) - self.invalidate_recordset(['alias_name']) - - @api.depends('name') - def _compute_alias_domain(self): - self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain") - - @api.depends('alias_id', 'alias_id.alias_name') - def _compute_alias_name(self): - for journal in self: - journal.alias_name = journal.alias_id.alias_name + @api.onchange('type') + def _onchange_type_for_alias(self): + self.filtered(lambda journal: journal.type not in {'sale', 'purchase'}).alias_name = False + for journal in self.filtered(lambda journal: ( + not journal.alias_name and journal.type in {'sale', 'purchase'}) + ): + journal.alias_name = self._alias_prepare_alias_name( + False, journal.name, journal.code, journal.type, journal.company_id) @api.constrains('account_control_ids') def _constrains_account_control_ids(self): @@ -512,7 +471,6 @@ class AccountJournal(models.Model): accounts = self.search([('bank_account_id', '=', bank_account.id)]) if accounts <= self: bank_accounts += bank_account - self.mapped('alias_id').sudo().unlink() ret = super(AccountJournal, self).unlink() bank_accounts.unlink() return ret @@ -549,6 +507,14 @@ class AccountJournal(models.Model): return super(AccountJournal, self).copy(default) def write(self, vals): + # for journals, force a readable name instead of a sanitized name e.g. non ascii in journal names + if vals.get('alias_name') and 'type' not in vals: + # will raise if writing name on more than 1 record, using self[0] is safe + if (not self.env['mail.alias']._is_encodable(vals['alias_name']) or + not self.env['mail.alias']._sanitize_alias_name(vals['alias_name'])): + vals['alias_name'] = self._alias_prepare_alias_name( + False, vals.get('name', self.name), vals.get('code', self.code), self[0].type, self[0].company_id) + for journal in self: company = journal.company_id if ('company_id' in vals and journal.company_id.id != vals['company_id']): @@ -573,6 +539,16 @@ class AccountJournal(models.Model): raise UserError(_("You cannot modify the field %s of a journal that already has accounting entries.", field_string)) result = super(AccountJournal, self).write(vals) + # Ensure alias coherency when changing type + if 'type' in vals: + for journal in self: + alias_vals = journal._alias_get_creation_values() + alias_vals = { + 'alias_defaults': alias_vals['alias_defaults'], + 'alias_name': alias_vals['alias_name'], + } + journal.update(alias_vals) + # Ensure the liquidity accounts are sharing the same foreign currency. if 'currency_id' in vals: for journal in self.filtered(lambda journal: journal.type in ('bank', 'cash')): @@ -588,6 +564,37 @@ class AccountJournal(models.Model): return result + def _alias_get_creation_values(self): + values = super()._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get_id('account.move') + if self.id: + values['alias_name'] = self._alias_prepare_alias_name(self.alias_name, self.name, self.code, self.type, self.company_id) + values['alias_defaults'] = defaults = literal_eval(self.alias_defaults or "{}") + defaults['company_id'] = self.company_id.id + defaults['move_type'] = 'in_invoice' if self.type == 'purchase' else 'out_invoice' + defaults['journal_id'] = self.id + return values + + @api.model + def _alias_prepare_alias_name(self, alias_name, name, code, jtype, company): + """ Tool method generating standard journal alias, to ensure uniqueness + and readability; reset for other journals than purchase / sale """ + if jtype not in ('purchase', 'sale'): + return False + + alias_name = next( + ( + string for string in (alias_name, name, code, jtype) + if (string and self.env['mail.alias']._is_encodable(string) and + self.env['mail.alias']._sanitize_alias_name(string)) + ), False + ) + if company != self.env.ref('base.main_company'): + company_identifier = company.name if self.env['mail.alias']._is_encodable(company.name) else company.id + if f'-{company_identifier}' not in alias_name: + alias_name = f"{alias_name}-{company_identifier}" + return self.env['mail.alias']._sanitize_alias_name(alias_name) + @api.model def get_next_bank_cash_default_code(self, journal_type, company, cache=None): journal_code_base = (journal_type == 'cash' and 'CSH' or 'BNK') @@ -661,6 +668,12 @@ class AccountJournal(models.Model): if 'refund_sequence' not in vals: vals['refund_sequence'] = vals['type'] in ('sale', 'purchase') + # === Fill missing alias name for sale / purchase, to force alias creation === + if journal_type in {'sale', 'purchase'} and 'alias_name' not in vals: + vals['alias_name'] = self._alias_prepare_alias_name( + False, vals.get('name'), vals.get('code'), journal_type, company + ) + @api.model_create_multi def create(self, vals_list): for vals in vals_list: diff --git a/addons/account/tests/test_account_incoming_supplier_invoice.py b/addons/account/tests/test_account_incoming_supplier_invoice.py index 47a8efa31da..40fac722805 100644 --- a/addons/account/tests/test_account_incoming_supplier_invoice.py +++ b/addons/account/tests/test_account_incoming_supplier_invoice.py @@ -28,17 +28,6 @@ class TestAccountIncomingSupplierInvoice(AccountTestInvoicingCommon): cls.journal = cls.company_data['default_journal_purchase'] - journal_alias = cls.env['mail.alias'].create({ - 'alias_name': 'test-bill', - 'alias_model_id': cls.env.ref('account.model_account_move').id, - 'alias_defaults': json.dumps({ - 'move_type': 'in_invoice', - 'company_id': cls.env.user.company_id.id, - 'journal_id': cls.journal.id, - }), - }) - cls.journal.write({'alias_id': journal_alias.id}) - def test_supplier_invoice_mailed_from_supplier(self): message_parsed = { 'message_id': 'message-id-dead-beef', diff --git a/addons/account/tests/test_account_journal.py b/addons/account/tests/test_account_journal.py index a67b49cd5f9..c3d7a35b008 100644 --- a/addons/account/tests/test_account_journal.py +++ b/addons/account/tests/test_account_journal.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- + +from ast import literal_eval from unittest.mock import patch from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.account.models.account_payment_method import AccountPaymentMethod -from odoo.tests import tagged +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import Form, tagged from odoo.exceptions import UserError, ValidationError @@ -137,24 +140,149 @@ class TestAccountJournal(AccountTestInvoicingCommon): self.assertFalse(second_method.exists()) - def test_account_journal_alias_name(self): - journal = self.company_data['default_journal_purchase'] - self.assertEqual(journal.alias_name, 'vendor-bills-company_1_data') - journal.name = 'ぁ' - journal.alias_name = False - self.assertEqual(journal.alias_name, 'bill-company_1_data') - journal.code = 'ぁ' - journal.alias_name = False - self.assertEqual(journal.alias_name, 'purchase-company_1_data') - company_2_id = str(self.company_data_2['company'].id) - journal_2 = self.company_data_2['default_journal_sale'] - self.company_data_2['company'].name = 'ぁ' - journal_2.alias_name = False - self.assertEqual(journal_2.alias_name, 'customer-invoices-' + company_2_id) - journal_2.name = 'ぁ' - journal_2.alias_name = False - self.assertEqual(journal_2.alias_name, 'inv-' + company_2_id) - journal_2.code = 'ぁ' - journal_2.alias_name = False - self.assertEqual(journal_2.alias_name, 'sale-' + company_2_id) +@tagged('post_install', '-at_install', 'mail_alias') +class TestAccountJournalAlias(AccountTestInvoicingCommon, MailCommon): + + def test_alias_name_creation(self): + """ Test alias creation, notably avoid raising constraints due to ascii + characters removal. See odoo/odoo@339cdffb68f91eb1455d447d1bdd7133c68723bd """ + # check base test data + journal1 = self.company_data['default_journal_purchase'] + company1 = journal1.company_id + journal2 = self.company_data_2['default_journal_sale'] + company2 = journal2.company_id + # have a non ascii company name + company2.name = 'ぁ' + + for (aname, jname, jcode, jtype, jcompany), expected_alias_name in zip( + [ + ('youpie', 'Journal Name', 'NEW1', 'purchase', company1), + (False, 'Journal Other Name', 'NEW2', 'purchase', company1), + (False, 'ぁ', 'NEW3', 'purchase', company1), + (False, 'ぁ', 'ぁ', 'purchase', company1), + ('youpie', 'Journal Name', 'NEW1', 'purchase', company2), + (False, 'Journal Other Name', 'NEW2', 'purchase', company2), + (False, 'ぁ', 'NEW3', 'purchase', company2), + (False, 'ぁ', 'ぁ', 'purchase', company2), + ], + [ + f'youpie-{company1.name}', + f'journal-other-name-{company1.name}', + f'new3-{company1.name}', + f'purchase-{company1.name}', + f'youpie-{company2.id}', + f'journal-other-name-{company2.id}', + f'new3-{company2.id}', + f'purchase-{company2.id}', + ] + ): + with self.subTest(aname=aname, jname=jname, jcode=jcode, jtype=jtype, jcompany=jcompany): + new_journal = self.env['account.journal'].create({ + 'code': jcode, + 'company_id': jcompany.id, + 'name': jname, + 'type': jtype, + # force alias_name only if given, to check default value otherwise + **({'alias_name': aname} if aname else {}), + }) + self.assertEqual(new_journal.alias_name, expected_alias_name) + + # other types: no mail support by default + journals = self.env['account.journal'].create([{ + 'code': f'NEW{jtype}', + 'name': f'Type {jtype}', + 'type': jtype} + for jtype in ('general', 'cash', 'bank') + ]) + self.assertFalse(journals.alias_id, 'Do not create useless aliases') + self.assertFalse(list(filter(None, journals.mapped('alias_name')))) + + def test_alias_name_form(self): + """ Test alias name update using Form tool (onchange) """ + journal = Form(self.env['account.journal']) + journal.name = 'Test With Form' + self.assertFalse(journal.alias_name) + journal.type = 'sale' + self.assertEqual(journal.alias_name, f'test-with-form-{self.env.company.name}') + journal.type = 'cash' + self.assertFalse(journal.alias_name) + + def test_alias_from_type(self): + """ Test alias behavior on journal, especially alias_name management as + well as defaults update, see odoo/odoo@400b6860271a11b9914166ff7e42939c4c6192dc """ + journal = self.company_data['default_journal_purchase'] + + # assert base test data + company_name = 'company_1_data' + journal_code = 'BILL' + journal_name = 'Vendor Bills' + journal_alias = journal.alias_id + self.assertEqual(journal.code, journal_code) + self.assertEqual(journal.company_id.name, company_name) + self.assertEqual(journal.name, journal_name) + self.assertEqual(journal.type, 'purchase') + + # assert default creation data + self.assertEqual(journal_alias.alias_contact, 'everyone') + self.assertDictEqual( + dict(literal_eval(journal_alias.alias_defaults)), + { + 'move_type': 'in_invoice', + 'company_id': journal.company_id.id, + 'journal_id': journal.id, + } + ) + self.assertFalse(journal_alias.alias_force_thread_id, 'Journal alias should create new moves') + self.assertEqual(journal_alias.alias_model_id, self.env['ir.model']._get('account.move'), + 'Journal alias targets moves') + self.assertEqual(journal_alias.alias_name, f'vendor-bills-{company_name}') + self.assertEqual(journal_alias.alias_parent_model_id, self.env['ir.model']._get('account.journal'), + 'Journal alias owned by journal itself') + self.assertEqual(journal_alias.alias_parent_thread_id, journal.id, + 'Journal alias owned by journal itself') + + # update alias_name, ensure a fallback on a real name when not explicit reset + for alias_name, expected in [ + (False, False), + ('', False), + (' ', f'vendor-bills-{company_name}'), # error recuperation + ('.', f'vendor-bills-{company_name}'), # error recuperation + ('😊', f'vendor-bills-{company_name}'), # resets, unicode not supported + ('ぁ', f'vendor-bills-{company_name}'), # resets, non ascii not supported + ('Youpie Boum', 'youpie-boum'), + ]: + with self.subTest(alias_name=alias_name): + journal.write({'alias_name': alias_name}) + self.assertEqual(journal.alias_name, expected) + self.assertEqual(journal_alias.alias_name, expected) + + # changing type should void if not purchase or sale + for jtype in ('general', 'cash', 'bank'): + journal.write({'type': jtype}) + self.assertEqual(journal.alias_id, journal_alias, + 'Dà not unlink aliases, just reset their value') + self.assertFalse(journal.alias_name) + self.assertFalse(journal_alias.alias_name) + + # changing type should reset if sale or purchase + journal.company_id.write({'name': 'New Company Name'}) + journal.write({'name': 'Reset Journal', 'type': 'sale'}) + journal_alias_2 = journal.alias_id + self.assertEqual(journal_alias_2.alias_contact, 'everyone') + self.assertDictEqual( + dict(literal_eval(journal_alias_2.alias_defaults)), + { + 'move_type': 'out_invoice', + 'company_id': journal.company_id.id, + 'journal_id': journal.id, + } + ) + self.assertFalse(journal_alias_2.alias_force_thread_id, 'Journal alias should create new moves') + self.assertEqual(journal_alias_2.alias_model_id, self.env['ir.model']._get('account.move'), + 'Journal alias targets moves') + self.assertEqual(journal_alias_2.alias_name, 'reset-journal-new-company-name') + self.assertEqual(journal_alias_2.alias_parent_model_id, self.env['ir.model']._get('account.journal'), + 'Journal alias owned by journal itself') + self.assertEqual(journal_alias_2.alias_parent_thread_id, journal.id, + 'Journal alias owned by journal itself') diff --git a/addons/crm/data/digest_data.xml b/addons/crm/data/digest_data.xml index 9da9d78e512..d044815d18e 100644 --- a/addons/crm/data/digest_data.xml +++ b/addons/crm/data/digest_data.xml @@ -16,9 +16,9 @@

Tip: Convert incoming emails into opportunities

- -

Did you know emails sent to generate opportunities in your pipeline?
- Try sending an email to your CRM. This email address is configurable by sales team members.

+ +

Did you know emails sent to generate opportunities in your pipeline?
+ Try sending an email to your CRM. This email address is configurable by sales team members.

Did you know emails sent to a Sales Team alias generate opportunities in your pipeline?

diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 1bcea9ed084..d8c789384e1 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1276,10 +1276,9 @@ class Lead(models.Model): ('alias_force_thread_id', '=', False) ], limit=1) - if alias_record and alias_record.alias_domain and alias_record.alias_name: - email = f'{alias_record.alias_name}@{alias_record.alias_domain}' + if alias_record.alias_domain and alias_record.alias_name: sub_title = Markup(_('Use the New button, or send an email to %(email_link)s to test the email gateway.')) % { - 'email_link': Markup("%s") % (email, email), + 'email_link': Markup("%s") % (alias_record.display_name, alias_record.display_name), } return super().get_empty_list_help( f'

{help_title}

{sub_title}

' diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index 4ada6a3b3b2..40ccee93b3d 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -24,9 +24,7 @@ class Team(models.Model): use_leads = fields.Boolean('Leads', help="Check this box to filter and qualify incoming requests as leads before converting them into opportunities and assigning them to a salesperson.") use_opportunities = fields.Boolean('Pipeline', default=True, help="Check this box to manage a presales process with opportunities.") - alias_id = fields.Many2one( - 'mail.alias', string='Alias', ondelete="restrict", required=True, - help="The email address associated with this channel. New emails received will automatically create new leads assigned to the channel.") + alias_id = fields.Many2one(help="The email address associated with this channel. New emails received will automatically create new leads assigned to the channel.") # assignment assignment_enabled = fields.Boolean('Lead Assign', compute='_compute_assignment_enabled') assignment_auto_enabled = fields.Boolean('Auto Assignment', compute='_compute_assignment_enabled') diff --git a/addons/crm/tests/test_crm_lead.py b/addons/crm/tests/test_crm_lead.py index 0b5e33f0492..8cf4b4a20f3 100644 --- a/addons/crm/tests/test_crm_lead.py +++ b/addons/crm/tests/test_crm_lead.py @@ -627,7 +627,7 @@ class TestCRMLead(TestCrmCommon): new_lead = self.format_and_process( INCOMING_EMAIL, 'unknown.sender@test.example.com', - '%s@%s' % (self.sales_team_1.alias_name, self.alias_domain), + self.sales_team_1.alias_email, subject='Delivery cost inquiry', target_model='crm.lead', ) diff --git a/addons/crm/tests/test_crm_lead_multicompany.py b/addons/crm/tests/test_crm_lead_multicompany.py index 7b6118d5b3a..ff1d57f9544 100644 --- a/addons/crm/tests/test_crm_lead_multicompany.py +++ b/addons/crm/tests/test_crm_lead_multicompany.py @@ -314,7 +314,7 @@ class TestCRMLeadMultiCompany(TestCrmCommon): new_lead = self.format_and_process( INCOMING_EMAIL, customer_company.email, - '%s@%s' % (self.sales_team_1.alias_name, self.alias_domain), + self.sales_team_1.alias_email, subject='Team having partner in company', target_model='crm.lead', ) diff --git a/addons/crm/views/crm_helper_templates.xml b/addons/crm/views/crm_helper_templates.xml index f3610e25e58..6cd44f01253 100644 --- a/addons/crm/views/crm_helper_templates.xml +++ b/addons/crm/views/crm_helper_templates.xml @@ -1,11 +1,11 @@