[MERGE] mail, various: cleanup alias usage

RATIONALE

We plan to move from a single alias-domain configuration to a multi company
enabled support for alias domains and aliases. First step to prepare this
change is to cleanup alias usage through various Odoo addons.

ALIAS USAGE CLEANUP

Cleanup alias usage and definition. Prepare code to ease future changes and
improvements. Notably

  * add a 'alias_email' computed field on the mixin allowing to have the
    complete alias email when set, and False in case it is inactive or linked
    to an inactive alias domain;
  * remove unnecessary alias_id field definition when just the help differs
    from the standard definition coming from the 'mail.alias.mixin';
  * use fields coming from 'inherits' instead of using alias_id and its sub-
    fields; notably use 'alias_display_name' and 'alias_email' fields;
  * remove useless custom code and management;
  * improve alias parameters support code in configuration parameters;

ALIAS MIXIN WITH OPTIONAL ALIAS_ID

Some models would like to use the 'mail.alias.mixin' but it creates an alias
for each record in the parent model. This leads to a lot of unused aliases
if only a subset of those records really use aliases i.e. a lot of aliases
with 'alias_name' being 'False'.

In this commit we introduce a new mixin 'mail.alias.mixin.optional' that
behaves like the old 'mail.alias.mixin' but without having the 'alias_id'
field required i.e. without the "inherits". When creating a record without
giving an 'alias_name' no alias is created.

In this PR we use it notably to remove custom code in account journal model
and make it more standard. Using it in more models will be done later, but
it is a candidate to cleanup unused aliases related to discuss channel model.

ALIAS NAME SANITIZE

Currently there is a constraint on alias name as we allow only a subset of
valid latin characters in it aka `[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]`. There
is also an automatic sanitize of alias name at create / write that replaces
any non-word characters by an hyphen. This sanitize is stricter than the
constraint and it is not really coherent.

In this commit we make the sanitize inlined with the constraint, allowing
more characters to go through the 'mail.alias.mixin' cleaning pass notably.

Enforce that void alias names are forced to False to avoid any constraint
issue. Sanitize method is now independent from the check method, to avoid
calling multiple times the sanitization as it is often used for other checks.

MISC

Lint / reorder code, prepare future changes. Add tests, notably for alias
name sanitize and management.

Provide various fixes, see sub commits for more details.

LINKS

After odoo/odoo#130768 and odoo/enterprise#45204 this is another preparation
for multi-company aliases (see odoo/odoo#76734 and odoo/enterprise#20983).

Task-3453343 (Mail: Cleanup Alias Usage)
Prepares Task-36879 (Mail: Support MultiCompany Aliases)

closes odoo/odoo#130632

Related: odoo/enterprise#45118
Related: odoo/upgrade#5012
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2023-08-09 17:04:28 +02:00
committed by GitHub
35 changed files with 872 additions and 407 deletions
+74 -61
View File
@@ -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:
@@ -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',
+149 -21
View File
@@ -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')
+3 -3
View File
@@ -16,9 +16,9 @@
<div>
<t t-set="record" t-value="object.env['crm.team'].search([('alias_name', '!=', 'False')], limit=1)" />
<p class="tip_title">Tip: Convert incoming emails into opportunities</p>
<t t-if="record and record.alias_domain">
<p class="tip_content">Did you know emails sent to <t t-out="record.alias_id.display_name or ''"></t> generate opportunities in your pipeline?<br/>
<a t-attf-href="mailto:{{record.alias_id.display_name}}" target="_blank">Try sending an email</a> to your CRM. This email address is configurable by sales team members.</p>
<t t-if="record.alias_email">
<p class="tip_content">Did you know emails sent to <t t-out="record.alias_email"></t> generate opportunities in your pipeline?<br/>
<a t-attf-href="mailto:{{record.alias_email}}" target="_blank">Try sending an email</a> to your CRM. This email address is configurable by sales team members.</p>
</t>
<t t-else="">
<p class="tip_content">Did you know emails sent to a Sales Team alias generate opportunities in your pipeline?</p>
+2 -3
View File
@@ -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 <i>New</i> button, or send an email to %(email_link)s to test the email gateway.')) % {
'email_link': Markup("<b><a href='mailto:%s'>%s</a></b>") % (email, email),
'email_link': Markup("<b><a href='mailto:%s'>%s</a></b>") % (alias_record.display_name, alias_record.display_name),
}
return super().get_empty_list_help(
f'<p class="o_view_nocontent_smiling_face">{help_title}</p><p class="oe_view_nocontent_alias">{sub_title}</p>'
+1 -3
View File
@@ -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')
+1 -1
View File
@@ -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',
)
@@ -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',
)
+2 -2
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="crm_action_helper" name="crm action helper">
<t t-if="team.alias_name and team.alias_domain">
<t t-if="team.alias_email">
<p class="o_view_nocontent_smiling_face">
Create an opportunity to start playing with your pipeline.
</p><p>Use the <i>New</i> button, or send an email to
<a t-attf-href="mailto:#{team.alias_id.display_name}"><t t-esc="team.alias_id.display_name"/></a>
<a t-attf-href="mailto:#{team.alias_email}"><t t-esc="team.alias_email"/></a>
to test the email gateway.</p>
</t>
<t t-else="">
+1 -2
View File
@@ -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(
"""<p>%(send_string)s <a href="mailto:%(alias_email)s?%(params)s">%(alias_email)s</a></p>"""
) % {
'alias_email': alias_email,
'alias_email': expense_alias.display_name,
'params': params,
'send_string': _("Or send your receipts at"),
}
+2 -2
View File
@@ -17,8 +17,8 @@
<p class="tip_content">
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.
<t t-set="record" t-value="object.env['hr.job'].search([('alias_name', '!=', False)], limit=1)" />
<t t-if="record and record.alias_domain">
<a t-attf-href="mailto:{{record.alias_id.display_name}}" target="_blank" style="color: #875a7b; text-decoration: none;">Try sending an email</a>
<t t-if="record.alias_email">
<a t-attf-href="mailto:{{record.alias_email}}" target="_blank" style="color: #875a7b; text-decoration: none;">Try sending an email</a>
</t>
</p>
</div>
+5 -6
View File
@@ -336,11 +336,11 @@ class Applicant(models.Model):
def get_empty_list_help(self, help_message):
if 'active_id' in self.env.context and self.env.context.get('active_model') == 'hr.job':
alias_id = self.env['hr.job'].browse(self.env.context['active_id']).alias_id
hr_job = self.env['hr.job'].browse(self.env.context['active_id'])
elif self.env.context.get('default_job_id'):
alias_id = self.env['hr.job'].browse(self.env.context['default_job_id']).alias_id
hr_job = self.env['hr.job'].browse(self.env.context['default_job_id'])
else:
alias_id = False
hr_job = self.env['hr.job']
nocontent_body = Markup("""
<p class="o_view_nocontent_smiling_face">%(help_title)s</p>
@@ -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('<p class="o_copy_paste_email oe_view_nocontent_alias">%(helper_email)s <a href="mailto:%(email)s">%(email)s</a></p>') % {
'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)
+1 -3
View File
@@ -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)
@@ -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
@@ -46,7 +46,8 @@
<field name="source_id" placeholder="e.g. LinkedIn" decoration-bf="1" attrs="{'readonly': [('id', '!=', False)]}"/>
<field name="medium_id" optional="hidden"/>
<field name="job_id" attrs="{'readonly': [('id', '!=', False)]}"/>
<field name="email" attrs="{'invisible': [('email', '=', False)]}" widget="email"/>
<field name="email" widget="email"
attrs="{'invisible': ['|', ('email', '=', False), ('has_domain', '=', False)]}"/>
<button name="create_alias" string="Generate Email" class="btn btn-primary" type="object" attrs="{'invisible': ['|', ('has_domain', '=', False), ('email', '!=', False)]}"/>
</tree>
</field>
+1 -1
View File
@@ -2,7 +2,7 @@
{
'name': 'Discuss',
'version': '1.14',
'version': '1.15',
'category': 'Productivity/Discuss',
'sequence': 145,
'summary': 'Chat, mail gateway and private channels',
+1
View File
@@ -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
+7 -14
View File
@@ -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)
+156 -95
View File
@@ -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 <jobs@example.odoo.com>")
# 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 <jobs@example.odoo.com>")
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" <local-part@domain>) 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
+11 -96
View File
@@ -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
@@ -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
+7 -7
View File
@@ -3,8 +3,8 @@
<data>
<!-- Alias Form View -->
<record model="ir.ui.view" id="view_mail_alias_form">
<field name="name">mail.alias.form</field>
<record model="ir.ui.view" id="mail_alias_view_form">
<field name="name">mail.alias.view.form</field>
<field name="model">mail.alias</field>
<field name="arch" type="xml">
<form string="Alias">
@@ -50,8 +50,8 @@
</record>
<!-- Alias List View -->
<record model="ir.ui.view" id="view_mail_alias_tree">
<field name="name">mail.alias.tree</field>
<record model="ir.ui.view" id="mail_alias_view_tree">
<field name="name">mail.alias.view.tree</field>
<field name="model">mail.alias</field>
<field name="arch" type="xml">
<tree string="Alias">
@@ -78,8 +78,8 @@
</record>
<!-- Alias Search View -->
<record model="ir.ui.view" id="view_mail_alias_search">
<field name="name">mail.alias.search</field>
<record model="ir.ui.view" id="mail_alias_view_search">
<field name="name">mail.alias.view.search</field>
<field name="model">mail.alias</field>
<field name="arch" type="xml">
<search string="Search Alias">
@@ -102,7 +102,7 @@
</field>
</record>
<record id="action_view_mail_alias" model="ir.actions.act_window">
<record id="mail_alias_action" model="ir.actions.act_window">
<field name="name">Aliases</field>
<field name="res_model">mail.alias</field>
<field name="context">{
+1 -1
View File
@@ -30,7 +30,7 @@
sequence="10"/>
<menuitem id="mail_alias_menu"
parent="base.menu_email"
action="action_view_mail_alias"
action="mail_alias_action"
sequence="11"
groups="base.group_no_one"/>
<menuitem id="mail.discuss_channel_menu_settings"
+1 -1
View File
@@ -7,7 +7,7 @@
'description': """
Manage your mailing lists from Odoo.
""",
'version': '1.0',
'version': '1.1',
'depends': [
'mail',
'portal',
+5 -15
View File
@@ -45,8 +45,6 @@ class MailGroup(models.Model):
active = fields.Boolean('Active', default=True)
name = fields.Char('Name', required=True, translate=True)
alias_name = fields.Char('Alias Name', copy=False, related='alias_id.alias_name', readonly=False)
alias_fullname = fields.Char('Alias Full Name', compute='_compute_alias_fullname')
description = fields.Text('Description')
image_128 = fields.Image('Image', max_width=128, max_height=128)
# Messages
@@ -85,14 +83,6 @@ class MailGroup(models.Model):
# UI
can_manage_group = fields.Boolean('Can Manage', help='Can manage the members', compute='_compute_can_manage_group')
@api.depends('alias_name', 'alias_domain')
def _compute_alias_fullname(self):
for group in self:
if group.alias_name and group.alias_domain:
group.alias_fullname = f'{group.alias_name}@{group.alias_domain}'
else:
group.alias_fullname = group.alias_name
@api.depends('mail_group_message_ids.create_date', 'mail_group_message_ids.moderation_status')
def _compute_mail_group_message_last_month_count(self):
month_date = datetime.today() - relativedelta.relativedelta(months=1)
@@ -436,11 +426,11 @@ class MailGroup(models.Model):
'Precedence': 'list',
'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange
}
if self.alias_name and self.alias_domain:
if self.alias_email:
headers.update({
'List-Id': f'<{self.alias_name}.{self.alias_domain}>',
'List-Post': f'<mailto:{self.alias_name}@{self.alias_domain}>',
'X-Forge-To': f'"{self.name}" <{self.alias_name}@{self.alias_domain}>',
'List-Id': f'<{self.alias_email}>',
'List-Post': f'<mailto:{self.alias_email}>',
'X-Forge-To': f'"{self.name}" <{self.alias_email}>',
})
if message.mail_message_id.parent_id:
@@ -448,7 +438,7 @@ class MailGroup(models.Model):
# Add the footer (member specific) in the body
template_values = {
'mailto': f'{self.alias_name}@{self.alias_domain}',
'mailto': f'{self.alias_email}',
'group_url': f'{base_url}/groups/{slug(self)}',
'unsub_label': f'{base_url}/groups?unsubscribe',
'unsub_url': f'{base_url}/groups?unsubscribe&group_id={self.id}&token={access_token}&email={email_url_encoded}',
+1 -1
View File
@@ -6,7 +6,7 @@
<field name="arch" type="xml">
<tree sample="1">
<field name="name"/>
<field name="alias_fullname" string="Alias"/>
<field name="alias_email" string="Alias"/>
<field name="moderation" string="Moderated"/>
<field name="member_count" string="Members"/>
</tree>
+4 -4
View File
@@ -30,10 +30,10 @@
<div t-else="" class="o_image_64_cover float-start me-3 d-lg-block d-md-none"/>
<div class="d-flex flex-row">
<strong><a t-attf-href="/groups/#{ slug(group) }" t-esc="group.name"/></strong>
<div t-if="group.alias_id and group.alias_id.alias_name and group.alias_id.alias_domain"
<div t-if="group.alias_email"
class="d-flex align-items-center ms-3">
<i class="fa fa-envelope-o me-1" role="img" aria-label="Alias" title="Alias"/>
<a class="text-break" t-attf-href="mailto:#{group.alias_id.alias_name}@#{group.alias_id.alias_domain}" t-field="group.alias_id"/>
<a class="text-break" t-attf-href="mailto:#{group.alias_email}" t-field="group.alias_email"/>
</div>
</div>
<div t-field="group.description" class="text-muted d-flex"/>
@@ -277,9 +277,9 @@
<template id="group_name">
<h1 class="text-center" t-esc="group.name"/>
<h4 class="text-center text-muted" t-if="group.alias_id and group.alias_id.sudo().alias_name and group.alias_id.sudo().alias_domain">
<h4 class="text-center text-muted" t-if="group.alias_email">
<i class="fa fa-envelope-o" role="img" aria-label="Alias" title="Alias"/>
<a class="text-break" t-attf-href="mailto:#{group.alias_id.sudo().display_name}" t-field="group.alias_id"/>
<a class="text-break" t-attf-href="mailto:#{group.alias_email}" t-field="group.alias_email"/>
</h4>
</template>
+1 -3
View File
@@ -47,9 +47,7 @@ class MaintenanceEquipmentCategory(models.Model):
equipment_count = fields.Integer(string="Equipment Count", compute='_compute_equipment_count')
maintenance_ids = fields.One2many('maintenance.request', 'category_id', copy=False)
maintenance_count = fields.Integer(string="Maintenance Count", compute='_compute_maintenance_count')
alias_id = fields.Many2one(
'mail.alias', 'Alias', ondelete='restrict', required=True,
help="Email alias for this equipment category. New emails will automatically "
alias_id = fields.Many2one(help="Email alias for this equipment category. New emails will automatically "
"create a new equipment under this category.")
fold = fields.Boolean(string='Folded in Maintenance Pipe', compute='_compute_fold', store=True)
+1 -1
View File
@@ -3,7 +3,7 @@
{
'name': 'Project',
'version': '1.2',
'version': '1.3',
'website': 'https://www.odoo.com/app/project',
'category': 'Services/Project',
'sequence': 45,
+2 -2
View File
@@ -30,8 +30,8 @@
<div>
<t t-set="project_record" t-value="object.env['project.project'].search([('alias_name', '!=', False)], limit=1, order='sequence asc')"/>
<p class="tip_title">Tip: Create tasks from incoming emails</p>
<t t-if="project_record and project_record.alias_domain">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{project_record.alias_value}}" target="_blank" style="color: #875a7b; text-decoration: none;"><t t-out="project_record.alias_value" /></a> will generate tasks in your <t t-out="project_record.name"></t> project.</p>
<t t-if="project_record.alias_email">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{project_record.alias_email}}" target="_blank" style="color: #875a7b; text-decoration: none;"><t t-out="project_record.alias_email" /></a> will generate tasks in your <t t-out="project_record.name"></t> project.</p>
</t>
<t t-else="">
<p class="tip_content">Create tasks by sending an email to the email address of your project.</p>
+3 -13
View File
@@ -130,10 +130,8 @@ class Project(models.Model):
color = fields.Integer(string='Color Index')
user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True)
alias_enabled = fields.Boolean(string='Use Email Alias', compute='_compute_alias_enabled', readonly=False)
alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True,
help="Internal email associated with this project. Incoming emails are automatically synchronized "
"with Tasks (or optionally Issues if the Issue Tracker module is installed).")
alias_value = fields.Char(string='Alias email', compute='_compute_alias_value')
alias_id = fields.Many2one(help="Internal email associated with this project. Incoming emails are automatically synchronized "
"with Tasks (or optionally Issues if the Issue Tracker module is installed).")
privacy_visibility = fields.Selection([
('followers', 'Invited internal users (private)'),
('employees', 'All internal users'),
@@ -217,7 +215,7 @@ class Project(models.Model):
def _compute_alias_enabled(self):
for project in self:
project.alias_enabled = project.alias_domain and project.alias_id.alias_name
project.alias_enabled = bool(project.alias_email)
def _compute_access_url(self):
super(Project, self)._compute_access_url()
@@ -337,14 +335,6 @@ class Project(models.Model):
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@api.depends('alias_name', 'alias_domain')
def _compute_alias_value(self):
for project in self:
if not project.alias_name or not project.alias_domain:
project.alias_value = ''
else:
project.alias_value = "%s@%s" % (project.alias_name, project.alias_domain)
@api.depends('collaborator_ids', 'privacy_visibility')
def _compute_collaborator_count(self):
project_sharings = self.filtered(lambda project: project.privacy_visibility == 'portal')
@@ -96,7 +96,7 @@
<!-- Always display the whole alias in edit mode. It depends in read only -->
<field name="alias_enabled" invisible="1"/>
<label for="alias_name" class="fw-bold o_form_label" string="Create tasks by sending an email to"/>
<field name="alias_value" class="oe_read_only d-inline" readonly="1" widget="email" attrs="{'invisible': [('alias_name', '=', False)]}" />
<field name="alias_email" class="oe_read_only d-inline" readonly="1" widget="email" attrs="{'invisible': [('alias_name', '=', False)]}" />
<span class="oe_edit_only o_row">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
</span>
@@ -375,7 +375,7 @@
<field name="milestone_count"/>
<field name="allow_milestones"/>
<field name="label_tasks"/>
<field name="alias_id"/>
<field name="alias_email"/>
<field name="alias_name"/>
<field name="alias_domain"/>
<field name="is_favorite"/>
@@ -449,8 +449,8 @@
<i t-if="record.date.raw_value and record.date_start.raw_value" class="fa fa-long-arrow-right mx-2 oe_read_only" aria-label="Arrow icon" title="Arrow"/>
<field name="date"/>
</div>
<div t-if="record.alias_name.value and record.alias_domain.value" class="text-muted text-truncate" t-att-title="record.alias_id.value">
<span class="fa fa-envelope-o me-2" aria-label="Domain Alias" title="Domain Alias"></span><t t-esc="record.alias_id.value"/>
<div t-if="record.alias_email.value" class="text-muted text-truncate" t-att-title="record.alias_email.value">
<span class="fa fa-envelope-o me-2" aria-label="Domain Alias" title="Domain Alias"></span><t t-esc="record.alias_email.value"/>
</div>
<div t-if="record.rating_active.raw_value and record.rating_count.raw_value &gt; 0" class="text-muted" groups="project.group_project_rating">
<b class="me-1">
+21 -3
View File
@@ -51,6 +51,27 @@ class MailTestSimpleUnfollow(models.Model):
email_from = fields.Char()
class MailTestAliasOptional(models.Model):
""" A chatter model inheriting from the alias mixin using optional alias_id
field, hence no inherits. """
_description = 'Chatter Model using Optional Alias Mixin'
_name = 'mail.test.alias.optional'
_inherit = ['mail.alias.mixin.optional']
name = fields.Char()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
email_from = fields.Char()
def _alias_get_creation_values(self):
""" Updates itself """
values = super()._alias_get_creation_values()
values['alias_model_id'] = self.env['ir.model']._get_id('mail.test.alias.optional')
if self.id:
values['alias_force_thread_id'] = self.id
values['alias_defaults'] = {'company_id': self.company_id.id}
return values
class MailTestGateway(models.Model):
""" A very simple model only inheriting from mail.thread to test pure mass
mailing features and base performances. """
@@ -310,9 +331,6 @@ class MailTestContainer(models.Model):
name = fields.Char()
description = fields.Text()
customer_id = fields.Many2one('res.partner', 'Customer')
alias_id = fields.Many2one(
'mail.alias', 'Alias',
delegate=True)
def _mail_get_partner_fields(self, introspect_fields=False):
return ['customer_id']
@@ -1,6 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mail_performance_thread,access_mail_performance_thread,model_mail_performance_thread,base.group_user,1,1,1,1
access_mail_performance_tracking_user,mail.performance.tracking,model_mail_performance_tracking,base.group_user,1,1,1,1
access_mail_test_alias_optional_portal,mail.test.alias.optional.portal,model_mail_test_alias_optional,base.group_portal,1,0,0,0
access_mail_test_alias_optional_user,mail.test.alias.optional.user,model_mail_test_alias_optional,base.group_user,1,1,1,1
access_mail_test_simple_portal,mail.test.simple.portal,model_mail_test_simple,base.group_portal,1,0,0,0
access_mail_test_simple_user,mail.test.simple.user,model_mail_test_simple,base.group_user,1,1,1,1
access_mail_test_simple_unfollow_portal,mail.test.simple.unfollow.portal,model_mail_test_simple_unfollow,base.group_portal,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_performance_thread access_mail_performance_thread model_mail_performance_thread base.group_user 1 1 1 1
3 access_mail_performance_tracking_user mail.performance.tracking model_mail_performance_tracking base.group_user 1 1 1 1
4 access_mail_test_alias_optional_portal mail.test.alias.optional.portal model_mail_test_alias_optional base.group_portal 1 0 0 0
5 access_mail_test_alias_optional_user mail.test.alias.optional.user model_mail_test_alias_optional base.group_user 1 1 1 1
6 access_mail_test_simple_portal mail.test.simple.portal model_mail_test_simple base.group_portal 1 0 0 0
7 access_mail_test_simple_user mail.test.simple.user model_mail_test_simple base.group_user 1 1 1 1
8 access_mail_test_simple_unfollow_portal mail.test.simple.unfollow.portal model_mail_test_simple_unfollow base.group_portal 0 0 0 0
+237 -16
View File
@@ -1,6 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import psycopg2
from ast import literal_eval
from odoo import exceptions
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged
@@ -15,11 +19,17 @@ class TestMailAliasCommon(MailCommon):
super().setUpClass()
cls._activate_multi_company()
cls.test_alias_mc = cls.env['mail.alias'].create({
'alias_model_id': cls.env['ir.model']._get('mail.test.container.mc').id,
'alias_name': 'test.alias',
})
@tagged('mail_gateway', 'mail_alias', 'multi_company')
class TestMailAlias(TestMailAliasCommon):
""" Test alias model features, constraints and behavior. """
@users('admin')
def test_alias_domain_allowed_validation(self):
""" Check the validation of `mail.catchall.domain.allowed` system parameter"""
for value in [',', ',,', ', ,']:
@@ -37,6 +47,50 @@ class TestMailAlias(TestMailAliasCommon):
self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value)
self.assertEqual(self.env['ir.config_parameter'].get_param('mail.catchall.domain.allowed'), expected)
@users('admin')
@mute_logger('odoo.models.unlink')
def test_alias_domain_parameters(self):
""" Check the validation of ``mail.bounce.alias`` and ``mail.catchall.alias``
parameters. """
ICP = self.env['ir.config_parameter']
# sanitization
for (bounce_value, catchall_value), (expected_bounce, expected_catchall) in zip(
[
('bounce+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', 'catchall+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~'),
('bounce+😊', 'catchall+😊'),
('Bouncâïde 😊', 'Catchôïee 😊'),
('ぁ', 'ぁぁ'),
],
[
('bounce+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', 'catchall+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~'),
('bounce+-', 'catchall+-'),
('bouncaide-', 'catchoiee-'),
('?', '??'),
]
):
with self.subTest(bounce_value=bounce_value):
ICP.set_param('mail.bounce.alias', bounce_value)
self.assertEqual(ICP.get_param('mail.bounce.alias'), expected_bounce)
with self.subTest(catchall_value=catchall_value):
ICP.set_param('mail.catchall.alias', catchall_value)
self.assertEqual(ICP.get_param('mail.catchall.alias'), expected_catchall)
# falsy values
for config_value in [False, None, '', ' ']:
with self.subTest(config_value=config_value):
ICP.set_param('mail.bounce.alias', config_value)
self.assertFalse(ICP.get_param('mail.bounce.alias'))
ICP.set_param('mail.catchall.alias', config_value)
self.assertFalse(ICP.get_param('mail.catchall.alias'))
# check successive param set, should not raise for unicity against itself
for _ in range(2):
ICP.set_param('mail.bounce.alias', 'bounce+double.test')
ICP.set_param('mail.catchall.alias', 'catchall+double.test')
self.assertEqual(ICP.get_param('mail.bounce.alias'), 'bounce+double.test')
self.assertEqual(ICP.get_param('mail.catchall.alias'), 'catchall+double.test')
@users('admin')
def test_alias_name_unique(self):
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')
@@ -52,22 +106,48 @@ class TestMailAlias(TestMailAliasCommon):
'alias_model_id': alias_model_id,
'alias_name': 'unused.test.alias'
})
other_alias = self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': 'other.test.alias'
})
# test that alias names should be unique
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': 'unused.test.alias',
})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create([
{
'alias_model_id': alias_model_id,
'alias_name': alias_name,
}
for alias_name in ('new.alias.1', 'new.alias.2', 'new.alias.1')
])
with self.assertRaises(psycopg2.errors.UniqueViolation), self.cr.savepoint(), mute_logger('odoo.sql_db'):
new_mail_alias.copy({'alias_name': 'unused.test.alias'})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_alias.write({'alias_name': 'unused.test.alias'})
# test that re-using catchall and bounce alias raises UserError
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({
'alias_name': catchall_alias
})
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': bounce_alias})
new_mail_alias.write({'alias_name': 'another.unused.test.alias'})
# test that duplicating an alias should have blank name
copy_new_mail_alias = new_mail_alias.copy()
self.assertFalse(copy_new_mail_alias.alias_name)
copy_1 = new_mail_alias.copy()
self.assertFalse(copy_1.alias_name)
# 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#')
# cannot batch update, would create duplicates
with self.assertRaises(exceptions.UserError):
(copy_1 + copy_2).write({'alias_name': 'test.alias.other'})
# cannot set catchall / bounce to used alias
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
@@ -75,21 +155,88 @@ class TestMailAlias(TestMailAliasCommon):
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['ir.config_parameter'].sudo().set_param('mail.bounce.alias', new_mail_alias.alias_name)
@users('admin')
@mute_logger('odoo.models.unlink')
def test_alias_name_sanitize(self):
alias = self.env['mail.alias'].create({
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_name': 'bidule...inc.',
})
self.assertEqual(alias.alias_name, 'bidule.inc', 'Emails cannot start or end with a dot, there cannot be a sequence of dots.')
""" Check sanitizer, at both create, copy and write on alias name. """
alias_names = [
'bidule...inc.',
'b4r+=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~',
'hélène.prôvâïder',
'😊',
'Déboulonneur 😊',
'ぁ',
]
expected_names = [
'bidule.inc',
'b4r+=*r3wl_#_-$-{}-~|-/!?&%^\'-`~',
'helene.provaider',
'-',
'deboulonneur-',
'?',
]
msgs = [
'Emails cannot start or end with a dot, there cannot be a sequence of dots.',
'Disallowed chars should be replaced by hyphens',
'Email alias should be unaccented',
'Only a subset of unaccented latin chars are valid, others are replaced',
'Only a subset of unaccented latin chars are valid, others are replaced',
'Only a subset of unaccented latin chars are valid, others are replaced',
]
for alias_name, expected, msg in zip(alias_names, expected_names, msgs):
with self.subTest(alias_name=alias_name):
alias = self.env['mail.alias'].create({
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_name': alias_name,
})
self.assertEqual(alias.alias_name, expected, msg)
alias.unlink()
alias = self.env['mail.alias'].create({
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_name': 'b4r+_#_R3wl$$',
})
self.assertEqual(alias.alias_name, 'b4r+_-_r3wl-', 'Disallowed chars should be replaced by hyphens')
# check at write
for alias_name, expected, msg in zip(alias_names, expected_names, msgs):
with self.subTest(alias_name=alias_name):
alias.write({'alias_name': alias_name})
self.assertEqual(alias.alias_name, expected, msg)
@users('admin')
def test_alias_name_sanitize_false(self):
""" Check empty-like aliases are forced to False, as otherwise unique
constraint might fail with empty strings. """
aliases = self.env['mail.alias'].create([
{
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_name': falsy_name,
}
# '.' -> not allowed to start with a "." hence False
for falsy_name in [False, None, '', ' ', '.']
])
for alias in aliases:
with self.subTest(alias_name=alias.alias_name):
self.assertFalse(alias.alias_name, 'Void values should resolve to False')
# try to reset names in batch: should work
for idx, alias in enumerate(aliases):
alias.write({'alias_name': f'unique-{idx}'})
aliases.write({'alias_name': ''})
for alias in aliases:
self.assertEqual(alias.alias_name, False)
@users('admin')
def test_alias_setup(self):
""" Test various constraints / configuration of alias model"""
alias = self.env['mail.alias'].create({
'alias_model_id': self.env['ir.model']._get('mail.test.container.mc').id,
'alias_name': 'unused.test.alias'
})
self.assertEqual(alias.alias_status, 'not_tested')
# validation of alias_defaults
with self.assertRaises(exceptions.ValidationError):
alias.write({'alias_defaults': "{'custom_field': brokendict"})
alias.write({'alias_defaults': "{'custom_field': 'validdict'}"})
@tagged('mail_gateway', 'mail_alias', 'multi_company')
@@ -99,7 +246,7 @@ class TestMailAliasMixin(TestMailAliasCommon):
@users('employee')
@mute_logger('odoo.addons.base.models.ir_model')
def test_alias_creation(self):
def test_alias_mixin(self):
record = self.env['mail.test.container'].create({
'name': 'Test Record',
'alias_name': 'alias.test',
@@ -132,6 +279,60 @@ class TestMailAliasMixin(TestMailAliasCommon):
with self.assertRaises(exceptions.ValidationError):
record.write({'alias_defaults': "{'custom_field': brokendict"})
@users('employee')
@mute_logger('odoo.addons.base.models.ir_model')
def test_alias_mixin_alias_id_management(self):
""" Test alias_id being not mandatory """
record_wo_alias, record_w_alias = self.env['mail.test.alias.optional'].create([
{
'name': 'Test WoAlias Name',
}, {
'alias_name': 'Alias Name',
'name': 'Test WoAlias Name',
}
])
self.assertFalse(record_wo_alias.alias_id, 'Alias record not created if not necessary (no alias_name)')
self.assertFalse(record_wo_alias.alias_id.alias_name)
self.assertFalse(record_wo_alias.alias_id.alias_defaults)
self.assertFalse(record_wo_alias.alias_name)
self.assertTrue(record_w_alias.alias_id, 'Alias record created as alias_name was given')
self.assertEqual(record_w_alias.alias_id.alias_name, 'alias-name', 'Alias name should go through sanitize')
self.assertEqual(
literal_eval(record_w_alias.alias_id.alias_defaults),
{'company_id': self.env.company.id}
)
self.assertEqual(record_w_alias.alias_name, 'alias-name', 'Alias name should go through sanitize')
self.assertEqual(
literal_eval(record_w_alias.alias_defaults),
{'company_id': self.env.company.id}
)
# update existing alias
record_w_alias.write({'alias_contact': 'followers', 'alias_name': 'Updated Alias Name'})
self.assertEqual(record_w_alias.alias_id.alias_contact, 'followers')
self.assertEqual(record_w_alias.alias_id.alias_name, 'updated-alias-name')
self.assertEqual(record_w_alias.alias_name, 'updated-alias-name')
# update non existing alias -> creates alias
record_wo_alias.write({'alias_name': 'trying a name'})
self.assertTrue(record_wo_alias.alias_id, 'Alias record should have been created to store the name')
self.assertEqual(record_wo_alias.alias_id.alias_name, 'trying-a-name')
self.assertEqual(
literal_eval(record_wo_alias.alias_id.alias_defaults),
{'company_id': self.env.company.id}
)
self.assertEqual(record_wo_alias.alias_name, 'trying-a-name')
self.assertEqual(
literal_eval(record_wo_alias.alias_defaults),
{'company_id': self.env.company.id}
)
# reset alias -> keep the alias as void, don't remove it
existing_aliases = record_wo_alias.alias_id + record_w_alias.alias_id
(record_wo_alias + record_w_alias).write({'alias_name': False})
self.assertEqual((record_wo_alias + record_w_alias).alias_id, existing_aliases)
self.assertFalse(list(filter(None, existing_aliases.mapped('alias_name'))))
@users('employee')
def test_alias_mixin_copy_content(self):
self.assertFalse(self.env.user.has_group('base.group_system'), 'Test user should not have Administrator access')
@@ -151,3 +352,23 @@ class TestMailAliasMixin(TestMailAliasCommon):
self.assertEqual(record_copy.alias_bounced_content, new_content)
record_copy2 = record_copy.copy()
self.assertEqual(record_copy2.alias_bounced_content, new_content)
@users('erp_manager')
def test_alias_mixin_mc(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)
record.write({'company_id': self.company_admin.id})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertEqual(record.company_id, self.company_admin)
record.write({'company_id': False})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertFalse(record.company_id)