[IMP] mail: link 'mail.alias_domain' to aliases

PURPOSE

Allow alias domains to be multiple, notably to be used in a multi company
environment where each company has its own alias domain.

SPECIFICATIONS

Add 'mail.alias.domain' information on alias model. Aliases do not use global
configuration parameters anymore. Instead they are linked to an alias domain
e.g. 'sales' linked to 'mycompany.com' alias domain: 'sales@mycompany.com'.

It is now considered as a different alias compared to 'sales@mycompany.in'
which has the same alias_name but a different alias domain.

Constraints and checks are added, as

  * uniqueness of aliases is now checked inside a given domain;
  * an alias name must not clash with its domain bounce or catchall;
  * a combination of (alias_name, alias_domain_id) must be unique;

Crm multi-company environment is also updated to match the new alias domain
behavior.

Task-36879 (Mail: Support Multi Domains Aliases)

Part-of: odoo/odoo#76734
This commit is contained in:
Thibault Delavallée
2023-10-24 19:24:50 +00:00
parent 1b16757d28
commit e8a41762ad
10 changed files with 426 additions and 148 deletions
+12
View File
@@ -265,6 +265,18 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'email': 'company.2@test.example.com',
'name': 'New Test Company',
})
cls.alias_bounce_c2 = 'bounce.c2'
cls.alias_catchall_c2 = 'catchall.c2'
cls.alias_default_from_c2 = 'notifications.c2'
cls.alias_domain_c2_name = 'test.mycompany2.com'
cls.mail_alias_domain_c2 = cls.env['mail.alias.domain'].create({
'bounce_alias': cls.alias_bounce_c2,
'catchall_alias': cls.alias_catchall_c2,
'company_ids': [(4, cls.company_2.id)],
'default_from': cls.alias_default_from_c2,
'name': cls.alias_domain_c2_name,
'sequence': 2,
})
cls.user_sales_manager_mc = mail_new_test_user(
cls.env,
+15 -11
View File
@@ -14,6 +14,8 @@ class NewLeadNotification(TestCrmCommon):
def setUpClass(cls):
""" Activate some langs to test lang propagation in various mail flows """
super(NewLeadNotification, cls).setUpClass()
cls._activate_multi_company()
cls.test_email = '"Test Email" <test.email@example.com>'
model_lang = cls.env['res.lang'].sudo().with_context(active_test=False)
@@ -221,14 +223,14 @@ class NewLeadNotification(TestCrmCommon):
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_new_lead_from_email_multicompany(self):
company0 = self.env.company
company1 = self.env['res.company'].create({'name': 'new_company'})
company1 = self.company_2
self.env.user.write({
'company_ids': [(4, company0.id, False), (4, company1.id, False)],
})
crm_team_model = self.env['ir.model'].search([('model', '=', 'crm.team')])
crm_lead_model = self.env['ir.model'].search([('model', '=', 'crm.lead')])
crm_team_model_id = self.env['ir.model']._get_id('crm.team')
crm_lead_model_id = self.env['ir.model']._get_id('crm.lead')
crm_team0 = self.env['crm.team'].create({
'name': 'crm team 0',
@@ -240,16 +242,18 @@ class NewLeadNotification(TestCrmCommon):
})
mail_alias0 = self.env['mail.alias'].create({
'alias_domain_id': company0.alias_domain_id.id,
'alias_name': 'sale_team_0',
'alias_model_id': crm_lead_model.id,
'alias_parent_model_id': crm_team_model.id,
'alias_model_id': crm_lead_model_id,
'alias_parent_model_id': crm_team_model_id,
'alias_parent_thread_id': crm_team0.id,
'alias_defaults': "{'type': 'opportunity', 'team_id': %s}" % crm_team0.id,
})
mail_alias1 = self.env['mail.alias'].create({
'alias_domain_id': company1.alias_domain_id.id,
'alias_name': 'sale_team_1',
'alias_model_id': crm_lead_model.id,
'alias_parent_model_id': crm_team_model.id,
'alias_model_id': crm_lead_model_id,
'alias_parent_model_id': crm_team_model_id,
'alias_parent_thread_id': crm_team1.id,
'alias_defaults': "{'type': 'opportunity', 'team_id': %s}" % crm_team1.id,
})
@@ -257,12 +261,12 @@ class NewLeadNotification(TestCrmCommon):
crm_team0.write({'alias_id': mail_alias0.id})
crm_team1.write({'alias_id': mail_alias1.id})
new_message0 = """MIME-Version: 1.0
new_message0 = f"""MIME-Version: 1.0
Date: Thu, 27 Dec 2018 16:27:45 +0100
Message-ID: <blablabla0>
Subject: sale team 0 in company 0
From: A client <client_a@someprovider.com>
To: sale_team_0@test.mycompany.com
To: {mail_alias0.display_name}
Content-Type: multipart/alternative; boundary="000000000000a47519057e029630"
--000000000000a47519057e029630
@@ -278,12 +282,12 @@ Content-Transfer-Encoding: quoted-printable
--000000000000a47519057e029630--
"""
new_message1 = """MIME-Version: 1.0
new_message1 = f"""MIME-Version: 1.0
Date: Thu, 27 Dec 2018 16:27:45 +0100
Message-ID: <blablabla1>
Subject: sale team 1 in company 1
From: B client <client_b@someprovider.com>
To: sale_team_1@test.mycompany.com
To: {mail_alias1.display_name}
Content-Type: multipart/alternative; boundary="000000000000a47519057e029630"
--000000000000a47519057e029630
@@ -40,6 +40,7 @@ class ResConfigSettings(models.TransientModel):
# create data again
alias = self.env['mail.alias'].sudo().create({
'alias_contact': 'employees',
'alias_domain_id': self.env.company.alias_domain_id.id,
'alias_model_id': self.env['ir.model']._get_id('hr.expense'),
'alias_name': self.hr_expense_alias_prefix,
})
@@ -30,6 +30,7 @@ class RecruitmentSource(models.Model):
'medium_id': medium.id,
'source_id': source.source_id.id,
},
'alias_domain_id': source.job_id.company_id.alias_domain_id.id or self.env.company.alias_domain_id.id,
'alias_model_id': self.env['ir.model']._get_id('hr.applicant'),
'alias_name': f"{source.job_id.alias_name or source.job_id.name}+{source.name}",
'alias_parent_thread_id': source.job_id.id,
+109 -82
View File
@@ -3,11 +3,12 @@
import ast
import re
from collections import defaultdict
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError, UserError
from odoo.osv import expression
from odoo.tools import is_html_empty, remove_accents
# see rfc5322 section 3.2.3
@@ -37,7 +38,10 @@ class Alias(models.Model):
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 <jobs@example.odoo.com>")
alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
alias_domain_id = fields.Many2one(
'mail.alias.domain', string='Alias Domain', ondelete='restrict',
default=lambda self: self.env.company.alias_domain_id)
alias_domain = fields.Char('Alias domain name', related='alias_domain_id.name')
# 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 "
@@ -86,9 +90,14 @@ class Alias(models.Model):
], compute='_compute_alias_status', store=True,
help='Alias status assessed on the last message received.')
_sql_constraints = [
('alias_unique', 'UNIQUE(alias_name)', 'Unfortunately this email alias is already used, please choose a unique one')
]
def init(self):
"""Make sure there aren't multiple records for the same name and alias
domain. Not in _sql_constraint because COALESCE is not supported for
PostgreSQL constraint. """
self.env.cr.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS mail_alias_name_domain_unique
ON mail_alias (alias_name, COALESCE(alias_domain_id, 0))
""")
@api.constrains('alias_name')
def _check_alias_is_ascii(self):
@@ -115,6 +124,19 @@ class Alias(models.Model):
_('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"')
) from e
@api.constrains('alias_name', 'alias_domain_id')
def _check_alias_domain_clash(self):
""" Within a given alias domain, aliases should not conflict with bounce
or catchall email addresses, as emails should be unique for the gateway. """
failing = self.filtered(lambda alias: alias.alias_name and alias.alias_name in [
alias.alias_domain_id.bounce_alias, alias.alias_domain_id.catchall_alias
])
if failing:
raise ValidationError(
_('Aliases %(alias_names)s is already used as bounce or catchall address. Please choose another alias.',
alias_names=', '.join(failing.mapped('display_name')))
)
@api.depends('alias_domain', 'alias_name')
def _compute_display_name(self):
""" Return the mail alias display alias_name, including the catchall
@@ -128,10 +150,6 @@ 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."""
@@ -139,33 +157,48 @@ class Alias(models.Model):
@api.model_create_multi
def create(self, vals_list):
""" Creates email.alias records according to the values provided in
``vals`` with 1 alteration:
""" Creates mail.alias records according to the values provided in
``vals`` but sanitize 'alias_name' by replacing certain unsafe
characters; set default alias domain if not given.
* ``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;
:raise UserError: if given (alias_name, alias_domain_id) already exists
or if 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
alias_names, alias_domains = [], []
for vals in vals_list:
vals['alias_name'] = self._sanitize_alias_name(vals.get('alias_name'))
alias_names.append(vals['alias_name'])
vals['alias_domain_id'] = vals.get('alias_domain_id', self.env.company.alias_domain_id.id)
alias_domains.append(self.env['mail.alias.domain'].browse(vals['alias_domain_id']))
self._check_unique(alias_names, alias_domains)
return super().create(vals_list)
def write(self, vals):
""" Raise UserError with a meaningfull message instead of letting the
unicity constraint give its error. """
""" Raise UserError with a meaningful message instead of letting the
uniqueness constraint raise an SQL error. To check uniqueness we have
to rebuild pairs of names / domains to validate, taking into account
that a void alias_domain_id is acceptable (but also raises for
uniqueness).
"""
alias_names, alias_domains = [], []
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))
)
alias_names = [vals['alias_name']] * len(self)
elif 'alias_name' not in vals and 'alias_domain_id' in vals:
# avoid checking when writing the same value
if [vals['alias_domain_id']] != self.alias_domain_id.ids:
alias_names = self.filtered('alias_name').mapped('alias_name')
if alias_names:
tocheck_records = self if vals.get('alias_name') else self.filtered('alias_name')
if 'alias_domain_id' in vals:
alias_domains = [self.env['mail.alias.domain'].browse(vals['alias_domain_id'])] * len(tocheck_records)
else:
alias_domains = [record.alias_domain_id for record in tocheck_records]
self._check_unique(alias_names, alias_domains)
return super().write(vals)
def _clean_and_check_mail_catchall_allowed_domains(self, value):
@@ -180,70 +213,64 @@ class Alias(models.Model):
)
return ",".join(value)
def _check_unique(self, sanitized_names, skip_icp_keys=None):
def _check_unique(self, alias_names, alias_domains):
""" Check unicity constraint won't be raised, otherwise raise a UserError
with a complete error message. Also check unicity against alias config
parameters.
:param list sanitized_names: a list of names (considered as sanitized
:param list alias_names: a list of names (considered as sanitized
and ready to be sent to DB);
:param list alias_domains: list of alias_domain records under which
the check is performed, as uniqueness is performed for given pair
(name, alias_domain);
"""
valid_names = list(filter(None, sanitized_names))
if len(alias_names) != len(alias_domains):
msg = (f"Invalid call to '_check_unique': names and domains should make coherent lists, "
f"received {', '.join(alias_names)} and {', '.join(alias_domains.mapped('name'))}")
raise ValueError(msg)
# 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))
)
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 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])
)
# reorder per alias domain, keep only not void alias names (void domain also checks uniqueness)
domain_to_names = defaultdict(list)
for alias_name, alias_domain in zip(alias_names, alias_domains):
if alias_name and alias_name in domain_to_names[alias_domain]:
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=alias_name)
)
if alias_name:
domain_to_names[alias_domain].append(alias_name)
# matches existing alias
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:
domain = expression.OR([
['&', ('alias_name', 'in', alias_names), ('alias_domain_id', '=', alias_domain.id)]
for alias_domain, alias_names in domain_to_names.items()
])
if domain and self:
domain = expression.AND([domain, [('id', 'not in', self.ids)]])
existing = self.search(domain, limit=1) if domain else self.env['mail.alias']
if not existing:
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.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.display_name,
alias_model_name=matching_alias.alias_model_id.name)
)
if existing.alias_parent_model_id and existing.alias_parent_thread_id:
parent_name = self.env[existing.alias_parent_model_id.model].sudo().browse(existing.alias_parent_thread_id).display_name
msg_begin = _(
'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s) and used by the %(parent_name)s %(parent_model_name)s.',
alias_model_name=existing.alias_model_id.name,
current_id=self.ids if self else _('your alias'),
matching_id=existing.id,
matching_name=existing.display_name,
parent_name=parent_name,
parent_model_name=existing.alias_parent_model_id.name
)
else:
msg_begin = _(
'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s).',
alias_model_name=existing.alias_model_id.name,
current_id=self.ids if self else _('new'),
matching_id=existing.id,
matching_name=existing.display_name,
)
msg_end = _('Choose another value or change it on the other document.')
raise UserError(f'{msg_begin} {msg_end}')
@api.model
def _sanitize_alias_name(self, name):
+13 -4
View File
@@ -80,9 +80,15 @@ class AliasDomain(models.Model):
catchall=tocheck.catchall_email)
)
existing = self.env['mail.alias'].search(
[('alias_name', 'in', list(set(names)))],
limit=1,
# search on left-part only to speedup, then filter on right part
potential_aliases = self.env['mail.alias'].search([
('alias_name', 'in', list(set(names))),
('alias_domain_id', '!=', False)
])
existing = next(
(alias for alias in potential_aliases
if alias.display_name in (self.mapped('bounce_email') + self.mapped('catchall_email'))),
self.env['mail.alias']
)
if existing:
document_name = False
@@ -126,11 +132,14 @@ class AliasDomain(models.Model):
alias_domains = super().create(vals_list)
# alias domain init: populate companies at first creation
# alias domain init: populate companies and aliases at first creation
if alias_domains and self.search_count([]) == len(alias_domains):
self.env['res.company'].search(
[('alias_domain_id', '=', False)]
).alias_domain_id = alias_domains[0].id
self.env['mail.alias'].sudo().search(
[('alias_domain_id', '=', False)]
).alias_domain_id = alias_domains[0].id
return alias_domains
+57 -11
View File
@@ -20,14 +20,13 @@ class AliasMixinOptional(models.AbstractModel):
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_domain_id = fields.Many2one(
'mail.alias.domain', string='Alias Domain',
related='alias_id.alias_domain_id', readonly=False)
alias_domain = fields.Char('Alias Domain Name', related='alias_id.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
@@ -45,15 +44,29 @@ class AliasMixinOptional(models.AbstractModel):
def create(self, vals_list):
""" Create aliases using sudo if an alias is required, notably if its
name is given. """
# prefetch company information, used for alias domain
company_fname = self._mail_get_company_field()
if company_fname:
company_id_default = self.default_get([company_fname]).get(company_fname) or self.env.company.id
company_prefetch_ids = {vals[company_fname] for vals in vals_list if vals.get(company_fname)}
company_prefetch_ids.add(company_id_default)
else:
company_id_default = self.env.company.id
company_prefetch_ids = {company_id_default}
# 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):
company_id = vals.get(company_fname) or company_id_default
company = self.env['res.company'].with_prefetch(company_prefetch_ids).browse(company_id)
alias_vals, record_vals = self._alias_filter_fields(vals)
# generate record-agnostic base alias values
alias_vals.update(self.env[self._name]._alias_get_creation_values())
alias_vals.update(self.env[self._name].with_context(
default_alias_domain_id=company.alias_domain_id.id,
)._alias_get_creation_values())
alias_vals_list.append(alias_vals)
record_vals_list.append(record_vals)
@@ -75,15 +88,20 @@ class AliasMixinOptional(models.AbstractModel):
records = super().create(valid_vals_list)
for record in records.filtered('alias_id'):
record.alias_id.sudo().write(record._alias_get_creation_values())
# update alias values with values coming from record, post-create to have
# access to all its values (notably its ID)
records_walias = records.filtered('alias_id')
for record in records_walias:
alias_values = record._alias_get_creation_values()
record.alias_id.sudo().write(alias_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. """
write with sudo and the other normally. Also handle alias_domain_id
update. 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 = [
@@ -101,6 +119,14 @@ class AliasMixinOptional(models.AbstractModel):
alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
if record_vals:
super().write(record_vals)
# synchronize alias domain if company environment changed
company_fname = self._mail_get_company_field()
if company_fname in vals:
alias_domain_values = self.filtered('alias_id')._alias_get_alias_domain_id()
for record, alias_domain_id in alias_domain_values.items():
record.sudo().alias_domain_id = alias_domain_id.id
if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
self.mapped('alias_id').sudo().write(alias_vals)
@@ -131,14 +157,34 @@ class AliasMixinOptional(models.AbstractModel):
# MIXIN TOOL OVERRIDE METHODS
# --------------------------------------------------
def _alias_get_alias_domain_id(self):
""" Return alias domain value to synchronize with owner's company.
Implementing it with a compute is complicated, as its 'alias_domain_id'
is a field on 'mail.alias' model, coming from 'alias_id' field and due
to current implementation of the mixin, notably the create / write
overrides, compute is not called in all cases. We therefore use a tool
method to call in the mixin. """
alias_domain_values = {}
record_companies = self._mail_get_companies()
for record in self:
record_company = record_companies[record.id]
alias_domain_values[record] = (
record_company.alias_domain_id
or record.alias_domain_id or self.env.company.alias_domain_id
)
return alias_domain_values
def _alias_get_creation_values(self):
""" Return values to create an alias, or to write on the alias after its
creation.
"""
return {
values = {
'alias_parent_thread_id': self.id if self.id else False,
'alias_parent_model_id': self.env['ir.model']._get_id(self._name),
}
if self.env.context.get('default_alias_domain_id'):
values['alias_domain_id'] = self.env.context['default_alias_domain_id']
return values
def _alias_filter_fields(self, values, filters=False):
""" Split the vals dict into two dictionnary of vals, one for alias
+47 -2
View File
@@ -22,6 +22,51 @@ class BaseModel(models.AbstractModel):
# FIELDS HELPERS
# ------------------------------------------------------------
def _mail_get_alias_domains(self, default_company=False):
""" Return alias domain linked to each record in self. It is based
on the company (record's company, environment company) and fallback
on the first found alias domain if configuration is not correct.
:param <res.company> default_company: default company in case records
have no company (or no company field); defaults to env.company;
:return: for each record ID in self, found <mail.alias.domain>
"""
record_companies = self._mail_get_companies(default=(default_company or self.env.company))
# prepare default alias domain, fetch only if necessary
default_domain = (default_company or self.env.company).alias_domain_id
all_companies = self.env['res.company'].browse({comp.id for comp in record_companies.values()})
# early optimization: search only if necessary
if not default_domain and any(not comp.alias_domain_id for comp in all_companies):
default_domain = self.env['mail.alias.domain'].search([], limit=1)
return {
record.id: (
record_companies[record.id].alias_domain_id or default_domain
)
for record in self
}
@api.model
def _mail_get_company_field(self):
return 'company_id' if 'company_id' in self else False
def _mail_get_companies(self, default=False):
""" Return company linked to each record in self.
:param <res.company> default: default value if no company field is found
or if it holds a void value. Defaults to a void recordset;
:return: for each record ID in self, found <res.company>
"""
default_company = default or self.env['res.company']
company_fname = self._mail_get_company_field()
return {
record.id: (record[company_fname] or default_company) if company_fname else default_company
for record in self
}
@api.model
def _mail_get_partner_fields(self, introspect_fields=False):
""" This method returns the fields to use to find the contact to link
@@ -33,7 +78,7 @@ class BaseModel(models.AbstractModel):
res.partner model. This is used notably when partners are
mandatory like in voip;
:return list: list of valid field names that can be used to retrieve
:return: list of valid field names that can be used to retrieve
a partner (customer) on the record;
"""
partner_fnames = [fname for fname in ('partner_id', 'partner_ids') if fname in self]
@@ -49,7 +94,7 @@ class BaseModel(models.AbstractModel):
:param bool introspect_fields: see '_mail_get_partner_fields';
:return dict: for each record ID, a res.partner recordsets being default
:return: for each record ID, a res.partner recordsets being default
customers to contact;
"""
partner_fields = self._mail_get_partner_fields(introspect_fields=introspect_fields)
+9 -1
View File
@@ -27,7 +27,11 @@
</button>
</div>
<div class="d-flex">
<h2 class="flex-grow-1"><field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline"/></h2>
<h2 class="flex-grow-1">
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</h2>
<field name="alias_status" widget="badge"
decoration-success="alias_status == 'valid'"
decoration-warning="alias_status == 'not_tested'"
@@ -55,6 +59,7 @@
<field name="arch" type="xml">
<tree string="Alias">
<field name="alias_name"/>
<field name="alias_domain_id"/>
<field name="alias_model_id"/>
<field name="alias_force_thread_id" optional="hide"/>
<field name="alias_parent_model_id"/>
@@ -82,6 +87,7 @@
<field name="arch" type="xml">
<search string="Search Alias">
<field name="alias_name"/>
<field name="alias_domain_id"/>
<field name="alias_model_id"/>
<field name="create_uid"/>
<field name="alias_force_thread_id"/>
@@ -92,6 +98,8 @@
<group expand="0" string="Group By">
<filter string="Creator" name="groupby_create_uid"
context="{'group_by': 'create_uid'}"/>
<filter string="Alias Domain" name="groupby_alias_domain_id"
context="{'group_by': 'alias_domain_id'}"/>
<filter string="Document Model" name="groupby_alias_model_id"
context="{'group_by': 'alias_model_id'}"/>
<filter string="Container Model" name="groupby_alias_model_id"
+162 -37
View File
@@ -19,6 +19,7 @@ class TestMailAliasCommon(MailCommon):
super().setUpClass()
cls.test_alias_mc = cls.env['mail.alias'].create({
'alias_domain_id': cls.mail_alias_domain.id,
'alias_model_id': cls.env['ir.model']._get('mail.test.container.mc').id,
'alias_name': 'test.alias',
})
@@ -92,10 +93,11 @@ class TestMailAlias(TestMailAliasCommon):
@users('admin')
def test_alias_name_unique(self):
""" Check uniqueness constraint on alias names, at create and update.
Also check conflict management with bounce / catchall aliases. """
Also check conflict management with bounce / catchall defined on
alias domains. """
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env)
alias_model_id = self.env['ir.model']._get('mail.test.gateway').id
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')
new_mail_alias = self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -105,18 +107,40 @@ class TestMailAlias(TestMailAliasCommon):
'alias_model_id': alias_model_id,
'alias_name': 'other.test.alias',
})
self.assertEqual((new_mail_alias + other_alias).alias_domain_id, mail_alias_domain)
# test you cannot create or update aliases matching bounce / catchall
# test you cannot create or update aliases matching bounce / catchall of same alias domain
with self.assertRaises(exceptions.ValidationError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': mail_alias_domain.catchall_alias,
})
with self.assertRaises(exceptions.ValidationError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': mail_alias_domain.bounce_alias,
})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': catchall_alias})
new_mail_alias.write({'alias_name': mail_alias_domain.catchall_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': bounce_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_name': catchall_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_name': bounce_alias})
new_mail_alias.write({'alias_name': mail_alias_domain.bounce_alias})
# test that alias names should be unique
# other domains bounce / catchall do not prevent
new_aliases = self.env['mail.alias'].create([
{'alias_model_id': alias_model_id, 'alias_name': self.alias_catchall_c2},
{'alias_model_id': alias_model_id, 'alias_name': self.alias_bounce_c2},
])
self.assertEqual(new_aliases.alias_domain_id, mail_alias_domain)
new_aliases.unlink()
# bounce/catchall of another domain is ok
new_mail_alias.write({'alias_name': mail_alias_domain_c2.bounce_alias})
other_alias.write({'alias_name': mail_alias_domain_c2.catchall_alias})
# changing domain would clash with existing catchall
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_domain_id': mail_alias_domain_c2.id,})
new_mail_alias.write({'alias_name': 'unused.test.alias'})
# test that alias {name, alias_domain_id} should be unique
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -133,9 +157,48 @@ class TestMailAlias(TestMailAliasCommon):
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_alias.write({'alias_name': 'unused.test.alias'})
# also valid for void domain
nodom_alias = self.env['mail.alias'].create({
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'no.domain',
})
self.assertFalse(nodom_alias.alias_domain_id)
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'no.domain',
})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create([
{
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'dupes.wo.domain',
} for _x in range(2)
])
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_alias.write({
'alias_domain_id': False,
'alias_name': 'no.domain',
})
# test that alias name can be duplicated in case of different alias domains
other_domain_alias = self.env['mail.alias'].create({
'alias_domain_id': mail_alias_domain_c2.id,
'alias_model_id': alias_model_id,
'alias_name': 'unused.test.alias'
})
self.assertEqual(other_domain_alias.alias_domain_id, mail_alias_domain_c2)
# changing domain would violate uniqueness
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_domain_alias.write({'alias_domain_id': mail_alias_domain.id})
@users('admin')
def test_alias_name_unique_copy(self):
""" Check uniqueness constraint check when copying aliases """
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
alias_model_id = self.env['ir.model']._get('mail.test.gateway').id
new_mail_alias = self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -148,9 +211,11 @@ class TestMailAlias(TestMailAliasCommon):
# test that duplicating an alias should have blank name
copy_1 = new_mail_alias.copy()
self.assertFalse(copy_1.alias_name)
self.assertEqual(copy_1.alias_domain_id, mail_alias_domain)
# test sanitize of copy with new name
copy_2 = new_mail_alias.copy({'alias_name': 'test.alias.2.éè#'})
self.assertEqual(copy_2.alias_name, 'test.alias.2.ee#')
self.assertEqual(copy_2.alias_domain_id, mail_alias_domain)
# cannot batch update, would create duplicates
with self.assertRaises(exceptions.UserError):
@@ -249,13 +314,23 @@ class TestAliasCompany(TestMailAliasCommon):
def test_alias_domain_setup(self):
""" Test synchronization of alias domain with companies when adding /
updating / removing alias domains """
self.assertEqual(self.company_admin.alias_domain_id, self.mail_alias_domain)
self.assertEqual(self.company_2.alias_domain_id, self.mail_alias_domain_c2)
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env)
# remove alias domain of first company, should not impact second company
self.mail_alias_domain.unlink()
self.assertEqual(self.company_admin.alias_domain_id, mail_alias_domain)
self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2)
# cannot unlink alias domain as there are aliases linked to it
with self.assertRaises(psycopg2.errors.ForeignKeyViolation), self.cr.savepoint(), mute_logger('odoo.sql_db'):
mail_alias_domain.unlink()
# eject linked aliases then remove alias domain of first company; should
# not impact second company
self.env['mail.alias'].sudo().search([]).write({'alias_domain_id': False})
mail_alias_domain.unlink()
self.assertFalse(self.company_admin.alias_domain_id)
self.assertEqual(self.company_2.alias_domain_id, self.mail_alias_domain_c2)
self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2)
self.assertFalse(self.test_alias_mc.alias_domain_id)
# remove all alias domains
self.env['mail.alias.domain'].search([]).unlink()
@@ -274,11 +349,13 @@ class TestAliasCompany(TestMailAliasCommon):
'name': 'test.global.bitnurk.com',
})
self.assertEqual(self.company_admin.alias_domain_id, alias_domain_new,
'MC Alias: from no domain to new domain')
'MC Alias: first domain should populate void companies')
self.assertEqual(self.company_2.alias_domain_id, alias_domain_new,
'MC Alias: should take alias domain with lower sequence')
self.assertEqual(self.company_3.alias_domain_id, alias_domain_new,
'MC Alias: should take alias domain with lower sequence')
self.assertEqual(self.test_alias_mc.alias_domain_id, alias_domain_new,
'MC Alias: first domain should populate void aliases')
# manual update
self.company_2.alias_domain_id = alias_domain_new.id
@@ -289,7 +366,7 @@ class TestAliasCompany(TestMailAliasCommon):
def test_assert_initial_values(self):
""" Test initial setup values: currently all companies share the same
alias configuration as it is unique. """
self.assertEqual(self.test_alias_mc.alias_domain, self.mail_alias_domain.name)
self.assertEqual(self.test_alias_mc.alias_domain_id, self.mail_alias_domain)
self.assertEqual(self.company_admin.alias_domain_id, self.mail_alias_domain)
self.assertEqual(self.company_admin.bounce_email, f'{self.alias_bounce}@{self.alias_domain}')
@@ -365,8 +442,13 @@ class TestMailAliasDomain(TestMailAliasCommon):
with self.assertRaises(exceptions.ValidationError):
self.env['mail.alias.domain'].create({
domain_config: self.test_alias_mc.alias_name,
'name': 'another.domain.name.com',
'name': self.test_alias_mc.alias_domain_id.name,
})
# left-part should not clech
self.env['mail.alias.domain'].create({
domain_config: self.test_alias_mc.alias_name,
'name': 'another.domain.name.com',
})
# should not clash with existing aliases, to avoid valid aliases be
# considered as bounce / catchall
@@ -435,7 +517,7 @@ class TestMailAliasDomain(TestMailAliasCommon):
]:
with self.subTest(failing_name=failing_name):
with self.assertRaises(exceptions.ValidationError):
new_domain = self.env['mail.alias.domain'].create({'name': failing_name})
_new_domain = self.env['mail.alias.domain'].create({'name': failing_name})
with self.assertRaises(exceptions.ValidationError):
alias_domain.write({'name': failing_name})
@@ -493,20 +575,23 @@ class TestMailAliasDomain(TestMailAliasCommon):
self.assertEqual(alias_domain.catchall_alias, 'catchall+double.test')
@tagged('mail_gateway', 'mail_alias', 'multi_company')
@tagged('mail_gateway', 'mail_alias', 'mail_alias_mixin', 'multi_company')
class TestMailAliasMixin(TestMailAliasCommon):
""" Test alias mixin implementation, synchornization of alias records
""" Test alias mixin implementation, synchronization of alias records
based on owner records. """
@users('employee')
@mute_logger('odoo.addons.base.models.ir_model')
def test_alias_mixin(self):
""" Various base checks on alias mixin behavior """
self.assertEqual(self.env.company.alias_domain_id, self.mail_alias_domain)
record = self.env['mail.test.container'].create({
'name': 'Test Record',
'alias_name': 'alias.test',
'alias_contact': 'followers',
})
self.assertEqual(record.alias_id.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record.alias_id.alias_model_id, self.env['ir.model']._get('mail.test.container'))
self.assertEqual(record.alias_id.alias_force_thread_id, record.id)
self.assertEqual(record.alias_id.alias_parent_model_id, self.env['ir.model']._get('mail.test.container'))
@@ -521,6 +606,11 @@ class TestMailAliasMixin(TestMailAliasCommon):
self.assertEqual(record.alias_id.alias_name, 'better.alias.test')
self.assertEqual(record.alias_id.alias_defaults, "{'default_name': 'defaults'}")
with self.assertRaises(exceptions.AccessError):
record.write({
'alias_domain_id': self.mail_alias_domain_c2.id,
})
with self.assertRaises(exceptions.AccessError):
record.write({
'alias_force_thread_id': 0,
@@ -619,20 +709,55 @@ class TestMailAliasMixin(TestMailAliasCommon):
@users('erp_manager')
def test_multi_company_setup(self):
""" Test company change does not impact anything at alias domain level """
record = self.env['mail.test.container.mc'].create({
'name': 'Test Record',
'alias_name': 'alias.test',
'alias_contact': 'followers',
'company_id': self.env.user.company_id.id,
})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertEqual(record.company_id, self.company_2)
""" Test company impact on alias domains when creating or updating
records in a MC environment. """
counter = 0
for create_cid, exp_company, exp_alias_domain in [
(None, self.company_2, self.mail_alias_domain_c2),
(False, self.env['res.company'], self.mail_alias_domain_c2),
(self.env.user.company_id.id, self.company_2, self.mail_alias_domain_c2),
(self.company_admin.id, self.company_admin, self.mail_alias_domain),
]:
with self.subTest(create_cid=create_cid, exp_company=exp_company, exp_alias_domain=exp_alias_domain):
counter += 1
base_values = {
'name': f'Test Record {counter}',
'alias_name': f'alias.test.{counter}',
'alias_contact': 'followers',
}
if create_cid is not None:
base_values['company_id'] = create_cid
record = self.env['mail.test.container.mc'].create(base_values)
self.assertEqual(record.alias_domain_id, exp_alias_domain)
self.assertEqual(record.company_id, exp_company)
record.write({'company_id': self.company_admin.id})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertEqual(record.company_id, self.company_admin)
# copy: keep company
record_copy = record.copy(
default={
'alias_name': f'alias.copy.{counter}',
'name': f'Copy of {record.name}',
}
)
self.assertEqual(record_copy.alias_domain_id, exp_alias_domain)
self.assertEqual(record_copy.company_id, record.company_id)
record.write({'company_id': False})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertFalse(record.company_id)
# copy: force company
record_copy_2 = record.copy(
default={
'alias_name': f'alias.copy.{counter}.2',
'company_id': self.company_admin.id,
'name': f'Copy 2 of {record.name}',
}
)
self.assertEqual(record_copy_2.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record_copy_2.company_id, self.company_admin)
# updating company: force same alias domain
record.write({'company_id': self.company_admin.id})
self.assertEqual(record.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record.company_id, self.company_admin)
# reset company: should not impact alias_domain if set
record.write({'company_id': False})
self.assertEqual(record.alias_domain_id, self.mail_alias_domain)
self.assertFalse(record.company_id)