Skip to content

Commit c101659

Browse files
robodooOdoo Online
authored andcommitted
[FW][FIX] sms: compose SMS on non-thread models
Nothing prevents from creating templates and composers on non thread models. However most necessary code relies on mail.thread. Here we move code from mail.thread directly to BaseModel. For most business models it does not change anything. For some side models it allows / fixes usage of SMS composer, allowing to dynamically find numbers / partners, ... One notable use case that triggered this PR is sending of SMS to users using their work phone number, or through their partner. Either we hack user model, either we provide a generic fix. Note that in current master (18.2+) most 'mail' generic features are supported on non-thread models (template usage and sending, mailing using composer, ...). SMS is still thread-specific while phone capabilities have already been moved to generic code. We therefore continue towards generic simple mail / sms usage on business records, while advanced features (followers, logs, direct post using SMS type) require thread-enabled models. Task-4113190 sentry-4681535519 closes odoo#202802 Forward-port-of: odoo#202454 Forward-port-of: odoo#200650 Signed-off-by: Thibault Delavallee (tde) <[email protected]>
2 parents 1288e71 + 8597bfb commit c101659

File tree

10 files changed

+238
-93
lines changed

10 files changed

+238
-93
lines changed

addons/mail/tests/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,7 @@ def setUpClass(cls):
15191519
'email': '[email protected]',
15201520
"name": "Mitchell Admin",
15211521
'notification_type': 'inbox',
1522+
'phone': '0455135790',
15221523
})
15231524
# have root available at hand, just in case
15241525
cls.user_root = cls.env.ref('base.user_root')

addons/sms/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from . import mail_message
99
from . import mail_notification
1010
from . import mail_thread
11+
from . import models
1112
from . import res_partner
1213
from . import sms_sms
1314
from . import sms_template

addons/sms/models/mail_thread.py

Lines changed: 0 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -42,89 +42,6 @@ def _compute_message_has_sms_error(self):
4242
def _search_message_has_sms_error(self, operator, operand):
4343
return ['&', ('message_ids.has_sms_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)]
4444

45-
def _sms_get_recipients_info(self, force_field=False, partner_fallback=True):
46-
"""" Get SMS recipient information on current record set. This method
47-
checks for numbers and sanitation in order to centralize computation.
48-
49-
Example of use cases
50-
51-
* click on a field -> number is actually forced from field, find customer
52-
linked to record, force its number to field or fallback on customer fields;
53-
* contact -> find numbers from all possible phone fields on record, find
54-
customer, force its number to found field number or fallback on customer fields;
55-
56-
:param force_field: either give a specific field to find phone number, either
57-
generic heuristic is used to find one based on ``_phone_get_number_fields``;
58-
:param partner_fallback: if no value found in the record, check its customer
59-
values based on ``_mail_get_partners``;
60-
61-
:return dict: record.id: {
62-
'partner': a res.partner recordset that is the customer (void or singleton)
63-
linked to the recipient. See ``_mail_get_partners``;
64-
'sanitized': sanitized number to use (coming from record's field or partner's
65-
phone fields). Set to False is number impossible to parse and format;
66-
'number': original number before sanitation;
67-
'partner_store': whether the number comes from the customer phone fields. If
68-
False it means number comes from the record itself, even if linked to a
69-
customer;
70-
'field_store': field in which the number has been found (generally mobile or
71-
phone, see ``_phone_get_number_fields``);
72-
} for each record in self
73-
"""
74-
result = dict.fromkeys(self.ids, False)
75-
tocheck_fields = [force_field] if force_field else self._phone_get_number_fields()
76-
for record in self:
77-
all_numbers = [record[fname] for fname in tocheck_fields if fname in record]
78-
all_partners = record._mail_get_partners()[record.id]
79-
80-
valid_number, fname = False, False
81-
for fname in [f for f in tocheck_fields if f in record]:
82-
valid_number = record._phone_format(fname=fname)
83-
if valid_number:
84-
break
85-
86-
if valid_number:
87-
result[record.id] = {
88-
'partner': all_partners[0] if all_partners else self.env['res.partner'],
89-
'sanitized': valid_number,
90-
'number': record[fname],
91-
'partner_store': False,
92-
'field_store': fname,
93-
}
94-
elif all_partners and partner_fallback:
95-
partner = self.env['res.partner']
96-
for partner in all_partners:
97-
for fname in self.env['res.partner']._phone_get_number_fields():
98-
valid_number = partner._phone_format(fname=fname)
99-
if valid_number:
100-
break
101-
102-
if not valid_number:
103-
fname = 'mobile' if partner.mobile else ('phone' if partner.phone else 'mobile')
104-
105-
result[record.id] = {
106-
'partner': partner,
107-
'sanitized': valid_number if valid_number else False,
108-
'number': partner[fname],
109-
'partner_store': True,
110-
'field_store': fname,
111-
}
112-
else:
113-
# did not find any sanitized number -> take first set value as fallback;
114-
# if none, just assign False to the first available number field
115-
value, fname = next(
116-
((value, fname) for value, fname in zip(all_numbers, tocheck_fields) if value),
117-
(False, tocheck_fields[0] if tocheck_fields else False)
118-
)
119-
result[record.id] = {
120-
'partner': self.env['res.partner'],
121-
'sanitized': False,
122-
'number': value,
123-
'partner_store': False,
124-
'field_store': fname
125-
}
126-
return result
127-
12845
@api.returns('mail.message', lambda value: value.id)
12946
def message_post(self, *args, body='', message_type='notification', **kwargs):
13047
# When posting an 'SMS' `message_type`, make sure that the body is used as-is in the sms,

addons/sms/models/models.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from odoo import models
2+
from odoo.addons.phone_validation.tools import phone_validation
3+
4+
5+
class BaseModel(models.AbstractModel):
6+
_inherit = 'base'
7+
8+
def _sms_get_recipients_info(self, force_field=False, partner_fallback=True):
9+
"""" Get SMS recipient information on current record set. This method
10+
checks for numbers and sanitation in order to centralize computation.
11+
12+
Example of use cases
13+
14+
* click on a field -> number is actually forced from field, find customer
15+
linked to record, force its number to field or fallback on customer fields;
16+
* contact -> find numbers from all possible phone fields on record, find
17+
customer, force its number to found field number or fallback on customer fields;
18+
19+
:param force_field: either give a specific field to find phone number, either
20+
generic heuristic is used to find one based on ``_phone_get_number_fields``;
21+
:param partner_fallback: if no value found in the record, check its customer
22+
values based on ``_mail_get_partners``;
23+
24+
:return dict: record.id: {
25+
'partner': a res.partner recordset that is the customer (void or singleton)
26+
linked to the recipient. See ``_mail_get_partners``;
27+
'sanitized': sanitized number to use (coming from record's field or partner's
28+
phone fields). Set to False is number impossible to parse and format;
29+
'number': original number before sanitation;
30+
'partner_store': whether the number comes from the customer phone fields. If
31+
False it means number comes from the record itself, even if linked to a
32+
customer;
33+
'field_store': field in which the number has been found (generally mobile or
34+
phone, see ``_phone_get_number_fields``);
35+
} for each record in self
36+
"""
37+
result = dict.fromkeys(self.ids, False)
38+
tocheck_fields = [force_field] if force_field else self._phone_get_number_fields()
39+
for record in self:
40+
all_numbers = [record[fname] for fname in tocheck_fields if fname in record]
41+
all_partners = record._mail_get_partners()[record.id]
42+
43+
valid_number, fname = False, False
44+
for fname in [f for f in tocheck_fields if f in record]:
45+
valid_number = record._phone_format(fname=fname)
46+
if valid_number:
47+
break
48+
49+
if valid_number:
50+
result[record.id] = {
51+
'partner': all_partners[0] if all_partners else self.env['res.partner'],
52+
'sanitized': valid_number,
53+
'number': record[fname],
54+
'partner_store': False,
55+
'field_store': fname,
56+
}
57+
elif all_partners and partner_fallback:
58+
partner = self.env['res.partner']
59+
for partner in all_partners:
60+
for fname in self.env['res.partner']._phone_get_number_fields():
61+
valid_number = partner._phone_format(fname=fname)
62+
if valid_number:
63+
break
64+
65+
if not valid_number:
66+
fname = 'mobile' if partner.mobile else ('phone' if partner.phone else 'mobile')
67+
68+
result[record.id] = {
69+
'partner': partner,
70+
'sanitized': valid_number if valid_number else False,
71+
'number': partner[fname],
72+
'partner_store': True,
73+
'field_store': fname,
74+
}
75+
else:
76+
# did not find any sanitized number -> take first set value as fallback;
77+
# if none, just assign False to the first available number field
78+
value, fname = next(
79+
((value, fname) for value, fname in zip(all_numbers, tocheck_fields) if value),
80+
(False, tocheck_fields[0] if tocheck_fields else False)
81+
)
82+
result[record.id] = {
83+
'partner': self.env['res.partner'],
84+
'sanitized': False,
85+
'number': value,
86+
'partner_store': False,
87+
'field_store': fname
88+
}
89+
return result

addons/sms/wizard/sms_composer.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def _compute_recipients(self):
103103
continue
104104

105105
records = composer._get_records()
106-
if records and isinstance(records, self.pool['mail.thread']):
106+
if records:
107107
res = records._sms_get_recipients_info(force_field=composer.number_field_name, partner_fallback=not composer.comment_single_recipient)
108108
composer.recipient_valid_count = len([rid for rid, rvalues in res.items() if rvalues['sanitized']])
109109
composer.recipient_invalid_count = len([rid for rid, rvalues in res.items() if not rvalues['sanitized']])
@@ -116,28 +116,28 @@ def _compute_recipients(self):
116116
def _compute_recipient_single_stored(self):
117117
for composer in self:
118118
records = composer._get_records()
119-
if not records or not isinstance(records, self.pool['mail.thread']) or not composer.comment_single_recipient:
119+
if not records or not composer.comment_single_recipient:
120120
composer.recipient_single_number_itf = ''
121121
continue
122122
records.ensure_one()
123123
res = records._sms_get_recipients_info(force_field=composer.number_field_name, partner_fallback=True)
124124
if not composer.recipient_single_number_itf:
125-
composer.recipient_single_number_itf = res[records.id]['number'] or ''
125+
composer.recipient_single_number_itf = res[records.id]['sanitized'] or res[records.id]['number'] or ''
126126
if not composer.number_field_name:
127127
composer.number_field_name = res[records.id]['field_store']
128128

129129
@api.depends('res_model', 'number_field_name')
130130
def _compute_recipient_single_non_stored(self):
131131
for composer in self:
132132
records = composer._get_records()
133-
if not records or not isinstance(records, self.pool['mail.thread']) or not composer.comment_single_recipient:
133+
if not records or not composer.comment_single_recipient:
134134
composer.recipient_single_description = False
135135
composer.recipient_single_number = ''
136136
continue
137137
records.ensure_one()
138138
res = records._sms_get_recipients_info(force_field=composer.number_field_name, partner_fallback=True)
139139
composer.recipient_single_description = res[records.id]['partner'].name or records._mail_get_partners()[records[0].id].display_name
140-
composer.recipient_single_number = res[records.id]['number'] or ''
140+
composer.recipient_single_number = res[records.id]['sanitized'] or res[records.id]['number'] or ''
141141

142142
@api.depends('recipient_single_number', 'recipient_single_number_itf')
143143
def _compute_recipient_single_valid(self):
@@ -204,7 +204,14 @@ def _action_send_sms(self):
204204
return self._action_send_sms_mass(records)
205205

206206
def _action_send_sms_numbers(self):
207-
sms_values = [{'body': self.body, 'number': number} for number in self.sanitized_numbers.split(',')]
207+
sms_values = [
208+
{
209+
'body': self.body,
210+
'number': number
211+
} for number in (
212+
self.sanitized_numbers.split(',') if self.sanitized_numbers else [self.recipient_single_number_itf or self.recipient_single_number or '']
213+
)
214+
]
208215
self.env['sms.sms'].sudo().create(sms_values).send()
209216
return True
210217

addons/test_mail_sms/models/test_mail_sms_models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,20 @@ def _mailing_get_opt_out_list_sms(self, mailing):
150150
('id', 'in', res_ids),
151151
('opt_out', '=', True)
152152
]).ids
153+
154+
# ------------------------------------------------------------
155+
# OTHER
156+
# ------------------------------------------------------------
157+
158+
class SMSTestNotMailThread(models.Model):
159+
""" Models not inheriting from mail.thread but using some cross models
160+
capabilities of mail. """
161+
_name = 'sms.test.nothread'
162+
_description = "NoThread Model"
163+
164+
name = fields.Char()
165+
company_id = fields.Many2one('res.company')
166+
customer_id = fields.Many2one('res.partner')
167+
168+
def _mail_get_partner_fields(self, introspect_fields=False):
169+
return ['customer_id']

addons/test_mail_sms/security/ir.model.access.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ access_mail_test_sms_partner_all,mail.test.sms.partner.all,model_mail_test_sms_p
1111
access_mail_test_sms_partner_user,mail.test.sms.partner.user,model_mail_test_sms_partner,base.group_user,1,1,1,1
1212
access_mail_test_sms_partner_2many_all,mail.test.sms.partner.2many.all,model_mail_test_sms_partner_2many,,0,0,0,0
1313
access_mail_test_sms_partner_2many_user,mail.test.sms.partner.2many.user,model_mail_test_sms_partner_2many,base.group_user,1,1,1,1
14+
access_sms_test_nothread_user,sms.test.nothread.user,model_sms_test_nothread,base.group_user,1,1,1,1

addons/test_mail_sms/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from . import test_sms_composer
88
from . import test_sms_controller
99
from . import test_sms_management
10+
from . import test_sms_mixin
1011
from . import test_sms_performance
1112
from . import test_sms_post
1213
from . import test_sms_server_actions

addons/test_mail_sms/tests/test_sms_composer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ def test_composer_nofield_w_customer(self):
184184
})
185185
self.assertFalse(composer.number_field_name)
186186
self.assertTrue(composer.recipient_single_valid)
187-
self.assertEqual(composer.recipient_single_number, self.partner_1.mobile)
188-
self.assertEqual(composer.recipient_single_number_itf, self.partner_1.mobile)
187+
self.assertEqual(composer.recipient_single_number, self.partner_numbers[0])
188+
self.assertEqual(composer.recipient_single_number_itf, self.partner_numbers[0])
189189

190190
def test_composer_internals(self):
191191
with self.with_user('employee'):
@@ -201,8 +201,8 @@ def test_composer_internals(self):
201201
self.assertEqual(composer.number_field_name, 'phone_nbr')
202202
self.assertTrue(composer.comment_single_recipient)
203203
self.assertEqual(composer.recipient_single_description, self.test_record.customer_id.display_name)
204-
self.assertEqual(composer.recipient_single_number, self.test_numbers[1])
205-
self.assertEqual(composer.recipient_single_number_itf, self.test_numbers[1])
204+
self.assertEqual(composer.recipient_single_number, self.test_numbers_san[1])
205+
self.assertEqual(composer.recipient_single_number_itf, self.test_numbers_san[1])
206206
self.assertTrue(composer.recipient_single_valid)
207207
self.assertEqual(composer.recipient_valid_count, 1)
208208
self.assertEqual(composer.recipient_invalid_count, 0)
@@ -289,6 +289,7 @@ def test_composer_sending_with_no_number_field(self):
289289
self.assertSMSNotification([{'number': self.random_numbers_san[0]}], self._test_body)
290290

291291

292+
@tagged('sms_composer')
292293
class TestSMSComposerBatch(SMSCommon):
293294
@classmethod
294295
def setUpClass(cls):
@@ -339,6 +340,7 @@ def test_composer_batch_res_ids(self):
339340
)
340341

341342

343+
@tagged('sms_composer')
342344
class TestSMSComposerMass(SMSCommon):
343345

344346
@classmethod

0 commit comments

Comments
 (0)