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 @@
-
+
Create an opportunity to start playing with your pipeline.
Use the New button, or send an email to
-
+
to test the email gateway.
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index 1ebfe49499a..fc045bf89b9 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -488,13 +488,12 @@ class HrExpense(models.Model):
use_mailgateway = self.env['ir.config_parameter'].sudo().get_param('hr_expense.use_mailgateway')
expense_alias = self.env.ref('hr_expense.mail_alias_expense') if use_mailgateway else False
if expense_alias and expense_alias.alias_domain and expense_alias.alias_name:
- alias_email = f'{expense_alias.alias_name}@{expense_alias.alias_domain}'
# encode, but force %20 encoding for space instead of a + (URL / mailto difference)
params = werkzeug.urls.url_encode({'subject': _("Lunch with customer $12.32")}).replace('+', '%20')
return Markup(
"""
By setting an alias to a job position, emails sent to this address create applications automatically. You can even use multiple trackers to get statistics according to the source of the application: LinkedIn, Monster, Indeed, etc.
-
- Try sending an email
+
+ Try sending an email
@@ -350,11 +350,10 @@ class Applicant(models.Model):
'para_2': _("You can search into attachment's content, like resumes, with the searchbar."),
}
- if alias_id and alias_id.alias_domain and alias_id.alias_name:
- email = alias_id.display_name
+ if hr_job.alias_email:
nocontent_body += Markup('
') % {
'helper_email': _("Create new applications by sending an email to"),
- 'email': email
+ 'email': hr_job.alias_email,
}
return super().get_empty_list_help(nocontent_body)
diff --git a/addons/hr_recruitment/models/hr_job.py b/addons/hr_recruitment/models/hr_job.py
index f1292f83805..8b6e1b2ddec 100644
--- a/addons/hr_recruitment/models/hr_job.py
+++ b/addons/hr_recruitment/models/hr_job.py
@@ -49,9 +49,7 @@ class Job(models.Model):
help="Person responsible of validating the employee's contracts.")
document_ids = fields.One2many('ir.attachment', compute='_compute_document_ids', string="Documents", readonly=True)
documents_count = fields.Integer(compute='_compute_document_ids', string="Document Count")
- alias_id = fields.Many2one(
- 'mail.alias', "Alias", ondelete="restrict", required=True,
- help="Email alias for this job position. New emails will automatically create new applicants for this job position.")
+ alias_id = fields.Many2one(help="Email alias for this job position. New emails will automatically create new applicants for this job position.")
color = fields.Integer("Color Index")
is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite')
favorite_user_ids = fields.Many2many('res.users', 'job_favorite_user_rel', 'job_id', 'user_id', default=_get_default_favorite_user_ids)
diff --git a/addons/hr_recruitment/models/hr_recruitment_source.py b/addons/hr_recruitment/models/hr_recruitment_source.py
index 7bd11eb8428..ca65cec23b2 100644
--- a/addons/hr_recruitment/models/hr_recruitment_source.py
+++ b/addons/hr_recruitment/models/hr_recruitment_source.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from odoo import fields, models
class RecruitmentSource(models.Model):
@@ -12,11 +12,12 @@ class RecruitmentSource(models.Model):
email = fields.Char(related='alias_id.display_name', string="Email", readonly=True)
has_domain = fields.Char(compute='_compute_has_domain')
job_id = fields.Many2one('hr.job', "Job", ondelete='cascade')
- alias_id = fields.Many2one('mail.alias', "Alias ID")
+ alias_id = fields.Many2one('mail.alias', "Alias ID", ondelete='restrict')
medium_id = fields.Many2one('utm.medium', default=lambda self: self.env.ref('utm.utm_medium_website'))
def _compute_has_domain(self):
- self.has_domain = bool(self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain"))
+ for source in self:
+ source.has_domain = bool(source.alias_id.alias_domain)
def create_alias(self):
campaign = self.env.ref('hr_recruitment.utm_campaign_job')
@@ -36,10 +37,9 @@ class RecruitmentSource(models.Model):
}
source.alias_id = self.env['mail.alias'].create(vals)
- @api.model
- def _get_view(self, view_id=None, view_type='form', **options):
- arch, view = super()._get_view(view_id, view_type, **options)
- if view_type == 'tree' and not bool(self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")):
- email = arch.xpath("//field[@name='email']")[0]
- email.getparent().remove(email)
- return arch, view
+ def unlink(self):
+ """ Cascade delete aliases to avoid useless / badly configured aliases. """
+ aliases = self.alias_id
+ res = super().unlink()
+ aliases.sudo().unlink()
+ return res
diff --git a/addons/hr_recruitment/views/hr_recruitment_source_views.xml b/addons/hr_recruitment/views/hr_recruitment_source_views.xml
index 9843d6cb7c0..280871825c6 100644
--- a/addons/hr_recruitment/views/hr_recruitment_source_views.xml
+++ b/addons/hr_recruitment/views/hr_recruitment_source_views.xml
@@ -46,7 +46,8 @@
-
+
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index e8860e247a1..ce06177d735 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -2,7 +2,7 @@
{
'name': 'Discuss',
- 'version': '1.14',
+ 'version': '1.15',
'category': 'Productivity/Discuss',
'sequence': 145,
'summary': 'Chat, mail gateway and private channels',
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index 6dc4d57c8a2..92146602a08 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -7,6 +7,7 @@ from . import models
# mixin
from . import mail_activity_mixin
+from . import mail_alias_mixin_optional
from . import mail_alias_mixin
from . import mail_render_mixin
from . import mail_composer_mixin
diff --git a/addons/mail/models/ir_config_parameter.py b/addons/mail/models/ir_config_parameter.py
index 7f9ba2bf62d..44fde74d8b4 100644
--- a/addons/mail/models/ir_config_parameter.py
+++ b/addons/mail/models/ir_config_parameter.py
@@ -7,19 +7,6 @@ from odoo import api, models
class IrConfigParameter(models.Model):
_inherit = 'ir.config_parameter'
- @api.model_create_multi
- def create(self, vals_list):
- for vals in vals_list:
- if vals.get('key') in ['mail.bounce.alias', 'mail.catchall.alias']:
- vals['value'] = self.env['mail.alias']._clean_and_check_unique([vals.get('value')])[0]
- return super().create(vals_list)
-
- def write(self, vals):
- for parameter in self:
- if 'value' in vals and parameter.key in ['mail.bounce.alias', 'mail.catchall.alias'] and vals['value'] != parameter.value:
- vals['value'] = self.env['mail.alias']._clean_and_check_unique([vals.get('value')])[0]
- return super().write(vals)
-
@api.model
def set_param(self, key, value):
if key == 'mail.restrict.template.rendering':
@@ -33,8 +20,14 @@ class IrConfigParameter(models.Model):
# remove existing users, including inactive template user
# admin will regain the right via implied_ids on group_system
group_user._remove_group(group_mail_template_editor)
+ # sanitize, normalize and check uniqueness of catchall / bounce aliases
+ # note that False = unlink ICP
+ elif key in {'mail.bounce.alias', 'mail.catchall.alias'} and value:
+ value = self.env['mail.alias']._sanitize_alias_name(value)
+ if value:
+ self.env['mail.alias']._check_unique([value], skip_icp_keys={key})
# sanitize and normalize allowed catchall domains
elif key == 'mail.catchall.domain.allowed' and value:
value = self.env['mail.alias']._clean_and_check_mail_catchall_allowed_domains(value)
- return super(IrConfigParameter, self).set_param(key, value)
+ return super().set_param(key, value)
diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py
index d949bd1312c..c873cfee3a9 100644
--- a/addons/mail/models/mail_alias.py
+++ b/addons/mail/models/mail_alias.py
@@ -33,7 +33,12 @@ class Alias(models.Model):
_rec_name = 'alias_name'
_order = 'alias_model_id, alias_name'
- alias_name = fields.Char('Alias Name', copy=False, help="The name of the email alias, e.g. 'jobs' if you want to catch emails for ")
+ # email definition
+ alias_name = fields.Char(
+ 'Alias Name', copy=False,
+ help="The name of the email alias, e.g. 'jobs' if you want to catch emails for ")
+ alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
+ # target: create / update
alias_model_id = fields.Many2one('ir.model', 'Aliased Model', required=True, ondelete="cascade",
help="The model (Odoo Document Kind) to which this alias "
"corresponds. Any incoming email that does not reply to an "
@@ -54,17 +59,22 @@ class Alias(models.Model):
'Record Thread ID',
help="Optional ID of a thread (record) to which all incoming messages will be attached, even "
"if they did not reply to it. If set, this will disable the creation of new records completely.")
- alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
+ # owner
alias_parent_model_id = fields.Many2one(
'ir.model', 'Parent Model',
help="Parent model holding the alias. The model holding the alias reference "
"is not necessarily the model given by alias_model_id "
"(example: project (parent_model) and task (model))")
- alias_parent_thread_id = fields.Integer('Parent Record Thread ID', help="ID of the parent record holding the alias (example: project holding the task creation alias)")
- alias_contact = fields.Selection([
- ('everyone', 'Everyone'),
- ('partners', 'Authenticated Partners'),
- ('followers', 'Followers only')], default='everyone',
+ alias_parent_thread_id = fields.Integer(
+ 'Parent Record Thread ID',
+ help="ID of the parent record holding the alias (example: project holding the task creation alias)")
+ # incoming configuration (mailgateway)
+ alias_contact = fields.Selection(
+ [
+ ('everyone', 'Everyone'),
+ ('partners', 'Authenticated Partners'),
+ ('followers', 'Followers only')
+ ], default='everyone',
string='Alias Contact Security', required=True,
help="Policy to post a message on the document using the mailgateway.\n"
"- everyone: everyone can post\n"
@@ -73,12 +83,12 @@ class Alias(models.Model):
alias_bounced_content = fields.Html(
"Custom Bounced Message", translate=True,
help="If set, this content will automatically be sent out to unauthorized users instead of the default message.")
- alias_status = fields.Selection([
- ('not_tested', 'Not Tested'),
- ('valid', 'Valid'),
- ('invalid', 'Invalid'),
- ],
- compute='_compute_alias_status', store=True,
+ alias_status = fields.Selection(
+ [
+ ('not_tested', 'Not Tested'),
+ ('valid', 'Valid'),
+ ('invalid', 'Invalid'),
+ ], compute='_compute_alias_status', store=True,
help='Alias status assessed on the last message received.')
_sql_constraints = [
@@ -86,66 +96,29 @@ class Alias(models.Model):
]
@api.constrains('alias_name')
- def _alias_is_ascii(self):
+ def _check_alias_is_ascii(self):
""" The local-part ("display-name" ) of an
address only contains limited range of ascii characters.
We DO NOT allow anything else than ASCII dot-atom formed
local-part. Quoted-string and internationnal characters are
to be rejected. See rfc5322 sections 3.4.1 and 3.2.3
"""
- for alias in self:
- if alias.alias_name and not dot_atom_text.match(alias.alias_name):
- raise ValidationError(_(
- "You cannot use anything else than unaccented latin characters in the alias address (%s).",
- alias.alias_name,
- ))
-
- @api.depends('alias_contact', 'alias_defaults', 'alias_model_id')
- def _compute_alias_status(self):
- """Reset alias_status to "not_tested" when fields, that can be the source of an error, are modified."""
- self.alias_status = 'not_tested'
-
- @api.depends('alias_name')
- def _compute_alias_domain(self):
- self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+ for alias in self.filtered('alias_name'):
+ if not dot_atom_text.match(alias.alias_name):
+ raise ValidationError(
+ _("You cannot use anything else than unaccented latin characters in the alias address %(alias_name)s.",
+ alias_name=alias.alias_name)
+ )
@api.constrains('alias_defaults')
def _check_alias_defaults(self):
for alias in self:
try:
dict(ast.literal_eval(alias.alias_defaults))
- except Exception:
- raise ValidationError(_('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"'))
-
- @api.model_create_multi
- def create(self, vals_list):
- """ Creates email.alias records according to the values provided in
- ``vals`` with 1 alteration:
-
- * ``alias_name`` value may be cleaned by replacing certain unsafe
- characters;
-
- :raise UserError: if given alias_name is already assigned or there are
- duplicates in given vals_list;
- """
- alias_names = [vals['alias_name'] for vals in vals_list if vals.get('alias_name')]
- if alias_names:
- sanitized_names = self._clean_and_check_unique(alias_names)
- for vals in vals_list:
- if vals.get('alias_name'):
- vals['alias_name'] = sanitized_names[alias_names.index(vals['alias_name'])]
- return super(Alias, self).create(vals_list)
-
- def write(self, vals):
- """"Raises UserError if given alias name is already assigned"""
- if vals.get('alias_name') and self.ids:
- if len(self) > 1:
- raise UserError(_(
- 'Email alias %(alias_name)s cannot be used on %(count)d records at the same time. Please update records one by one.',
- alias_name=vals['alias_name'], count=len(self)
- ))
- vals['alias_name'] = self._clean_and_check_unique([vals.get('alias_name')])[0]
- return super(Alias, self).write(vals)
+ except Exception as e:
+ raise ValidationError(
+ _('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"')
+ ) from e
@api.depends('alias_domain', 'alias_name')
def _compute_display_name(self):
@@ -161,70 +134,154 @@ class Alias(models.Model):
else:
record.display_name = _("Inactive Alias")
+ @api.depends('alias_name')
+ def _compute_alias_domain(self):
+ self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+
+ @api.depends('alias_contact', 'alias_defaults', 'alias_model_id')
+ def _compute_alias_status(self):
+ """Reset alias_status to "not_tested" when fields, that can be the source of an error, are modified."""
+ self.alias_status = 'not_tested'
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Creates email.alias records according to the values provided in
+ ``vals`` with 1 alteration:
+
+ * ``alias_name`` value may be cleaned by replacing certain unsafe
+ characters;
+
+ :raise UserError: if given alias_name is already assigned or there are
+ duplicates in given vals_list;
+ """
+ alias_names = [self._sanitize_alias_name(vals.get('alias_name')) for vals in vals_list]
+ self._check_unique(alias_names)
+ for vals, alias_name in zip(vals_list, alias_names):
+ vals['alias_name'] = alias_name
+ return super().create(vals_list)
+
+ def write(self, vals):
+ """ Raise UserError with a meaningfull message instead of letting the
+ unicity constraint give its error. """
+ if 'alias_name' in vals:
+ vals['alias_name'] = self._sanitize_alias_name(vals['alias_name'])
+ if vals.get('alias_name') and self.ids:
+ self._check_unique([vals['alias_name']])
+ if len(self) > 1:
+ raise UserError(
+ _('Email alias %(alias_name)s cannot be used on %(count)d records at the same time. Please update records one by one.',
+ alias_name=vals['alias_name'], count=len(self))
+ )
+ return super().write(vals)
+
def _clean_and_check_mail_catchall_allowed_domains(self, value):
""" The purpose of this system parameter is to avoid the creation
of records from incoming emails with a domain != alias_domain
but that have a pattern matching an internal mail.alias . """
value = [domain.strip().lower() for domain in value.split(',') if domain.strip()]
if not value:
- raise ValidationError(_("Value for `mail.catchall.domain.allowed` cannot be validated.\n"
- "It should be a comma separated list of domains e.g. example.com,example.org."))
+ raise ValidationError(
+ _("Value for `mail.catchall.domain.allowed` cannot be validated.\n"
+ "It should be a comma separated list of domains e.g. example.com,example.org.")
+ )
return ",".join(value)
- def _clean_and_check_unique(self, names):
- """When an alias name appears to already be an email, we keep the local
- part only. A sanitizing / cleaning is also performed on the name. If
- name already exists an UserError is raised. """
+ def _check_unique(self, sanitized_names, skip_icp_keys=None):
+ """ Check unicity constraint won't be raised, otherwise raise a UserError
+ with a complete error message. Also check unicity against alias config
+ parameters.
- def _sanitize_alias_name(name):
- """ Cleans and sanitizes the alias name """
- sanitized_name = remove_accents(name).lower().split('@')[0]
- sanitized_name = re.sub(r'[^\w+.]+', '-', sanitized_name)
- sanitized_name = re.sub(r'^\.+|\.+$|\.+(?=\.)', '', sanitized_name)
- sanitized_name = sanitized_name.encode('ascii', errors='replace').decode()
- return sanitized_name
+ :param list sanitized_names: a list of names (considered as sanitized
+ and ready to be sent to DB);
+ """
+ valid_names = list(filter(None, sanitized_names))
- sanitized_names = [_sanitize_alias_name(name) for name in names]
+ # list itself should be unique obviously
+ seen = set()
+ dupes = [name for name in valid_names if name in seen or seen.add(name)]
+ if dupes:
+ raise UserError(
+ _('Email aliases %(alias_name)s cannot be used on several records at the same time. Please update records one by one.',
+ alias_name=', '.join(dupes))
+ )
- catchall_alias = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.alias')
- bounce_alias = self.env['ir.config_parameter'].sudo().get_param('mail.bounce.alias')
alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+ icp_to_check = dict(
+ (icp_key, self.env["ir.config_parameter"].sudo().get_param(icp_key))
+ for icp_key in {'mail.bounce.alias', 'mail.catchall.alias'}
+ if icp_key not in (skip_icp_keys or ())
+ )
+ icp_label_by_key = {
+ 'mail.bounce.alias': _('bounce'),
+ 'mail.catchall.alias': _('catchall'),
+ }
# matches catchall or bounce alias
- for sanitized_name in sanitized_names:
- if sanitized_name in [catchall_alias, bounce_alias]:
- matching_alias_name = '%s@%s' % (sanitized_name, alias_domain) if alias_domain else sanitized_name
- raise UserError(
- _('The e-mail alias %(matching_alias_name)s is already used as %(alias_duplicate)s alias. Please choose another alias.',
- matching_alias_name=matching_alias_name,
- alias_duplicate=_('catchall') if sanitized_name == catchall_alias else _('bounce'))
- )
+ for sanitized_name in valid_names:
+ for icp_key, icp_value in icp_to_check.items():
+ if icp_value and sanitized_name == icp_value:
+ matching_alias_name = f'{sanitized_name}@{alias_domain}' if alias_domain else sanitized_name
+ raise UserError(
+ _('The e-mail alias %(matching_alias_name)s is already used as %(alias_duplicate)s alias. Please choose another alias.',
+ matching_alias_name=matching_alias_name,
+ alias_duplicate=icp_label_by_key[icp_key])
+ )
# matches existing alias
- domain = [('alias_name', 'in', sanitized_names)]
- if self:
- domain += [('id', 'not in', self.ids)]
- matching_alias = self.search(domain, limit=1)
+ matching_alias = self.env['mail.alias']
+ if valid_names:
+ domain = [('alias_name', 'in', valid_names)]
+ if self:
+ domain += [('id', 'not in', self.ids)]
+ matching_alias = self.search(domain, limit=1)
if not matching_alias:
- return sanitized_names
-
- sanitized_alias_name = _sanitize_alias_name(matching_alias.alias_name)
- matching_alias_name = '%s@%s' % (sanitized_alias_name, alias_domain) if alias_domain else sanitized_alias_name
+ return
if matching_alias.alias_parent_model_id and matching_alias.alias_parent_thread_id:
# If parent model and parent thread ID both are set, display document name also in the warning
document_name = self.env[matching_alias.alias_parent_model_id.model].sudo().browse(matching_alias.alias_parent_thread_id).display_name
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already used by the %(document_name)s %(model_name)s. Choose another alias or change it on the other document.',
- matching_alias_name=matching_alias_name,
+ matching_alias_name=matching_alias.display_name,
document_name=document_name,
model_name=matching_alias.alias_parent_model_id.name)
)
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already linked with %(alias_model_name)s. Choose another alias or change it on the linked model.',
- matching_alias_name=matching_alias_name,
+ matching_alias_name=matching_alias.display_name,
alias_model_name=matching_alias.alias_model_id.name)
)
+ @api.model
+ def _sanitize_alias_name(self, name):
+ """ Cleans and sanitizes the alias name """
+ sanitized_name = name.strip() if name else ''
+ if sanitized_name:
+ sanitized_name = remove_accents(sanitized_name).lower().split('@')[0]
+ # cannot start and end with dot
+ sanitized_name = re.sub(r'^\.+|\.+$|\.+(?=\.)', '', sanitized_name)
+ # subset of allowed characters
+ sanitized_name = re.sub(r'[^\w!#$%&\'*+\-/=?^_`{|}~.]+', '-', sanitized_name)
+ sanitized_name = sanitized_name.encode('ascii', errors='replace').decode()
+ if not sanitized_name.strip():
+ return False
+ return sanitized_name
+
+ @api.model
+ def _is_encodable(self, alias_name, charset='ascii'):
+ """ Check if alias_name is encodable. Standard charset is ascii, as
+ UTF-8 requires a specific extension. Not recommended for outgoing
+ aliases. 'remove_accents' is performed as sanitization process of
+ the name will do it anyway. """
+ try:
+ remove_accents(alias_name).encode(charset)
+ except UnicodeEncodeError:
+ return False
+ return True
+
+ # ------------------------------------------------------------
+ # ACTIONS
+ # ------------------------------------------------------------
+
def open_document(self):
if not self.alias_model_id or not self.alias_force_thread_id:
return False
@@ -245,6 +302,10 @@ class Alias(models.Model):
'type': 'ir.actions.act_window',
}
+ # ------------------------------------------------------------
+ # MAIL GATEWAY
+ # ------------------------------------------------------------
+
def _get_alias_bounced_body_fallback(self, message_dict):
contact_description = self._get_alias_contact_description()
default_email = self.env.company.partner_id.email_formatted if self.env.company.partner_id.email else self.env.company.name
diff --git a/addons/mail/models/mail_alias_mixin.py b/addons/mail/models/mail_alias_mixin.py
index c4efa17e3b5..d9ebd41f5f2 100644
--- a/addons/mail/models/mail_alias_mixin.py
+++ b/addons/mail/models/mail_alias_mixin.py
@@ -3,93 +3,34 @@
import logging
-from odoo import _, api, fields, models
+from odoo import fields, models
_logger = logging.getLogger(__name__)
class AliasMixin(models.AbstractModel):
- """ A mixin for models that inherits mail.alias. This mixin initializes the
- alias_id column in database, and manages the expected one-to-one
- relation between your model and mail aliases.
- """
+ """ A mixin for models that inherits mail.alias to have a one-to-one relation
+ between the model and its alias. """
_name = 'mail.alias.mixin'
+ _inherit = 'mail.alias.mixin.optional'
_inherits = {'mail.alias': 'alias_id'}
_description = 'Email Aliases Mixin'
- ALIAS_WRITEABLE_FIELDS = ['alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
- alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True)
+ alias_id = fields.Many2one(required=True)
+ alias_name = fields.Char(inherited=True)
+ alias_defaults = fields.Text(inherited=True)
# --------------------------------------------------
# CRUD
# --------------------------------------------------
- @api.model_create_multi
- def create(self, vals_list):
- """ Create a record with each ``vals`` or ``vals_list`` and create a corresponding alias. """
- # prepare all alias values
- alias_vals_list, record_vals_list = [], []
- for vals in vals_list:
- new_alias = not vals.get('alias_id')
- if new_alias:
- alias_vals, record_vals = self._alias_filter_fields(vals)
- alias_vals.update(self._alias_get_creation_values())
- alias_vals_list.append(alias_vals)
- record_vals_list.append(record_vals)
-
- # create all aliases
- alias_ids = []
- if alias_vals_list:
- alias_ids = iter(self.env['mail.alias'].sudo().create(alias_vals_list).ids)
-
- # update alias values in create vals directly
- valid_vals_list = []
- record_vals_iter = iter(record_vals_list)
- for vals in vals_list:
- new_alias = not vals.get('alias_id')
- if new_alias:
- record_vals = next(record_vals_iter)
- record_vals['alias_id'] = next(alias_ids)
- valid_vals_list.append(record_vals)
- else:
- valid_vals_list.append(vals)
-
- records = super(AliasMixin, self).create(valid_vals_list)
-
- for record in records:
- record.alias_id.sudo().write(record._alias_get_creation_values())
-
- return records
-
- def write(self, vals):
- """ Split writable fields of mail.alias and other fields alias fields will
- write with sudo and the other normally """
- alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
- if record_vals:
- super(AliasMixin, self).write(record_vals)
- if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
- self.mapped('alias_id').sudo().write(alias_vals)
-
- return True
-
- def unlink(self):
- """ Delete the given records, and cascade-delete their corresponding alias. """
- aliases = self.mapped('alias_id')
- res = super(AliasMixin, self).unlink()
- aliases.sudo().unlink()
- return res
-
- @api.returns(None, lambda value: value[0])
- def copy_data(self, default=None):
- data = super(AliasMixin, self).copy_data(default)[0]
- for fields_not_writable in set(self.env['mail.alias']._fields.keys()) - set(self.ALIAS_WRITEABLE_FIELDS):
- if fields_not_writable in data:
- del data[fields_not_writable]
- return [data]
+ def _require_new_alias(self, record_vals):
+ """ alias_id field is always required, due to inherits """
+ return not record_vals.get('alias_id')
def _init_column(self, name):
""" Create aliases for existing rows. """
- super(AliasMixin, self)._init_column(name)
+ super()._init_column(name)
if name == 'alias_id':
# as 'mail.alias' records refer to 'ir.model' records, create
# aliases after the reflection of models
@@ -109,29 +50,3 @@ class AliasMixin(models.AbstractModel):
record.with_context(mail_notrack=True).alias_id = alias
_logger.info('Mail alias created for %s %s (id %s)',
record._name, record.display_name, record.id)
-
- # --------------------------------------------------
- # MIXIN TOOL OVERRIDE METHODS
- # --------------------------------------------------
-
- def _alias_get_creation_values(self):
- """ Return values to create an alias, or to write on the alias after its
- creation.
- """
- return {
- 'alias_parent_thread_id': self.id if self.id else False,
- 'alias_parent_model_id': self.env['ir.model']._get(self._name).id,
- }
-
- def _alias_filter_fields(self, values, filters=False):
- """ Split the vals dict into two dictionnary of vals, one for alias
- field and the other for other fields """
- if not filters:
- filters = self.env['mail.alias']._fields.keys()
- alias_values, record_values = {}, {}
- for fname in values.keys():
- if fname in filters:
- alias_values[fname] = values.get(fname)
- else:
- record_values[fname] = values.get(fname)
- return alias_values, record_values
diff --git a/addons/mail/models/mail_alias_mixin_optional.py b/addons/mail/models/mail_alias_mixin_optional.py
new file mode 100644
index 00000000000..94cf427418e
--- /dev/null
+++ b/addons/mail/models/mail_alias_mixin_optional.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class AliasMixinOptional(models.AbstractModel):
+ """ A mixin for models that handles underlying 'mail.alias' records to use
+ the mail gateway. Field is not mandatory and its creation is done dynamically
+ based on given 'alias_name', allowing to gradually populate the alias table
+ without having void aliases as when used with an inherits-like implementation.
+ """
+ _name = 'mail.alias.mixin.optional'
+ _description = 'Email Aliases Mixin (light)'
+ ALIAS_WRITEABLE_FIELDS = ['alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
+
+ alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=False)
+ alias_name = fields.Char(related='alias_id.alias_name', readonly=False)
+ alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
+ alias_defaults = fields.Text(related='alias_id.alias_defaults')
+ alias_email = fields.Char('Email Alias', compute='_compute_alias_email')
+
+ @api.depends('alias_name')
+ def _compute_alias_domain(self):
+ self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+
+ @api.depends('alias_domain', 'alias_name')
+ def _compute_alias_email(self):
+ """ Alias email can be used in views, as it is Falsy when having no domain
+ or no name. Alias display name itself contains more info and cannot be
+ used as it is in views. """
+ self.alias_email = False
+ for record in self.filtered(lambda rec: rec.alias_name and rec.alias_domain):
+ record.alias_email = f"{record.alias_name}@{record.alias_domain}"
+
+ # --------------------------------------------------
+ # CRUD
+ # --------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Create aliases using sudo if an alias is required, notably if its
+ name is given. """
+ # prepare all alias values
+ alias_vals_list, record_vals_list = [], []
+ for vals in vals_list:
+ if vals.get('alias_name'):
+ vals['alias_name'] = self.env['mail.alias']._sanitize_alias_name(vals['alias_name'])
+ if self._require_new_alias(vals):
+ alias_vals, record_vals = self._alias_filter_fields(vals)
+ alias_vals.update(self._alias_get_creation_values())
+ alias_vals_list.append(alias_vals)
+ record_vals_list.append(record_vals)
+
+ # create all aliases
+ alias_ids = []
+ if alias_vals_list:
+ alias_ids = iter(self.env['mail.alias'].sudo().create(alias_vals_list).ids)
+
+ # update alias values in create vals directly
+ valid_vals_list = []
+ record_vals_iter = iter(record_vals_list)
+ for vals in vals_list:
+ if self._require_new_alias(vals):
+ record_vals = next(record_vals_iter)
+ record_vals['alias_id'] = next(alias_ids)
+ valid_vals_list.append(record_vals)
+ else:
+ valid_vals_list.append(vals)
+
+ records = super().create(valid_vals_list)
+
+ for record in records.filtered('alias_id'):
+ record.alias_id.sudo().write(record._alias_get_creation_values())
+
+ return records
+
+ def write(self, vals):
+ """ Split writable fields of mail.alias and other fields alias fields will
+ write with sudo and the other normally. If alias does not exist and we
+ try to set a name, create the alias automatically. """
+ # create missing aliases
+ if vals.get('alias_name'):
+ alias_create_values = [
+ dict(
+ record._alias_get_creation_values(),
+ alias_name=self.env['mail.alias']._sanitize_alias_name(vals['alias_name']),
+ )
+ for record in self.filtered(lambda rec: not rec.alias_id)
+ ]
+ if alias_create_values:
+ aliases = self.env['mail.alias'].sudo().create(alias_create_values)
+ for record, alias in zip(self.filtered(lambda rec: not rec.alias_id), aliases):
+ record.alias_id = alias.id
+
+ alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
+ if record_vals:
+ super().write(record_vals)
+ if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
+ self.mapped('alias_id').sudo().write(alias_vals)
+
+ return True
+
+ def unlink(self):
+ """ Delete the given records, and cascade-delete their corresponding alias. """
+ aliases = self.mapped('alias_id')
+ res = super().unlink()
+ aliases.sudo().unlink()
+ return res
+
+ @api.returns(None, lambda value: value[0])
+ def copy_data(self, default=None):
+ data = super().copy_data(default)[0]
+ for fields_not_writable in set(self.env['mail.alias']._fields.keys()) - set(self.ALIAS_WRITEABLE_FIELDS):
+ if fields_not_writable in data:
+ del data[fields_not_writable]
+ return [data]
+
+ def _require_new_alias(self, record_vals):
+ """ Create only if no existing alias, and if a name is given, to avoid
+ creating inactive aliases (falsy name). """
+ return not record_vals.get('alias_id') and record_vals.get('alias_name')
+
+ # --------------------------------------------------
+ # MIXIN TOOL OVERRIDE METHODS
+ # --------------------------------------------------
+
+ def _alias_get_creation_values(self):
+ """ Return values to create an alias, or to write on the alias after its
+ creation.
+ """
+ return {
+ 'alias_parent_thread_id': self.id if self.id else False,
+ 'alias_parent_model_id': self.env['ir.model']._get_id(self._name),
+ }
+
+ def _alias_filter_fields(self, values, filters=False):
+ """ Split the vals dict into two dictionnary of vals, one for alias
+ field and the other for other fields """
+ if not filters:
+ filters = self.env['mail.alias']._fields.keys()
+ alias_values, record_values = {}, {}
+ for fname in values.keys():
+ if fname in filters:
+ alias_values[fname] = values.get(fname)
+ else:
+ record_values[fname] = values.get(fname)
+ return alias_values, record_values
diff --git a/addons/mail/views/mail_alias_views.xml b/addons/mail/views/mail_alias_views.xml
index abe7afb26de..6222557588f 100644
--- a/addons/mail/views/mail_alias_views.xml
+++ b/addons/mail/views/mail_alias_views.xml
@@ -3,8 +3,8 @@
-
- mail.alias.form
+
+ mail.alias.view.formmail.alias
-
- mail.alias.tree
+
+ mail.alias.view.treemail.alias
@@ -78,8 +78,8 @@
-
- mail.alias.search
+
+ mail.alias.view.searchmail.alias
@@ -102,7 +102,7 @@
-
+ Aliasesmail.alias{
diff --git a/addons/mail/views/mail_menus.xml b/addons/mail/views/mail_menus.xml
index 8a5531d19d6..9f267f3c195 100644
--- a/addons/mail/views/mail_menus.xml
+++ b/addons/mail/views/mail_menus.xml
@@ -30,7 +30,7 @@
sequence="10"/>